Skip to content

Commit 708696c

Browse files
committed
TaskSorting-v1.3.1 fix for Tags and mentions containing accented, non-Latin or symbol characters
1 parent 197d175 commit 708696c

4 files changed

Lines changed: 101 additions & 3 deletions

File tree

‎dwertheimer.TaskSorting/CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
See Plugin [README](https://github.com/NotePlan/plugins/blob/main/dwertheimer.TaskSorting/README.md) for details on available commands and use case.
66

7+
## [1.3.1] - 2026-08-11 (@dwertheimer)
8+
9+
### Fixed
10+
- **Tags and mentions containing accented, non-Latin or symbol characters were truncated when sorting** ([#776](https://github.com/NotePlan/plugins/issues/776)). The tag-matching regex only accepted `a-z`, `A-Z`, `0-9` and `/`, so `#Führung` was read as the tag `F` — which sorted wrongly and produced a `#### #F:` heading when *Include Primary Sort Key Heading in Output?* was on.
11+
- Tag and mention matching now follows the same rule NotePlan itself uses, verified case by case against the app's own `paragraph.hashtags` / `paragraph.mentions`: letters, combining marks and digits of **any** script, plus **any** Unicode symbol (`$ + = ~ ^ < | € £ ° →` and emoji), plus the three punctuation exceptions `-`, `_` and `/`. Every other punctuation character (`. , % & ' ! ? : ; ( ) [ ] { } * @ " \ #`) and any whitespace ends the tag, so ordinary sentence punctuation is not swallowed (`#shopping.` still sorts as `shopping`).
12+
- Consequences of the above, all matching NotePlan: `#my-tag` now sorts as `my-tag` rather than `my`; a trailing slash is trimmed (`#tag/` → `tag`); and a tag that is only digits and dashes (`#123`, `#2024-05`) is correctly not treated as a tag at all.
13+
- Mentions get the same treatment (`@Müller` no longer becomes `@M`). As in NotePlan, the trailing `(...)` is not part of the mention, so `@estimate(2)` and `@estimate(5)` still sort together under `@estimate`.
14+
715
## [1.3.0] - 2026-08-02 (@dwertheimer)
816

917
### Fixed

‎dwertheimer.TaskSorting/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"noteplan.minAppVersion": "3.4.0",
44
"plugin.id": "dwertheimer.TaskSorting",
55
"plugin.name": "🥷 Task Sorting & Tools",
6-
"plugin.version": "1.3.0",
6+
"plugin.version": "1.3.1",
77
"plugin.lastUpdateInfo": "1.3.0: Indented subtasks are no longer flattened when sorting -- a task now keeps its whole nested subtree, at every depth. 'Sort tasks under heading' now respects the 'Combine Related Task Types?' setting (it was previously ignored by that command). NOTE: indent with TABS -- NotePlan does not report space-indentation to plugins, so space-indented subtasks cannot be preserved.",
88
"plugin.description": "Commands for sorting tasks in a note",
99
"plugin.releaseStatus": "",

‎helpers/__tests__/sorting.test.js‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,76 @@ describe('sorting.js', () => {
484484
const result = s.getSortableTask(paragraph)
485485
expect(result).toHaveProperty('mentions', ['foo'])
486486
})
487+
/**
488+
* Tag/mention character set (issue #776).
489+
* Every expectation below was read back from NotePlan's OWN parser (paragraph.hashtags /
490+
* paragraph.mentions) on a probe note, so these lock our regex to the app's behaviour.
491+
* The rule: letters/marks/digits of any script + any Unicode symbol + the three punctuation
492+
* exceptions - _ / ; every other punctuation character and whitespace ends the tag.
493+
*/
494+
describe('hashtag/mention character set (issue #776)', () => {
495+
const hashtagsOf = (content) => s.getSortableTask(new Paragraph({ type: 'open', content, filename: 'testFile.md' })).hashtags
496+
const mentionsOf = (content) => s.getSortableTask(new Paragraph({ type: 'open', content, filename: 'testFile.md' })).mentions
497+
498+
test('should keep accented characters in a hashtag (the reported bug)', () => {
499+
expect(hashtagsOf('!!! #Führen #Roadmap → Priorisierung 1')).toEqual(['Führen', 'Roadmap'])
500+
})
501+
test('should keep non-Latin scripts, combining marks and emoji in a hashtag', () => {
502+
expect(hashtagsOf('test #日本語 #Ålesund #café #a😀b')).toEqual(['日本語', 'Ålesund', 'café', 'a😀b'])
503+
})
504+
test('should keep the - _ / punctuation exceptions', () => {
505+
expect(hashtagsOf('test #a-b #c_d #tag/sub #a/b/c #-lead #_lead #trail-')).toEqual(['a-b', 'c_d', 'tag/sub', 'a/b/c', '-lead', '_lead', 'trail-'])
506+
})
507+
test('should keep Unicode symbols ($ + = ~ ^ < | and currency)', () => {
508+
expect(hashtagsOf('test #a$b #a+b #a=b #a~b #a^b #a<b #a|b #a€b #a£b #a°b #a→b')).toEqual([
509+
'a$b',
510+
'a+b',
511+
'a=b',
512+
'a~b',
513+
'a^b',
514+
'a<b',
515+
'a|b',
516+
'a€b',
517+
'a£b',
518+
'a°b',
519+
'a→b',
520+
])
521+
})
522+
test('should stop at any other punctuation character', () => {
523+
expect(hashtagsOf(`#a.b #a,b #a%b #a&b #a'b #a!b #a?b #a:b #a;b #a(b #a[b #a*b #a@b #a"b #a{b`)).toEqual(Array(15).fill('a'))
524+
})
525+
test('should trim trailing sentence punctuation', () => {
526+
expect(hashtagsOf('buy milk #shopping. and #errands, and #chores)')).toEqual(['shopping', 'errands', 'chores'])
527+
})
528+
test('should trim a trailing slash', () => {
529+
expect(hashtagsOf('test #tag/')).toEqual(['tag'])
530+
})
531+
test('should keep a multi-level tag as one full path, not split into levels', () => {
532+
// Deliberate: NotePlan's own paragraph.hashtags returns every level (["#a", "#a/b", "#a/b/c"]),
533+
// but the sort key is hashtags[0], so emitting parents would collapse all "#project/*" tasks into
534+
// a single "#project" group. Full paths still sort adjacently and in tree order.
535+
expect(hashtagsOf('test #project/alpha')).toEqual(['project/alpha'])
536+
const tasks = ['#work', '#project/beta', '#project/alpha', '#project', '#zebra'].map((tag) => hashtagsOf(`task ${tag}`)[0])
537+
expect([...tasks].sort()).toEqual(['project', 'project/alpha', 'project/beta', 'work', 'zebra'])
538+
})
539+
test('should not treat a digits-only tag as a hashtag', () => {
540+
expect(hashtagsOf('test #123 #2024-05 #foo2 #a1b2')).toEqual(['foo2', 'a1b2'])
541+
})
542+
test('should not treat a # in the middle of a word as a hashtag', () => {
543+
expect(hashtagsOf('word#nottag')).toEqual([])
544+
})
545+
test('should find a hashtag wrapped in parentheses', () => {
546+
expect(hashtagsOf('(#parens) done')).toEqual(['parens'])
547+
})
548+
test('should apply the same rules to mentions', () => {
549+
expect(mentionsOf('test @Müller @a-b @a_b @a/b @a$b @日本語')).toEqual(['Müller', 'a-b', 'a_b', 'a/b', 'a$b', '日本語'])
550+
expect(mentionsOf('test @a.b @a%b')).toEqual(['a', 'a'])
551+
expect(mentionsOf('test @123')).toEqual([])
552+
})
553+
test('should drop the parenthetical part of a mention, as NotePlan does, so siblings group together', () => {
554+
expect(mentionsOf('test @estimate(2) @done(2026-08-11)')).toEqual(['estimate', 'done'])
555+
})
556+
})
487557
test('should not have exclamation mark priority', () => {
488558
const paragraph = new Paragraph({ type: 'open', content: 'test content !!!', filename: 'testFile.md' })
489559
const result = s.getSortableTask(paragraph)

‎helpers/sorting.js‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,28 @@ export type GroupedTasks = {
5252
/** Sorted/grouped task buckets produced by TaskSorting (always SortableParagraphSubset elements). */
5353
export type ParagraphsGroupedByType = GroupedTasks
5454

55-
const RE_HASHTAGS: RegExp = /\B#([a-zA-Z0-9\/]+\b)/g
56-
const RE_MENTIONS: RegExp = /\B@([a-zA-Z0-9\/]+\b)/g
55+
// Tag/mention matching, deliberately mirroring how NotePlan itself parses `paragraph.hashtags` /
56+
// `paragraph.mentions`, so that sorting groups tasks under the same tag the app shows on the line.
57+
// The rule (verified against NotePlan's own parser) is: a tag may contain letters, combining marks and
58+
// digits of ANY script, plus ANY Unicode symbol ($ + = ~ ^ < | € £ ° → and emoji), plus the three
59+
// punctuation exceptions - _ / . Every other punctuation character (. , % & ' ! ? : ; ( ) [ ] { } * @ " \ #)
60+
// and any whitespace ends the tag.
61+
// The old class was /[a-zA-Z0-9\/]+/, which truncated "#Führung" to "F" (see issue #776).
62+
// Details:
63+
// - leading (?!...) drops tags that are only digits/-/_//, so "#123" and "#2024-05" are not tags, as in NotePlan
64+
// - the final character class excludes "/" so a trailing slash is trimmed ("#tag/" -> "tag"), again as in NotePlan
65+
// - no trailing \b: it is ASCII-only, so a tag ending in a non-ASCII character would backtrack to a truncated match
66+
//
67+
// Multi-level tags: we return the FULL path as a single value ("#project/alpha" -> "project/alpha"), which is
68+
// how this helper has behaved since 2022. Note that NotePlan's own `paragraph.hashtags` instead returns every
69+
// level ("#a/b/c" -> ["#a", "#a/b", "#a/b/c"]); we deliberately do not do that here, because the sort key is
70+
// `hashtags[0]` (see firstValue()), so emitting parents would collapse every "#project/*" task into one
71+
// "#project" group. Keeping the full path still sorts siblings adjacently and in tree order, since the key is
72+
// a plain string -- so the hierarchy reads correctly without losing the distinction between the leaves.
73+
const RE_HASHTAGS: RegExp = /\B(?:#|#)((?![\p{N}\-_\/]+(?:$|[^\p{L}\p{M}\p{N}\p{S}\-_\/]))[\p{L}\p{M}\p{N}\p{S}\-_\/]{0,59}[\p{L}\p{M}\p{N}\p{S}\-_])/gu
74+
// Mentions follow the same rule. NotePlan reports "@estimate(2)" as the mention "@estimate", so the
75+
// trailing "(...)" is left out here too -- which also keeps @estimate(2) and @estimate(5) in one sort group.
76+
const RE_MENTIONS: RegExp = /\B@((?![\p{N}\-_\/]+(?:$|[^\p{L}\p{M}\p{N}\p{S}\-_\/]))[\p{L}\p{M}\p{N}\p{S}\-_\/]{0,59}[\p{L}\p{M}\p{N}\p{S}\-_])/gu
5777
const RE_LEADING_EXCLAMATIONS: RegExp = /^\s*(!+)/g // at start of content, though allowing for leading whitespace (as NP does)
5878
const RE_LEADING_PARENS_PRIORITY: RegExp = /^\s*\(([a-zA-z])\)\B/g // must be at start of content
5979
export const TASK_TYPES: Array<string> = ['open', 'scheduled', 'done', 'cancelled', 'checklist', 'checklistDone', 'checklistCancelled', 'checklistScheduled']

0 commit comments

Comments
 (0)