Skip to content

Commit ea3fe09

Browse files
authored
ZenNotes 2.32.0: wrapped Vim motions, your way (#645)
Includes configurable wrapped Vim motion semantics (#638), selective Ctrl+D cleanup in Open Buffers (#641), server-backed custom task status persistence (#643), and the 2.32.0 workspace version bump. Verified by the full local release gate and all required GitHub checks on macOS, Windows, x64 Linux, ARM64 Linux, and CodeQL.
1 parent d0a07de commit ea3fe09

27 files changed

Lines changed: 549 additions & 86 deletions

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/desktop",
33
"productName": "ZenNotes",
4-
"version": "2.31.0",
4+
"version": "2.32.0",
55
"description": "ZenNotes desktop shell",
66
"private": true,
77
"main": "./out/main/index.js",

apps/desktop/src/main/app-config.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ describe('TOML serialization', () => {
9494
it('round-trips portable prefs, including nullable and map fields', () => {
9595
const portable: AppConfigPortable = {
9696
vimMode: false,
97+
vimWrappedLineMotions: 'logical',
9798
editorFontSize: 18,
9899
editorLineHeight: 1.6,
99100
themeFamily: 'nord',
@@ -120,6 +121,7 @@ describe('TOML serialization', () => {
120121
const { version, portable: round } = deserializeConfig(text)
121122
expect(version).toBe(CONFIG_VERSION)
122123
expect(round.vimMode).toBe(false)
124+
expect(round.vimWrappedLineMotions).toBe('logical')
123125
expect(round.editorFontSize).toBe(18)
124126
expect(round.editorLineHeight).toBeCloseTo(1.6)
125127
expect(round.themeFamily).toBe('nord')
@@ -154,6 +156,9 @@ describe('TOML serialization', () => {
154156
'auto_pair_quotes_in_prose = false # also auto-insert matching quotes outside Markdown code'
155157
)
156158
expect(text).toContain('[vim]')
159+
expect(text).toContain(
160+
'wrapped_line_motions = "display" # display | logical — how $, I, A and dependent operators treat soft-wrapped lines'
161+
)
157162
expect(text).toContain('[view]')
158163
// Keymaps: every action listed as a commented, grouped default reference.
159164
expect(text).toContain('[keymaps]')

apps/desktop/src/main/app-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
5656
tomlKey: 'yank_to_clipboard',
5757
comment: 'sync the system clipboard with Vim yank/delete/change and p/P paste'
5858
},
59+
vimWrappedLineMotions: {
60+
section: 'vim',
61+
tomlKey: 'wrapped_line_motions',
62+
comment: 'display | logical — how $, I, A and dependent operators treat soft-wrapped lines'
63+
},
5964
whichKeyHints: {
6065
section: 'vim',
6166
tomlKey: 'which_key_hints',

apps/server/internal/vault/parse.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ var (
254254
inlineDueRe = regexp.MustCompile(`(?i)(?:^|\s)due:\s*(\S+)`)
255255
inlinePriority = regexp.MustCompile(`(?i)(?:^|\s)!(high|med|medium|low|h|m|l)\b`)
256256
inlineWaitingRe = regexp.MustCompile(`(?i)(?:^|\s)@waiting\b`)
257+
inlineFieldRe = regexp.MustCompile(`(?i)(?:^|\s)@([a-z][a-z0-9_-]*):([\p{L}\d][\p{L}\d/_-]*)`)
257258
inlineTagRe = regexp.MustCompile(`(?:^|\s)#([\p{L}\d][\p{L}\d/_\-]*)`)
258259
isoDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
259260
)
@@ -504,6 +505,8 @@ func parseTaskFile(path, title string, folder NoteFolder, body string) (Task, bo
504505
Due: normalizeDueDate(firstScalar(fm["due"])),
505506
Priority: normalizePriority(firstScalar(fm["priority"])),
506507
Waiting: status == "waiting",
508+
Fields: map[string]string{"status": status},
509+
Status: status,
507510
Tags: tags,
508511
Kind: "file",
509512
Scheduled: normalizeDueDate(firstScalar(fm["scheduled"])),
@@ -586,6 +589,7 @@ func ParseTasksWith(path, title string, folder NoteFolder, body string, opts Par
586589
due := ""
587590
priority := ""
588591
waiting := false
592+
fields := map[string]string{}
589593
tags := []string{}
590594
stripped := tail
591595

@@ -603,6 +607,18 @@ func ParseTasksWith(path, title string, folder NoteFolder, body string, opts Par
603607
waiting = true
604608
stripped = inlineWaitingRe.ReplaceAllString(stripped, " ")
605609
}
610+
for _, fm := range inlineFieldRe.FindAllStringSubmatch(stripped, -1) {
611+
if len(fm) < 3 {
612+
continue
613+
}
614+
key := strings.ToLower(fm[1])
615+
if _, exists := fields[key]; !exists {
616+
fields[key] = strings.ToLower(fm[2])
617+
}
618+
}
619+
if len(fields) > 0 {
620+
stripped = inlineFieldRe.ReplaceAllString(stripped, " ")
621+
}
606622
for _, tm := range inlineTagRe.FindAllStringSubmatch(tail, -1) {
607623
if len(tm) >= 2 {
608624
tag := strings.ToLower(tm[1])
@@ -630,6 +646,9 @@ func ParseTasksWith(path, title string, folder NoteFolder, body string, opts Par
630646
if priority == "" {
631647
priority = defaults.Priority
632648
}
649+
if _, hasStatus := fields["status"]; !hasStatus && defaults.Status != "" {
650+
fields["status"] = defaults.Status
651+
}
633652

634653
task := Task{
635654
ID: fmtTaskID(path, taskIndex),
@@ -647,6 +666,8 @@ func ParseTasksWith(path, title string, folder NoteFolder, body string, opts Par
647666
Due: due,
648667
Priority: priority,
649668
Waiting: waiting,
669+
Fields: fields,
670+
Status: fields["status"],
650671
Tags: tags,
651672
}
652673
out = append(out, task)

apps/server/internal/vault/parse_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,46 @@ func TestParseTaskFileInProgressStatus(t *testing.T) {
193193
}
194194
}
195195

196+
// #643: a server-backed board must receive the same custom-status fields as
197+
// the desktop parser. Otherwise the optimistic move sticks until the watcher
198+
// rescan replaces it with a task that appears to have no status.
199+
func TestParseTaskFileIncludesCustomStatusField(t *testing.T) {
200+
body := "---\ntags: [task]\ntitle: Rewrite\nstatus: A\n---\n\nDetails.\n"
201+
task, ok := parseTaskFile("inbox/x.md", "x", FolderInbox, body)
202+
if !ok {
203+
t.Fatal("expected a file task")
204+
}
205+
if task.Status != "a" {
206+
t.Errorf("Status=%q, want %q", task.Status, "a")
207+
}
208+
if got := task.Fields["status"]; got != "a" {
209+
t.Errorf("Fields[status]=%q, want %q", got, "a")
210+
}
211+
}
212+
213+
func TestParseTasksIncludesCustomFields(t *testing.T) {
214+
body := "---\nstatus: Backlog\n---\n- [ ] inherits\n- [ ] override @status:Review @sprint:24 @area:Backend\n"
215+
tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body)
216+
if len(tasks) != 2 {
217+
t.Fatalf("expected 2 tasks, got %d", len(tasks))
218+
}
219+
if tasks[0].Status != "backlog" || tasks[0].Fields["status"] != "backlog" {
220+
t.Errorf("inherited task fields=%#v status=%q, want status=backlog", tasks[0].Fields, tasks[0].Status)
221+
}
222+
want := map[string]string{"status": "review", "sprint": "24", "area": "backend"}
223+
for key, value := range want {
224+
if got := tasks[1].Fields[key]; got != value {
225+
t.Errorf("Fields[%s]=%q, want %q", key, got, value)
226+
}
227+
}
228+
if tasks[1].Status != "review" {
229+
t.Errorf("Status=%q, want review", tasks[1].Status)
230+
}
231+
if tasks[1].Content != "override" {
232+
t.Errorf("Content=%q, want custom-field tokens stripped", tasks[1].Content)
233+
}
234+
}
235+
196236
// #458: the frontmatter `tasks:` key turns a note's checkboxes back into plain
197237
// checkboxes. The server mirrors noteTasksMode in shared-domain; the accepted
198238
// values must stay byte-identical across runtimes.

apps/server/internal/vault/types.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -420,11 +420,16 @@ type Task struct {
420420
// Forwarded is true for a `[>]` record: the task moved to another note
421421
// and a live copy exists there (#316). Without it, a web client read
422422
// carried tasks as open twice, record and copy alike (#611 review).
423-
Forwarded bool `json:"forwarded,omitempty"`
424-
Due string `json:"due,omitempty"`
425-
Priority string `json:"priority,omitempty"`
426-
Waiting bool `json:"waiting"`
427-
Tags []string `json:"tags"`
423+
Forwarded bool `json:"forwarded,omitempty"`
424+
Due string `json:"due,omitempty"`
425+
Priority string `json:"priority,omitempty"`
426+
Waiting bool `json:"waiting"`
427+
// Fields contains inline @key:value metadata (or a file task's
428+
// frontmatter status) so remote Kanban boards group tasks exactly like the
429+
// desktop parser. Status mirrors Fields["status"] for convenience (#643).
430+
Fields map[string]string `json:"fields"`
431+
Status string `json:"status,omitempty"`
432+
Tags []string `json:"tags"`
428433
// Kind is how the task is stored: "file" for a whole-note task
429434
// (TaskNotes-style, tagged `task` with metadata in frontmatter) or
430435
// empty/"inline" for a classic `- [ ]` checkbox line. The renderer

apps/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/server",
33
"private": true,
4-
"version": "2.31.0",
4+
"version": "2.32.0",
55
"scripts": {
66
"dev": "node ../../tooling/scripts/run-go-server-dev.mjs",
77
"prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs",

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/web",
33
"private": true,
4-
"version": "2.31.0",
4+
"version": "2.32.0",
55
"type": "module",
66
"description": "ZenNotes web client for self-hosted and hosted deployments",
77
"homepage": "https://zennotes.org",

package-lock.json

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zennotes-monorepo",
33
"private": true,
4-
"version": "2.31.0",
4+
"version": "2.32.0",
55
"description": "ZenNotes monorepo for desktop, web, and self-hosted server builds",
66
"packageManager": "npm@10.9.2",
77
"engines": {

0 commit comments

Comments
 (0)