diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f7ebfeb4..d112b639 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.28.2", + "version": "2.29.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/cli/commands/tasks.ts b/apps/desktop/src/cli/commands/tasks.ts index ef5c5235..52455e26 100644 --- a/apps/desktop/src/cli/commands/tasks.ts +++ b/apps/desktop/src/cli/commands/tasks.ts @@ -18,8 +18,11 @@ export async function cmdTaskList(vault: VaultBackend, args: ParsedArgs): Promis let tasks = await vault.scanAllTasks(includeExcluded ? { includeExcluded: true } : undefined) if (!showAll) { - if (onlyUnchecked) tasks = tasks.filter((t) => !t.checked) - else tasks = tasks.filter((t) => !t.checked && !t.waiting) + // A `[>]` forwarded record is history of a move; its live copy is in the + // destination note and lists on its own, so showing both doubled every + // carried task (#611 review). `--all` still surfaces the records. + if (onlyUnchecked) tasks = tasks.filter((t) => !t.checked && !t.forwarded) + else tasks = tasks.filter((t) => !t.checked && !t.waiting && !t.forwarded) } if (tag) tasks = tasks.filter((t) => t.tags.includes(tag)) @@ -34,13 +37,15 @@ export async function cmdTaskList(vault: VaultBackend, args: ParsedArgs): Promis for (const t of tasks) { const box = t.checked ? '[x]' - : t.cancelled - ? '[-]' - : t.inProgress - ? '[/]' - : t.waiting - ? '[~]' - : '[ ]' + : t.forwarded + ? '[>]' + : t.cancelled + ? '[-]' + : t.inProgress + ? '[/]' + : t.waiting + ? '[~]' + : '[ ]' const due = t.due ? ` due:${t.due}` : '' const pri = t.priority ? ` !${t.priority}` : '' emitLine(`${box} ${pad(t.id, 40)} ${truncate(t.content, 80)}${due}${pri}`) diff --git a/apps/desktop/src/main/note-docx.ts b/apps/desktop/src/main/note-docx.ts index d45efba2..247ce6ba 100644 --- a/apps/desktop/src/main/note-docx.ts +++ b/apps/desktop/src/main/note-docx.ts @@ -47,6 +47,7 @@ import { convertInchesToTwip } from 'docx' import { withExportTitle } from '@shared/export-title' +import { stripBlockAnchorMarkers } from '@shared/block-anchors' /* -------------------------------------------------------------------------- */ /* The intermediate representation */ @@ -244,14 +245,16 @@ function blockOf(node: RootContent): IRBlock[] | null { } /** Parse a note's markdown (title already stated, see `withExportTitle`) into - * the IR. Exported for tests: every mapping decision is visible here. */ + * the IR. Exported for tests: every mapping decision is visible here. + * `^block-id` markers are addressing, not prose, and a Word document handed + * to non-ZenNotes readers is the last place they should print. (#601) */ export function noteMarkdownToIR(markdown: string): IRBlock[] { const tree = unified() .use(remarkParse) .use(remarkGfm) .use(remarkFrontmatter, ['yaml', 'toml']) .use(remarkMath) - .parse(markdown) as Root + .parse(stripBlockAnchorMarkers(markdown)) as Root return tree.children.flatMap((node) => blockOf(node) ?? []) } diff --git a/apps/desktop/src/main/workflow-apply.ts b/apps/desktop/src/main/workflow-apply.ts index 06ec58a7..14002799 100644 --- a/apps/desktop/src/main/workflow-apply.ts +++ b/apps/desktop/src/main/workflow-apply.ts @@ -355,6 +355,11 @@ function stringField(record: Record, key: string): string | nul * Rebuilt field by field rather than cast, which is not ceremony: the result is * what gets persisted into the ledger, so anything extra that rode in on the * wire is dropped here instead of being kept forever in the run history. + * + * SYNCED COPIES: the same validator exists in @shared/workflows/prepare-run + * (the web client's), and the Go server mirrors the field list in + * requiredWorkflowOpFields (apps/server/internal/vault/workflows.go). A new + * op kind or field lands in all three. */ export function parseWorkflowOp(value: unknown): WorkflowOp | null { if (typeof value !== 'object' || value === null) return null diff --git a/apps/desktop/src/main/workflows.test.ts b/apps/desktop/src/main/workflows.test.ts index f75030e2..44368e20 100644 --- a/apps/desktop/src/main/workflows.test.ts +++ b/apps/desktop/src/main/workflows.test.ts @@ -297,6 +297,22 @@ describe('writeWorkflowFile', () => { expect(await readFile(path.join(workflowsDirOf(root), 'same.md'), 'utf8')).toBe('second\n') }) + it('survives a rename that differs from the old file only by case (#605 review)', async () => { + const root = await makeVault() + // Seed the odd-cased spelling directly: on a case-insensitive filesystem + // it names the same physical file the save lands on, and the cleanup used + // to delete the workflow that was just written. + await mkdir(workflowsDirOf(root), { recursive: true }) + await writeFile(path.join(workflowsDirOf(root), 'My-Flow.md'), 'old\n') + const saved = await writeWorkflowFile(root, { + slug: 'my-flow', + raw: 'new\n', + previousSourcePath: '.zennotes/workflows/My-Flow.md' + }) + expect(saved.raw).toBe('new\n') + expect(await readFile(path.join(workflowsDirOf(root), 'my-flow.md'), 'utf8')).toBe('new\n') + }) + it('tolerates a previousSourcePath that no longer exists', async () => { const root = await makeVault() const saved = await writeWorkflowFile(root, { diff --git a/apps/desktop/src/main/workflows.ts b/apps/desktop/src/main/workflows.ts index a43c6c6f..41cf1d7a 100644 --- a/apps/desktop/src/main/workflows.ts +++ b/apps/desktop/src/main/workflows.ts @@ -141,8 +141,20 @@ export async function writeWorkflowFile( // A rename during an edit. The old file goes only after the new one is // durably in place, so a failed write leaves the original as the surviving - // copy instead of destroying both. - if (prevAbs && prevAbs !== abs) await fs.rm(prevAbs, { force: true }) + // copy instead of destroying both. On a case-insensitive filesystem two + // differently-cased paths can name the SAME file the write just landed on, + // so compare file identity, never path spelling: a spelling compare deleted + // the workflow that was just saved. + if (prevAbs && prevAbs !== abs) { + let sameFile = false + try { + const [prevStat, newStat] = await Promise.all([fs.stat(prevAbs), fs.stat(abs)]) + sameFile = prevStat.dev === newStat.dev && prevStat.ino === newStat.ino + } catch { + // Either side unstattable: the remove below treats a missing file as done. + } + if (!sameFile) await fs.rm(prevAbs, { force: true }) + } return { id: workflowIdForName(name), sourcePath, raw: input.raw } } diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 0e6e5574..b9075f8b 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -669,7 +669,9 @@ const TOOLS: ToolDef[] = [ const all = await scanAllTasks(vault, includeExcluded ? { includeExcluded: true } : undefined) return all.filter((t) => { if (folder && t.noteFolder !== folder) return false - if (status === 'open' && (t.checked || t.waiting)) return false + // A `[>]` record's live copy sits in the destination note; listing the + // record as open doubled every carried task for agents (#611 review). + if (status === 'open' && (t.checked || t.waiting || t.forwarded)) return false if (status === 'done' && !t.checked) return false if (status === 'waiting' && !t.waiting) return false if (priority && t.priority !== priority) return false diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index f4889ec4..b38014d2 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -294,6 +294,11 @@ export interface VaultTask { checked: boolean /** True for a `[-]` cancelled task — intentionally abandoned (#450). */ cancelled?: boolean + /** True for a `[>]` forwarded record: the task moved to another note and a + * live copy exists there, so this line is history, not open work (#316). + * Without this flag the subtree forward (#611) doubled every carried task + * in MCP/CLI listings: the records read as open beside the live copies. */ + forwarded?: boolean /** True for a `[/]` task in progress: started, not finished (#512). Still * open work, unlike checked/cancelled. */ inProgress?: boolean @@ -1339,6 +1344,7 @@ function parseTasksFromBody( const checked = checkedChar === 'x' || checkedChar === 'X' const cancelled = checkedChar === '-' const inProgress = checkedChar === '/' + const forwarded = checkedChar === '>' let due: string | undefined let priority: 'high' | 'med' | 'low' | undefined @@ -1381,6 +1387,7 @@ function parseTasksFromBody( checked, cancelled, inProgress, + forwarded, due: due ?? defaults.due, priority: priority ?? defaults.priority, waiting, diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index d10b77dc..152add90 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -103,7 +103,8 @@ const DESKTOP_CAPABILITIES: ZenCapabilities = { // and is gated to a follow-up. supportsCliInstall: process.platform === 'darwin' || process.platform === 'linux', supportsCustomTemplates: true, - supportsCustomCodeLanguages: true + supportsCustomCodeLanguages: true, + supportsWorkflows: true } const DESKTOP_APP_INFO: ZenAppInfo = { diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index d894c737..0fc1d558 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -230,6 +230,14 @@ func (s *Server) registerProtectedRoutes(r chi.Router) { r.Post("/demo/generate", s.demoGenerate) r.Post("/demo/remove", s.demoRemove) + r.Get("/workflows", s.listWorkflows) + r.Post("/workflows/write", s.writeWorkflow) + r.Post("/workflows/delete", s.deleteWorkflow) + r.Post("/workflows/apply", s.applyWorkflow) + r.Post("/workflows/undo", s.undoWorkflowRun) + r.Get("/workflows/runs", s.listWorkflowRuns) + r.Post("/workflows/runs/delete", s.deleteWorkflowRuns) + r.Get("/watch", s.watchWS) } @@ -289,6 +297,14 @@ func writeError(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if errors.Is(err, vault.ErrInvalidWorkflow) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if errors.Is(err, vault.ErrWorkflowConflict) { + http.Error(w, err.Error(), http.StatusConflict) + return + } // A missing file is the caller's answer, not our failure. Clients rely on // this to tell "absent" apart from "broken": desktop remote databases map // 404 to null and surface everything else. @@ -395,6 +411,10 @@ func (s *Server) capabilities(w http.ResponseWriter, _ *http.Request) { // on this to give older servers a "server needs an update" message // instead of a bare 404. "supportsAssetOps": true, + // Workflow files and run journals live in the mounted vault, and the + // prepared-run endpoint applies them under the same vault lock as note + // writes. Its presence lets bundled web clients enable authoring and Run. + "supportsWorkflows": true, // Says out loud that a missing file answers 404 rather than 500. // Databases are composed from file reads where "absent" and "failed" // mean opposite things (see remote-absence.ts), and a server that diff --git a/apps/server/internal/httpserver/workflows.go b/apps/server/internal/httpserver/workflows.go new file mode 100644 index 00000000..06affa91 --- /dev/null +++ b/apps/server/internal/httpserver/workflows.go @@ -0,0 +1,128 @@ +package httpserver + +import ( + "net/http" + "strings" + + "github.com/ZenNotes/zennotes/apps/server/internal/vault" +) + +const ( + maxWorkflowRequestBytes = 128 << 20 + maxWorkflowMetadataRequestBytes = 64 << 10 +) + +func (s *Server) listWorkflows(w http.ResponseWriter, _ *http.Request) { + files, err := s.currentVault().ListWorkflows() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, files) +} + +func (s *Server) writeWorkflow(w http.ResponseWriter, r *http.Request) { + cfg := s.currentConfig() + r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes) + var input vault.WriteWorkflowInput + if err := readJSON(r, &input); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + file, err := s.currentVault().WriteWorkflow(input) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, file) +} + +func (s *Server) deleteWorkflow(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowMetadataRequestBytes) + var request struct { + SourcePath string `json:"sourcePath"` + } + if err := readJSON(r, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := s.currentVault().DeleteWorkflow(request.SourcePath); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (s *Server) applyWorkflow(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowRequestBytes) + var input vault.PreparedWorkflowRun + if err := readJSON(r, &input); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + cfg := s.currentConfig() + for _, change := range input.Changes { + // Only what the run WRITES counts against the limit. Before is the + // note's bytes already on disk: counting those made an oversized note + // impossible to shrink, move, or trash from the web client, 413 on + // every apply, while desktop applied the identical run. + if cfg.MaxNoteBytes > 0 && change.After != nil && int64(len(*change.After)) > cfg.MaxNoteBytes { + http.Error(w, "workflow note exceeds the configured note size limit", http.StatusRequestEntityTooLarge) + return + } + } + receipt, err := s.currentVault().ApplyPreparedWorkflow(input) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, receipt) +} + +func (s *Server) undoWorkflowRun(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowMetadataRequestBytes) + var request struct { + RunID string `json:"runId"` + } + if err := readJSON(r, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := s.currentVault().UndoWorkflowRun(request.RunID) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) listWorkflowRuns(w http.ResponseWriter, _ *http.Request) { + runs, err := s.currentVault().ListWorkflowRuns() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, runs) +} + +func (s *Server) deleteWorkflowRuns(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowMetadataRequestBytes) + var request struct { + WorkflowID string `json:"workflowId"` + } + if err := readJSON(r, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + workflowID := strings.TrimSpace(request.WorkflowID) + if workflowID == "" { + http.Error(w, "workflowId is required", http.StatusBadRequest) + return + } + removed, err := s.currentVault().DeleteWorkflowRuns(workflowID) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, removed) +} diff --git a/apps/server/internal/httpserver/workflows_test.go b/apps/server/internal/httpserver/workflows_test.go new file mode 100644 index 00000000..f28ab47a --- /dev/null +++ b/apps/server/internal/httpserver/workflows_test.go @@ -0,0 +1,217 @@ +package httpserver + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/ZenNotes/zennotes/apps/server/internal/config" +) + +// The Docker image serves the web client and owns the mounted vault. Workflow +// authoring, execution and Undo therefore have to cross the HTTP boundary and +// persist inside that mounted vault rather than being treated as desktop-only. +func TestWorkflowEndpointsAuthorApplyAndUndo(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { + t.Fatal(err) + } + server, _ := newTestServer(t, config.Config{ + VaultPath: root, + DefaultVaultPath: root, + Bind: "127.0.0.1:7878", + AuthToken: "secret-token", + BrowseRoots: []string{root}, + }) + jar := loginAndJar(t, server, "secret-token") + client := &http.Client{Jar: jar} + + post := func(path string, payload any) *http.Response { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + resp, err := client.Post(server.URL+path, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST %s: %v", path, err) + } + return resp + } + requireOK := func(resp *http.Response) { + t.Helper() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("%s %s: got %d: %s", resp.Request.Method, resp.Request.URL.Path, resp.StatusCode, body) + } + } + + workflowRaw := "---\nname: Docker workflow\nstatus: active\n---\n\nall | append done\n" + writeResp := post("/api/workflows/write", map[string]any{ + "slug": "Docker workflow", + "raw": workflowRaw, + }) + requireOK(writeResp) + var written struct { + ID string `json:"id"` + SourcePath string `json:"sourcePath"` + Raw string `json:"raw"` + } + if err := json.NewDecoder(writeResp.Body).Decode(&written); err != nil { + t.Fatal(err) + } + writeResp.Body.Close() + if written.ID != "docker-workflow" || written.SourcePath != ".zennotes/workflows/docker-workflow.md" || written.Raw != workflowRaw { + t.Fatalf("written workflow = %+v", written) + } + + listResp, err := client.Get(server.URL + "/api/workflows") + if err != nil { + t.Fatal(err) + } + requireOK(listResp) + var listed []map[string]any + if err := json.NewDecoder(listResp.Body).Decode(&listed); err != nil { + t.Fatal(err) + } + listResp.Body.Close() + if len(listed) != 1 || listed[0]["id"] != "docker-workflow" { + t.Fatalf("listed workflows = %#v", listed) + } + + applyResp := post("/api/workflows/apply", map[string]any{ + "workflowId": "docker-workflow", + "ops": []any{map[string]any{"kind": "append", "path": "inbox/A.md", "text": "done"}}, + "applied": 1, + "irreversible": 0, + "changes": []any{map[string]any{ + "path": "inbox/A.md", + "before": "# A\n", + "after": "# A\n\ndone", + }}, + }) + requireOK(applyResp) + var receipt struct { + RunID string `json:"runId"` + WorkflowID string `json:"workflowId"` + Applied int `json:"applied"` + Paths []string `json:"paths"` + } + if err := json.NewDecoder(applyResp.Body).Decode(&receipt); err != nil { + t.Fatal(err) + } + applyResp.Body.Close() + if receipt.RunID == "" || receipt.WorkflowID != "docker-workflow" || receipt.Applied != 1 || len(receipt.Paths) != 1 { + t.Fatalf("receipt = %+v", receipt) + } + if body, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(body) != "# A\n\ndone" { + t.Fatalf("applied note = %q, %v", body, err) + } + + runsResp, err := client.Get(server.URL + "/api/workflows/runs") + if err != nil { + t.Fatal(err) + } + requireOK(runsResp) + var runs []struct { + RunID string `json:"runId"` + Undoable bool `json:"undoable"` + } + if err := json.NewDecoder(runsResp.Body).Decode(&runs); err != nil { + t.Fatal(err) + } + runsResp.Body.Close() + if len(runs) != 1 || runs[0].RunID != receipt.RunID || !runs[0].Undoable { + t.Fatalf("runs = %+v", runs) + } + + undoResp := post("/api/workflows/undo", map[string]string{"runId": receipt.RunID}) + requireOK(undoResp) + undoResp.Body.Close() + if body, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(body) != "# A\n" { + t.Fatalf("undone note = %q, %v", body, err) + } + + deleteResp := post("/api/workflows/delete", map[string]string{"sourcePath": written.SourcePath}) + requireOK(deleteResp) + deleteResp.Body.Close() + if _, err := os.Stat(filepath.Join(root, ".zennotes", "workflows", "docker-workflow.md")); !os.IsNotExist(err) { + t.Fatalf("workflow still exists after delete: %v", err) + } +} + +func TestCapabilitiesAdvertiseWorkflowSupport(t *testing.T) { + root := t.TempDir() + server, _ := newTestServer(t, config.Config{ + VaultPath: root, + DefaultVaultPath: root, + Bind: "127.0.0.1:7878", + BrowseRoots: []string{root}, + }) + resp, err := http.Get(server.URL + "/api/capabilities") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var caps map[string]any + if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil { + t.Fatal(err) + } + if caps["supportsWorkflows"] != true { + t.Fatalf("supportsWorkflows = %v, want true", caps["supportsWorkflows"]) + } +} + +func TestApplyWorkflowRespectsPerNoteSizeLimit(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { + t.Fatal(err) + } + server, _ := newTestServer(t, config.Config{ + VaultPath: root, + DefaultVaultPath: root, + Bind: "127.0.0.1:7878", + AuthToken: "secret-token", + BrowseRoots: []string{root}, + MaxNoteBytes: 8, + }) + jar := loginAndJar(t, server, "secret-token") + client := &http.Client{Jar: jar} + body, err := json.Marshal(map[string]any{ + "workflowId": "oversized", + "ops": []any{map[string]any{"kind": "write-note", "path": "inbox/A.md", "text": "this is too large"}}, + "applied": 1, + "irreversible": 0, + "changes": []any{map[string]any{ + "path": "inbox/A.md", + "before": "# A\n", + "after": "this is too large", + }}, + }) + if err != nil { + t.Fatal(err) + } + resp, err := client.Post(server.URL+"/api/workflows/apply", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusRequestEntityTooLarge { + responseBody, _ := io.ReadAll(resp.Body) + t.Fatalf("oversized workflow note: got %d: %s", resp.StatusCode, responseBody) + } + if got, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(got) != "# A\n" { + t.Fatalf("oversized workflow changed note to %q (%v)", got, err) + } +} diff --git a/apps/server/internal/vault/parse.go b/apps/server/internal/vault/parse.go index ae0985f9..5cd6723e 100644 --- a/apps/server/internal/vault/parse.go +++ b/apps/server/internal/vault/parse.go @@ -581,6 +581,7 @@ func ParseTasksWith(path, title string, folder NoteFolder, body string, opts Par checked := checkedChar == "x" || checkedChar == "X" cancelled := checkedChar == "-" inProgress := checkedChar == "/" + forwarded := checkedChar == ">" due := "" priority := "" @@ -642,6 +643,7 @@ func ParseTasksWith(path, title string, folder NoteFolder, body string, opts Par Checked: checked, Cancelled: cancelled, InProgress: inProgress, + Forwarded: forwarded, Due: due, Priority: priority, Waiting: waiting, diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go index 468cd6b2..77c601fd 100644 --- a/apps/server/internal/vault/types.go +++ b/apps/server/internal/vault/types.go @@ -416,7 +416,11 @@ type Task struct { // InProgress is true for a `[/]` task: started, not finished (#512). // Unlike Checked/Cancelled it is still open work, so it keeps its place // in the active buckets on every surface. - InProgress bool `json:"inProgress,omitempty"` + InProgress bool `json:"inProgress,omitempty"` + // Forwarded is true for a `[>]` record: the task moved to another note + // and a live copy exists there (#316). Without it, a web client read + // carried tasks as open twice, record and copy alike (#611 review). + Forwarded bool `json:"forwarded,omitempty"` Due string `json:"due,omitempty"` Priority string `json:"priority,omitempty"` Waiting bool `json:"waiting"` diff --git a/apps/server/internal/vault/workflows.go b/apps/server/internal/vault/workflows.go new file mode 100644 index 00000000..21b53a41 --- /dev/null +++ b/apps/server/internal/vault/workflows.go @@ -0,0 +1,809 @@ +package vault + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + "unicode/utf8" +) + +const ( + workflowsRelDir = ".zennotes/workflows" + workflowRunsRelDir = ".zennotes/workflows/.runs" + workflowLedgerVersion = 1 + maxWorkflowSlugLength = 64 + maxWorkflowIDLength = 256 + maxWorkflowOps = 5000 + maxWorkflowChanges = 10000 + maxRetainedWorkflowRuns = 100 + maxRetainedWorkflowRunByte = 50 * 1024 * 1024 +) + +var ( + ErrInvalidWorkflow = errors.New("invalid workflow request") + ErrWorkflowConflict = errors.New("workflow plan is stale") + workflowRunIDPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,160}$`) +) + +type WorkflowFile struct { + ID string `json:"id"` + SourcePath string `json:"sourcePath"` + Raw string `json:"raw"` +} + +type WriteWorkflowInput struct { + Slug string `json:"slug"` + Raw string `json:"raw"` + PreviousSourcePath string `json:"previousSourcePath,omitempty"` +} + +type WorkflowRunFileChange struct { + Path string `json:"path"` + Before *string `json:"before"` + After *string `json:"after"` +} + +type PreparedWorkflowRun struct { + WorkflowID string `json:"workflowId"` + Ops []json.RawMessage `json:"ops"` + Applied int `json:"applied"` + Irreversible int `json:"irreversible"` + Changes []WorkflowRunFileChange `json:"changes"` +} + +type WorkflowRunReceipt struct { + RunID string `json:"runId"` + WorkflowID string `json:"workflowId"` + StartedAt int64 `json:"startedAt"` + Applied int `json:"applied"` + Paths []string `json:"paths"` + Irreversible int `json:"irreversible"` + RolledBack *WorkflowRollback `json:"rolledBack,omitempty"` +} + +type WorkflowRollback struct { + Reason string `json:"reason"` +} + +type WorkflowUndoResult struct { + RunID string `json:"runId"` + Restored int `json:"restored"` + DriftedPaths []string `json:"driftedPaths,omitempty"` +} + +type WorkflowRunSummary struct { + RunID string `json:"runId"` + WorkflowID string `json:"workflowId"` + StartedAt int64 `json:"startedAt"` + Applied int `json:"applied"` + Paths []string `json:"paths"` + Undoable bool `json:"undoable"` + Interrupted bool `json:"interrupted,omitempty"` +} + +type workflowJournalEntry struct { + Path string `json:"path"` + Before *string `json:"before"` +} + +type workflowRunLedger struct { + Version int `json:"version"` + RunID string `json:"runId"` + WorkflowID string `json:"workflowId"` + StartedAt int64 `json:"startedAt"` + FinishedAt int64 `json:"finishedAt"` + Applied int `json:"applied"` + Irreversible int `json:"irreversible"` + Paths []string `json:"paths"` + Ops []json.RawMessage `json:"ops"` + Journal []workflowJournalEntry `json:"journal"` + Hashes map[string]*string `json:"hashes"` + Undone bool `json:"undone"` + UndoneAt int64 `json:"undoneAt,omitempty"` + RolledBack *WorkflowRollback `json:"rolledBack,omitempty"` + Interrupted *WorkflowRollback `json:"interrupted,omitempty"` +} + +func workflowDir(root string) string { + return filepath.Join(root, ".zennotes", "workflows") +} + +func safeWorkflowSlug(value string) string { + var out strings.Builder + dash := false + for _, r := range strings.ToLower(strings.TrimSpace(value)) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + if dash && out.Len() > 0 && out.Len() < maxWorkflowSlugLength { + out.WriteByte('-') + } + dash = false + if out.Len() < maxWorkflowSlugLength { + out.WriteRune(r) + } + continue + } + dash = true + } + result := strings.Trim(out.String(), "-") + if result == "" { + return "workflow" + } + return result +} + +func (v *Vault) resolveWorkflowFilePath(sourcePath string) (string, error) { + abs, err := SafeJoin(v.root, sourcePath) + if err != nil { + return "", err + } + dir := workflowDir(v.root) + rel, err := filepath.Rel(dir, abs) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { + return "", fmt.Errorf("%w: refusing workflow path outside workflows dir", ErrInvalidWorkflow) + } + if !strings.EqualFold(filepath.Ext(rel), ".md") { + return "", fmt.Errorf("%w: workflow path must be a .md file", ErrInvalidWorkflow) + } + return abs, nil +} + +func workflowIDForName(name string) string { + return strings.TrimSuffix(name, filepath.Ext(name)) +} + +func (v *Vault) ListWorkflows() ([]WorkflowFile, error) { + v.mu.RLock() + defer v.mu.RUnlock() + entries, err := os.ReadDir(workflowDir(v.root)) + if errors.Is(err, os.ErrNotExist) { + return []WorkflowFile{}, nil + } + if err != nil { + return nil, err + } + out := make([]WorkflowFile, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { + continue + } + sourcePath := workflowsRelDir + "/" + name + abs, err := v.resolveWorkflowFilePath(sourcePath) + if err != nil { + continue + } + raw, err := os.ReadFile(abs) + if err != nil { + continue + } + out = append(out, WorkflowFile{ID: workflowIDForName(name), SourcePath: sourcePath, Raw: string(raw)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (v *Vault) WriteWorkflow(input WriteWorkflowInput) (WorkflowFile, error) { + v.mu.Lock() + defer v.mu.Unlock() + name := safeWorkflowSlug(input.Slug) + ".md" + sourcePath := workflowsRelDir + "/" + name + abs, err := v.resolveWorkflowFilePath(sourcePath) + if err != nil { + return WorkflowFile{}, err + } + var previous string + if input.PreviousSourcePath != "" { + previous, err = v.resolveWorkflowFilePath(input.PreviousSourcePath) + if err != nil { + return WorkflowFile{}, err + } + } + if err := writeFileAtomic(abs, []byte(input.Raw), v.fileMode, v.dirMode); err != nil { + return WorkflowFile{}, err + } + if previous != "" && previous != abs { + // On a case-insensitive filesystem two differently-cased paths can name + // the SAME file, and writeFileAtomic just landed the new content on it; + // a spelling compare then let os.Remove delete the workflow that was + // just saved. Compare file identity, not path strings. + sameFile := false + if prevInfo, statErr := os.Stat(previous); statErr == nil { + if newInfo, statErr := os.Stat(abs); statErr == nil && os.SameFile(prevInfo, newInfo) { + sameFile = true + } + } + if !sameFile { + if err := os.Remove(previous); err != nil && !errors.Is(err, os.ErrNotExist) { + return WorkflowFile{}, err + } + } + } + return WorkflowFile{ID: workflowIDForName(name), SourcePath: sourcePath, Raw: input.Raw}, nil +} + +func (v *Vault) DeleteWorkflow(sourcePath string) error { + v.mu.Lock() + defer v.mu.Unlock() + abs, err := v.resolveWorkflowFilePath(sourcePath) + if err != nil { + return err + } + if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func workflowPathSegments(path string) []string { + return strings.Split(strings.ReplaceAll(path, "\\", "/"), "/") +} + +func (v *Vault) resolveWorkflowNotePath(rel string) (string, error) { + if rel == "" || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, "\\") || filepath.IsAbs(rel) || (len(rel) >= 2 && ((rel[0] >= 'A' && rel[0] <= 'Z') || (rel[0] >= 'a' && rel[0] <= 'z')) && rel[1] == ':') { + return "", fmt.Errorf("%w: workflow note path is absolute or empty: %s", ErrInvalidWorkflow, rel) + } + segments := workflowPathSegments(rel) + for _, segment := range segments { + if segment == ".." { + return "", fmt.Errorf("%w: workflow note path escapes the vault: %s", ErrInvalidWorkflow, rel) + } + } + if len(segments) > 0 && strings.EqualFold(segments[0], internalVaultDir) { + return "", fmt.Errorf("%w: workflow note path is inside %s: %s", ErrInvalidWorkflow, internalVaultDir, rel) + } + ext := strings.ToLower(filepath.Ext(rel)) + if ext != ".md" && ext != excalidrawExt { + return "", fmt.Errorf("%w: workflow path is not a note: %s", ErrInvalidWorkflow, rel) + } + return SafeJoin(v.root, rel) +} + +func nullableString(value string) *string { + copy := value + return © +} + +func readOptionalText(abs string) (*string, error) { + body, err := os.ReadFile(abs) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + return nullableString(string(body)), nil +} + +func optionalStringsEqual(left, right *string) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + +// coerceUTF8ForWire mirrors what encoding/json does to a string on its way to +// the client: every invalid UTF-8 byte becomes one U+FFFD replacement. The +// client can never echo back bytes JSON already destroyed, so before-bytes +// comparisons must compare against this view of the disk, byte-for-byte +// identical to what /notes/read served. +func coerceUTF8ForWire(s string) string { + if utf8.ValidString(s) { + return s + } + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + b.WriteRune(utf8.RuneError) + i++ + continue + } + b.WriteString(s[i : i+size]) + i += size + } + return b.String() +} + +func optionalWireEqual(disk, client *string) bool { + if optionalStringsEqual(disk, client) { + return true + } + if disk == nil || client == nil { + return false + } + return coerceUTF8ForWire(*disk) == *client +} + +func workflowJournalKey(path string) string { + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + return strings.ToLower(path) + } + return path +} + +func workflowHash(value *string) *string { + if value == nil { + return nil + } + hash := sha256.Sum256([]byte(*value)) + encoded := hex.EncodeToString(hash[:]) + return &encoded +} + +func newWorkflowRunID(startedAt int64) string { + var suffix [6]byte + if _, err := rand.Read(suffix[:]); err != nil { + return fmt.Sprintf("%013d-%d", startedAt, time.Now().UnixNano()) + } + return fmt.Sprintf("%013d-%s", startedAt, hex.EncodeToString(suffix[:])) +} + +func (v *Vault) resolveWorkflowLedgerPath(runID string) (string, error) { + if !workflowRunIDPattern.MatchString(runID) { + return "", fmt.Errorf("%w: invalid workflow run id", ErrInvalidWorkflow) + } + return SafeJoin(v.root, workflowRunsRelDir+"/"+runID+".json") +} + +func (v *Vault) resolveWorkflowRunsDir() (string, error) { + return SafeJoin(v.root, workflowRunsRelDir) +} + +func (v *Vault) writeWorkflowLedgerLocked(ledger workflowRunLedger) error { + abs, err := v.resolveWorkflowLedgerPath(ledger.RunID) + if err != nil { + return err + } + body, err := json.MarshalIndent(ledger, "", " ") + if err != nil { + return err + } + body = append(body, '\n') + return writeFileAtomic(abs, body, v.fileMode, v.dirMode) +} + +func (v *Vault) readWorkflowLedgerLocked(runID string) (workflowRunLedger, error) { + abs, err := v.resolveWorkflowLedgerPath(runID) + if err != nil { + return workflowRunLedger{}, err + } + body, err := os.ReadFile(abs) + if err != nil { + return workflowRunLedger{}, err + } + var ledger workflowRunLedger + if err := json.Unmarshal(body, &ledger); err != nil { + return workflowRunLedger{}, err + } + if ledger.Version != workflowLedgerVersion || ledger.RunID != runID { + return workflowRunLedger{}, fmt.Errorf("%w: unsupported workflow run ledger", ErrInvalidWorkflow) + } + return ledger, nil +} + +func (v *Vault) restoreWorkflowJournalLocked(journal []workflowJournalEntry) (int, []error) { + return v.restoreWorkflowJournalSnapshotLocked(journal, nil, nil) +} + +// restoreWorkflowJournalSnapshotLocked restores the journal, consulting an +// optional pre-read snapshot (liveByPath/absByPath) so a caller that already +// read every file, like undo's drift check, does not read the whole run a +// second time while holding the exclusive vault lock. Entries missing from +// the snapshot fall back to resolving and reading here. +func (v *Vault) restoreWorkflowJournalSnapshotLocked( + journal []workflowJournalEntry, + liveByPath map[string]*string, + absByPath map[string]string, +) (int, []error) { + restored := 0 + failures := []error{} + for _, entry := range journal { + abs, haveAbs := absByPath[entry.Path] + if !haveAbs { + resolved, err := v.resolveWorkflowNotePath(entry.Path) + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", entry.Path, err)) + continue + } + abs = resolved + } + live, haveLive := liveByPath[entry.Path] + if !haveLive { + read, err := readOptionalText(abs) + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", entry.Path, err)) + continue + } + live = read + } + if optionalStringsEqual(live, entry.Before) { + continue + } + var err error + if entry.Before == nil { + err = os.Remove(abs) + if errors.Is(err, os.ErrNotExist) { + err = nil + } + } else { + err = writeFileAtomic(abs, []byte(*entry.Before), v.fileMode, v.dirMode) + } + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", entry.Path, err)) + continue + } + restored++ + } + return restored, failures +} + +func workflowFailureMessage(failures []error) string { + parts := make([]string, len(failures)) + for index, err := range failures { + parts[index] = err.Error() + } + return strings.Join(parts, "; ") +} + +// requiredWorkflowOpFields is the Go mirror of the workflow op schema. Three +// synced copies exist and MUST change together (the stripCodeContent rule): +// the op types in packages/shared-domain/src/workflows/types.ts, the +// parseWorkflowOp validator in packages/shared-domain/src/workflows/ +// prepare-run.ts (duplicated into apps/desktop/src/main/workflow-apply.ts), +// and this map. Miss this one and every web run carrying the new op kind +// 400s as "not valid" while desktop applies it fine. +var requiredWorkflowOpFields = map[string][]string{ + "set-frontmatter": {"path", "field", "value"}, + "add-tag": {"path", "tag"}, + "remove-tag": {"path", "tag"}, + "move": {"path", "to"}, + "rename": {"path", "to"}, + "append": {"path", "text"}, + "prepend": {"path", "text"}, + "write-section": {"path", "heading", "text"}, + "write-note": {"path", "text"}, + "create-note": {"path", "body"}, + "apply-template": {"path", "template"}, + "archive": {"path"}, + "trash": {"path"}, + "notify": {"message"}, + "clipboard": {"text"}, +} + +func validatePreparedWorkflowOps(ops []json.RawMessage) (int, error) { + irreversible := 0 + for index, raw := range ops { + var op map[string]json.RawMessage + if err := json.Unmarshal(raw, &op); err != nil { + return 0, fmt.Errorf("%w: workflow op %d is not valid", ErrInvalidWorkflow, index) + } + var kind string + if err := json.Unmarshal(op["kind"], &kind); err != nil { + return 0, fmt.Errorf("%w: workflow op %d is not valid", ErrInvalidWorkflow, index) + } + required, valid := requiredWorkflowOpFields[kind] + if !valid { + return 0, fmt.Errorf("%w: workflow op %d is not valid", ErrInvalidWorkflow, index) + } + for _, field := range required { + var value string + if err := json.Unmarshal(op[field], &value); err != nil { + return 0, fmt.Errorf("%w: workflow op %d is missing string field %s", ErrInvalidWorkflow, index, field) + } + } + if kind == "notify" || kind == "clipboard" { + irreversible++ + } + } + return irreversible, nil +} + +func (v *Vault) ApplyPreparedWorkflow(input PreparedWorkflowRun) (WorkflowRunReceipt, error) { + v.mu.Lock() + defer v.mu.Unlock() + startedAt := time.Now().UnixMilli() + workflowID := strings.TrimSpace(input.WorkflowID) + if workflowID == "" { + workflowID = "unknown" + } + if len(workflowID) > maxWorkflowIDLength { + return WorkflowRunReceipt{}, fmt.Errorf("%w: workflow id is too long", ErrInvalidWorkflow) + } + // Name the cap when a run is over it: the dry run just promised success, + // so a bare "invalid counts" read as a client bug instead of a server + // limit the user can see and reason about. + if len(input.Ops) > maxWorkflowOps { + return WorkflowRunReceipt{}, fmt.Errorf("%w: this run has %d operations, over the server limit of %d; split the workflow or run it from the desktop app", ErrInvalidWorkflow, len(input.Ops), maxWorkflowOps) + } + if len(input.Changes) > maxWorkflowChanges { + return WorkflowRunReceipt{}, fmt.Errorf("%w: this run touches %d files, over the server limit of %d; split the workflow or run it from the desktop app", ErrInvalidWorkflow, len(input.Changes), maxWorkflowChanges) + } + if input.Applied < 0 || input.Irreversible < 0 || input.Applied > len(input.Ops) || input.Irreversible > len(input.Ops) { + return WorkflowRunReceipt{}, fmt.Errorf("%w: invalid workflow run counts", ErrInvalidWorkflow) + } + irreversible, err := validatePreparedWorkflowOps(input.Ops) + if err != nil { + return WorkflowRunReceipt{}, err + } + if input.Irreversible != irreversible || input.Applied != len(input.Ops)-irreversible || (len(input.Changes) > 0 && input.Applied == 0) { + return WorkflowRunReceipt{}, fmt.Errorf("%w: workflow operation counts do not match the prepared changes", ErrInvalidWorkflow) + } + + paths := make([]string, 0, len(input.Changes)) + journal := make([]workflowJournalEntry, 0, len(input.Changes)) + hashes := make(map[string]*string, len(input.Changes)) + resolved := make([]string, 0, len(input.Changes)) + seen := map[string]struct{}{} + for _, change := range input.Changes { + path := filepath.ToSlash(filepath.Clean(filepath.FromSlash(change.Path))) + abs, err := v.resolveWorkflowNotePath(path) + if err != nil { + return WorkflowRunReceipt{}, err + } + key := workflowJournalKey(path) + if _, exists := seen[key]; exists { + return WorkflowRunReceipt{}, fmt.Errorf("%w: duplicate workflow path %s", ErrInvalidWorkflow, path) + } + seen[key] = struct{}{} + live, err := readOptionalText(abs) + if err != nil { + return WorkflowRunReceipt{}, err + } + // Compare against the client's WIRE view of the file: JSON coerced any + // invalid UTF-8 to U+FFFD on the way out, so a note carrying one stray + // non-UTF-8 byte would otherwise 409 on every apply, forever, and + // re-planning reads the same lossy view so the loop never resolved. + if !optionalWireEqual(live, change.Before) { + return WorkflowRunReceipt{}, fmt.Errorf("%w: %s changed after the dry run", ErrWorkflowConflict, path) + } + paths = append(paths, path) + journal = append(journal, workflowJournalEntry{Path: path, Before: change.Before}) + hashes[path] = workflowHash(change.After) + resolved = append(resolved, abs) + } + + runID := newWorkflowRunID(startedAt) + ledger := workflowRunLedger{ + Version: workflowLedgerVersion, + RunID: runID, + WorkflowID: workflowID, + StartedAt: startedAt, + FinishedAt: startedAt, + Applied: 0, + Irreversible: input.Irreversible, + Paths: paths, + Ops: input.Ops, + Journal: journal, + Hashes: map[string]*string{}, + Undone: false, + Interrupted: &WorkflowRollback{Reason: "ZenNotes stopped while this run was still applying, so part of it may have landed. Undo restores every file it had recorded."}, + } + if len(input.Ops) > 0 { + if err := v.writeWorkflowLedgerLocked(ledger); err != nil { + return WorkflowRunReceipt{}, err + } + } + if len(input.Changes) > 0 { + defer v.invalidateTextSearchCache() + } + + for index, change := range input.Changes { + var err error + if change.After == nil { + err = os.Remove(resolved[index]) + if errors.Is(err, os.ErrNotExist) { + err = nil + } + } else { + err = writeFileAtomic(resolved[index], []byte(*change.After), v.fileMode, v.dirMode) + } + if err == nil { + continue + } + _, failures := v.restoreWorkflowJournalLocked(journal) + reason := fmt.Sprintf("%v. The run was rolled back; your vault is unchanged.", err) + if len(failures) == 0 { + if abs, pathErr := v.resolveWorkflowLedgerPath(runID); pathErr == nil { + _ = os.Remove(abs) + } + return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Paths: []string{}, Irreversible: input.Irreversible, RolledBack: &WorkflowRollback{Reason: reason}}, nil + } + reason = fmt.Sprintf("%v. ROLLBACK INCOMPLETE: %s", err, workflowFailureMessage(failures)) + ledger.FinishedAt = time.Now().UnixMilli() + ledger.RolledBack = &WorkflowRollback{Reason: reason} + ledger.Interrupted = nil + _ = v.writeWorkflowLedgerLocked(ledger) + return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Paths: paths, Irreversible: input.Irreversible, RolledBack: &WorkflowRollback{Reason: reason}}, nil + } + + if len(input.Ops) > 0 { + ledger.FinishedAt = time.Now().UnixMilli() + ledger.Applied = input.Applied + ledger.Hashes = hashes + ledger.Interrupted = nil + if err := v.writeWorkflowLedgerLocked(ledger); err != nil { + _, failures := v.restoreWorkflowJournalLocked(journal) + if len(failures) == 0 { + if abs, pathErr := v.resolveWorkflowLedgerPath(runID); pathErr == nil { + _ = os.Remove(abs) + } + return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Paths: []string{}, Irreversible: input.Irreversible, RolledBack: &WorkflowRollback{Reason: fmt.Sprintf("The run could not be recorded, so it was rolled back (%v).", err)}}, nil + } + return WorkflowRunReceipt{}, fmt.Errorf("record workflow run: %w; rollback: %s", err, workflowFailureMessage(failures)) + } + v.pruneWorkflowRunsLocked() + } + return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Applied: input.Applied, Paths: paths, Irreversible: input.Irreversible}, nil +} + +func (v *Vault) pruneWorkflowRunsLocked() { + runsDir, err := v.resolveWorkflowRunsDir() + if err != nil { + return + } + entries, err := os.ReadDir(runsDir) + if err != nil { + return + } + type retainedFile struct { + name string + size int64 + } + files := []retainedFile{} + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { + continue + } + info, err := entry.Info() + if err == nil { + files = append(files, retainedFile{name: entry.Name(), size: info.Size()}) + } + } + sort.Slice(files, func(i, j int) bool { return files[i].name > files[j].name }) + var total int64 + for index, file := range files { + total += file.size + // The newest ledger is the run the user is being shown right now. Keep + // it even when one whole-vault run exceeds the history byte budget, or + // pruning would remove Undo from the run that just completed. + if index == 0 || (index < maxRetainedWorkflowRuns && total <= maxRetainedWorkflowRunByte) { + continue + } + _ = os.Remove(filepath.Join(runsDir, file.name)) + } +} + +func (v *Vault) UndoWorkflowRun(runID string) (WorkflowUndoResult, error) { + v.mu.Lock() + defer v.mu.Unlock() + ledger, err := v.readWorkflowLedgerLocked(runID) + if errors.Is(err, os.ErrNotExist) { + return WorkflowUndoResult{}, fmt.Errorf("%w: unknown workflow run %s", ErrInvalidWorkflow, runID) + } + if err != nil { + return WorkflowUndoResult{}, err + } + if ledger.Undone { + return WorkflowUndoResult{}, fmt.Errorf("%w: workflow run was already undone", ErrInvalidWorkflow) + } + // One read per journaled file: the drift check and the restore both need + // the live bytes, and reading a whole-vault run twice under the exclusive + // lock doubled how long every other request stayed blocked. The lock + // guarantees nothing changes between this pass and the restore. + liveByPath := make(map[string]*string, len(ledger.Journal)) + absByPath := make(map[string]string, len(ledger.Journal)) + drifted := []string{} + for _, entry := range ledger.Journal { + abs, err := v.resolveWorkflowNotePath(entry.Path) + if err != nil { + continue + } + absByPath[entry.Path] = abs + live, err := readOptionalText(abs) + if err != nil { + continue + } + liveByPath[entry.Path] = live + if expected, tracked := ledger.Hashes[entry.Path]; tracked { + if !optionalStringsEqual(workflowHash(live), expected) { + drifted = append(drifted, entry.Path) + } + } + } + restored, failures := v.restoreWorkflowJournalSnapshotLocked(ledger.Journal, liveByPath, absByPath) + if len(failures) > 0 { + return WorkflowUndoResult{}, fmt.Errorf("undo of run %s is incomplete: %s", runID, workflowFailureMessage(failures)) + } + ledger.Undone = true + ledger.UndoneAt = time.Now().UnixMilli() + if err := v.writeWorkflowLedgerLocked(ledger); err != nil { + return WorkflowUndoResult{}, err + } + v.invalidateTextSearchCache() + return WorkflowUndoResult{RunID: runID, Restored: restored, DriftedPaths: drifted}, nil +} + +func (v *Vault) ListWorkflowRuns() ([]WorkflowRunSummary, error) { + v.mu.RLock() + defer v.mu.RUnlock() + runsDir, err := v.resolveWorkflowRunsDir() + if err != nil { + return nil, err + } + entries, err := os.ReadDir(runsDir) + if errors.Is(err, os.ErrNotExist) { + return []WorkflowRunSummary{}, nil + } + if err != nil { + return nil, err + } + runs := []WorkflowRunSummary{} + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { + continue + } + runID := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + ledger, err := v.readWorkflowLedgerLocked(runID) + if err != nil { + continue + } + runs = append(runs, WorkflowRunSummary{ + RunID: ledger.RunID, + WorkflowID: ledger.WorkflowID, + StartedAt: ledger.StartedAt, + Applied: ledger.Applied, + Paths: ledger.Paths, + Undoable: !ledger.Undone && len(ledger.Journal) > 0, + Interrupted: ledger.Interrupted != nil, + }) + } + sort.Slice(runs, func(i, j int) bool { + if runs[i].StartedAt != runs[j].StartedAt { + return runs[i].StartedAt > runs[j].StartedAt + } + return runs[i].RunID > runs[j].RunID + }) + return runs, nil +} + +func (v *Vault) DeleteWorkflowRuns(workflowID string) (int, error) { + v.mu.Lock() + defer v.mu.Unlock() + runsDir, err := v.resolveWorkflowRunsDir() + if err != nil { + return 0, err + } + entries, err := os.ReadDir(runsDir) + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + if err != nil { + return 0, err + } + removed := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { + continue + } + runID := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + ledger, err := v.readWorkflowLedgerLocked(runID) + if err != nil || ledger.WorkflowID != workflowID { + continue + } + if err := os.Remove(filepath.Join(runsDir, entry.Name())); err == nil || errors.Is(err, os.ErrNotExist) { + removed++ + } + } + return removed, nil +} diff --git a/apps/server/internal/vault/workflows_hardening_test.go b/apps/server/internal/vault/workflows_hardening_test.go new file mode 100644 index 00000000..23a19eba --- /dev/null +++ b/apps/server/internal/vault/workflows_hardening_test.go @@ -0,0 +1,92 @@ +package vault + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// A save whose slug differs from the previous filename only by case used to +// delete the workflow that was just written: on a case-insensitive filesystem +// both spellings name one physical file, and the string-compare guard let the +// cleanup remove it. Either filesystem must end with exactly one surviving +// workflow carrying the new content. +func TestWriteWorkflowCaseOnlyRenameKeepsTheFile(t *testing.T) { + v, root := workflowTestVault(t) + dir := filepath.Join(root, ".zennotes", "workflows") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "My-Flow.md"), []byte("old\n"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := v.WriteWorkflow(WriteWorkflowInput{ + Slug: "my-flow", + Raw: "new\n", + PreviousSourcePath: ".zennotes/workflows/My-Flow.md", + }); err != nil { + t.Fatal(err) + } + + body, err := os.ReadFile(filepath.Join(dir, "my-flow.md")) + if err != nil { + t.Fatalf("saved workflow unreadable after case-only rename: %v", err) + } + if string(body) != "new\n" { + t.Fatalf("saved workflow = %q, want the new content", body) + } +} + +// A note carrying invalid UTF-8 reaches the browser through JSON, which +// coerces the bad bytes to U+FFFD; the client can only echo that view back. +// Comparing it against raw disk bytes made every apply 409 forever. +func TestApplyPreparedWorkflowAcceptsWireCoercedBeforeBytes(t *testing.T) { + v, root := workflowTestVault(t) + raw := []byte("head \xff\xfe tail\n") + if err := os.WriteFile(filepath.Join(root, "inbox", "B.md"), raw, 0o600); err != nil { + t.Fatal(err) + } + + // What the client saw: each invalid byte as one replacement char. + before := coerceUTF8ForWire(string(raw)) + if !strings.Contains(before, "��") { + t.Fatalf("test fixture did not coerce: %q", before) + } + after := "rewritten\n" + + receipt, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{ + WorkflowID: "utf8", + Ops: []json.RawMessage{rawWorkflowOp(t, map[string]string{ + "kind": "write-note", "path": "inbox/B.md", "text": after, + })}, + Applied: 1, + Changes: []WorkflowRunFileChange{{Path: "inbox/B.md", Before: &before, After: &after}}, + }) + if err != nil { + t.Fatalf("apply over wire-coerced before bytes = %v, want success", err) + } + if receipt.RolledBack != nil { + t.Fatalf("run rolled back: %v", receipt.RolledBack.Reason) + } + body, err := os.ReadFile(filepath.Join(root, "inbox", "B.md")) + if err != nil || string(body) != after { + t.Fatalf("note after run = %q (%v), want %q", body, err, after) + } +} + +// An over-cap run must say WHICH limit it crossed: the dry run just promised +// success, so a bare "invalid counts" reads as a client bug. +func TestApplyPreparedWorkflowNamesTheScaleCap(t *testing.T) { + v, _ := workflowTestVault(t) + ops := make([]json.RawMessage, maxWorkflowOps+1) + for i := range ops { + ops[i] = rawWorkflowOp(t, map[string]string{"kind": "notify", "message": "x"}) + } + _, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{WorkflowID: "big", Ops: ops}) + if err == nil || !strings.Contains(err.Error(), "server limit") { + t.Fatalf("over-cap error = %v, want the limit named", err) + } +} diff --git a/apps/server/internal/vault/workflows_security_test.go b/apps/server/internal/vault/workflows_security_test.go new file mode 100644 index 00000000..9e58d1fd --- /dev/null +++ b/apps/server/internal/vault/workflows_security_test.go @@ -0,0 +1,160 @@ +package vault + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func workflowTestVault(t *testing.T) (*Vault, string) { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { + t.Fatal(err) + } + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + return v, root +} + +func rawWorkflowOp(t *testing.T, value any) json.RawMessage { + t.Helper() + body, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return body +} + +func TestPreparedWorkflowRequiresValidMatchingOps(t *testing.T) { + v, root := workflowTestVault(t) + before := "# A\n" + after := "# Changed\n" + + _, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{ + WorkflowID: "missing-op", + Changes: []WorkflowRunFileChange{{ + Path: "inbox/A.md", Before: &before, After: &after, + }}, + }) + if !errors.Is(err, ErrInvalidWorkflow) { + t.Fatalf("missing op error = %v, want ErrInvalidWorkflow", err) + } + if body, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(body) != before { + t.Fatalf("missing-op request changed note to %q (%v)", body, err) + } + + _, err = v.ApplyPreparedWorkflow(PreparedWorkflowRun{ + WorkflowID: "unknown-op", + Ops: []json.RawMessage{rawWorkflowOp(t, map[string]string{"kind": "shell"})}, + Applied: 1, + }) + if !errors.Is(err, ErrInvalidWorkflow) { + t.Fatalf("unknown op error = %v, want ErrInvalidWorkflow", err) + } + + _, err = v.ApplyPreparedWorkflow(PreparedWorkflowRun{ + WorkflowID: "malformed-op", + Ops: []json.RawMessage{rawWorkflowOp(t, map[string]string{"kind": "write-note"})}, + Applied: 1, + }) + if !errors.Is(err, ErrInvalidWorkflow) { + t.Fatalf("malformed op error = %v, want ErrInvalidWorkflow", err) + } +} + +func TestPreparedWorkflowRejectsStaleAndInternalPaths(t *testing.T) { + v, root := workflowTestVault(t) + stale := "# Stale\n" + after := "# Changed\n" + op := rawWorkflowOp(t, map[string]string{"kind": "write-note", "path": "inbox/A.md", "text": after}) + + _, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{ + WorkflowID: "stale", + Ops: []json.RawMessage{op}, + Applied: 1, + Changes: []WorkflowRunFileChange{{ + Path: "inbox/A.md", Before: &stale, After: &after, + }}, + }) + if !errors.Is(err, ErrWorkflowConflict) { + t.Fatalf("stale error = %v, want ErrWorkflowConflict", err) + } + + missing := (*string)(nil) + _, err = v.ApplyPreparedWorkflow(PreparedWorkflowRun{ + WorkflowID: "internal", + Ops: []json.RawMessage{op}, + Applied: 1, + Changes: []WorkflowRunFileChange{{ + Path: ".zennotes/workflows/owned.md", Before: missing, After: &after, + }}, + }) + if !errors.Is(err, ErrInvalidWorkflow) { + t.Fatalf("internal path error = %v, want ErrInvalidWorkflow", err) + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "workflows", "owned.md")); !os.IsNotExist(err) { + t.Fatalf("internal path was written: %v", err) + } +} + +func TestWorkflowRunsReadDesktopInterruptedLedger(t *testing.T) { + v, root := workflowTestVault(t) + runsDir := filepath.Join(root, ".zennotes", "workflows", ".runs") + if err := os.MkdirAll(runsDir, 0o700); err != nil { + t.Fatal(err) + } + ledger := map[string]any{ + "version": 1, + "runId": "desktop-run", + "workflowId": "desktop-workflow", + "startedAt": 1, + "finishedAt": 2, + "applied": 0, + "irreversible": 0, + "paths": []string{"inbox/A.md"}, + "ops": []any{}, + "journal": []any{map[string]any{"path": "inbox/A.md", "before": "# A\n"}}, + "hashes": map[string]any{}, + "undone": false, + "interrupted": map[string]string{"reason": "desktop stopped while applying"}, + } + body, err := json.Marshal(ledger) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runsDir, "desktop-run.json"), body, 0o600); err != nil { + t.Fatal(err) + } + + runs, err := v.ListWorkflowRuns() + if err != nil { + t.Fatal(err) + } + if len(runs) != 1 || !runs[0].Interrupted || !runs[0].Undoable { + t.Fatalf("desktop interrupted runs = %+v", runs) + } +} + +func TestWorkflowRunsRejectSymlinkedHistoryDirectory(t *testing.T) { + v, root := workflowTestVault(t) + external := t.TempDir() + workflowDir := filepath.Join(root, ".zennotes", "workflows") + if err := os.MkdirAll(workflowDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(workflowDir, ".runs")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if _, err := v.ListWorkflowRuns(); !errors.Is(err, ErrPathEscape) { + t.Fatalf("symlinked history error = %v, want ErrPathEscape", err) + } +} diff --git a/apps/server/package.json b/apps/server/package.json index 2806e085..0189674a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.28.2", + "version": "2.29.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index d64fc061..c0595dbc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.28.2", + "version": "2.29.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 6fa596d2..be1000bc 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -33,6 +33,7 @@ import type { WorkflowUndoResult, WriteWorkflowInput } from '@zennotes/bridge-contract/workflows' +import { prepareWorkflowRun } from '@shared/workflows/prepare-run' import type { AppUpdateState, AssetMeta, @@ -92,7 +93,8 @@ const WEB_CAPABILITIES: ZenCapabilities = { supportsCloudSync: false, supportsCliInstall: false, supportsCustomTemplates: false, - supportsCustomCodeLanguages: false + supportsCustomCodeLanguages: false, + supportsWorkflows: false } const WEB_APP_INFO: ZenAppInfo = { @@ -730,49 +732,67 @@ function removeDemoTour(): Promise { return jsonRequest('/demo/remove', { method: 'POST' }) } -// Workflows are authored as files under `.zennotes/workflows`, which the web -// app cannot reach, so it simply has none. The canvas still renders, it is -// just empty, which is friendlier than an error the user cannot act on. -function listWorkflows(): Promise { - return Promise.resolve([]) +async function serverSupportsWorkflows(): Promise { + const capabilities = lastServerCapabilities ?? (await getServerCapabilities()) + return capabilities?.supportsWorkflows === true } -// Authoring is a different matter from listing: rejecting is the only honest -// answer, because resolving would leave the editor showing a saved workflow -// that exists nowhere. -function writeWorkflow(_input: WriteWorkflowInput): Promise { - return Promise.reject(new Error('Editing workflows is unavailable on the web')) +async function requireServerWorkflowSupport(): Promise { + if (await serverSupportsWorkflows()) return + throw new Error('This ZenNotes server does not support workflows yet. Update the server and reload.') } -function deleteWorkflow(_sourcePath: string): Promise { - return Promise.reject(new Error('Editing workflows is unavailable on the web')) +async function listWorkflows(): Promise { + if (!(await serverSupportsWorkflows())) return [] + return jsonRequest('/workflows') } -// Applying, undoing and the run history all need the local filesystem: the -// journal that makes a run undoable is a file in the vault, and without it -// there is no honest way to promise an undo. Rejecting is the only answer that -// does not overstate what the web app can do, and it means a run can never land -// somewhere its undo could not reach. -function applyWorkflow(_input: ApplyWorkflowInput): Promise { - return Promise.reject(new Error('Running workflows is unavailable on the web')) +async function writeWorkflow(input: WriteWorkflowInput): Promise { + await requireServerWorkflowSupport() + return jsonRequest('/workflows/write', { + method: 'POST', + body: input as unknown as Record + }) } -function undoWorkflowRun(_runId: string): Promise { - return Promise.reject(new Error('Running workflows is unavailable on the web')) +async function deleteWorkflow(sourcePath: string): Promise { + await requireServerWorkflowSupport() + await jsonRequest('/workflows/delete', { method: 'POST', body: { sourcePath } }) } -// A read, not a run: a vault the web app cannot run workflows in simply has no -// recorded runs, the same answer `listWorkflows` gives for the files. A future -// history panel then shows an empty list here instead of tripping on a -// rejection where the workflow list quietly showed nothing. -function listWorkflowRuns(): Promise { - return Promise.resolve([]) +async function applyWorkflow(input: ApplyWorkflowInput): Promise { + // Independent requests; no reason to stack their round trips in front of an + // already read-heavy prepare phase. + const [, settings] = await Promise.all([requireServerWorkflowSupport(), getVaultSettings()]) + const prepared = await prepareWorkflowRun(input, { + read: readFileTextOrNull, + systemFolderDirs: settings.systemFolderPaths ?? {} + }) + return jsonRequest('/workflows/apply', { + method: 'POST', + body: prepared as unknown as Record + }) } -// Nothing can have recorded a run here (see above), so there is never anything -// to delete: zero, not a rejection, for the same reason the list is empty. -function deleteWorkflowRuns(_workflowId: string): Promise { - return Promise.resolve(0) +async function undoWorkflowRun(runId: string): Promise { + await requireServerWorkflowSupport() + return jsonRequest('/workflows/undo', { + method: 'POST', + body: { runId } + }) +} + +async function listWorkflowRuns(): Promise { + if (!(await serverSupportsWorkflows())) return [] + return jsonRequest('/workflows/runs') +} + +async function deleteWorkflowRuns(workflowId: string): Promise { + await requireServerWorkflowSupport() + return jsonRequest('/workflows/runs/delete', { + method: 'POST', + body: { workflowId } + }) } // Custom templates require local-filesystem CRUD, which the web app does not @@ -1342,7 +1362,14 @@ function clipboardReadText(): string { // -------------------------------------------------------------------- export const httpBridge: ZenBridge = { - getCapabilities: (): ZenCapabilities => WEB_CAPABILITIES, + // Workflows are the one capability the SERVER decides; derive it from the + // cached /capabilities response instead of mutating the const in place, so + // the UI gate (this) and the request gate (serverSupportsWorkflows) can + // never disagree about the same fact. + getCapabilities: (): ZenCapabilities => ({ + ...WEB_CAPABILITIES, + supportsWorkflows: lastServerCapabilities?.supportsWorkflows === true + }), getAppInfo: (): ZenAppInfo => WEB_APP_INFO, platform, platformSync, diff --git a/docs/ideas/workflows.md b/docs/ideas/workflows.md index 71297aa2..2e81f2fd 100644 --- a/docs/ideas/workflows.md +++ b/docs/ideas/workflows.md @@ -2,7 +2,7 @@ A visual, keyboard-drivable pipeline editor for the vault. -> **Status (updated for v2.20, July 2026).** This is the design document the +> **Status (updated for v2.29, August 2026).** This is the design document the > feature was built from, kept as rationale, not as a manual. If you are here to > learn how workflows work, the real docs are the in-app Help (`:help`, section > "Workflows") and https://zennotes.org/docs. What this doc calls "the gallery" @@ -13,10 +13,12 @@ A visual, keyboard-drivable pipeline editor for the vault. > Shipped in 2.20: manual runs (from the view and the command palette), the > canvas/text lossless pair, the dry-run confirmation, byte-for-byte Undo with > crash recovery, presets, import-as-review, and the guided tutorial. Desktop -> only. Not shipped yet, and described below as design: event and schedule -> triggers (they parse but do not fire), server-side execution in Go, workflow -> MCP tools, and anything labeled community. The web client shows workflows -> read-only. +> local vaults shipped first. Since 2.29, current self-hosted web servers also +> store workflow files and apply the browser-prepared transaction under the Go +> vault lock, with the same journalled Undo and crash recovery. Electron remote +> workspaces remain read-only. Not shipped yet, and described below as design: +> event and schedule triggers (they parse but do not fire), workflow MCP tools, +> and anything labeled community. ## Problem Statement diff --git a/package-lock.json b/package-lock.json index 350f5d3d..e11e4686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.28.2", + "version": "2.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.28.2", + "version": "2.29.0", "workspaces": [ "apps/*", "packages/*" @@ -20,7 +20,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.28.2", + "version": "2.29.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -99,11 +99,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.28.2" + "version": "2.29.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.28.2", + "version": "2.29.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17123,7 +17123,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.28.2", + "version": "2.29.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17187,11 +17187,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.28.2" + "version": "2.29.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.28.2", + "version": "2.29.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -17199,7 +17199,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.28.2" + "version": "2.29.0" } } } diff --git a/package.json b/package.json index f9086e92..e96bc4c1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.28.2", + "version": "2.29.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index e19a5984..0fc387eb 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.28.2", + "version": "2.29.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index 7980f96c..4798df4a 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -442,6 +442,31 @@ describe("CloudSettings", () => { expect(host.textContent).not.toContain("Error invoking remote method"); }); + it("refreshes and clears a connection error when the network comes back", async () => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValue(serviceAccount); + mocks.listCloudVaults.mockResolvedValue([]); + mocks.getCloudVaultLink.mockResolvedValue(null); + + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + + expect(host.textContent).toContain("fetch failed"); + + await act(async () => window.dispatchEvent(new Event("online"))); + + expect(host.textContent).not.toContain("fetch failed"); + expect(host.textContent).toContain("SyncIncluded"); + }); + it("guides a vault linked to another cloud service into the current account", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue({ diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index b7efb488..ed3de35a 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -132,12 +132,22 @@ export function CloudSettings({ ); useEffect(() => { - void loadStatus().catch((cause) => { - setError(errorMessage(cause, "Could not load ZenNotes Cloud.")); - }); - return bridge.onCloudAccountChange((next) => { + const refresh = (): void => { + void loadStatus().catch((cause) => { + setError(errorMessage(cause, "Could not load ZenNotes Cloud.")); + }); + }; + + refresh(); + const unsubscribe = bridge.onCloudAccountChange((next) => { void loadStatus(next); }); + window.addEventListener("online", refresh); + + return () => { + unsubscribe(); + window.removeEventListener("online", refresh); + }; }, [bridge, loadStatus]); const refreshServiceAccount = useCallback(async (): Promise => { diff --git a/packages/app-core/src/components/ConnectionsPanel.tsx b/packages/app-core/src/components/ConnectionsPanel.tsx index 97d80dc6..8049c5a9 100644 --- a/packages/app-core/src/components/ConnectionsPanel.tsx +++ b/packages/app-core/src/components/ConnectionsPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { NoteContent, NoteMeta } from '@shared/ipc' import { useStore } from '../store' import { + blockAnchorsTargeting, extractWikilinkTargets, extractMarkdownLinkHrefs, extractMentionSnippet, @@ -20,6 +21,12 @@ interface MentionItem { snippet: string } +interface BacklinkItem { + note: NoteMeta + /** `^block` ids this note points at here, when it aimed at a block. (#601) */ + blocks: string[] +} + interface MissingLinkItem { target: string suggestedPath: string @@ -39,7 +46,7 @@ export function ConnectionsPanel({ note }: { note: NoteContent }): JSX.Element { const setConnectionsCursorIndex = useStore((s) => s.setConnectionsCursorIndex) const setConnectionPreview = useStore((s) => s.setConnectionPreview) const closeTimerRef = useRef | null>(null) - const [backlinks, setBacklinks] = useState([]) + const [backlinks, setBacklinks] = useState([]) const [mentions, setMentions] = useState([]) const [scanLoading, setScanLoading] = useState(false) const isConnectionsFocused = focusedPanel === 'connections' @@ -193,6 +200,10 @@ export function ConnectionsPanel({ note }: { note: NoteContent }): JSX.Element { return { note: candidate, backlink: linksHere, + // Which block ids this note reached for, so the row can say so. + // Only backlink rows render them, so only those pay for the + // per-target resolution. (#601) + blocks: linksHere ? blockAnchorsTargeting(notes, targets, note.path) : [], mentionSnippet: snippet } } catch { @@ -201,11 +212,11 @@ export function ConnectionsPanel({ note }: { note: NoteContent }): JSX.Element { }) ).then((results) => { if (cancelled) return - const nextBacklinks: NoteMeta[] = [] + const nextBacklinks: BacklinkItem[] = [] const nextMentions: MentionItem[] = [] for (const item of results) { if (!item) continue - if (item.backlink) nextBacklinks.push(item.note) + if (item.backlink) nextBacklinks.push({ note: item.note, blocks: item.blocks }) if (item.mentionSnippet) { nextMentions.push({ note: item.note, snippet: item.mentionSnippet }) } @@ -312,11 +323,23 @@ export function ConnectionsPanel({ note }: { note: NoteContent }): JSX.Element { subtitle="Notes already pointing at this page." empty="No backlinks yet." > - {backlinks.map((item) => ( + {backlinks.map(({ note: item, blocks }) => ( `^${id}`).join(', ')}` + : '' + ] + .filter(Boolean) + .join(' · ') || 'No excerpt available yet.' + } onOpen={() => void selectNote(item.path)} onHover={(rect) => { cancelScheduledClose() diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 969fea27..c66f7029 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -20,14 +20,10 @@ import { rankItems } from "../lib/fuzzy-score"; import { BUILTIN_TEMPLATES } from "@shared/builtin-templates"; import { mergeTemplates } from "@shared/template-files"; import type { PaneLayout, PaneSplit } from "../lib/pane-layout"; -import { - parseCreateNotePath, - resolveWikilinkTarget, - wikilinkHeadingAnchor, -} from "../lib/wikilinks"; +import { parseCreateNotePath, resolveWikilinkPath } from "../lib/wikilinks"; import { openDatabaseFromWikilink, - openWikilinkHeading, + openWikilinkTarget, } from "../lib/wikilink-navigation"; import { classifyLocalAssetHref, @@ -648,20 +644,13 @@ function registerVimCommands(): void { } const notes = state.notes; - const resolved = resolveWikilinkTarget(notes, target); - if (resolved) { + const wikilinkPath = resolveWikilinkPath(notes, target, state.selectedPath); + if (wikilinkPath) { const focusEditorSoon = (): void => { state.setFocusedPanel("editor"); requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()); }; - const headingAnchor = wikilinkHeadingAnchor(target); - if (headingAnchor) { - void openWikilinkHeading(resolved.path, headingAnchor).then( - focusEditorSoon, - ); - } else { - void state.selectNote(resolved.path).then(focusEditorSoon); - } + void openWikilinkTarget(wikilinkPath, target).then(focusEditorSoon); return; } @@ -674,8 +663,8 @@ function registerVimCommands(): void { state.setFocusedPanel("editor"); requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()); }; - if (internal.heading) { - void openWikilinkHeading(internal.path, internal.heading).then( + if (internal.anchor) { + void openWikilinkTarget(internal.path, `#${internal.anchor}`).then( focusEditorSoon, ); } else { diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 80474045..96781ddd 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -123,7 +123,13 @@ import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands import { calloutTypeSource } from '../lib/cm-callouts' import { dateShortcutSource } from '../lib/cm-date-shortcuts' import { latexCommandSource } from '../lib/cm-latex-completions' -import { wikilinkSource, wikilinkHeadingSource, atNoteSource } from '../lib/cm-wikilinks' +import { typstCommandSource } from '../lib/cm-typst-completions' +import { + wikilinkSource, + wikilinkHeadingSource, + wikilinkBlockSource, + atNoteSource +} from '../lib/cm-wikilinks' import { linkRangeAtCursor, markdownLinkAt } from '../lib/internal-links' import { setBlockType, toggleWrap, wrapLink } from '../lib/cm-format' import { EditorSelectionToolbar } from './EditorSelectionToolbar' @@ -1789,11 +1795,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { calloutTypeSource, dateShortcutSource, latexCommandSource, + typstCommandSource, atNoteSource, frontmatterTagSource, hashtagSource, wikilinkSource, - wikilinkHeadingSource + wikilinkHeadingSource, + wikilinkBlockSource ], addToOptions: [{ render: slashCommandRender.render, position: 0 }], icons: false, diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 6c2e4cd3..b7a71c07 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -49,7 +49,11 @@ import { headingFolding } from '../lib/cm-heading-fold' import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { calloutTypeSource } from '../lib/cm-callouts' import { dateShortcutSource } from '../lib/cm-date-shortcuts' -import { wikilinkSource, wikilinkHeadingSource } from '../lib/cm-wikilinks' +import { + wikilinkSource, + wikilinkHeadingSource, + wikilinkBlockSource +} from '../lib/cm-wikilinks' import { hashtagSource } from '../lib/cm-hashtag-complete' import { frontmatterTagSource } from '../lib/cm-frontmatter-tag-complete' import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' @@ -245,7 +249,8 @@ export function PinnedReferencePane(): JSX.Element | null { frontmatterTagSource, hashtagSource, wikilinkSource, - wikilinkHeadingSource + wikilinkHeadingSource, + wikilinkBlockSource ], addToOptions: [{ render: slashCommandRender.render, position: 0 }], icons: false, diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index f5291036..fe9a79f8 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -13,11 +13,13 @@ import { selectTypstPreambleFor } from "../lib/typst-preamble-select"; import { useStore } from "../store"; import { useDiagramTheme } from "../lib/use-diagram-theme-mode"; import { + isSameFileBlockLink, isSameFileHeadingLink, resolveWikilinkTarget, - wikilinkHeadingAnchor, } from "../lib/wikilinks"; -import { openWikilinkHeading } from "../lib/wikilink-navigation"; +import { + openWikilinkTarget, +} from "../lib/wikilink-navigation"; import { listDatabaseLinkTargets, resolveDatabaseWikilink } from "../lib/database-links"; import { externalLinkUrl, resolveInternalNoteHref } from "../lib/internal-links"; import { toggleTaskAtIndex } from "../lib/tasklists"; @@ -435,10 +437,9 @@ export const Preview = memo(function Preview({ e.preventDefault(); const path = anchor.dataset.resolvedPath; if (path) { - // Scroll to the #heading when the link carries one. (#196) - const headingAnchor = wikilinkHeadingAnchor(anchor.dataset.wikilink ?? ""); - if (headingAnchor) void openWikilinkHeading(path, headingAnchor); - else void selectNoteRef.current(path); + // Scroll to the #heading or the ^block when the link carries one. + // (#196, #601) + void openWikilinkTarget(path, anchor.dataset.wikilink ?? ""); } else if (anchor.dataset.databaseCsv) { void useStore.getState().openDatabase(anchor.dataset.databaseCsv); } @@ -464,8 +465,10 @@ export const Preview = memo(function Preview({ ); if (internalNote) { e.preventDefault(); - if (internalNote.heading) - void openWikilinkHeading(internalNote.path, internalNote.heading); + // `#` lets openWikilinkTarget decide heading vs block, so the + // Obsidian form `Note.md#^id` reaches the block here too. (#601) + if (internalNote.anchor) + void openWikilinkTarget(internalNote.path, `#${internalNote.anchor}`); else void selectNoteRef.current(internalNote.path); return; } @@ -645,9 +648,10 @@ export const Preview = memo(function Preview({ delete a.dataset.databaseCsv; return; } - // `[[#heading]]` (no note part) links to a heading in THIS note — resolve - // it to the note being previewed so the click scrolls in place. (#291) - if (isSameFileHeadingLink(target)) { + // `[[#heading]]` / `[[^block]]` (no note part) point inside THIS note: + // resolve them to the note being previewed so the click scrolls in + // place. (#291, #601) + if (isSameFileHeadingLink(target) || isSameFileBlockLink(target)) { a.classList.remove("broken"); a.dataset.resolvedPath = notePath; delete a.dataset.databaseCsv; diff --git a/packages/app-core/src/components/WorkflowsView.tsx b/packages/app-core/src/components/WorkflowsView.tsx index 906bc1eb..396d7999 100644 --- a/packages/app-core/src/components/WorkflowsView.tsx +++ b/packages/app-core/src/components/WorkflowsView.tsx @@ -88,6 +88,7 @@ import { useStore } from '../store' import type { WorkflowRunRecord } from '../store' import { useToastStore } from '../lib/toast' import { createVaultReader } from '../lib/workflow-vault-reader' +import { canManageWorkflows } from '../lib/workflow-workspace' import { advanceSequence, formatKeyToken, @@ -1363,32 +1364,32 @@ export function WorkflowsView(): JSX.Element { // the drag that was made, and must record it against the file it was made on. const pendingLayoutWrite = useRef(null) - // Workflow files live on the local filesystem, which web and remote - // workspaces do not have: the web bridge rejects a write and the main process - // refuses one for a remote vault. Both are knowable up front, so the - // affordances are hidden rather than offered and then failed, and the typeof - // checks stay as the last line of defence for a bridge that predates these - // methods. Authoring degrades to read-only; it never throws. - const localVault = window.zen.getAppInfo().runtime === 'desktop' && workspaceMode !== 'remote' - const canWrite = localVault && typeof window.zen.writeWorkflow === 'function' - const canDelete = localVault && typeof window.zen.deleteWorkflow === 'function' + // Desktop owns local workflow files directly. The Docker web client owns + // them through a server that explicitly advertises journalled workflow + // support. Older servers and Electron remote workspaces stay read-only. + const appInfo = window.zen.getAppInfo() + const capabilities = window.zen.getCapabilities() + const writableWorkspace = canManageWorkflows(appInfo.runtime, workspaceMode, capabilities) + const nativeLocalVault = appInfo.runtime === 'desktop' && workspaceMode !== 'remote' + const canWrite = writableWorkspace && typeof window.zen.writeWorkflow === 'function' + const canDelete = writableWorkspace && typeof window.zen.deleteWorkflow === 'function' // Applying is the only thing in this view that writes NOTES, and it runs in // the main process, so it is gated exactly like the authoring affordances: // a bridge that predates these methods offers no Run button at all rather // than one that throws. - const canApply = localVault && typeof window.zen.applyWorkflow === 'function' - const canUndoRuns = localVault && typeof window.zen.undoWorkflowRun === 'function' + const canApply = writableWorkspace && typeof window.zen.applyWorkflow === 'function' + const canUndoRuns = writableWorkspace && typeof window.zen.undoWorkflowRun === 'function' // `revealNote` reveals any VAULT-RELATIVE path, which is what a workflow's // `sourcePath` is, so the file manager entry needs no bridge of its own. Gated // the same way as the rest: a workspace with no local files gets no item at // all rather than one that opens nothing. - const canReveal = localVault && typeof window.zen.revealNote === 'function' + const canReveal = nativeLocalVault && typeof window.zen.revealNote === 'function' // Saving a copy somewhere else, and reading one back, both need a native file // dialog and a filesystem, so both are desktop-only and hidden elsewhere // rather than offered and then refused. Copying to the CLIPBOARD is not gated // this way on purpose: it needs nothing but a clipboard, and it is the form of // sharing that actually travels through a chat message or a gist. - const canExportFile = localVault && typeof window.zen.exportWorkflow === 'function' + const canExportFile = nativeLocalVault && typeof window.zen.exportWorkflow === 'function' const canImportFile = canWrite && typeof window.zen.importWorkflowFile === 'function' // A stable action, so subscribing costs nothing; see the selectors above. const addToast = useToastStore((s) => s.addToast) diff --git a/packages/app-core/src/lib/block-anchors.test.ts b/packages/app-core/src/lib/block-anchors.test.ts new file mode 100644 index 00000000..601d0179 --- /dev/null +++ b/packages/app-core/src/lib/block-anchors.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { extractBlock, findBlockAnchor, parseBlockAnchors } from './block-anchors' + +const body = [ + '---', + 'title: Daily Note', + 'alias: ^frontmatter-id', + '---', + '', + '# Daily Note', + '', + '## Notes', + '', + '- First note', + '- Second note ^note-two', + '- Third note', + '', + 'A standalone marker follows this paragraph.', + '', + '^standalone', + '', + '```md', + '- Fenced example ^not-an-anchor', + '```', + '', + 'Math stays prose: 2^3 and x ^ y are not ids.', + '' +].join('\n') + +describe('parseBlockAnchors (#601)', () => { + it('finds a trailing ^id and a marker on its own line, in document order', () => { + expect(parseBlockAnchors(body).map((a) => a.id)).toEqual(['note-two', 'standalone']) + }) + + it('ignores ids inside frontmatter and fenced code', () => { + const ids = parseBlockAnchors(body).map((a) => a.id) + expect(ids).not.toContain('frontmatter-id') + expect(ids).not.toContain('not-an-anchor') + }) + + it('does not treat a caret operator as an id', () => { + expect(parseBlockAnchors('Exponent 2^3\n').map((a) => a.id)).toEqual([]) + expect(parseBlockAnchors('Spaced x ^ y\n').map((a) => a.id)).toEqual([]) + }) + + it('only accepts a marker that ends its line', () => { + expect(parseBlockAnchors('- Note ^mid-line and more text\n').map((a) => a.id)).toEqual([]) + expect(parseBlockAnchors('- Note ^trailing \n').map((a) => a.id)).toEqual(['trailing']) + }) + + it('points from at the start of the marked line and brackets the marker itself', () => { + const anchor = parseBlockAnchors(body).find((a) => a.id === 'note-two') + expect(anchor).toBeDefined() + expect(body.slice(anchor!.from).startsWith('- Second note ^note-two')).toBe(true) + expect(body.slice(anchor!.markerFrom, anchor!.markerTo)).toBe('^note-two') + expect(body.split('\n')[anchor!.line - 1]).toBe('- Second note ^note-two') + }) + + it('points a standalone marker at the paragraph it names while retaining marker coordinates', () => { + const anchor = parseBlockAnchors(body).find((a) => a.id === 'standalone') + expect(anchor).toBeDefined() + expect(anchor!.line).toBe(14) + expect(body.slice(anchor!.from)).toMatch(/^A standalone marker follows this paragraph\./) + expect(anchor!.markerLine).toBe(16) + expect(body.slice(anchor!.markerFrom, anchor!.markerTo)).toBe('^standalone') + }) +}) + +describe('findBlockAnchor (#601)', () => { + it('resolves an id to its block', () => { + expect(findBlockAnchor(body, 'note-two')?.line).toBe(11) + }) + + it('matches case-insensitively and tolerates a leading caret', () => { + expect(findBlockAnchor(body, 'NOTE-TWO')?.id).toBe('note-two') + expect(findBlockAnchor(body, '^note-two')?.id).toBe('note-two') + }) + + it('returns null for an unknown or empty id', () => { + expect(findBlockAnchor(body, 'nope')).toBeNull() + expect(findBlockAnchor(body, ' ')).toBeNull() + }) + + it('resolves a repeated id to the first occurrence', () => { + const repeated = '- One ^dup\n- Two ^dup\n' + expect(findBlockAnchor(repeated, 'dup')?.line).toBe(1) + }) +}) + +describe('extractBlock (#601)', () => { + it('takes the list item, without the marker', () => { + expect(extractBlock(body, 'note-two')).toBe('- Second note') + }) + + it('brings the item children along', () => { + const nested = ['- Parent ^parent', ' - Child one', ' - Child two', '- Sibling', ''].join('\n') + expect(extractBlock(nested, 'parent')).toBe('- Parent\n - Child one\n - Child two') + }) + + it('takes the whole paragraph for a marker on an ordinary line', () => { + const prose = ['First line of the paragraph', 'and its second line. ^para', '', 'Elsewhere.'].join('\n') + expect(extractBlock(prose, 'para')).toBe('First line of the paragraph\nand its second line.') + }) + + it('takes the paragraph above a marker sitting on its own line', () => { + const standalone = ['Intro.', '', 'The block being tagged.', '', '^standalone', '', 'After.'].join('\n') + expect(extractBlock(standalone, 'standalone')).toBe('The block being tagged.') + }) + + it('is null for an id the note does not carry', () => { + expect(extractBlock(body, 'nope')).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/block-anchors.ts b/packages/app-core/src/lib/block-anchors.ts new file mode 100644 index 00000000..b4b2dd30 --- /dev/null +++ b/packages/app-core/src/lib/block-anchors.ts @@ -0,0 +1,11 @@ +// Block-anchor grammar and walks live in shared-domain so the desktop main +// process (DOCX export) and any future pipeline resolve the same rules as the +// renderer. This module keeps the app-core import path stable. +export { + extractBlock, + findBlockAnchor, + parseBlockAnchors, + stripBlockAnchorMarkers, + trailingBlockIdRange, + type BlockAnchor +} from '@shared/block-anchors' diff --git a/packages/app-core/src/lib/cm-live-preview.test.ts b/packages/app-core/src/lib/cm-live-preview.test.ts index d25085da..ea9198f1 100644 --- a/packages/app-core/src/lib/cm-live-preview.test.ts +++ b/packages/app-core/src/lib/cm-live-preview.test.ts @@ -167,6 +167,19 @@ describe('livePreviewPlugin', () => { view.destroy() }) + it('hides a standalone block id off-cursor and reveals it on its line (#601)', () => { + const doc = 'Named paragraph.\n\n^standalone\n\nAfter.' + const view = mountEditor(doc, doc.indexOf('After')) + + expect(view.dom.textContent).toContain('Named paragraph.') + expect(view.dom.textContent).not.toContain('^standalone') + + view.dispatch({ selection: { anchor: doc.indexOf('^standalone') + 2 } }) + expect(view.dom.textContent).toContain('^standalone') + + view.destroy() + }) + it('replaces an unchecked task marker with a checkbox widget', () => { // Cursor on the intro line — the task line is inactive, so it renders. const doc = 'intro\n\n- [ ] Buy milk' diff --git a/packages/app-core/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index 06b7125c..88fd50ee 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -1,5 +1,5 @@ import { syntaxTree } from '@codemirror/language' -import { RangeSetBuilder, StateEffect } from '@codemirror/state' +import { RangeSetBuilder, StateEffect, type EditorState } from '@codemirror/state' import { Decoration, DecorationSet, @@ -16,6 +16,7 @@ import { resolveAssetVaultRelativePath, resolveLocalAssetUrl } from './local-assets' +import { parseBlockAnchors } from './block-anchors' import { setImageBlockDragPayload } from './image-block-dnd' import { imageCacheKey, rememberImageOnLoad, takeCachedImage } from './image-element-cache' import { assetTabPath } from './asset-tabs' @@ -978,6 +979,23 @@ class InProgressMarkerWidget extends WidgetType { } } +// Block-anchor markers by 1-based line number, memoized per document. The +// parser's grammar (fence and frontmatter aware) decides what hides, and the +// scan runs once per doc version rather than on every cursor move, since +// computeDecorations also fires on selection changes. +const blockAnchorCache = new WeakMap>() + +function blockAnchorMarkersFor(state: EditorState): Map { + const cached = blockAnchorCache.get(state.doc) + if (cached) return cached + const markers = new Map() + for (const anchor of parseBlockAnchors(state.doc.toString())) { + markers.set(anchor.markerLine, { from: anchor.markerFrom, to: anchor.markerTo }) + } + blockAnchorCache.set(state.doc, markers) + return markers +} + function computeDecorations(view: EditorView): DecorationSet { const { state } = view @@ -1159,6 +1177,23 @@ function computeDecorations(view: EditorView): DecorationSet { deco: imageEmbedLine }) } + + // #601: a trailing `^block-id` names the line so `[[Note^id]]` can point + // at it. That is addressing, not prose, so hide it the way other markers + // are hidden and reveal it when the cursor is on the line to edit. Only + // markers the parser accepts are hidden: a per-line regex here blanked + // literal `^word` tails inside code fences and frontmatter that are not + // anchors at all, so code samples looked corrupted in the editor. + if (!lineActive && !replacedLines.has(lineNo)) { + const blockId = blockAnchorMarkersFor(state).get(lineNo) + if (blockId) { + pending.push({ + from: blockId.from, + to: blockId.to, + deco: hide + }) + } + } } } diff --git a/packages/app-core/src/lib/cm-slash-commands.ts b/packages/app-core/src/lib/cm-slash-commands.ts index 53bdcccf..24a47f8e 100644 --- a/packages/app-core/src/lib/cm-slash-commands.ts +++ b/packages/app-core/src/lib/cm-slash-commands.ts @@ -2,6 +2,7 @@ import type { CompletionContext, CompletionResult, Completion } from '@codemirro import type { EditorView } from '@codemirror/view' import { useStore } from '../store' import { renderLatexCompletion } from './cm-latex-completions' +import { renderTypstCompletion } from './cm-typst-completions' interface SlashCmd { label: string @@ -70,6 +71,8 @@ const COMMANDS: SlashCmd[] = [ function renderCompletion(completion: Completion): HTMLElement { const latex = renderLatexCompletion(completion) if (latex) return latex + const typst = renderTypstCompletion(completion) + if (typst) return typst const decorated = completion as DecoratedCompletion if (decorated._kind === 'callout') { const el = document.createElement('div') diff --git a/packages/app-core/src/lib/cm-typst-completions.test.ts b/packages/app-core/src/lib/cm-typst-completions.test.ts new file mode 100644 index 00000000..7b4d24bb --- /dev/null +++ b/packages/app-core/src/lib/cm-typst-completions.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { markdown } from '@codemirror/lang-markdown' +import { CompletionContext } from '@codemirror/autocomplete' +import { previewSourceOf, typstCommandSource, typstTokenBefore } from './cm-typst-completions' +import { mathRenderExtension } from './cm-math-render' + +function state(doc: string, renderer: 'katex' | 'typst' = 'typst'): EditorState { + return EditorState.create({ + doc, + extensions: [markdown(), mathRenderExtension(renderer)] + }) +} + +function sourceAt(doc: string, renderer: 'katex' | 'typst' = 'typst', explicit = false) { + return typstCommandSource(new CompletionContext(state(doc, renderer), doc.length, explicit)) +} + +describe('typstTokenBefore', () => { + it('matches the identifier being typed, from two letters on', () => { + const doc = '$su' + const token = typstTokenBefore(state(doc), doc.length) + expect(token).not.toBeNull() + expect(token!.query).toBe('su') + expect(token!.from).toBe(1) + }) + + it('stays silent on a single letter unless summoned explicitly', () => { + // One-letter variables are the normal case in math, not a prefix. + const doc = '$x' + expect(typstTokenBefore(state(doc), doc.length)).toBeNull() + expect(typstTokenBefore(state(doc), doc.length, true)).not.toBeNull() + }) + + it('matches dotted names and rejects non-identifiers', () => { + const dotted = '$dots.h' + expect(typstTokenBefore(state(dotted), dotted.length)!.query).toBe('dots.h') + + const afterDigits = '$12' + expect(typstTokenBefore(state(afterDigits), afterDigits.length)).toBeNull() + }) +}) + +describe('typstCommandSource', () => { + it('offers Typst words inside math when the note compiles as Typst', () => { + const result = sourceAt('formule $su') + expect(result).not.toBeNull() + const labels = result!.options.map((o) => o.label) + expect(labels).toContain('sum') + expect(labels).toContain('alpha') + }) + + it('stays out of the way when the note compiles as KaTeX', () => { + // The exact mirror of the LaTeX source's Typst gate: `sum_(i=1)^(n)` is + // not KaTeX, so offering it there would be wrong every time. + expect(sourceAt('formule $su', 'katex')).toBeNull() + }) + + it('stays out of prose and code even with Typst selected', () => { + expect(sourceAt('prose without math: su')).toBeNull() + expect(sourceAt('```bash\necho su')).toBeNull() + }) + + it('works in display math still being typed', () => { + expect(sourceAt('$$\nx = su')).not.toBeNull() + }) + + it('covers the slice-3 families: arrows, comparisons, sets, styles', () => { + const labels = sourceAt('formule $ar')!.options.map((o) => o.label) + for (const word of ['arrow.r.double', 'lt.eq', 'inter', 'nothing', 'dif', 'bold', 'bb']) { + expect(labels, word).toContain(word) + } + }) + + it('the token matcher reaches doubly-dotted names like arrow.l.r.double', () => { + const doc = '$arrow.l.r.double' + expect(typstTokenBefore(state(doc), doc.length)!.query).toBe('arrow.l.r.double') + }) +}) + +describe('compiled previews', () => { + it('derives the preview by unwrapping the snippet fields', () => { + expect(previewSourceOf({ template: 'frac(${a}, ${b})' })).toBe('frac(a, b)') + expect(previewSourceOf({ template: 'sum_(${i=1})^(${n})' })).toBe('sum_(i=1)^(n)') + expect(previewSourceOf({})).toBeNull() + expect(previewSourceOf({ preview: 'mat(1;2)', template: 'mat(${})' })).toBe('mat(1;2)') + }) + + it('every templated option carries a well-formed preview', () => { + const result = sourceAt('formule $su')! + for (const option of result.options) { + const preview = (option as { _preview?: string | null })._preview + if ((option as { apply?: unknown }).apply === undefined) continue + expect(preview, option.label).toBeTruthy() + // No snippet syntax may leak into what the compiler will typeset. + expect(preview, option.label).not.toMatch(/\$\{|\}/) + } + }) +}) diff --git a/packages/app-core/src/lib/cm-typst-completions.ts b/packages/app-core/src/lib/cm-typst-completions.ts new file mode 100644 index 00000000..1acb7955 --- /dev/null +++ b/packages/app-core/src/lib/cm-typst-completions.ts @@ -0,0 +1,254 @@ +/** + * Typst math completion, the sibling of cm-latex-completions for notes whose + * typesetter is Typst. Typst has no backslash: commands are bare words + * (`sum`, `alpha`, `frac(a, b)`), so the trigger is the identifier being + * typed (from two letters on, to stay out of the way of one-letter + * variables) inside the same `$…$` / `$$…$$` regions. + * + * Where LaTeX previews need KaTeX, most Typst entries are single glyphs with + * an exact Unicode form (α, ∑, ∫, ℝ, ∀ …), shown directly in the icon slot. + */ +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete' +import { snippet } from '@codemirror/autocomplete' +import type { EditorState } from '@codemirror/state' +import { isInMathContext } from './cm-latex-completions' +import { mathRendererOf } from './cm-math-render' +import { peekTypstMathSvg, renderTypstMathToSvg } from './typst-math-render' + +interface TypstCommand { + /** The word as typed: `sum`, `alpha`, `frac`. */ + label: string + detail: string + /** Snippet template when the function takes arguments. */ + template?: string + /** Unicode glyph (or short sketch) for the icon slot. */ + icon: string + /** Typst math compiled for the icon slot; the glyph paints while it loads. + * Constructs need this: no single glyph says `mat(1, 2; 3, 4)`. */ + preview?: string + boost?: number +} + +const GREEK: Array<[string, string]> = [ + ['alpha', 'α'], ['beta', 'β'], ['gamma', 'γ'], ['delta', 'δ'], ['epsilon', 'ε'], + ['zeta', 'ζ'], ['eta', 'η'], ['theta', 'θ'], ['iota', 'ι'], ['kappa', 'κ'], + ['lambda', 'λ'], ['mu', 'μ'], ['nu', 'ν'], ['xi', 'ξ'], ['pi', 'π'], ['rho', 'ρ'], + ['sigma', 'σ'], ['tau', 'τ'], ['upsilon', 'υ'], ['phi', 'φ'], ['chi', 'χ'], + ['psi', 'ψ'], ['omega', 'ω'], + ['Gamma', 'Γ'], ['Delta', 'Δ'], ['Theta', 'Θ'], ['Lambda', 'Λ'], ['Xi', 'Ξ'], + ['Pi', 'Π'], ['Sigma', 'Σ'], ['Phi', 'Φ'], ['Psi', 'Ψ'], ['Omega', 'Ω'] +] + +const SYMBOLS: Array<[string, string, string]> = [ + // [word, glyph, detail] + ['oo', '∞', 'infinity'], + ['diff', '∂', 'partial'], + ['nabla', '∇', 'nabla'], + ['forall', '∀', 'for all'], + ['exists', '∃', 'exists'], + ['in', '∈', 'element of'], + ['union', '∪', 'set union'], + ['subset', '⊂', 'subset'], + ['supset', '⊃', 'superset'], + ['approx', '≈', 'approximately'], + ['equiv', '≡', 'equivalent'], + ['prop', '∝', 'proportional'], + ['times', '×', 'times'], + ['dot.op', '⋅', 'dot operator'], + ['plus.minus', '±', 'plus-minus'], + ['RR', 'ℝ', 'reals'], + ['NN', 'ℕ', 'naturals'], + ['ZZ', 'ℤ', 'integers'], + ['QQ', 'ℚ', 'rationals'], + ['CC', 'ℂ', 'complexes'], + ['dots.h', '⋯', 'horizontal dots'], + ['dots.v', '⋮', 'vertical dots'], + + // Arrows. Typst also accepts the ASCII shorthands noted in the detail. + ['arrow.r', '→', 'right arrow, or ->'], + ['arrow.l', '←', 'left arrow, or <-'], + ['arrow.l.r', '↔', 'left-right arrow, or <->'], + ['arrow.r.double', '⇒', 'implies, or =>'], + ['arrow.l.r.double', '⇔', 'if and only if, or <=>'], + ['arrow.r.bar', '↦', 'maps to, or |->'], + ['arrow.t', '↑', 'up arrow'], + ['arrow.b', '↓', 'down arrow'], + + // Comparisons. + ['lt.eq', '≤', 'less or equal, or <='], + ['gt.eq', '≥', 'greater or equal, or >='], + ['eq.not', '≠', 'not equal, or !='], + + // Sets. + ['inter', '∩', 'set intersection'], + ['nothing', '∅', 'empty set'], + ['subset.eq', '⊆', 'subset or equal'], + ['supset.eq', '⊇', 'superset or equal'], + ['in.not', '∉', 'not element of'], + + // Operators. + ['compose', '∘', 'function composition'], + ['plus.o', '⊕', 'direct sum'], + ['times.o', '⊗', 'tensor product'], + ['dif', 'd', 'differential: dif x in integrals'] +] + +const FUNCTIONS = [ + 'sin', 'cos', 'tan', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh', + 'exp', 'log', 'ln', 'det', 'max', 'min', 'sup', 'inf', 'arg', 'gcd', 'mod' +] + +const TYPST_COMMANDS: TypstCommand[] = [ + // Everyday constructs, boosted to the top; templates are valid Typst math. + { label: 'frac', detail: 'fraction, or just a/b', template: 'frac(${a}, ${b})', icon: '⅟', boost: 99 }, + { label: 'sqrt', detail: 'square root', template: 'sqrt(${x})', icon: '√', boost: 98 }, + { label: 'root', detail: 'nth root', template: 'root(${n}, ${x})', icon: '∛' }, + { label: 'sum', detail: 'sum', template: 'sum_(${i=1})^(${n})', icon: '∑', boost: 97 }, + { label: 'integral', detail: 'integral', template: 'integral_(${a})^(${b})', icon: '∫', boost: 96 }, + { label: 'lim', detail: 'limit', template: 'lim_(${x -> 0})', icon: 'lim', boost: 95 }, + { label: 'product', detail: 'product', template: 'product_(${i=1})^(${n})', icon: '∏', boost: 90 }, + { label: 'binom', detail: 'binomial', template: 'binom(${n}, ${k})', icon: '(ⁿₖ)' }, + { label: 'mat', detail: 'matrix (; ends a row)', template: 'mat(${1, 2; 3, 4})', icon: '⊞' }, + { label: 'vec', detail: 'column vector', template: 'vec(${a}, ${b})', icon: '⇣' }, + { label: 'cases', detail: 'case distinction', template: 'cases(${x &"if" x > 0}, ${-x &"else"})', icon: '{' }, + { label: 'abs', detail: 'absolute value', template: 'abs(${x})', icon: '|x|' }, + { label: 'norm', detail: 'norm', template: 'norm(${x})', icon: '‖x‖' }, + { label: 'floor', detail: 'floor', template: 'floor(${x})', icon: '⌊x⌋' }, + { label: 'ceil', detail: 'ceiling', template: 'ceil(${x})', icon: '⌈x⌉' }, + + // Accents. + { label: 'hat', detail: 'hat accent', template: 'hat(${x})', icon: 'x̂' }, + { label: 'tilde', detail: 'tilde accent', template: 'tilde(${x})', icon: 'x̃' }, + { label: 'dot', detail: 'dot accent', template: 'dot(${x})', icon: 'ẋ' }, + { label: 'arrow', detail: 'vector arrow', template: 'arrow(${x})', icon: 'x⃗' }, + { label: 'overline', detail: 'overline', template: 'overline(${x})', icon: 'x̄' }, + { label: 'underline', detail: 'underline', template: 'underline(${x})', icon: 'x̲' }, + + // Text styles. + { label: 'bold', detail: 'bold', template: 'bold(${x})', icon: '𝐱' }, + { label: 'upright', detail: 'upright (non-italic)', template: 'upright(${x})', icon: 'x' }, + { label: 'cal', detail: 'calligraphic', template: 'cal(${A})', icon: '𝒜' }, + { label: 'bb', detail: 'blackboard bold', template: 'bb(${A})', icon: '𝔸' }, + + ...GREEK.map(([label, icon]): TypstCommand => ({ label, icon, detail: 'greek' })), + ...SYMBOLS.map(([label, icon, detail]): TypstCommand => ({ label, icon, detail })), + ...FUNCTIONS.map((label): TypstCommand => ({ label, icon: 'ƒ', detail: 'function' })) +] + +/** The word being typed at `pos`, or null. Two letters minimum unless the + * completion was summoned explicitly: one-letter variables are the normal + * case in math and must not pop a menu. Dotted names (`dots.h`) match too. */ +export function typstTokenBefore( + state: EditorState, + pos: number, + explicit = false +): { from: number; query: string } | null { + const line = state.doc.lineAt(pos) + const textBefore = state.doc.sliceString(line.from, pos) + const match = textBefore.match(/[A-Za-z][A-Za-z.]*$/) + if (!match) return null + if (!explicit && match[0].length < 2) return null + return { from: pos - match[0].length, query: match[0] } +} + +/** The compiled preview is the template with its `${…}` fields unwrapped: + * `frac(${a}, ${b})` previews as `frac(a, b)`. Deriving it keeps preview and + * insertion from ever drifting apart. Exported for tests. */ +export function previewSourceOf(cmd: { template?: string; preview?: string }): string | null { + if (cmd.preview) return cmd.preview + if (!cmd.template) return null + return cmd.template.replace(/\$\{([^}]*)\}/g, '$1') +} + +let cachedOptions: Completion[] | null = null + +function buildOptions(): Completion[] { + cachedOptions ??= TYPST_COMMANDS.map( + (cmd): Completion => + ({ + label: cmd.label, + detail: cmd.detail, + type: 'keyword', + boost: cmd.boost ?? 0, + _kind: 'typst', + _icon: cmd.icon, + _preview: previewSourceOf(cmd), + apply: cmd.template ? snippet(cmd.template) : undefined + }) as Completion & { _kind: string; _icon: string; _preview: string | null } + ) + return cachedOptions +} + +/** Full option row for a Typst completion. The Unicode glyph paints + * immediately; entries with arguments swap in the compiled Typst preview as + * soon as the shared render queue produces it (cached across popups, so the + * swap only happens the first time). Null for every other completion kind. */ +export function renderTypstCompletion(completion: Completion): HTMLElement | null { + const { _kind, _icon, _preview } = completion as Completion & { + _kind?: string + _icon?: string + _preview?: string | null + } + if (_kind !== 'typst') return null + + const el = document.createElement('div') + el.className = 'slash-cmd-item' + + const icon = document.createElement('span') + icon.className = 'slash-cmd-icon typst-cmd-icon' + icon.style.fontSize = '0.8em' + icon.style.lineHeight = '1' + icon.style.display = 'inline-flex' + icon.style.alignItems = 'center' + icon.style.justifyContent = 'center' + icon.style.overflow = 'hidden' + icon.textContent = _icon ?? '' + if (_preview) { + const showSvg = (svg: string): void => { + icon.innerHTML = svg + const svgEl = icon.querySelector('svg') + if (svgEl) { + svgEl.style.maxWidth = '2.6em' + svgEl.style.maxHeight = '2.2em' + } + } + const cached = peekTypstMathSvg(_preview, false) + if (cached?.ok) { + showSvg(cached.svg) + } else if (!cached) { + renderTypstMathToSvg(_preview, false) + .then((res) => { + // A closed popup leaves the node detached; the warm cache still + // pays off on the next open. + if (res.ok && icon.isConnected) showSvg(res.svg) + }) + .catch(() => undefined) + } + // A cached error keeps the glyph: it said all it had to say once. + } + + const label = document.createElement('span') + label.className = 'slash-cmd-label' + label.textContent = completion.label + + const detail = document.createElement('span') + detail.className = 'slash-cmd-detail' + detail.textContent = completion.detail ?? '' + + el.appendChild(icon) + el.appendChild(label) + el.appendChild(detail) + return el +} + +export function typstCommandSource(context: CompletionContext): CompletionResult | null { + if (mathRendererOf(context.state) !== 'typst') return null + const token = typstTokenBefore(context.state, context.pos, context.explicit) + if (!token) return null + if (!isInMathContext(context.state, token.from)) return null + return { + from: token.from, + options: buildOptions(), + validFor: /^[A-Za-z][A-Za-z.]*$/ + } +} diff --git a/packages/app-core/src/lib/cm-wikilink-render.ts b/packages/app-core/src/lib/cm-wikilink-render.ts index 5a736314..1fc2d69f 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.ts @@ -20,8 +20,8 @@ import { type ViewUpdate } from '@codemirror/view' import { useStore } from '../store' -import { isSameFileHeadingLink, resolveWikilinkTarget, wikilinkHeadingAnchor } from './wikilinks' -import { openDatabaseFromWikilink, openWikilinkHeading } from './wikilink-navigation' +import { isSameFileBlockLink, isSameFileHeadingLink, resolveWikilinkTarget } from './wikilinks' +import { openDatabaseFromWikilink, openWikilinkTarget } from './wikilink-navigation' import { offerCreateNoteFromLink } from './create-note-from-link' // Same shape as the Preview pipeline (remarkWikilinks). @@ -144,13 +144,12 @@ function openWikilink(target: string): void { requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) } - const anchor = wikilinkHeadingAnchor(target) const resolved = resolveWikilinkTarget(state.notes, target) if (!resolved) { - // `[[#heading]]` (no note part) points at a heading in the current note, - // so scroll within the note being edited. (#291) - if (anchor && isSameFileHeadingLink(target) && state.selectedPath) { - void openWikilinkHeading(state.selectedPath, anchor).then(focusEditorSoon) + // `[[#heading]]` / `[[^block]]` (no note part) point within the note being + // edited, so scroll there instead of hunting for a note by name. (#291, #601) + if ((isSameFileHeadingLink(target) || isSameFileBlockLink(target)) && state.selectedPath) { + void openWikilinkTarget(state.selectedPath, target).then(focusEditorSoon) return } // Not a note — maybe a `.base` database; otherwise offer to create the note @@ -160,11 +159,7 @@ function openWikilink(target: string): void { return } - if (!anchor) { - void state.selectNote(resolved.path).then(focusEditorSoon) - return - } - void openWikilinkHeading(resolved.path, anchor).then(focusEditorSoon) + void openWikilinkTarget(resolved.path, target).then(focusEditorSoon) } // Click a rendered wikilink to jump. Intercept on mousedown so CodeMirror diff --git a/packages/app-core/src/lib/cm-wikilinks.test.ts b/packages/app-core/src/lib/cm-wikilinks.test.ts index 31872e99..98be2f3e 100644 --- a/packages/app-core/src/lib/cm-wikilinks.test.ts +++ b/packages/app-core/src/lib/cm-wikilinks.test.ts @@ -4,7 +4,12 @@ import { CompletionContext } from '@codemirror/autocomplete' import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { describe, expect, it, vi } from 'vitest' -import { wikilinkSource, wikilinkHeadingSource, atNoteSource } from './cm-wikilinks' +import { + wikilinkSource, + wikilinkHeadingSource, + wikilinkBlockSource, + atNoteSource +} from './cm-wikilinks' const storeState = vi.hoisted(() => ({ activeNote: { @@ -19,10 +24,13 @@ const storeState = vi.hoisted(() => ({ wikilinks: [], hasAttachments: false, excerpt: '', - body: '# Home\n\n## Tasks\n' + body: '# Home\n\n## Tasks\n\n- Daily standup ^standup\n' }, noteContents: { - 'inbox/Zen Garden.md': { body: '# Intro\n\n## Setup\n\n## Usage Notes\n\nbody\n' } + 'inbox/Zen Garden.md': { + body: + '# Intro\n\n## Setup\n\n- Install the thing ^install\n\n## Usage Notes\n\n- Run it ^run-it\n\nStandalone explanation.\n\n^standalone\n' + } }, notes: [ { @@ -176,6 +184,56 @@ describe('wikilinkHeadingSource (#196 — heading autocomplete)', () => { }) }) +function blockResult(doc: string) { + const state = EditorState.create({ doc }) + return wikilinkBlockSource(new CompletionContext(state, doc.length, true)) +} + +describe('wikilinkBlockSource (#601, block id autocomplete)', () => { + it('suggests the target note block ids after ^', async () => { + const result = await blockResult('[[Zen Garden^') + expect(result?.options.map((o) => o.label)).toEqual(['install', 'run-it', 'standalone']) + }) + + it('shows the block text so ids can be told apart', async () => { + const result = await blockResult('[[Zen Garden^') + expect(result?.options.map((o) => o.detail)).toEqual([ + '- Install the thing', + '- Run it', + 'Standalone explanation.' + ]) + }) + + it('anchors the completion just after the ^ so the id lands inside the link', async () => { + expect((await blockResult('[[Zen Garden^ru'))?.from).toBe('[[Zen Garden^'.length) + }) + + it('inserts the chosen id and closes the link', async () => { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ parent, state: EditorState.create({ doc: '[[Zen Garden^ru' }) }) + const result = await wikilinkBlockSource( + new CompletionContext(view.state, view.state.doc.length, true) + ) + const option = result?.options.find((o) => o.label === 'run-it') + const apply = option?.apply + if (typeof apply !== 'function') throw new Error('expected a function apply handler') + apply(view, option!, result!.from, view.state.doc.length) + expect(view.state.doc.toString()).toBe('[[Zen Garden^run-it]]') + view.destroy() + parent.remove() + }) + + it('falls back to the current note for [[^', async () => { + expect((await blockResult('[[^'))?.options.map((o) => o.label)).toEqual(['standup']) + }) + + it('returns null without a ^, and yields to the heading source when # comes first', async () => { + expect(await blockResult('[[Zen Garden')).toBeNull() + expect(await blockResult('[[Zen Garden#Setup^')).toBeNull() + }) +}) + function atResult(doc: string) { const state = EditorState.create({ doc }) return atNoteSource(new CompletionContext(state, doc.length, true)) diff --git a/packages/app-core/src/lib/cm-wikilinks.ts b/packages/app-core/src/lib/cm-wikilinks.ts index 36ec04ed..dc8f4703 100644 --- a/packages/app-core/src/lib/cm-wikilinks.ts +++ b/packages/app-core/src/lib/cm-wikilinks.ts @@ -2,6 +2,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro import type { EditorView } from '@codemirror/view' import { useStore } from '../store' import { resolveWikilinkTarget } from './wikilinks' +import { parseBlockAnchors } from './block-anchors' import { parseOutline } from './outline' import { linkCandidates, type LinkCandidate } from './link-candidates' @@ -178,7 +179,10 @@ export function atNoteSource(context: CompletionContext): CompletionResult | nul * The note is everything before the first `#`; the heading query is whatever * follows the last `#` (so nested `#a#b` still completes the deepest part). */ -function wikilinkHeadingMatch(context: CompletionContext): { +function wikilinkAnchorMatch( + context: CompletionContext, + marker: '#' | '^' +): { from: number notePart: string query: string @@ -191,50 +195,131 @@ function wikilinkHeadingMatch(context: CompletionContext): { const inside = before.slice(openIndex + 2) if (inside.includes(']]') || inside.includes('|')) return null - const firstHash = inside.indexOf('#') - if (firstHash < 0) return null // no heading anchor — `wikilinkSource` owns this - const lastHash = inside.lastIndexOf('#') + const first = inside.indexOf(marker) + if (first < 0) return null // no anchor of this kind; `wikilinkSource` owns this + // Whichever marker opens the anchor owns everything after it, the same rule + // wikilinkHeadingAnchor / wikilinkBlockAnchor follow, with the same Obsidian + // exception: in `[[Note#^` the hash immediately followed by the caret is the + // canonical block form, so the caret owns the anchor and the heading source + // stands down. (#601) + const other = inside.indexOf(marker === '#' ? '^' : '#') + let noteEnd = first + if (marker === '#') { + if (other >= 0 && other < first) return null + if (inside.slice(first + 1).startsWith('^')) return null + } else if (other >= 0 && other < first) { + if (inside.slice(other + 1, first).trim() !== '') return null + noteEnd = other + } + const last = inside.lastIndexOf(marker) return { - from: line.from + openIndex + 2 + lastHash + 1, - notePart: inside.slice(0, firstHash).trim(), - query: inside.slice(lastHash + 1) + from: line.from + openIndex + 2 + last + 1, + notePart: inside.slice(0, noteEnd).trim(), + query: inside.slice(last + 1) } } -// Bodies fetched for heading completion are cached so typing the heading query -// doesn't re-read the file on every keystroke (`validFor` keeps the option list -// while the query stays anchor-shaped, so this mostly matters across notes). -const headingBodyCache = new Map() +// Bodies fetched for anchor completion are cached so typing the query doesn't +// re-read the file on every keystroke. An entry is only trusted while the +// note's `updatedAt` still matches: block ids are typically created seconds +// before being linked, so a session-long snapshot made block completion a +// first-use failure (new ids never appeared until restart), and a vault switch +// could even serve another vault's ids for a same-relative-path note. +const anchorBodyCache = new Map() +const ANCHOR_BODY_CACHE_LIMIT = 32 /** - * Autocomplete headings inside a wikilink: typing `[[Note#` (or `[[#` for the - * current note) suggests that note's headings. (#196) + * The body used for `[[Note#…]]` / `[[Note^…]]` completion. An open buffer + * always wins (it holds unsaved ids); otherwise a read validated against the + * note's `updatedAt`. */ -export async function wikilinkHeadingSource( +async function anchorNoteBody(notePart: string): Promise { + const state = useStore.getState() + const note = notePart ? resolveWikilinkTarget(state.notes, notePart) : state.activeNote + if (!note) return null + + const open = state.noteContents[note.path]?.body + if (open != null) return open + const inline = (note as { body?: string }).body // activeNote ([[#…]]) carries its body + if (inline != null) return inline + + const updatedAt = (note as { updatedAt?: number }).updatedAt ?? 0 + const cached = anchorBodyCache.get(note.path) + if (cached && cached.updatedAt === updatedAt) return cached.body + + try { + const read = (await window.zen.readNote(note.path)).body + if (anchorBodyCache.size >= ANCHOR_BODY_CACHE_LIMIT) anchorBodyCache.clear() + anchorBodyCache.set(note.path, { updatedAt, body: read }) + return read + } catch { + return null + } +} + +/** + * Insert `text` at the completion range, closing the wikilink when the source + * hasn't already got a `]]` waiting. + */ +function applyAnchorCompletion(text: string) { + return (view: EditorView, _completion: Completion, from: number, to: number): void => { + const existingClose = view.state.doc.sliceString(to, to + 2) === ']]' + const insert = `${text}${existingClose ? '' : ']]'}` + view.dispatch({ + changes: { from, to, insert }, + selection: { anchor: from + text.length + (existingClose ? 0 : 2) } + }) + } +} + +/** + * Autocomplete block ids inside a wikilink: typing `[[Note^` (or `[[^` for the + * current note) suggests that note's block ids, with the block's own text as + * the hint so you can tell them apart. (#601) + */ +export async function wikilinkBlockSource( context: CompletionContext ): Promise { - const match = wikilinkHeadingMatch(context) + const match = wikilinkAnchorMatch(context, '^') if (!match) return null - const state = useStore.getState() - const note = match.notePart - ? resolveWikilinkTarget(state.notes, match.notePart) - : state.activeNote - if (!note) return null + const body = await anchorNoteBody(match.notePart) + if (body == null) return null - let body = - state.noteContents[note.path]?.body ?? - (note as { body?: string }).body ?? // activeNote ([[#…]]) already carries its body - headingBodyCache.get(note.path) - if (body == null) { - try { - body = (await window.zen.readNote(note.path)).body - headingBodyCache.set(note.path, body) - } catch { - return null - } + const lines = body.split('\n') + const seen = new Set() + const options: Completion[] = [] + for (const anchor of parseBlockAnchors(body)) { + const key = normalize(anchor.id) + if (seen.has(key)) continue + seen.add(key) + // Show what the id actually marks; an id on its own line describes the + // block above it, so fall back to that. + const markerLine = lines[anchor.markerLine - 1] ?? '' + const own = markerLine.slice(0, anchor.markerFrom - anchor.markerLineFrom).trim() + const detail = own || (lines[anchor.line - 1] ?? '').trim() + options.push({ + label: anchor.id, + detail: detail.slice(0, 60) || undefined, + type: 'text', + apply: applyAnchorCompletion(anchor.id) + }) + if (options.length >= 100) break } + if (options.length === 0) return null + + return { from: match.from, options, validFor: /^[^\]|]*$/ } +} + +export async function wikilinkHeadingSource( + context: CompletionContext +): Promise { + const match = wikilinkAnchorMatch(context, '#') + if (!match) return null + + const body = await anchorNoteBody(match.notePart) + if (body == null) return null const seen = new Set() const options: Completion[] = [] @@ -247,14 +332,7 @@ export async function wikilinkHeadingSource( label: text, detail: `H${heading.level}`, type: 'text', - apply: (view: EditorView, _completion: Completion, from: number, to: number) => { - const existingClose = view.state.doc.sliceString(to, to + 2) === ']]' - const insert = `${text}${existingClose ? '' : ']]'}` - view.dispatch({ - changes: { from, to, insert }, - selection: { anchor: from + text.length + (existingClose ? 0 : 2) } - }) - } + apply: applyAnchorCompletion(text) }) if (options.length >= 100) break } diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 7a48c313..753581b1 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -16,6 +16,7 @@ import { findLeaf } from './pane-layout' import { requestPaneMode } from './pane-mode' import { resolveQuickNoteTitle } from './quick-note-title' import { forwardTaskWithPicker, taskAtEditorCursor } from './forward-task' +import { canManageWorkflows } from './workflow-workspace' import { toggleCheckbox } from './cm-toggle-checkbox' import { getKeymapDisplay, type KeymapId } from './keymaps' import { dispatchKeyboardContextMenu, findTabContextMenuTarget } from './keyboard-context-menu' @@ -1852,14 +1853,20 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma // run, and a row that only answers with an error is worse than no row. { const state = getState() - // Where a workflow could run at all: the desktop app, on a local vault. + // Where a workflow could run at all: any host that can keep workflow + // files and journals, the same predicate the Workflows view uses. Gating + // on runtime === 'desktop' left capable web workspaces (a 2.29 server + // advertising supportsWorkflows) with a working view but no palette rows + // and no vim ex-commands, on the keyboard-first surface of all places. // Deliberately NOT gated on the feature switch, because the tutorial below // must be findable BEFORE someone has opted in: starting it IS the opt-in, // exactly like the button in Settings. const workflowsPossibleHere = - state.workspaceMode !== 'remote' && - window.zen.getAppInfo().runtime === 'desktop' && - typeof window.zen.applyWorkflow === 'function' + canManageWorkflows( + window.zen.getAppInfo().runtime, + state.workspaceMode, + window.zen.getCapabilities() + ) && typeof window.zen.applyWorkflow === 'function' if (workflowsPossibleHere) { cmds.push({ id: 'workflow.tutorial', diff --git a/packages/app-core/src/lib/follow-link.test.ts b/packages/app-core/src/lib/follow-link.test.ts new file mode 100644 index 00000000..08865470 --- /dev/null +++ b/packages/app-core/src/lib/follow-link.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + selectedPath: 'inbox/Current.md' as string | null, + notes: [ + { + path: 'inbox/Current.md', + title: 'Current', + folder: 'inbox' as const + } + ], + setFocusedPanel: vi.fn(), + editorViewRef: null, + selectNote: vi.fn() +})) + +const openWikilinkTarget = vi.hoisted(() => vi.fn(() => new Promise(() => undefined))) +const offerCreateNoteFromLink = vi.hoisted(() => vi.fn()) + +vi.mock('../store', () => ({ + useStore: { getState: () => state } +})) + +vi.mock('./wikilink-navigation', () => ({ + openDatabaseFromWikilink: () => false, + openWikilinkHeading: vi.fn(), + openWikilinkTarget +})) + +vi.mock('./create-note-from-link', () => ({ offerCreateNoteFromLink })) + +const { followLinkTarget } = await import('./follow-link') + +describe('followLinkTarget: same-note anchors (#601)', () => { + it('opens [[^block]] in the selected note instead of offering to create a note', () => { + expect(followLinkTarget('^standalone')).toBe(true) + + expect(openWikilinkTarget).toHaveBeenCalledWith('inbox/Current.md', '^standalone') + expect(offerCreateNoteFromLink).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/lib/follow-link.ts b/packages/app-core/src/lib/follow-link.ts index 1053f216..0aa6b8f2 100644 --- a/packages/app-core/src/lib/follow-link.ts +++ b/packages/app-core/src/lib/follow-link.ts @@ -2,8 +2,11 @@ import { useStore } from '../store' import { offerCreateNoteFromLink } from './create-note-from-link' import { externalFileLink, openExternalFileLink } from './external-file-link' import { externalLinkUrl, resolveInternalNoteHref } from './internal-links' -import { resolveWikilinkTarget, wikilinkHeadingAnchor } from './wikilinks' -import { openDatabaseFromWikilink, openWikilinkHeading } from './wikilink-navigation' +import { resolveWikilinkPath } from './wikilinks' +import { + openDatabaseFromWikilink, + openWikilinkTarget +} from './wikilink-navigation' /** * Follow a link target from the active note. The `target` is either a @@ -29,15 +32,13 @@ export function followLinkTarget(target: string): boolean { } const internal = resolveInternalNoteHref(state.selectedPath, target, state.notes) if (internal) { - if (internal.heading) void openWikilinkHeading(internal.path, internal.heading).then(focusSoon) + if (internal.anchor) void openWikilinkTarget(internal.path, `#${internal.anchor}`).then(focusSoon) else void state.selectNote(internal.path).then(focusSoon) return true } - const wikilink = resolveWikilinkTarget(state.notes, target) - if (wikilink) { - const heading = wikilinkHeadingAnchor(target) - if (heading) void openWikilinkHeading(wikilink.path, heading).then(focusSoon) - else void state.selectNote(wikilink.path).then(focusSoon) + const wikilinkPath = resolveWikilinkPath(state.notes, target, state.selectedPath) + if (wikilinkPath) { + void openWikilinkTarget(wikilinkPath, target).then(focusSoon) return true } if (openDatabaseFromWikilink(target)) { diff --git a/packages/app-core/src/lib/forward-task.test.ts b/packages/app-core/src/lib/forward-task.test.ts new file mode 100644 index 00000000..b977979d --- /dev/null +++ b/packages/app-core/src/lib/forward-task.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { NoteMeta } from '@shared/ipc' +import { parseTasksFromBody } from '@shared/tasks' +import { buildForwardTaskPrompt } from './forward-task' + +const task = parseTasksFromBody('- [ ] Ship the picker', { + path: 'inbox/today.md', + title: 'today', + folder: 'inbox' +})[0] + +const notes: Pick[] = [ + { path: 'inbox/today.md', title: 'today', folder: 'inbox' }, + { path: 'inbox/Work.md', title: 'Work', folder: 'inbox' }, + { path: 'archive/Work.md', title: 'Work', folder: 'archive' }, + { path: 'trash/Old.md', title: 'Old', folder: 'trash' }, + { path: 'inbox/notes.txt', title: 'notes', folder: 'inbox' } +] + +describe('forward-task destination picker (#600)', () => { + it('preselects the first match as you type, the way the folder pickers do (#467)', () => { + expect(buildForwardTaskPrompt(task, notes)?.options.autoHighlightFirst).toBe(true) + }) + + it('names the Ctrl+J / Ctrl+K navigation in the hint, so the shortcut is discoverable', () => { + expect(buildForwardTaskPrompt(task, notes)?.options.suggestionsHint).toContain('⌃J/⌃K') + }) + + it('offers every other markdown note, never the source note or the trash', () => { + const suggestions = buildForwardTaskPrompt(task, notes)?.options.suggestions + expect(suggestions?.map((s) => s.value)).toEqual(['inbox/Work.md', 'archive/Work.md']) + expect(suggestions?.map((s) => s.label)).toEqual(['Work', 'Work']) + }) + + it('resolves the picker answer from either a path or a title', () => { + const prompt = buildForwardTaskPrompt(task, notes) + expect(prompt?.resolveTargetPath('archive/Work.md')).toBe('archive/Work.md') + expect(prompt?.resolveTargetPath(' inbox/Work.md ')).toBe('inbox/Work.md') + // A bare title takes the first note carrying it; the twin stays reachable by path. + expect(prompt?.resolveTargetPath('Work')).toBe('inbox/Work.md') + expect(prompt?.resolveTargetPath('Not a note')).toBeUndefined() + }) + + it('still refuses a typed value that is not an existing note', () => { + const validate = buildForwardTaskPrompt(task, notes)?.options.validate + expect(validate?.('inbox/Work.md')).toBeNull() + expect(validate?.('Something new')).toBe('Pick an existing note') + }) + + it('has nothing to offer when the vault holds no other note', () => { + expect(buildForwardTaskPrompt(task, [notes[0]])).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/forward-task.ts b/packages/app-core/src/lib/forward-task.ts index bc704f94..17ace94c 100644 --- a/packages/app-core/src/lib/forward-task.ts +++ b/packages/app-core/src/lib/forward-task.ts @@ -1,8 +1,62 @@ import type { EditorView } from '@codemirror/view' +import type { NoteMeta } from '@shared/ipc' import { parseTasksFromBody, type VaultTask } from '@shared/tasks' +import type { PromptOptions } from '../components/PromptModal' import { promptApp } from './prompt-requests' import { useStore } from '../store' +/** The note fields the destination picker needs. */ +type ForwardCandidate = Pick + +export interface ForwardTaskPrompt { + options: PromptOptions + /** Map the picker's answer (a suggestion value, or a typed title/path) to a note path. */ + resolveTargetPath: (chosen: string) => string | undefined +} + +/** + * The destination picker for a forwarded task, or null when the vault holds no + * other note to forward to. + * + * Split out from the prompt call so the keyboard behaviour is testable. The + * picker preselects the first match as you type (#600), matching the folder + * pickers (#467). It matters more here than there: this prompt only accepts an + * existing note, so without a preselection, typing a filter and pressing Enter + * submitted the raw text and failed validation instead of forwarding. + */ +export function buildForwardTaskPrompt( + task: VaultTask, + notes: ForwardCandidate[] +): ForwardTaskPrompt | null { + const candidates = notes.filter( + (n) => n.folder !== 'trash' && n.path !== task.sourcePath && n.path.endsWith('.md') + ) + if (candidates.length === 0) return null + + // A path always wins over a title, so same-titled notes in different folders + // stay reachable by typing their full path. + const byKey = new Map() + const suggestions = candidates.map((n) => { + byKey.set(n.path, n.path) + if (!byKey.has(n.title)) byKey.set(n.title, n.path) + return { value: n.path, label: n.title, detail: n.path } + }) + + return { + options: { + title: `Forward "${task.content || 'task'}" to…`, + description: 'The original stays as a forwarded record; a copy is added to the note you pick.', + placeholder: 'Note title or path', + okLabel: 'Forward', + suggestions, + autoHighlightFirst: true, + suggestionsHint: '↑↓ or ⌃J/⌃K pick a note · Enter to forward', + validate: (input) => (byKey.has(input.trim()) ? null : 'Pick an existing note') + }, + resolveTargetPath: (chosen) => byKey.get(chosen.trim()) + } +} + /** * Task forwarding (#316). `forwardTaskWithPicker` prompts for a destination note * and moves the task there: the original stays as a `- [>]` record linking to @@ -10,32 +64,15 @@ import { useStore } from '../store' * the target note. */ export async function forwardTaskWithPicker(task: VaultTask): Promise { - const notes = useStore - .getState() - .notes.filter((n) => n.folder !== 'trash' && n.path !== task.sourcePath && n.path.endsWith('.md')) - if (notes.length === 0) { + const prompt = buildForwardTaskPrompt(task, useStore.getState().notes) + if (!prompt) { window.alert('There are no other notes to forward this task to.') return } - // Resolve the picker result (a suggestion value = path, or a typed title/path). - const byKey = new Map() - const suggestions = notes.map((n) => { - byKey.set(n.path, n.path) - if (!byKey.has(n.title)) byKey.set(n.title, n.path) - return { value: n.path, label: n.title, detail: n.path } - }) - const chosen = await promptApp({ - title: `Forward "${task.content || 'task'}" to…`, - description: 'The original stays as a forwarded record; a copy is added to the note you pick.', - placeholder: 'Note title or path', - okLabel: 'Forward', - suggestions, - suggestionsHint: 'Pick a note', - validate: (input) => (byKey.has(input.trim()) ? null : 'Pick an existing note') - }) + const chosen = await promptApp(prompt.options) if (!chosen) return - const targetPath = byKey.get(chosen.trim()) + const targetPath = prompt.resolveTargetPath(chosen) if (targetPath) await useStore.getState().forwardTask(task, targetPath) } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 03c3efc0..5a1b6aa5 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -287,7 +287,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Forward a task to another note', body: - 'Forwarding moves a task to a different note while leaving a record behind — the bullet-journal “migrate” gesture. Type `>` inside a task’s checkbox (turning `- [ ]` into `- [>]`) to open a note picker, run “Forward Task to Note…” from the command palette with the cursor on a task, or press `>` on a task in the Tasks list. The original stays as `- [>] … [[Target]]` (a forwarded marker linking to where it went), and a fresh `- [ ] … [[Source]]` copy is added to the note you pick, backlinked home. Forwarded tasks collect under their own “Forwarded” group in the Tasks list, kept out of Today and Done.' + 'Forwarding moves a task to a different note while leaving a record behind (the bullet-journal “migrate” gesture). Type `>` inside a task’s checkbox (turning `- [ ]` into `- [>]`) to open a note picker, run “Forward Task to Note…” from the command palette with the cursor on a task, or press `>` on a task in the Tasks list. The picker filters as you type and preselects the first match, so typing part of a note’s name and pressing Enter forwards straight there; ↑↓, Ctrl+J / Ctrl+K, and Ctrl+N / Ctrl+P step between the matches. The original stays as `- [>] … [[Target]]` (a forwarded marker linking to where it went), and a fresh `- [ ] … [[Source]]` copy is added to the note you pick, backlinked home. A task’s indented subtasks travel with it: the copy carries the whole block, done and cancelled children included, so the destination reflects the task’s current state, while open subtasks in the source flip to `[>]` alongside the parent (done and cancelled ones keep their state as history). Forwarded tasks collect under their own “Forwarded” group in the Tasks list, kept out of Today and Done.' }, { title: 'Cancel a task', @@ -384,6 +384,11 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'Use [[wikilinks]] or markdown links. Following a link — click it, Cmd/Ctrl-click it, or use the follow-link motion (`gd`) in normal mode — opens the note under the cursor and pins PDFs into the reference pane. If the note does not exist yet, following the link offers to create it (after you confirm) rather than leaving a dead link. Prefix a wikilink with `!` to embed rather than link: `![[Note]]` inlines the target note content in the reading view and PDF export — recursively, with cycle protection — so a master note can pull in sub-notes and export to PDF as one document. `![[image.png]]` embeds an image and `![[drawing.excalidraw]]` embeds an Excalidraw drawing as a PNG preview; both take optional `|width` or `|WxH` size hints (`![[image.png|300]]`, `![[image.png|600x400]]`), and the markdown form carries the same hint after the alt text (`![caption|300](image.png)`).' }, + { + title: 'Point at one block, not a whole note', + body: + 'End any line with a `^block-id` marker (letters, digits and hyphens, e.g. `- Ship the picker ^ship-it`) to name that block, then link at it with `[[Note^ship-it]]`. Following the link opens the note scrolled to that exact block, the way `[[Note#Heading]]` reaches a section. Put the marker on its own line to tag the paragraph above it without touching its text. Typing `^` inside a wikilink lists the target note\'s ids with the block text beside each one, so you can pick without leaving the keyboard, and `[[^id]]` points at a block in the note you are already in. Embedding follows the same rule: `![[Note^ship-it]]` inlines just that block rather than the whole note, bringing a bullet\'s children along with it. Obsidian\'s spellings work as-is, so a vault you share with Obsidian keeps its block links: `[[Note#^id]]`, `[[#^id]]`, and markdown-style `[text](Note.md#^id)` all reach the block. The marker itself is addressing, not prose, so it stays hidden in the reading view and in the editor until your cursor is on the line to edit it. The Connections panel says which block a note reached for instead of only that it linked here.' + }, { title: 'Files stay local', body: @@ -412,7 +417,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Math, diagrams, and plots render from plain fences', body: - 'Inline `$…$` and display `$$…$$` math render via KaTeX by default. Settings ▸ Editor ▸ Math renderer switches the typesetter to Typst, which reads the same `$…$` / `$$…$$` blocks as Typst markup instead of LaTeX (a note’s math is written for whichever engine you pick). Beyond math, four fenced block languages turn into live diagrams in preview and split mode: `mermaid` for flow, sequence, state, gantt, and graph diagrams (and `mermaid` alone also draws inline in the editor, with live preview on: the diagram stands in for the fence until your cursor enters it, which brings the source back for editing); `tikz` for LaTeX-native coordinate systems, commutative diagrams, and figure-quality plots (the TeX engine runs on-device so no network is required); `jsxgraph` for interactive geometry and function plots driven by a small JSON config; and `function-plot` for compact Cartesian function plotting. Each block is ordinary markdown on disk, so the source remains portable and diffable.' + 'Inline `$…$` and display `$$…$$` math render via KaTeX by default. Settings ▸ Editor ▸ Math renderer switches the typesetter to Typst, which reads the same `$…$` / `$$…$$` blocks as Typst markup instead of LaTeX (a note’s math is written for whichever engine you pick). Both typesetters complete commands as you write math: type two letters inside a math region to get Greek letters, operators, arrows, sets, and functions with rendered previews, and argument-taking commands insert editable snippets such as `frac(a, b)`. Beyond math, four fenced block languages turn into live diagrams in preview and split mode: `mermaid` for flow, sequence, state, gantt, and graph diagrams (and `mermaid` alone also draws inline in the editor, with live preview on: the diagram stands in for the fence until your cursor enters it, which brings the source back for editing); `tikz` for LaTeX-native coordinate systems, commutative diagrams, and figure-quality plots (the TeX engine runs on-device so no network is required); `jsxgraph` for interactive geometry and function plots driven by a small JSON config; and `function-plot` for compact Cartesian function plotting. Each block is ordinary markdown on disk, so the source remains portable and diffable.' }, { title: 'Equation environments number themselves', @@ -437,7 +442,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Workflows plan first and write second', body: - 'A workflow is a plain `.md` file under `.zennotes/workflows/`: frontmatter plus one pipeline per line, like `good = books | where rating >= 4`. Wires carry sets of notes, so every wire on the canvas shows the live count flowing through it, and the canvas and the text are lossless projections of the same file (layout is computed, so no coordinates ever land in your vault). The engine can only propose changes: running shows the full dry-run diff before anything is applied, applying journals every file\'s pre-run bytes so Undo restores them exactly, and a run that fails midway rolls the whole thing back on its own. There are no code steps, no shell, and no network, which is why a workflow you did not write is safe to read and run. In this release workflows run when you run them: an event or schedule `trigger:` in the frontmatter parses but does not fire yet, running is desktop-only, and the web client shows workflows read-only. The feature is off by default; enable it under Settings → Workflows.' + 'A workflow is a plain `.md` file under `.zennotes/workflows/`: frontmatter plus one pipeline per line, like `good = books | where rating >= 4`. Wires carry sets of notes, so every wire on the canvas shows the live count flowing through it, and the canvas and the text are lossless projections of the same file (layout is computed, so no coordinates ever land in your vault). The engine can only propose changes: running shows the full dry-run diff before anything is applied, applying journals every file\'s pre-run bytes so Undo restores them exactly, and a run that fails midway rolls the whole thing back on its own. There are no code steps, no shell, and no network, which is why a workflow you did not write is safe to read and run. In this release workflows run when you run them: an event or schedule `trigger:` in the frontmatter parses but does not fire yet. Local desktop vaults and current self-hosted web servers can author and run workflows; desktop remote workspaces remain read-only. The feature is off by default; enable it under Settings → Workflows.' }, { title: 'The workflow grammar in one card', @@ -614,7 +619,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'g g / G', action: 'Jump to top or bottom', detail: 'Move to the first or last visible result.' }, { keys: 'Enter / o', action: 'Open current result', detail: 'Open the selected task source note, tagged note, or trashed note.' }, { keys: 'x', action: 'Toggle task', detail: 'Tasks view only: check or uncheck the selected task. A checked task lingers in place for a couple of seconds before it drops into Done, so you can toggle it again to undo. Space also toggles unless Space is your Vim leader key, in which case it starts a leader sequence.' }, - { keys: '>', action: 'Forward task', detail: 'Tasks list only: forward the selected task to another note. Opens a note picker; the original becomes a forwarded record (`[>]`) linking to the target, and a fresh copy is added there, backlinked home. Forwarded tasks live under a “Forwarded” group.' }, + { keys: '>', action: 'Forward task', detail: 'Tasks list only: forward the selected task to another note. Opens a note picker that filters as you type and preselects the first match, so Enter forwards without reaching for an arrow key. The original becomes a forwarded record (`[>]`) linking to the target, and a fresh copy is added there, backlinked home, subtasks and all. Forwarded tasks live under a “Forwarded” group.' }, { keys: 'i', action: 'Mark task in progress', detail: 'Tasks list only: mark the selected task as started (`- [/]`), or set it back to open. In-progress tasks stay in Today and on the calendar, so the row keeps its place.' }, { keys: 'c', action: 'Cancel task', detail: 'Tasks list only: mark the selected task as intentionally abandoned (`- [-]`), or un-cancel it. Cancelled tasks live under a “Cancelled” group, out of Today and Done.' }, { keys: 'K / J', action: 'Move task up / down', detail: 'Tasks list only: reorder the selected task within its group. Works with Vim mode on or off.' }, diff --git a/packages/app-core/src/lib/internal-links.test.ts b/packages/app-core/src/lib/internal-links.test.ts index e442bfdd..480e43b8 100644 --- a/packages/app-core/src/lib/internal-links.test.ts +++ b/packages/app-core/src/lib/internal-links.test.ts @@ -21,7 +21,7 @@ describe('resolveInternalNoteHref', () => { it('resolves a same-folder relative link', () => { expect(resolveInternalNoteHref(from, 'Another Note.md', NOTES)).toEqual({ path: 'Work/Documentation/Another Note.md', - heading: null + anchor: null }) }) @@ -44,7 +44,14 @@ describe('resolveInternalNoteHref', () => { it('carries a #heading anchor', () => { expect(resolveInternalNoteHref(from, 'Another%20Note.md#My%20Heading', NOTES)).toEqual({ path: 'Work/Documentation/Another Note.md', - heading: 'My Heading' + anchor: 'My Heading' + }) + }) + + it('carries an Obsidian #^block fragment raw, for the dispatcher to type (#601 review)', () => { + expect(resolveInternalNoteHref(from, 'Another%20Note.md#^note-two', NOTES)).toEqual({ + path: 'Work/Documentation/Another Note.md', + anchor: '^note-two' }) }) diff --git a/packages/app-core/src/lib/internal-links.ts b/packages/app-core/src/lib/internal-links.ts index 68391146..e958153e 100644 --- a/packages/app-core/src/lib/internal-links.ts +++ b/packages/app-core/src/lib/internal-links.ts @@ -10,8 +10,11 @@ export interface InternalNoteLink { /** Vault-relative path of the resolved note. */ path: string - /** A `#heading` anchor carried by the link, or null. */ - heading: string | null + /** The raw fragment carried by the link, or null: heading text for + * `Note.md#Heading`, `^id` for the Obsidian block form `Note.md#^id`. + * Callers hand it to `openWikilinkTarget` (as `#`) so the anchor + * KIND is decided in one place, not per click surface. (#601) */ + anchor: string | null } interface NoteRef { @@ -90,7 +93,7 @@ export function resolveInternalNoteHref( const hashIdx = raw.indexOf('#') const rawPath = hashIdx >= 0 ? raw.slice(0, hashIdx) : raw if (!rawPath) return null // pure "#heading" — same note, handled elsewhere - const heading = hashIdx >= 0 ? decode(raw.slice(hashIdx + 1)).trim() || null : null + const anchor = hashIdx >= 0 ? decode(raw.slice(hashIdx + 1)).trim() || null : null const decoded = decode(rawPath) const noteDir = notePath.includes('/') ? notePath.slice(0, notePath.lastIndexOf('/')) : '' @@ -103,7 +106,7 @@ export function resolveInternalNoteHref( if (!target || target === '..' || target.startsWith('../')) return null const match = matchNote(notes, target) - return match ? { path: match, heading } : null + return match ? { path: match, anchor } : null } function unwrapMdUrl(url: string): string { diff --git a/packages/app-core/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts index b5d4fea1..3c0dd31f 100644 --- a/packages/app-core/src/lib/markdown.test.ts +++ b/packages/app-core/src/lib/markdown.test.ts @@ -16,6 +16,38 @@ describe('renderMarkdown', () => { expect(toml).not.toContain('title =') }) + it('hides trailing and standalone block ids from rendered prose (#601)', () => { + const html = renderMarkdown( + ['A trailing marker. ^trailing', '', 'A standalone marker names this.', '', '^standalone'].join( + '\n' + ) + ) + + expect(html).toContain('A trailing marker.') + expect(html).toContain('A standalone marker names this.') + expect(html).not.toContain('^trailing') + expect(html).not.toContain('^standalone') + }) + + it('never deletes prose that merely resembles a block id (#601 review)', () => { + const html = renderMarkdown('See note ^ref *below* for details\n\n| a |\n| - |\n| 10 ^2 |') + // The caret is mid-line, so it is prose, and the joining space survives. + expect(html).toContain('^ref below') + expect(html).toContain('10 ^2') + }) + + it('strips a genuine anchor on a non-final paragraph line (#601 review)', () => { + const html = renderMarkdown('first line ^mid\nsecond line') + expect(html).not.toContain('^mid') + expect(html).toContain('first line') + expect(html).toContain('second line') + }) + + it('leaves code-fence and frontmatter carets untouched (#601 review)', () => { + const html = renderMarkdown('```bash\nkill %1 ^Z2\n```') + expect(html).toContain('^Z2') + }) + it('sanitizes raw HTML and javascript URLs', () => { const html = renderMarkdown( [ diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index fcde3fa4..82a62347 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -15,6 +15,8 @@ import type { Root as MdRoot } from 'mdast' import type { Root as HastRoot, Element as HastElement } from 'hast' import type { VFile } from 'vfile' import { recordRendererPerf } from './perf' +import { stripBlockAnchorMarkers } from './block-anchors' +import { wikilinkDisplayLabel } from './wikilinks' import { classifyLocalAssetHref } from './local-assets' import { parseEmbedSizeHint, splitEmbedLabel } from './excalidraw-preview' import { parseColWidthsComment } from './markdown-table' @@ -146,7 +148,12 @@ function remarkWikilinks() { 'data-wikilink': target } }, - children: [{ type: 'text', value: label }] + // An un-aliased anchored link would otherwise read `Daily Note^note-two` + // mid-sentence; the label is `target` exactly when no alias was + // given. (#601) + children: [ + { type: 'text', value: label === target ? wikilinkDisplayLabel(target) : label } + ] } } @@ -1247,10 +1254,16 @@ export function renderMarkdown(src: string): string { const startedAt = performance.now() try { + // Anchor markers are stripped from the SOURCE, with the parser's own + // line-level grammar, before remark ever sees it. Stripping per mdast + // text node deleted real prose (a mid-line `^word` before emphasis) and + // missed genuine anchors on non-final paragraph lines. (#601) const html = sanitizeRenderedHtml( String( activeProcessor().processSync( - escapeTableMathPipes(normalizeBlockMathFences(src, markdownLooseMathDelimiters())) + escapeTableMathPipes( + normalizeBlockMathFences(stripBlockAnchorMarkers(src), markdownLooseMathDelimiters()) + ) ) ) ) diff --git a/packages/app-core/src/lib/outline.ts b/packages/app-core/src/lib/outline.ts index 7aaa3a73..e29a754f 100644 --- a/packages/app-core/src/lib/outline.ts +++ b/packages/app-core/src/lib/outline.ts @@ -16,6 +16,13 @@ * character offset where the heading line starts — useful when the * caller already has the full body in hand. */ +import { scanMarkdownLines } from '@shared/markdown-lines' + +// The line walker (frontmatter and fence rules) lives in shared-domain now so +// the block-anchor grammar and the DOCX export share it; this re-export keeps +// the historical app-core import path working. +export { scanMarkdownLines, type MarkdownLine } from '@shared/markdown-lines' + export interface OutlineItem { level: number // 1..6 text: string @@ -25,77 +32,17 @@ export interface OutlineItem { const ATX_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/ const SETEXT_UNDERLINE_RE = /^(=+|-+)\s*$/ -// A fenced code block opens with a run of >=3 backticks or tildes; the second -// group is the rest of the line (the info string). -const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})(.*)$/ -// A closing fence is a run of fence characters alone on its line, save for -// trailing whitespace (no info string). -const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})[ \t]*$/ - -/** - * 0-based index of the closing `---` of a leading YAML frontmatter block, - * or -1 when the body has none. Matches the editor's frontmatter detection - * (cm-wysiwyg-blocks): the very first line must be `---`, and the block runs - * to the next `---` line. - */ -function frontmatterEndIndex(lines: string[]): number { - if (lines.length < 2 || lines[0].trim() !== '---') return -1 - for (let i = 1; i < lines.length; i++) { - if (lines[i].trim() === '---') return i - } - return -1 -} export function parseOutline(body: string): OutlineItem[] { const items: OutlineItem[] = [] - if (!body) return items - - const lines = body.split('\n') - // Everything up to and including this line is frontmatter and is skipped. - const frontmatterEnd = frontmatterEndIndex(lines) - // The marker run (``` / ~~~) that opened the current fence, or null when not - // inside one. Tracking the exact marker — instead of toggling a boolean on - // any fence-looking line — means a `~~~` line can't close a ``` block, a - // longer closer is required for a longer opener, and an inline `​```…``` ` - // code span isn't mistaken for a block fence (#249). - let fence: string | null = null - let offset = 0 - - for (let i = 0; i < lines.length; i++) { - const raw = lines[i] - const lineStart = offset - offset += raw.length + 1 // +1 for the stripped newline - - // Skip the leading frontmatter block wholesale — its fences and `key: value` - // lines are YAML, not markdown, and must never surface as outline entries. - if (i <= frontmatterEnd) continue - - if (fence) { - const close = raw.match(FENCE_CLOSE_RE) - if (close && close[1][0] === fence[0] && close[1].length >= fence.length) { - fence = null - } - continue - } - - const open = raw.match(FENCE_OPEN_RE) - if (open) { - const [, marker, info] = open - // A backtick fence's info string may not contain backticks; when it does, - // the line is an inline code span (e.g. ```[[link]]```), not a block - // fence — so don't enter a fence, and let heading parsing fall through. - if (marker[0] !== '`' || !info.includes('`')) { - fence = marker - continue - } - } + for (const { text: raw, next, line, from: lineStart } of scanMarkdownLines(body)) { const atx = raw.match(ATX_RE) if (atx) { items.push({ level: atx[1].length, text: atx[2].trim(), - line: i + 1, + line, from: lineStart }) continue @@ -104,14 +51,13 @@ export function parseOutline(body: string): OutlineItem[] { // Setext: current line is the title, next line is `===` or `---`. // Only treat it as a heading when the title line has content and // the next line is purely underline characters. - const next = lines[i + 1] if (next !== undefined && raw.trim().length > 0) { const under = next.match(SETEXT_UNDERLINE_RE) if (under) { items.push({ level: under[1].startsWith('=') ? 1 : 2, text: raw.trim(), - line: i + 1, + line, from: lineStart }) } diff --git a/packages/app-core/src/lib/transclusion.test.ts b/packages/app-core/src/lib/transclusion.test.ts index 873c4784..2255ec7d 100644 --- a/packages/app-core/src/lib/transclusion.test.ts +++ b/packages/app-core/src/lib/transclusion.test.ts @@ -98,3 +98,54 @@ describe('note-embed wrapper renders its inner markdown (foundation)', () => { expect(html).toContain('bold') }) }) + +// The real `resolve` runs through resolveWikilinkTarget, which strips the +// `#heading` / `^block` anchor before matching a note. Mirror that here. +function anchorAwareCtxFor(vault: Record) { + return { + resolve: (target: string) => { + const key = target.split(/[#^]/)[0].trim().replace(/\.md$/i, '') + return vault[key] ? { path: `${key}.md`, title: vault[key].title } : null + }, + loadNote: async (path: string) => vault[path.replace(/\.md$/i, '')]?.body ?? null + } +} + +describe('expandEmbeds: block transclusion (#601)', () => { + const vault = { + Daily: { + title: 'Daily', + body: '# Daily\n\n## Notes\n\n- First note\n- Second note ^note-two\n- Third note\n' + } + } + + it('embeds only the block the id marks, not the whole note', async () => { + const out = await expandEmbeds('![[Daily^note-two]]', 'Master.md', anchorAwareCtxFor(vault)) + expect(out).toContain('- Second note') + expect(out).not.toContain('First note') + expect(out).not.toContain('Third note') + expect(out).not.toContain('# Daily') + }) + + it('drops the marker from the embedded text', async () => { + const out = await expandEmbeds('![[Daily^note-two]]', 'Master.md', anchorAwareCtxFor(vault)) + expect(out).not.toContain('^note-two\n') + }) + + it('titles the embed with the note and the block', async () => { + const out = await expandEmbeds('![[Daily^note-two]]', 'Master.md', anchorAwareCtxFor(vault)) + expect(out).toContain('Daily > note-two') + }) + + it('shows a notice when the id is gone, rather than the whole note', async () => { + const out = await expandEmbeds('![[Daily^deleted]]', 'Master.md', anchorAwareCtxFor(vault)) + expect(out).toContain('Embedded block not found') + expect(out).not.toContain('First note') + }) + + it('still embeds the whole note when no block is named', async () => { + const out = await expandEmbeds('![[Daily]]', 'Master.md', anchorAwareCtxFor(vault)) + expect(out).toContain('First note') + expect(out).toContain('Third note') + }) +}) diff --git a/packages/app-core/src/lib/transclusion.ts b/packages/app-core/src/lib/transclusion.ts index 4c29bcf7..86379566 100644 --- a/packages/app-core/src/lib/transclusion.ts +++ b/packages/app-core/src/lib/transclusion.ts @@ -12,6 +12,9 @@ * pipeline. */ +import { extractBlock } from './block-anchors' +import { wikilinkBlockAnchor } from './wikilinks' + export interface ExpandEmbedsCtx { /** Resolve an embed target (text inside `![[…]]`) to a note, or null when it * isn't a note (image/unknown) — those are left as-is. */ @@ -64,13 +67,18 @@ function wrapEmbed(target: string, title: string, inner: string): string { } /** A non-recursing placeholder for cycles / too-deep / missing targets. */ -function notice(target: string, kind: 'circular' | 'too-deep' | 'missing'): string { +function notice( + target: string, + kind: 'circular' | 'too-deep' | 'missing' | 'missing-block' +): string { const msg = kind === 'circular' ? `⚠ Circular embed skipped` : kind === 'too-deep' ? `⚠ Embed nesting too deep — stopped here` - : `⚠ Embedded note not found` + : kind === 'missing-block' + ? `⚠ Embedded block not found` + : `⚠ Embedded note not found` return `\n\n
\n\n*${msg}: [[${target}]]*\n\n
\n\n` } @@ -110,8 +118,20 @@ async function expand( out += notice(target, 'missing') continue } - const inner = await expand(stripFrontmatter(body), note.path, ctx, [...stack, note.path], depth + 1) - out += wrapEmbed(target, note.title, inner) + // `![[Note^id]]` embeds just the block that id marks, not the whole + // note. (#601) + const blockId = wikilinkBlockAnchor(target) + let source = stripFrontmatter(body) + if (blockId) { + const block = extractBlock(body, blockId) + if (block == null) { + out += notice(target, 'missing-block') + continue + } + source = block + } + const inner = await expand(source, note.path, ctx, [...stack, note.path], depth + 1) + out += wrapEmbed(target, blockId ? `${note.title || target} > ${blockId}` : note.title, inner) } out += md.slice(last) return out diff --git a/packages/app-core/src/lib/wikilink-navigation.test.ts b/packages/app-core/src/lib/wikilink-navigation.test.ts index 53b99c13..c6eaa875 100644 --- a/packages/app-core/src/lib/wikilink-navigation.test.ts +++ b/packages/app-core/src/lib/wikilink-navigation.test.ts @@ -15,7 +15,9 @@ vi.mock('./database-links', () => ({ resolveDatabaseWikilink: () => null })) -const { openWikilinkHeading } = await import('./wikilink-navigation') +const { openWikilinkBlock, openWikilinkHeading, openWikilinkTarget } = await import( + './wikilink-navigation' +) afterEach(() => { openNoteAtOffset.mockClear() @@ -64,3 +66,78 @@ describe('[[#heading]] same-file navigation (#291)', () => { expect(selectNote).toHaveBeenCalledWith(currentNote) }) }) + +describe('[[Note^block]] navigation (#601)', () => { + const target = 'inbox/Daily Note.md' + const body = [ + '# Daily Note', + '', + '## Notes', + '', + '- First note', + '- Second note ^note-two', + '- Third note' + ].join('\n') + + it('scrolls to the block the id marks', async () => { + noteContents = { [target]: { body } } + await openWikilinkBlock(target, 'note-two') + + expect(selectNote).not.toHaveBeenCalled() + expect(openNoteAtOffset).toHaveBeenCalledTimes(1) + const [calledPath, offset, opts] = openNoteAtOffset.mock.calls[0] + expect(calledPath).toBe(target) + expect(body.slice(offset)).toMatch(/^- Second note \^note-two/) + expect(opts).toMatchObject({ scrollMode: 'start' }) + }) + + it('scrolls a standalone marker to the paragraph above it', async () => { + const standaloneBody = [ + '# Daily Note', + '', + 'First line of the named paragraph.', + 'Second line of the named paragraph.', + '', + '^standalone', + '', + 'After.' + ].join('\n') + noteContents = { [target]: { body: standaloneBody } } + + await openWikilinkBlock(target, 'standalone') + + const [, offset] = openNoteAtOffset.mock.calls[0] + expect(standaloneBody.slice(offset)).toMatch(/^First line of the named paragraph\./) + }) + + it('falls back to the top of the note when the id is gone', async () => { + noteContents = { [target]: { body } } + await openWikilinkBlock(target, 'deleted-id') + expect(openNoteAtOffset).not.toHaveBeenCalled() + expect(selectNote).toHaveBeenCalledWith(target) + }) +}) + +describe('openWikilinkTarget dispatches on the anchor kind (#601)', () => { + const target = 'inbox/Daily Note.md' + const body = '# Daily Note\n\n## Notes\n\n- Second note ^note-two\n' + + it('sends a ^block target to the block', async () => { + noteContents = { [target]: { body } } + await openWikilinkTarget(target, 'Daily Note^note-two') + expect(body.slice(openNoteAtOffset.mock.calls[0][1])).toMatch(/^- Second note/) + }) + + it('sends a #heading target to the heading', async () => { + noteContents = { [target]: { body } } + await openWikilinkTarget(target, 'Daily Note#Notes') + expect(body.slice(openNoteAtOffset.mock.calls[0][1])).toMatch(/^## Notes/) + }) + + it('opens the note plainly when the target carries no anchor', async () => { + noteContents = { [target]: { body } } + await openWikilinkTarget(target, 'Daily Note') + expect(openNoteAtOffset).not.toHaveBeenCalled() + expect(selectNote).toHaveBeenCalledWith(target) + }) +}) diff --git a/packages/app-core/src/lib/wikilink-navigation.ts b/packages/app-core/src/lib/wikilink-navigation.ts index 1e5f496c..23b58583 100644 --- a/packages/app-core/src/lib/wikilink-navigation.ts +++ b/packages/app-core/src/lib/wikilink-navigation.ts @@ -1,6 +1,8 @@ import { useStore } from '../store' +import { findBlockAnchor } from './block-anchors' import { parseOutline } from './outline' import { listDatabaseLinkTargets, resolveDatabaseWikilink } from './database-links' +import { wikilinkBlockAnchor, wikilinkHeadingAnchor } from './wikilinks' /** * If `target` names a `.base` database, open its grid and return true; otherwise @@ -26,20 +28,55 @@ export function openDatabaseFromWikilink(target: string): boolean { * preview pane so `[[Doc#Heading]]` lands on the heading. (#196) */ export async function openWikilinkHeading(path: string, headingAnchor: string): Promise { - const s = useStore.getState() - let body = s.noteContents[path]?.body - if (body == null) { - try { - body = (await window.zen.readNote(path)).body - } catch { - body = '' - } - } + const body = await noteBody(path) const needle = headingAnchor.trim().toLowerCase() const heading = parseOutline(body).find((h) => h.text.trim().toLowerCase() === needle) if (heading) { - await s.openNoteAtOffset(path, heading.from, { scrollMode: 'start' }) + await useStore.getState().openNoteAtOffset(path, heading.from, { scrollMode: 'start' }) } else { - await s.selectNote(path) + await useStore.getState().selectNote(path) + } +} + +/** + * Open `path` and scroll to the block marked `^blockAnchor`. The block-level + * twin of {@link openWikilinkHeading}, with the same fallback: an id the note + * no longer carries opens the note at the top rather than going nowhere. (#601) + */ +export async function openWikilinkBlock(path: string, blockAnchor: string): Promise { + const block = findBlockAnchor(await noteBody(path), blockAnchor) + if (block) { + await useStore.getState().openNoteAtOffset(path, block.from, { scrollMode: 'start' }) + } else { + await useStore.getState().selectNote(path) + } +} + +/** + * Open the note at `path` at whatever a raw wikilink target points to: a + * `#heading`, a `^block`, or the top of the note. + * + * Every click surface used to repeat this branch, which is why `^block` links + * quietly opened the note and stopped there for as long as they did: adding an + * anchor kind meant remembering six call sites. (#601) + */ +export async function openWikilinkTarget(path: string, target: string): Promise { + const heading = wikilinkHeadingAnchor(target) + if (heading) return openWikilinkHeading(path, heading) + + const block = wikilinkBlockAnchor(target) + if (block) return openWikilinkBlock(path, block) + + await useStore.getState().selectNote(path) +} + +/** The note's body from the store, falling back to a read, then to empty. */ +async function noteBody(path: string): Promise { + const cached = useStore.getState().noteContents[path]?.body + if (cached != null) return cached + try { + return (await window.zen.readNote(path)).body + } catch { + return '' } } diff --git a/packages/app-core/src/lib/wikilinks.test.ts b/packages/app-core/src/lib/wikilinks.test.ts index 143103ef..dc28c1f8 100644 --- a/packages/app-core/src/lib/wikilinks.test.ts +++ b/packages/app-core/src/lib/wikilinks.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from 'vitest' import { + blockAnchorsTargeting, extractWikilinkTargets, extractMarkdownLinkHrefs, + isSameFileBlockLink, isSameFileHeadingLink, parseCreateNotePath, + resolveWikilinkPath, resolveWikilinkTarget, stripWikilinkAnchor, suggestCreateNotePath, + wikilinkBlockAnchor, + wikilinkDisplayLabel, wikilinkHeadingAnchor } from './wikilinks' @@ -51,6 +56,65 @@ describe('wikilinkHeadingAnchor (#196)', () => { }) }) +describe('wikilinkBlockAnchor (#601)', () => { + it('returns the block id after ^', () => { + expect(wikilinkBlockAnchor('Daily Note^note-two')).toBe('note-two') + expect(wikilinkBlockAnchor('projects/Spec^abc123')).toBe('abc123') + }) + + it('is null without a block anchor', () => { + expect(wikilinkBlockAnchor('My Document')).toBeNull() + expect(wikilinkBlockAnchor('My Document#My Heading')).toBeNull() + expect(wikilinkBlockAnchor('My Document^')).toBeNull() + }) + + it('yields to whichever anchor marker comes first', () => { + // A heading link that happens to contain a caret stays a heading link. + expect(wikilinkBlockAnchor('Doc#Heading^id')).toBeNull() + expect(wikilinkHeadingAnchor('Doc#Heading^id')).toBe('Heading^id') + // ...and the reverse. + expect(wikilinkBlockAnchor('Doc^id#not-a-heading')).toBe('id#not-a-heading') + expect(wikilinkHeadingAnchor('Doc^id#not-a-heading')).toBeNull() + }) + + it("parses Obsidian's canonical block form Note#^id as a block link (#601 review)", () => { + expect(wikilinkBlockAnchor('Daily Note#^note-two')).toBe('note-two') + expect(wikilinkHeadingAnchor('Daily Note#^note-two')).toBeNull() + // The same-file spelling Obsidian writes. + expect(wikilinkBlockAnchor('#^note-two')).toBe('note-two') + expect(isSameFileBlockLink('#^note-two')).toBe(true) + }) +}) + +describe('wikilinkDisplayLabel (#601)', () => { + it('separates the note from the anchor it points at', () => { + expect(wikilinkDisplayLabel('Daily Note^note-two')).toBe('Daily Note > note-two') + expect(wikilinkDisplayLabel('Daily Note#Notes')).toBe('Daily Note > Notes') + }) + + it('shows just the anchor for a same-note link', () => { + expect(wikilinkDisplayLabel('^note-two')).toBe('note-two') + expect(wikilinkDisplayLabel('#Notes')).toBe('Notes') + }) + + it('leaves an un-anchored target alone', () => { + expect(wikilinkDisplayLabel('Daily Note')).toBe('Daily Note') + expect(wikilinkDisplayLabel('projects/Spec')).toBe('projects/Spec') + }) +}) + +describe('isSameFileBlockLink (#601)', () => { + it('is true for a block link with no note part', () => { + expect(isSameFileBlockLink('^note-two')).toBe(true) + }) + + it('is false when a note part is present, or there is no block anchor', () => { + expect(isSameFileBlockLink('Doc^note-two')).toBe(false) + expect(isSameFileBlockLink('#My Heading')).toBe(false) + expect(isSameFileBlockLink('^')).toBe(false) + }) +}) + describe('isSameFileHeadingLink (#291)', () => { it('is true for a heading link with no note part', () => { expect(isSameFileHeadingLink('#My Heading')).toBe(true) @@ -69,6 +133,24 @@ describe('isSameFileHeadingLink (#291)', () => { }) }) +describe('blockAnchorsTargeting (#601)', () => { + const here = 'inbox/My Document.md' + + it('collects the block ids a note aimed at here', () => { + const targets = ['My Document^note-two', 'My Document^intro', 'Other Note^elsewhere'] + expect(blockAnchorsTargeting(notes, targets, here)).toEqual(['note-two', 'intro']) + }) + + it('ignores links that carry no block anchor', () => { + expect(blockAnchorsTargeting(notes, ['My Document', 'My Document#Heading'], here)).toEqual([]) + }) + + it('de-duplicates a block referenced twice', () => { + const targets = ['My Document^note-two', 'My Document^note-two'] + expect(blockAnchorsTargeting(notes, targets, here)).toEqual(['note-two']) + }) +}) + describe('resolveWikilinkTarget — heading/block anchors (#196)', () => { it('resolves [[Doc#heading]] to the document', () => { expect(resolveWikilinkTarget(notes, 'My Document#My Heading')?.path).toBe('inbox/My Document.md') @@ -91,6 +173,28 @@ describe('resolveWikilinkTarget — heading/block anchors (#196)', () => { }) }) +describe('resolveWikilinkPath: same-note anchors (#601)', () => { + const currentPath = 'inbox/My Document.md' + + it('uses the current note for a same-note block link', () => { + expect(resolveWikilinkPath(notes, '^note-two', currentPath)).toBe(currentPath) + }) + + it('uses the current note for a same-note heading link', () => { + expect(resolveWikilinkPath(notes, '#Introduction', currentPath)).toBe(currentPath) + }) + + it('still resolves a cross-note anchored link normally', () => { + expect(resolveWikilinkPath(notes, 'projects/Spec^design', currentPath)).toBe( + 'inbox/projects/Spec.md' + ) + }) + + it('returns null when a same-note anchor has no current note', () => { + expect(resolveWikilinkPath(notes, '^note-two', null)).toBeNull() + }) +}) + describe('suggestCreateNotePath — anchored targets (#196)', () => { it('suggests the document, not the invalid anchored name', () => { expect(suggestCreateNotePath('New Doc#Heading')).toBe('/New Doc.md') diff --git a/packages/app-core/src/lib/wikilinks.ts b/packages/app-core/src/lib/wikilinks.ts index 51b8d410..b66f38d8 100644 --- a/packages/app-core/src/lib/wikilinks.ts +++ b/packages/app-core/src/lib/wikilinks.ts @@ -82,7 +82,55 @@ export function wikilinkHeadingAnchor(target: string): string | null { if (hash < 0) return null const caret = target.indexOf('^') if (caret >= 0 && caret < hash) return null // a ^block anchor comes first - return target.slice(hash + 1).trim() || null + const anchor = target.slice(hash + 1).trim() + // Obsidian writes block references as `Note#^id`: the hash is its anchor + // separator and the caret makes it a block, never a heading named "^id". + // Treating it as one sent every imported Obsidian block link to the top of + // the note. (#601) + if (anchor.startsWith('^')) return null + return anchor || null +} + +/** + * The `^block` id from a wikilink target, or null when there's no block anchor. + * `[[Doc^note-two]]` → `note-two`; `[[Doc#Heading]]` / `[[Doc]]` → null. The + * mirror of {@link wikilinkHeadingAnchor}: whichever marker comes first owns + * the anchor (`[[Doc#Heading^id]]` is a heading link), with one exception in + * Obsidian's favor: `[[Doc#^id]]`, hash immediately followed by caret, is its + * canonical block reference and parses as one. (#601) + */ +export function wikilinkBlockAnchor(target: string): string | null { + const caret = target.indexOf('^') + if (caret < 0) return null + const hash = target.indexOf('#') + if (hash >= 0 && hash < caret) { + // `Note#Heading^tail` is a heading link, but Obsidian's canonical block + // form `Note#^id` (nothing between the two markers) is a block link. + if (target.slice(hash + 1, caret).trim() !== '') return null + } + return target.slice(caret + 1).trim() || null +} + +/** + * True for `[[^block]]`: a wikilink whose note part is empty, so it targets a + * block *in the current note*, the block-level twin of + * {@link isSameFileHeadingLink}. (#601) + */ +export function isSameFileBlockLink(target: string): boolean { + return stripWikilinkAnchor(target).trim() === '' && wikilinkBlockAnchor(target) != null +} + +/** + * What an un-aliased `[[target]]` should read as. An anchored target is + * addressing, so showing it raw puts `Daily Note^note-two` in the middle of a + * sentence; separate the note from what it points at instead. A target with no + * anchor is already its own label. (#601) + */ +export function wikilinkDisplayLabel(target: string): string { + const anchor = wikilinkHeadingAnchor(target) ?? wikilinkBlockAnchor(target) + if (!anchor) return target + const note = stripWikilinkAnchor(target).trim() + return note ? `${note} > ${anchor}` : anchor } /** @@ -144,6 +192,25 @@ export function resolveWikilinkTarget(notes: T[], target: str return visible.find((note) => normalizeForCompare(note.title) === needle) ?? null } +/** + * The note path a wikilink should open, including same-note `[[#heading]]` and + * `[[^block]]` targets whose note part is intentionally empty. Keeping this + * decision beside resolution prevents keyboard and raw-link entry points from + * rejecting the target before anchor navigation gets a chance to dispatch it. + */ +export function resolveWikilinkPath( + notes: T[], + target: string, + currentPath: string | null | undefined +): string | null { + const resolved = resolveWikilinkTarget(notes, target) + if (resolved) return resolved.path + if (currentPath && (isSameFileHeadingLink(target) || isSameFileBlockLink(target))) { + return currentPath + } + return null +} + export function backlinksForNote>( notes: T[], current: Pick @@ -159,6 +226,29 @@ export function backlinksForNote return out } +/** + * The `^block` ids among `targets` that point into the note at `currentPath`, + * de-duplicated and in first-seen order. + * + * Backlinks are resolved with the anchor stripped, so a note that reached for + * one specific block looks identical to one that linked at the whole page. + * This recovers that detail for the Connections panel. (#601) + */ +export function blockAnchorsTargeting( + notes: T[], + targets: string[], + currentPath: string +): string[] { + const seen = new Set() + for (const target of targets) { + const block = wikilinkBlockAnchor(target) + if (!block) continue + if (resolveWikilinkTarget(notes, target)?.path !== currentPath) continue + seen.add(block) + } + return [...seen] +} + export function extractWikilinkTargets(body: string): string[] { const stripped = stripCodeContent(body) const re = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g diff --git a/packages/app-core/src/lib/workflow-tutorial.ts b/packages/app-core/src/lib/workflow-tutorial.ts index 9bb0e32c..44bab648 100644 --- a/packages/app-core/src/lib/workflow-tutorial.ts +++ b/packages/app-core/src/lib/workflow-tutorial.ts @@ -221,8 +221,8 @@ export const TUTORIAL_STEPS: readonly TutorialStep[] = [ /* -------------------------------------------------------------------------- */ /** The slice of the bridge the tutorial needs, injectable so tests can run it - * against a fake. `deleteWorkflowRuns` is optional because the web bridge - * does not have it (workflows are read-only there anyway). */ + * against a fake. `deleteWorkflowRuns` stays optional for older or minimal + * injected bridges; current desktop and web hosts both provide it. */ export interface TutorialBridge { writeNote(relPath: string, body: string): Promise createFolder(folder: 'inbox', subpath: string): Promise diff --git a/packages/app-core/src/lib/workflow-workspace.test.ts b/packages/app-core/src/lib/workflow-workspace.test.ts new file mode 100644 index 00000000..d20394a5 --- /dev/null +++ b/packages/app-core/src/lib/workflow-workspace.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { canManageWorkflows } from './workflow-workspace' + +describe('canManageWorkflows', () => { + it('enables workflows in web workspaces backed by a capable server', () => { + expect(canManageWorkflows('web', 'local', { supportsWorkflows: true })).toBe(true) + }) + + it('keeps older web servers and remote desktop workspaces read-only', () => { + expect(canManageWorkflows('web', 'local', {})).toBe(false) + expect(canManageWorkflows('desktop', 'remote', { supportsWorkflows: true })).toBe(false) + }) + + it('retains local desktop workflow support', () => { + expect(canManageWorkflows('desktop', 'local', {})).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/workflow-workspace.ts b/packages/app-core/src/lib/workflow-workspace.ts new file mode 100644 index 00000000..248ce7b8 --- /dev/null +++ b/packages/app-core/src/lib/workflow-workspace.ts @@ -0,0 +1,11 @@ +/** Whether this renderer is paired with a host that can keep workflow files + * and run journals in the vault. Remote desktop workspaces remain read-only + * until their Electron bridge delegates these calls to the remote server. */ +export function canManageWorkflows( + runtime: 'desktop' | 'web', + workspaceMode: 'local' | 'remote', + capabilities: { supportsWorkflows?: boolean } +): boolean { + if (workspaceMode === 'remote') return false + return runtime === 'desktop' || capabilities.supportsWorkflows === true +} diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 16267377..a50a2483 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -759,6 +759,37 @@ describe('cancelTaskFromList (#450)', () => { }) }) +describe('task forwarding carries the subtree (#611)', () => { + it('moves subtasks with the parent and leaves the whole block as the source record', async () => { + installZen() + const { useStore } = await loadStore() + const srcBody = ['- [ ] Main task', ' - [ ] sub a', ' - [x] sub b'].join('\n') + const task = makeTask('Main task', 0) + useStore.setState({ + notes: [makeNote(srcBody, 'inbox/Note.md'), makeNote('# Work', 'inbox/Work.md')], + noteContents: { + 'inbox/Note.md': makeNote(srcBody, 'inbox/Note.md'), + 'inbox/Work.md': makeNote('# Work', 'inbox/Work.md') + }, + vaultTasks: [task] + }) + + await useStore.getState().forwardTask(task, 'inbox/Work.md') + + const state = useStore.getState() + expect(state.noteContents['inbox/Note.md'].body).toBe( + ['- [>] Main task [[Work]]', ' - [>] sub a', ' - [x] sub b'].join('\n') + ) + expect(state.noteContents['inbox/Work.md'].body).toBe( + ['# Work', '- [ ] Main task [[Note]]', ' - [ ] sub a', ' - [x] sub b', ''].join('\n') + ) + // The rebuilt index keeps the open work only in the destination; the + // source subtree is a forwarded record plus its done history. + const open = state.vaultTasks.filter((t) => !t.checked && !t.forwarded) + expect(open.map((t) => t.sourcePath)).toEqual(['inbox/Work.md', 'inbox/Work.md']) + }) +}) + describe('optimistic task state transitions (#512)', () => { it('starting a completed file task immediately clears every competing state', async () => { installZen() diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 58f25854..ba32842b 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -76,13 +76,13 @@ import { FENCE_RE, TASK_LINE_RE, extractOpenTaskBlocks, + forwardTaskSubtreeAtIndex, insertTasksUnderTasksHeading, moveTaskLine, removeTaskAtIndex, takeTaskLineAtIndex, setTaskCheckedAtIndex, setTaskDueAtIndex, - setTaskForwardedAtIndex, setTaskCancelledAtIndex, setTaskInProgressAtIndex, setTaskPriorityAtIndex, @@ -5602,14 +5602,22 @@ export const useStore = create((set, get) => { const backLink = `[[${task.noteTitle}]]` const forwardLink = `[[${targetMeta.title}]]` - // Original: flip to `[>]` and record where it went. - const nextSrc = setTaskForwardedAtIndex(srcBody, task.taskIndex, forwardLink) + // Original: flip to `[>]` and record where it went. The task's indented + // subtree is part of the record: open subtasks flip to `[>]` with the + // parent, done and cancelled subtasks keep their state (#611). + const { body: nextSrc, childLines } = forwardTaskSubtreeAtIndex( + srcBody, + task.taskIndex, + forwardLink + ) if (nextSrc === srcBody) return - // Copy: a fresh open task in the target, backlinked to the origin. Slot it - // under the target's `## Tasks` heading when it has one, else append (#452). + // Copy: a fresh open task in the target, backlinked to the origin, with the + // subtree beneath it verbatim so the destination holds a faithful copy of + // the task's current state (#611). Slot it under the target's `## Tasks` + // heading when it has one, else append (#452). const copyLine = `- [ ] ${task.content} ${backLink}`.replace(/\s+$/u, '') - const nextTgt = insertTasksUnderTasksHeading(tgtBody, [copyLine]) + const nextTgt = insertTasksUnderTasksHeading(tgtBody, [copyLine, ...childLines]) if (srcBuffer) get().updateNoteBody(task.sourcePath, nextSrc) else { diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 882f420a..1a40b5d2 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.28.2", + "version": "2.29.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index c6e230d1..303cb246 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -96,6 +96,9 @@ export interface ZenCapabilities { /** Custom templates require local-filesystem CRUD; false on web/remote. */ supportsCustomTemplates: boolean supportsCustomCodeLanguages?: boolean + /** Local desktop support, or a web client paired with a server that owns + * workflow files and journalled apply/undo. */ + supportsWorkflows?: boolean } export interface ZenAppInfo { @@ -204,8 +207,8 @@ export interface ZenBridge { /** * Raw contents of every `.zennotes/workflows/*.md` file, newest name order. * Parsing lives in `@shared/workflows/parse` so the format has one home, the - * same split the templates API uses. Returns [] where the filesystem is not - * reachable (web, remote workspaces). + * same split the templates API uses. Returns [] where the host cannot reach + * workflow storage (older web servers and remote desktop workspaces). */ listWorkflows(): Promise /** Create or overwrite a workflow file; returns the saved file. */ diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 9384ca8e..45e9296a 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -742,6 +742,9 @@ export interface ServerCapabilities { * (delete/duplicate/restore/purge). Absent on servers before 2.24, which * is what turns a bare 404 into a "server needs an update" message. */ supportsAssetOps?: boolean + /** Server-side workflow file CRUD plus journalled apply/undo. Absent before + * 2.29, where the web client must keep Workflows read-only. */ + supportsWorkflows?: boolean } export interface ServerSessionStatus { diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index a6b8dace..2c10d4ae 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.28.2", + "version": "2.29.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/block-anchors.test.ts b/packages/shared-domain/src/block-anchors.test.ts new file mode 100644 index 00000000..c47b10fa --- /dev/null +++ b/packages/shared-domain/src/block-anchors.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + extractBlock, + findBlockAnchor, + parseBlockAnchors, + stripBlockAnchorMarkers +} from './block-anchors' + +describe('stripBlockAnchorMarkers (#601 review)', () => { + it('leaves mid-line carets alone: only what the parser accepts is stripped', () => { + const src = 'See note ^ref *below* for details' + expect(stripBlockAnchorMarkers(src)).toBe(src) + expect(stripBlockAnchorMarkers('| 10 ^2 |')).toBe('| 10 ^2 |') + expect(stripBlockAnchorMarkers('x ^ y')).toBe('x ^ y') + }) + + it('strips a genuine anchor even on a non-final paragraph line', () => { + expect(stripBlockAnchorMarkers('first line ^mid\nsecond line')).toBe( + 'first line\nsecond line' + ) + }) + + it('never touches code fences or frontmatter', () => { + const code = '```bash\nkill %1 ^Z2\n```' + expect(stripBlockAnchorMarkers(code)).toBe(code) + const fm = '---\nalias: ^my-alias\n---\nbody text' + expect(stripBlockAnchorMarkers(fm)).toBe(fm) + }) + + it('blanks a standalone marker line without changing the line count', () => { + const src = 'A paragraph.\n\n^tag\n\nNext.' + const out = stripBlockAnchorMarkers(src) + expect(out.split('\n').length).toBe(src.split('\n').length) + expect(out).toBe('A paragraph.\n\n\n\nNext.') + }) +}) + +describe('standalone markers respect frontmatter and fences (#601 review)', () => { + it('a marker directly after frontmatter does not anchor the YAML', () => { + const body = '---\ntitle: X\n---\n^intro\n\nReal content.' + const anchor = parseBlockAnchors(body)[0] + expect(anchor.id).toBe('intro') + // Nothing markdown-owned sits above, so the anchor stays on its own line. + expect(anchor.line).toBe(4) + expect(extractBlock(body, 'intro')).toBeNull() + }) + + it('a marker after a fenced block tags the whole fence, markers included', () => { + const body = 'Intro.\n\n```js\nconst a = 1\n```\n^snippet' + const anchor = findBlockAnchor(body, 'snippet') + expect(anchor?.line).toBe(3) + expect(extractBlock(body, 'snippet')).toBe('```js\nconst a = 1\n```') + }) +}) + +describe('extractBlock walks (#601 review)', () => { + it('carries loose children and whole fences under a marked list item', () => { + const body = [ + '- Parent ^id', + ' ```txt', + ' inside', + '', + ' still inside', + ' ```', + '', + ' loose child paragraph', + '- Sibling' + ].join('\n') + expect(extractBlock(body, 'id')).toBe( + [ + '- Parent', + ' ```txt', + ' inside', + '', + ' still inside', + ' ```', + '', + ' loose child paragraph' + ].join('\n') + ) + }) + + it('stops a paragraph at a fence boundary instead of climbing into it', () => { + const body = '```\ncode line\n```\ntext ^id' + expect(extractBlock(body, 'id')).toBe('text') + }) +}) diff --git a/packages/shared-domain/src/block-anchors.ts b/packages/shared-domain/src/block-anchors.ts new file mode 100644 index 00000000..cdf2a22f --- /dev/null +++ b/packages/shared-domain/src/block-anchors.ts @@ -0,0 +1,270 @@ +import { + FENCE_CLOSE_RE, + FENCE_OPEN_RE, + frontmatterEndIndex, + scanMarkdownLines +} from './markdown-lines' + +/** + * Obsidian-style block ids: a `^id` marker at the very end of a line, naming + * the block on that line so `[[Note^id]]` can point at it. (#601) + * + * The marker must end the line and sit at a word boundary, which is what keeps + * it apart from a caret used as an operator: `2^3` and `x ^ y` are not ids, + * while `- Second note ^note-two` and a bare `^note-two` on its own line are. + * Ids are alphanumeric with hyphens, matching what Obsidian generates and + * accepts, so a stray `^` in prose cannot silently become an anchor. + * + * Lives in shared-domain so every markdown pipeline (reading view, DOCX + * export, share viewer) strips and resolves the same grammar the parser + * defines, instead of each renderer approximating it. + */ +const BLOCK_ID_RE = /(?:^|\s)\^([A-Za-z0-9][A-Za-z0-9-]*)\s*$/ + +/** + * The `^id` marker ending a single line, as offsets within that line, or null + * when the line carries none. Rendering uses this to hide the marker; the + * parser below uses it so both agree on what an id is. + */ +export function trailingBlockIdRange( + lineText: string +): { id: string; from: number; to: number } | null { + const match = lineText.match(BLOCK_ID_RE) + if (!match) return null + + // `index` points at the boundary character (the space before `^`) unless the + // marker starts the line, so find the caret itself. + const from = lineText.indexOf('^', match.index ?? 0) + return { id: match[1], from, to: from + 1 + match[1].length } +} + +export interface BlockAnchor { + id: string + /** 1-based line number of the block the id marks. */ + line: number + /** 0-based char offset where that line starts, for jumping to the block. */ + from: number + /** 1-based line number carrying the literal `^id` marker. */ + markerLine: number + /** 0-based char offset where the marker's line starts. */ + markerLineFrom: number + /** 0-based char offsets of the `^id` marker itself, for hiding it. */ + markerFrom: number + markerTo: number +} + +/** Everything the block walks need to know about a body's lines: the raw + * lines, their offsets, which lines markdown owns (outside frontmatter and + * fences), and where the frontmatter ends. */ +interface BodyMap { + lines: string[] + lineStarts: number[] + owned: boolean[] + frontmatterEnd: number +} + +function mapBody(body: string): BodyMap { + const lines = body.split('\n') + const lineStarts: number[] = [] + let offset = 0 + for (const line of lines) { + lineStarts.push(offset) + offset += line.length + 1 + } + const owned = new Array(lines.length).fill(false) + for (const { line } of scanMarkdownLines(body)) owned[line - 1] = true + return { lines, lineStarts, owned, frontmatterEnd: frontmatterEndIndex(lines) } +} + +/** + * The 0-based [start, end] line range of the block a standalone marker at + * `markerIndex` names: the block directly above it. Null when nothing usable + * sits above (top of note, or only frontmatter: frontmatter is metadata, never + * a block, and anchoring it embedded raw YAML). A fenced code block above IS a + * block, fence markers included, matching how a bare `^id` tags whatever it + * follows. + */ +function standaloneBlockRange( + map: BodyMap, + markerIndex: number +): { start: number; end: number } | null { + const { lines, owned, frontmatterEnd } = map + let previous = markerIndex - 1 + while (previous >= 0 && owned[previous] && lines[previous].trim() === '') previous-- + if (previous < 0 || previous <= frontmatterEnd) return null + if (lines[previous].trim() === '') return null + if (!owned[previous]) { + // Fence territory: the whole contiguous unowned run is the block. + let start = previous + while (start - 1 > frontmatterEnd && !owned[start - 1]) start-- + let end = previous + while (end + 1 < markerIndex && !owned[end + 1]) end++ + return { start, end } + } + let start = previous + while (start - 1 > frontmatterEnd && owned[start - 1] && lines[start - 1].trim() !== '') start-- + return { start, end: previous } +} + +/** + * Every block id in a note body, in document order. Frontmatter and fenced + * code are skipped, so a `^id` inside a code sample is not an anchor. + * + * A repeated id keeps every occurrence: the note is the user's file and we do + * not get to reject it. Lookup resolves to the first, which is the same rule + * headings already follow. + */ +export function parseBlockAnchors(body: string): BlockAnchor[] { + const anchors: BlockAnchor[] = [] + const map = mapBody(body) + + for (const { text, line, from } of scanMarkdownLines(body)) { + const marker = trailingBlockIdRange(text) + if (!marker) continue + + let targetLine = line + let targetFrom = from + const markerIsStandalone = text.slice(0, marker.from).trim() === '' + if (markerIsStandalone) { + const range = standaloneBlockRange(map, line - 1) + if (range) { + targetLine = range.start + 1 + targetFrom = map.lineStarts[range.start] + } + } + + anchors.push({ + id: marker.id, + line: targetLine, + from: targetFrom, + markerLine: line, + markerLineFrom: from, + markerFrom: from + marker.from, + markerTo: from + marker.to + }) + } + + return anchors +} + +/** + * The block a `^id` anchor points at, or null when the note has no such id. + * Ids are matched case-insensitively, the way heading anchors are. + */ +export function findBlockAnchor(body: string, id: string): BlockAnchor | null { + const needle = id.trim().replace(/^\^/, '').toLowerCase() + if (!needle) return null + + return parseBlockAnchors(body).find((anchor) => anchor.id.toLowerCase() === needle) ?? null +} + +const LIST_ITEM_RE = /^(\s*)(?:[-+*]|\d+[.)])\s/ + +/** + * The text of the block a `^id` marks, with the marker removed, for embedding + * it elsewhere with `![[Note^id]]`. Null when the note has no such id. + * + * What counts as "the block" follows how the marker was written: + * - on a list item, the item and everything indented under it. Loose + * children (separated by blank lines) belong to the item, and a fenced + * code block inside a child is carried whole: cutting one open mid-fence + * leaked an unclosed ``` into the embedding note and swallowed the rest + * of its rendering; + * - on any other line, the paragraph that line belongs to, bounded by + * markdown-owned lines so it can never climb into a fence or frontmatter; + * - alone on its own line, the block directly above it, which is how + * Obsidian lets you tag a block without touching its text. + */ +export function extractBlock(body: string, id: string): string | null { + const anchor = findBlockAnchor(body, id) + if (!anchor) return null + + const map = mapBody(body) + const { lines } = map + const index = anchor.markerLine - 1 + const marked = lines[index] ?? '' + const withoutMarker = marked + .slice(0, anchor.markerFrom - anchor.markerLineFrom) + .replace(/[ \t]+$/, '') + + // A marker on its own line describes the block above it. + if (withoutMarker.trim() === '') { + const range = standaloneBlockRange(map, index) + if (!range) return null + return lines.slice(range.start, range.end + 1).join('\n').trim() || null + } + + const list = withoutMarker.match(LIST_ITEM_RE) + if (list) { + // Keep everything indented under the item: wrapped text, children, loose + // sub-paragraphs, and any fenced block in full. Only a dedent to the + // item's level (outside a fence) or the end of the note closes it. + const indent = list[1].length + const collected = [withoutMarker] + let lastContent = 0 + let childFence: string | null = null + for (let i = index + 1; i < lines.length; i++) { + const line = lines[i] + if (childFence) { + collected.push(line) + lastContent = collected.length - 1 + const close = line.match(FENCE_CLOSE_RE) + if (close && close[1][0] === childFence[0] && close[1].length >= childFence.length) { + childFence = null + } + continue + } + if (line.trim() === '') { + let j = i + 1 + while (j < lines.length && lines[j].trim() === '') j++ + const next = lines[j] + if (next === undefined) break + const nextIndent = next.length - next.trimStart().length + if (nextIndent <= indent) break + collected.push(line) + continue + } + const lineIndent = line.length - line.trimStart().length + if (lineIndent <= indent) break + collected.push(line) + lastContent = collected.length - 1 + const open = line.match(FENCE_OPEN_RE) + if (open && (open[1][0] !== '`' || !open[2].includes('`'))) childFence = open[1] + } + return collected.slice(0, lastContent + 1).join('\n').trim() || null + } + + // An ordinary line: take the paragraph it sits in, never crossing out of + // markdown-owned territory. + let first = index + while (first > 0 && map.owned[first - 1] && lines[first - 1].trim() !== '') first-- + let last = index + while (last + 1 < lines.length && map.owned[last + 1] && lines[last + 1].trim() !== '') last++ + const paragraph = lines.slice(first, last + 1) + paragraph[index - first] = withoutMarker + return paragraph.join('\n').trim() || null +} + +/** + * The note body with every `^id` anchor marker removed, for rendering. The + * marker is addressing, not prose, so reading surfaces hide it; stripping at + * the source, with the parser's own grammar, is what keeps "what navigates" + * and "what disappears" the same set of markers. A per-text-node strip here + * once deleted real prose (`See note ^ref *below*` lost its `^ref` AND the + * joining space) while leaving a genuine mid-paragraph anchor visible. + * + * Line-preserving: markers are cut from their line, marker-only lines become + * blank lines, and no line is added or removed, so source-line mappings + * (scroll sync, task indexes) stay valid. + */ +export function stripBlockAnchorMarkers(body: string): string { + const anchors = parseBlockAnchors(body) + if (anchors.length === 0) return body + const lines = body.split('\n') + for (const anchor of anchors) { + const index = anchor.markerLine - 1 + const local = anchor.markerFrom - anchor.markerLineFrom + lines[index] = (lines[index] ?? '').slice(0, local).replace(/[ \t]+$/, '') + } + return lines.join('\n') +} diff --git a/packages/shared-domain/src/cloud-sync-coordinator.test.ts b/packages/shared-domain/src/cloud-sync-coordinator.test.ts index fbfa22b8..c4bd120f 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.test.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.test.ts @@ -29,6 +29,16 @@ function content(data: string): CloudSyncContent { } } +function binaryContent(data: string): CloudSyncContent { + return { + encoding: 'base64', + data, + sha256: `hash:${data}`, + byte_length: data.length, + media_type: 'image/jpeg' + } +} + function ids(): CloudSyncIdSource { let item = 0 let operation = 0 @@ -425,6 +435,57 @@ describe('CloudSyncCoordinator', () => { expect(apply).not.toHaveBeenCalled() }) + it('checkpoints binary uploads one per request while retaining text batches', async () => { + const states = memoryState({ version: 1, vault_id: 'vault-1', cursor: 0, items: {} }) + const repository = memoryRepository([ + { path: 'a.md', kind: 'text', content: content('a') }, + { path: 'b.md', kind: 'text', content: content('b') }, + { path: 'c.jpg', kind: 'binary', content: binaryContent('c') }, + { path: 'd.jpg', kind: 'binary', content: binaryContent('d') }, + { path: 'e.jpg', kind: 'binary', content: binaryContent('e') }, + { path: 'f.jpg', kind: 'binary', content: binaryContent('f') }, + { path: 'g.md', kind: 'text', content: content('g') }, + { path: 'h.md', kind: 'text', content: content('h') } + ]) + const requests: CloudSyncMutationRequest[] = [] + let sequence = 0 + const server: CloudSyncRemote = { + async manifest() { + return { data: [], cursor: 0, next_page: null } + }, + async changes(_vaultId, after) { + return { data: [], cursor: after, has_more: false } + }, + async mutate(_vaultId, body) { + requests.push(body) + const acknowledged = body.mutations.map((mutation) => ({ + operation_id: mutation.operation_id, + item_id: mutation.item_id, + revision: 1, + sequence: ++sequence + })) + return { acknowledged, conflicts: [], cursor: sequence } + } + } + + await new CloudSyncCoordinator('vault-1', server, repository, states, ids()).sync() + + expect( + requests.map((request) => + request.mutations.map((mutation) => + mutation.type === 'upsert' ? mutation.path : mutation.type + ) + ) + ).toEqual([ + ['a.md', 'b.md'], + ['c.jpg'], + ['d.jpg'], + ['e.jpg'], + ['f.jpg'], + ['g.md', 'h.md'] + ]) + }) + it('stops initial sync on same-path content conflicts', async () => { const repository = memoryRepository([ { path: 'plan.md', kind: 'text', content: content('local') } diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index 92043583..a7517dc0 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -5,6 +5,7 @@ import type { CloudSyncLocalConflict, CloudSyncManifestItem, CloudSyncManifestResponse, + CloudSyncMutation, CloudSyncMutationRequest, CloudSyncMutationResponse } from '@zennotes/bridge-contract/cloud-sync' @@ -137,8 +138,7 @@ export class CloudSyncCoordinator { let mutationCursor = state.cursor let pushed = 0 - for (let offset = 0; offset < plan.mutations.length; offset += MUTATION_BATCH_SIZE) { - const batch = { mutations: plan.mutations.slice(offset, offset + MUTATION_BATCH_SIZE) } + for (const batch of mutationBatches(plan.mutations)) { const response = await this.remote.mutate(this.vaultId, batch) const resolution = resolveCloudSyncMutations(state, batch, response) state = resolution.state @@ -277,6 +277,37 @@ export class CloudSyncCoordinator { } } +function mutationBatches(mutations: CloudSyncMutation[]): CloudSyncMutationRequest[] { + const batches: CloudSyncMutationRequest[] = [] + let batch: CloudSyncMutation[] = [] + + const flush = (): void => { + if (batch.length === 0) return + batches.push({ mutations: batch }) + batch = [] + } + + for (const mutation of mutations) { + // The cloud server persists non-UTF-8 payloads to object storage serially. + // Isolating each one keeps several assets from exhausting one request's + // timeout and rolling back the whole batch before progress is checkpointed. + const usesObjectStorage = + mutation.type === 'upsert' && mutation.content.encoding !== 'utf8' + + if (usesObjectStorage) { + flush() + batches.push({ mutations: [mutation] }) + continue + } + + batch.push(mutation) + if (batch.length === MUTATION_BATCH_SIZE) flush() + } + + flush() + return batches +} + function manifestState( vaultId: string, cursor: number, diff --git a/packages/shared-domain/src/markdown-lines.ts b/packages/shared-domain/src/markdown-lines.ts new file mode 100644 index 00000000..a3120919 --- /dev/null +++ b/packages/shared-domain/src/markdown-lines.ts @@ -0,0 +1,94 @@ +// Line-level markdown ownership: which lines of a note body are markdown +// prose, as opposed to YAML frontmatter or the inside of a fenced code block. +// Headings, block anchors, and the reading-view marker strip are all +// line-level grammars and have to agree on what counts as markdown, so they +// share this walker instead of keeping copies of the frontmatter and fence +// rules in step. Lives in shared-domain so the desktop main process (DOCX +// export) can consume the same rules as the renderer. + +// A fenced code block opens with a run of >=3 backticks or tildes; the second +// group is the rest of the line (the info string). +export const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})(.*)$/ +// A closing fence is a run of fence characters alone on its line, save for +// trailing whitespace (no info string). +export const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})[ \t]*$/ + +/** + * 0-based index of the closing `---` of a leading YAML frontmatter block, + * or -1 when the body has none. Matches the editor's frontmatter detection + * (cm-wysiwyg-blocks): the very first line must be `---`, and the block runs + * to the next `---` line. + */ +export function frontmatterEndIndex(lines: string[]): number { + if (lines.length < 2 || lines[0].trim() !== '---') return -1 + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') return i + } + return -1 +} + +/** One body line that markdown owns: outside frontmatter, outside a code fence. */ +export interface MarkdownLine { + /** Raw line text. */ + text: string + /** The line after it, or undefined at the end of the body (setext lookahead). */ + next: string | undefined + /** 1-based line number. */ + line: number + /** 0-based char offset where the line starts. */ + from: number +} + +/** + * Walk the lines of a note body that markdown owns, skipping leading YAML + * frontmatter and the inside of fenced code blocks. + */ +export function scanMarkdownLines(body: string): MarkdownLine[] { + const scanned: MarkdownLine[] = [] + if (!body) return scanned + + const lines = body.split('\n') + // Everything up to and including this line is frontmatter and is skipped. + const frontmatterEnd = frontmatterEndIndex(lines) + // The marker run (``` / ~~~) that opened the current fence, or null when not + // inside one. Tracking the exact marker (instead of toggling a boolean on + // any fence-looking line) means a `~~~` line can't close a ``` block, a + // longer closer is required for a longer opener, and an inline `​```…``` ` + // code span isn't mistaken for a block fence (#249). + let fence: string | null = null + let offset = 0 + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] + const lineStart = offset + offset += raw.length + 1 // +1 for the stripped newline + + // Skip the leading frontmatter block wholesale: its fences and `key: value` + // lines are YAML, not markdown, and must never surface as outline entries. + if (i <= frontmatterEnd) continue + + if (fence) { + const close = raw.match(FENCE_CLOSE_RE) + if (close && close[1][0] === fence[0] && close[1].length >= fence.length) { + fence = null + } + continue + } + + const open = raw.match(FENCE_OPEN_RE) + if (open) { + const [, marker, info] = open + // A backtick fence's info string may not contain backticks; when it does, + // the line is an inline code span (e.g. ```[[link]]```), not a block + // fence: don't enter a fence, and let parsing fall through. + if (marker[0] !== '`' || !info.includes('`')) { + fence = marker + continue + } + } + + scanned.push({ text: raw, next: lines[i + 1], line: i + 1, from: lineStart }) + } + + return scanned +} diff --git a/packages/shared-domain/src/tasklists.ts b/packages/shared-domain/src/tasklists.ts index e274b4bd..c2bfd5b0 100644 --- a/packages/shared-domain/src/tasklists.ts +++ b/packages/shared-domain/src/tasklists.ts @@ -300,6 +300,66 @@ export function setTaskForwardedAtIndex( }) } +/** + * Forward the task at `taskIndex` together with its indented subtree (#611). + * + * The parent line flips to `[>]` and gains `linkToken`, exactly like + * {@link setTaskForwardedAtIndex}. Its subtree (the indented block + * `taskBlockEnd` delimits, the same walk `extractOpenTaskBlocks` uses, loose + * sub-paragraphs included) becomes part of the forwarded record. Open + * subtasks (`[ ]` / `[/]`) flip to `[>]` so they stop reading as live work in + * the source; done and cancelled subtasks keep their state, since that + * history is what the record preserves. Only the parent carries the link. + * + * `childLines` is the subtree as it read BEFORE the flip, a faithful copy of + * the task's current state, re-based to the parent's indent so the caller can + * place it under an unindented copy in the destination note. + * + * Returns the markdown unchanged (and no child lines) when the index is out of + * range or the parent line would not change. + */ +export function forwardTaskSubtreeAtIndex( + markdown: string, + taskIndex: number, + linkToken: string +): { body: string; childLines: string[] } { + const unchanged = { body: markdown, childLines: [] as string[] } + if (taskIndex < 0) return unchanged + const lines = markdown.split('\n') + const parentAt = taskLineNumbers(lines)[taskIndex] + if (parentAt == null) return unchanged + + const parentLine = lines[parentAt] + const match = parentLine.match(TASK_LINE_RE) + if (!match || !match[3].startsWith(']')) return unchanged + const tail = match[3].slice(1).replace(/\s+$/u, '') + const nextTail = !linkToken || tail.includes(linkToken) ? tail : `${tail} ${linkToken}` + const nextParent = `${match[1]}>]${nextTail}` + if (nextParent === parentLine) return unchanged + + const parentIndent = parentLine.match(/^[ \t]*/u)?.[0] ?? '' + const end = taskBlockEnd(lines, parentAt, leadingIndentWidth(parentLine)) + + // Re-base children to the parent's indent. When a child does not share the + // parent's exact whitespace prefix (tabs under a space-indented parent), + // fall back to trimming the same NUMBER of whitespace characters, so the + // copy still nests under the destination line instead of keeping its + // absolute depth. + const childLines = lines.slice(parentAt + 1, end).map((l) => { + if (l.startsWith(parentIndent)) return l.slice(parentIndent.length) + return l.replace(/^[ \t]+/u, (ws) => ws.slice(Math.min(ws.length, parentIndent.length))) + }) + + lines[parentAt] = nextParent + for (let i = parentAt + 1; i < end; i++) { + const childMatch = lines[i].match(TASK_LINE_RE) + if (childMatch && (childMatch[2] === ' ' || childMatch[2] === '/')) { + lines[i] = `${childMatch[1]}>${childMatch[3]}` + } + } + return { body: lines.join('\n'), childLines } +} + /** Mark the task line at `taskIndex` as in progress (`[/]`) when `inProgress`, * or flip it back to open (`[ ]`) when not. In progress = started but not * finished, so it stays an OPEN task everywhere: it keeps its place in Today, @@ -376,6 +436,38 @@ function leadingIndentWidth(line: string): number { return line.match(/^[ \t]*/)?.[0].length ?? 0 } +/** + * The exclusive end index of the indented block belonging to the task line at + * `start`: wrapped text, children, and loose sub-paragraphs. A blank line does + * not end the block when the content after it is still deeper-indented than + * the task, so a loose list travels whole; a dedent, a fence, or the end of + * the note closes it, and trailing blanks are left behind. + * + * Shared by the daily roll-forward and task forwarding so the two carry + * mechanisms always agree about which lines belong to a task. Stopping at the + * first blank line used to split loose lists: forwarding carried half a task's + * children and stranded the rest live in the source (#611 review). + */ +function taskBlockEnd(lines: string[], start: number, baseIndent: number): number { + let end = start + 1 + while (end < lines.length) { + const next = lines[end] + if (next.trim() === '') { + let j = end + 1 + while (j < lines.length && lines[j].trim() === '') j++ + if (j >= lines.length) break + if (FENCE_RE.test(lines[j])) break + if (leadingIndentWidth(lines[j]) <= baseIndent) break + end = j + continue + } + if (FENCE_RE.test(next)) break + if (leadingIndentWidth(next) <= baseIndent) break + end++ + } + return end +} + /** * Pull every OPEN task line — together with its indented continuation / child * lines — out of `markdown`. Used to roll unfinished tasks forward from past @@ -392,9 +484,9 @@ function leadingIndentWidth(line: string): number { * (`- [-]`) was abandoned on purpose. Carrying those forward would undo the * decision the state records. * - `- [ ]` inside fenced code blocks is ignored (never a real task). - * - A task's indented children (deeper-indented following lines, up to the - * first blank line, dedent, or fence) move with it so sub-bullets aren't - * orphaned. + * - A task's indented children move with it so sub-bullets aren't orphaned: + * deeper-indented following lines, loose sub-paragraphs included, up to a + * dedent or a fence (see `taskBlockEnd`). * * Returns the moved raw lines (in document order) and the remaining body. */ @@ -432,18 +524,14 @@ export function extractOpenTaskBlocks(markdown: string): { moved.push(line) consumed[i] = true - // Carry indented continuation/child lines along with the task. - let j = i + 1 - while (j < lines.length) { - const next = lines[j] - if (next.trim() === '') break - if (FENCE_RE.test(next)) break - if (leadingIndentWidth(next) <= baseIndent) break - moved.push(next) + // Carry indented continuation/child lines along with the task, loose + // sub-paragraphs included (see taskBlockEnd, shared with forwarding). + const blockEnd = taskBlockEnd(lines, i, baseIndent) + for (let j = i + 1; j < blockEnd; j++) { + moved.push(lines[j]) consumed[j] = true - j++ } - i = j - 1 // skip the consumed block (its children are not new tasks) + i = blockEnd - 1 // skip the consumed block (its children are not new tasks) } const rest = lines.filter((_, idx) => !consumed[idx]).join('\n') diff --git a/packages/shared-domain/src/tasks-forward.test.ts b/packages/shared-domain/src/tasks-forward.test.ts index 05312cfe..3c5744c5 100644 --- a/packages/shared-domain/src/tasks-forward.test.ts +++ b/packages/shared-domain/src/tasks-forward.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { setTaskForwardedAtIndex, TASK_LINE_RE } from './tasklists' -import { parseTasksFromBody, groupTasks, type ParseTasksContext } from './tasks' +import { forwardTaskSubtreeAtIndex, setTaskForwardedAtIndex, TASK_LINE_RE } from './tasklists' +import { + bucketTasksByDueDate, + groupTasks, + inferDailyTaskDueDates, + parseTasksFromBody, + type ParseTasksContext +} from './tasks' const ctx: ParseTasksContext = { path: 'inbox/t.md', title: 't', folder: 'inbox' } @@ -33,3 +39,147 @@ describe('task forwarding primitives (#316)', () => { expect(g.done.map((t) => t.content)).toEqual(['done']) }) }) + +describe('forwardTaskSubtreeAtIndex (#611)', () => { + it('flips the parent and its open subtasks, keeping done/cancelled history in place', () => { + const src = [ + '- [ ] Main task', + ' - [ ] sub a', + ' - [x] sub done', + ' - [-] sub dropped', + ' - [/] sub started', + '- [ ] Sibling' + ].join('\n') + const { body, childLines } = forwardTaskSubtreeAtIndex(src, 0, '[[Target]]') + expect(body).toBe( + [ + '- [>] Main task [[Target]]', + ' - [>] sub a', + ' - [x] sub done', + ' - [-] sub dropped', + ' - [>] sub started', + '- [ ] Sibling' + ].join('\n') + ) + // The copy is the subtree BEFORE the flip: a faithful snapshot, with the + // in-progress and closed states intact. + expect(childLines).toEqual([ + ' - [ ] sub a', + ' - [x] sub done', + ' - [-] sub dropped', + ' - [/] sub started' + ]) + }) + + it('carries every nesting level and plain continuation lines, tokens verbatim', () => { + const src = [ + '- [ ] Upload run due:2026-08-20 !high', + ' - [ ] country list #ops', + ' - [x] France', + ' a plain note line', + '', + '- [ ] After the blank' + ].join('\n') + const { body, childLines } = forwardTaskSubtreeAtIndex(src, 0, '[[T]]') + expect(childLines).toEqual([ + ' - [ ] country list #ops', + ' - [x] France', + ' a plain note line' + ]) + const lines = body.split('\n') + expect(lines[0]).toBe('- [>] Upload run due:2026-08-20 !high [[T]]') + expect(lines[1]).toBe(' - [>] country list #ops') + expect(lines[2]).toBe(' - [x] France') + expect(lines[3]).toBe(' a plain note line') + expect(lines[5]).toBe('- [ ] After the blank') + }) + + it('re-bases the subtree when the forwarded task is itself indented', () => { + const src = ['- [ ] Outer', ' - [ ] Inner parent', ' - [ ] deep sub'].join('\n') + const { body, childLines } = forwardTaskSubtreeAtIndex(src, 1, '[[T]]') + expect(childLines).toEqual([' - [ ] deep sub']) + expect(body.split('\n')).toEqual([ + '- [ ] Outer', + ' - [>] Inner parent [[T]]', + ' - [>] deep sub' + ]) + }) + + it('stops the subtree at a dedent, leaving siblings untouched', () => { + const src = ['- [ ] First', ' - [ ] child', '- [ ] Second', ' - [ ] second child'].join( + '\n' + ) + const { body, childLines } = forwardTaskSubtreeAtIndex(src, 0, '[[T]]') + expect(childLines).toEqual([' - [ ] child']) + expect(body).toContain('- [ ] Second\n - [ ] second child') + }) + + it('is a no-op for an out-of-range index or an already-recorded forward', () => { + expect(forwardTaskSubtreeAtIndex('- [ ] a\n - [ ] b', 5, '[[T]]')).toEqual({ + body: '- [ ] a\n - [ ] b', + childLines: [] + }) + const done = '- [>] a [[T]]\n - [ ] b' + expect(forwardTaskSubtreeAtIndex(done, 0, '[[T]]')).toEqual({ body: done, childLines: [] }) + }) + + it('counts task indexes fence-aware, like every other mutator', () => { + const src = ['```', '- [ ] fake', '```', '- [ ] real', ' - [ ] sub'].join('\n') + const { body, childLines } = forwardTaskSubtreeAtIndex(src, 0, '[[T]]') + expect(childLines).toEqual([' - [ ] sub']) + expect(body.split('\n')[3]).toBe('- [>] real [[T]]') + }) + + it('carries loose children across blank lines, like the rollover walk (#611 review)', () => { + const src = ['- [ ] Parent', ' - [ ] a', '', ' - [ ] b', '- [ ] Sibling'].join('\n') + const { body, childLines } = forwardTaskSubtreeAtIndex(src, 0, '[[T]]') + expect(childLines).toEqual([' - [ ] a', '', ' - [ ] b']) + expect(body.split('\n')).toEqual([ + '- [>] Parent [[T]]', + ' - [>] a', + '', + ' - [>] b', + '- [ ] Sibling' + ]) + }) + + it('re-bases mixed tab/space children by whitespace count (#611 review)', () => { + const src = [' - [ ] Parent', '\t\t\t- [ ] child'].join('\n') + const { childLines } = forwardTaskSubtreeAtIndex(src, 0, '[[T]]') + expect(childLines).toEqual(['\t- [ ] child']) + }) +}) + +describe('forwarded records on dated surfaces (#610)', () => { + const dayCtx = (day: string): ParseTasksContext => ({ + path: `inbox/Daily Notes/${day}.md`, + title: day, + folder: 'inbox' + }) + + it('does not infer a daily due for a forwarded record, so a carry chain stays one task', () => { + const dueByPath = new Map([ + ['inbox/Daily Notes/2026-08-15.md', '2026-08-15'], + ['inbox/Daily Notes/2026-08-16.md', '2026-08-16'] + ]) + const record = parseTasksFromBody('- [>] Pay rent [[2026-08-16]]', dayCtx('2026-08-15')) + const live = parseTasksFromBody('- [ ] Pay rent [[2026-08-15]]', dayCtx('2026-08-16')) + const out = inferDailyTaskDueDates([...record, ...live], dueByPath) + expect(out[0].due).toBeUndefined() + expect(out[1].due).toBe('2026-08-16') + expect(out[1].dueInferred).toBe(true) + }) + + it('keeps an explicit due: on a forwarded record, and buckets it on that date', () => { + const tasks = parseTasksFromBody('- [>] pay due:2026-08-20 [[X]]', dayCtx('2026-08-15')) + const out = inferDailyTaskDueDates(tasks, new Map([[tasks[0].sourcePath, '2026-08-15']])) + expect(out[0].due).toBe('2026-08-20') + expect(bucketTasksByDueDate(out).get('2026-08-20')?.length).toBe(1) + }) + + it('keeps an undated forwarded record off the calendar entirely, including unscheduled', () => { + const tasks = parseTasksFromBody('- [>] gone [[X]]\n- [ ] still here', dayCtx('2026-08-15')) + const buckets = bucketTasksByDueDate(tasks) + expect(buckets.get('unscheduled')?.map((t) => t.content)).toEqual(['still here']) + }) +}) diff --git a/packages/shared-domain/src/tasks-in-progress.test.ts b/packages/shared-domain/src/tasks-in-progress.test.ts index 8e687333..5886436c 100644 --- a/packages/shared-domain/src/tasks-in-progress.test.ts +++ b/packages/shared-domain/src/tasks-in-progress.test.ts @@ -107,6 +107,13 @@ describe('task in-progress primitives (#512)', () => { expect(rest).toBe('- [x] done\n- [-] scrapped\n- [>] gone [[X]]') }) + it('rolls a loose list forward whole, blank lines included (#611 review)', () => { + const md = ['- [ ] task', ' - [ ] a', '', ' - [ ] b', '', 'A paragraph.'].join('\n') + const { moved, rest } = extractOpenTaskBlocks(md) + expect(moved).toEqual(['- [ ] task', ' - [ ] a', '', ' - [ ] b']) + expect(rest).toBe('\nA paragraph.') + }) + it('reads a file-task `status: in-progress` as in progress, and writes it', () => { const body = '---\ntags: [task]\ntitle: Rewrite\nstatus: in-progress\n---\n\nHalf done.' const t = parseTaskFile(body, ctx) diff --git a/packages/shared-domain/src/tasks.ts b/packages/shared-domain/src/tasks.ts index 2bf7c797..fff31185 100644 --- a/packages/shared-domain/src/tasks.ts +++ b/packages/shared-domain/src/tasks.ts @@ -600,6 +600,14 @@ export function tasksDueOn(tasks: VaultTask[], iso: string): VaultTask[] { * wins, so only tasks with no `due` are touched; the result is flagged * `dueInferred` so UIs can distinguish it. Returns the same array instance when * nothing changed (cheap to call from a memo). + * + * Forwarded (`[>]`) records are exempt: the work left this note, so the note's + * date no longer says anything about when it is due. Inferring it anyway put + * the record back on the calendar and, for a task carried across daily notes, + * stamped every hop with a different date, so one task read as several (#610). + * An explicit `due:` on a forwarded record still shows, since the author wrote + * that date on the line (the case the calendar deliberately keeps, see + * `isTaskOpen`). */ export function inferDailyTaskDueDates( tasks: VaultTask[], @@ -608,7 +616,7 @@ export function inferDailyTaskDueDates( if (dueByPath.size === 0) return tasks let changed = false const out = tasks.map((task) => { - if (task.due) return task + if (task.due || task.forwarded) return task const iso = dueByPath.get(task.sourcePath) if (!iso) return task changed = true @@ -620,13 +628,18 @@ export function inferDailyTaskDueDates( /** Bucket tasks by `due` ISO date. Done (checked) and cancelled tasks are * skipped (see `isTaskOpen`); waiting tasks are kept so a `@waiting` task with * a due date still appears on the calendar on its date (#236). Tasks without a - * due date land in the special `'unscheduled'` key. */ + * due date land in the special `'unscheduled'` key. A forwarded record only + * buckets when it carries an explicit date: dated, it keeps its calendar slot + * (the copy is written without the `due:` token, see `isTaskOpen`); undated, + * it is a record of a move, not unscheduled work, and listing it beside the + * live copy made one task read as two (#610). */ export function bucketTasksByDueDate( tasks: VaultTask[] ): Map { const map = new Map() for (const task of tasks) { if (!isTaskOpen(task)) continue + if (task.forwarded && !task.due) continue const key = task.due ?? 'unscheduled' const list = map.get(key) if (list) list.push(task) diff --git a/packages/shared-domain/src/workflows/prepare-run.test.ts b/packages/shared-domain/src/workflows/prepare-run.test.ts new file mode 100644 index 00000000..49670246 --- /dev/null +++ b/packages/shared-domain/src/workflows/prepare-run.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import type { WorkflowOp } from './types' +import { prepareWorkflowRun } from './prepare-run' + +describe('prepareWorkflowRun', () => { + it('turns workflow ops into one optimistic file transaction for the server', async () => { + const files = new Map([['inbox/A.md', '# A\n']]) + const ops: WorkflowOp[] = [ + { kind: 'append', path: 'inbox/A.md', text: 'done' }, + { kind: 'archive', path: 'inbox/A.md' }, + { kind: 'notify', message: 'Archived' } + ] + + const prepared = await prepareWorkflowRun( + { workflowId: 'docker-workflow', ops }, + { + read: async (path) => files.get(path) ?? null, + systemFolderDirs: {} + } + ) + + expect(prepared).toEqual({ + workflowId: 'docker-workflow', + ops, + applied: 2, + irreversible: 1, + changes: [ + { path: 'inbox/A.md', before: '# A\n', after: null }, + { path: 'archive/A.md', before: null, after: '# A\ndone\n' } + ] + }) + }) + + it('refuses a create that would replace an existing note', async () => { + await expect( + prepareWorkflowRun( + { + workflowId: 'unsafe-create', + ops: [{ kind: 'create-note', path: 'inbox/A.md', body: '' }] + }, + { + read: async (path) => (path === 'inbox/A.md' ? '# Existing\n' : null), + systemFolderDirs: {} + } + ) + ).rejects.toThrow('create-note would replace the existing note inbox/A.md') + }) + + it('keeps a colliding archive destination unique in the prepared transaction', async () => { + const files = new Map([ + ['inbox/A.md', '# A\n'], + ['archive/A.md', '# Existing archive\n'] + ]) + + const prepared = await prepareWorkflowRun( + { + workflowId: 'collision', + ops: [ + { kind: 'append', path: 'inbox/A.md', text: 'done' }, + { kind: 'archive', path: 'inbox/A.md' } + ] + }, + { + read: async (path) => files.get(path) ?? null, + systemFolderDirs: {} + } + ) + + expect(prepared.changes).toEqual([ + { path: 'inbox/A.md', before: '# A\n', after: null }, + { path: 'archive/A 2.md', before: null, after: '# A\ndone\n' } + ]) + }) + + it('rejects malformed operations before reading or preparing files', async () => { + await expect( + prepareWorkflowRun( + { workflowId: 'malformed', ops: [{ kind: 'write-note' }] }, + { read: async () => null, systemFolderDirs: {} } + ) + ).rejects.toThrow('Workflow op 0 is not a valid operation') + }) +}) diff --git a/packages/shared-domain/src/workflows/prepare-run.ts b/packages/shared-domain/src/workflows/prepare-run.ts new file mode 100644 index 00000000..aca39ca5 --- /dev/null +++ b/packages/shared-domain/src/workflows/prepare-run.ts @@ -0,0 +1,306 @@ +import type { ApplyWorkflowInput } from '@zennotes/bridge-contract/workflows' +import { applyTextOp } from './apply-ops' +import { + folderTarget, + joinRel, + moveTarget, + normalizeRel, + noteExtensionOf, + notePathProblem, + relBasename, + relDirname, + renameTarget, + stripNoteExtension, + type SystemFolderDirs +} from './paths' +import { IRREVERSIBLE_OP_KINDS, type WorkflowOp } from './types' + +export interface WorkflowRunFileChange { + path: string + before: string | null + after: string | null +} + +export interface PreparedWorkflowRun { + workflowId: string + ops: WorkflowOp[] + applied: number + irreversible: number + changes: WorkflowRunFileChange[] +} + +export interface WorkflowRunSource { + /** Current bytes, or null when the note does not exist. */ + read(path: string): Promise + systemFolderDirs: SystemFolderDirs +} + +const PER_NOTE_TEXT_OPS = new Set([ + 'set-frontmatter', + 'add-tag', + 'remove-tag', + 'append', + 'prepend', + 'apply-template' +]) + +function stringField(record: Record, key: string): string | null { + const value = record[key] + return typeof value === 'string' ? value : null +} + +/** Validate one operation that crossed a process or HTTP boundary. + * SYNCED COPIES: the same validator exists in apps/desktop/src/main/ + * workflow-apply.ts, and the Go server keeps a field map in + * requiredWorkflowOpFields (apps/server/internal/vault/workflows.go). + * A new op kind or field lands in all three or web and desktop disagree + * about which runs are valid. */ +export function parseWorkflowOp(value: unknown): WorkflowOp | null { + if (typeof value !== 'object' || value === null) return null + const record = value as Record + const kind = stringField(record, 'kind') + const path = stringField(record, 'path') + switch (kind) { + case 'set-frontmatter': { + const field = stringField(record, 'field') + const fieldValue = stringField(record, 'value') + return path === null || field === null || fieldValue === null + ? null + : { kind, path, field, value: fieldValue } + } + case 'add-tag': + case 'remove-tag': { + const tag = stringField(record, 'tag') + return path === null || tag === null ? null : { kind, path, tag } + } + case 'move': + case 'rename': { + const to = stringField(record, 'to') + return path === null || to === null ? null : { kind, path, to } + } + case 'append': + case 'prepend': { + const text = stringField(record, 'text') + return path === null || text === null ? null : { kind, path, text } + } + case 'write-section': { + const heading = stringField(record, 'heading') + const text = stringField(record, 'text') + return path === null || heading === null || text === null + ? null + : { kind, path, heading, text } + } + case 'write-note': { + const text = stringField(record, 'text') + return path === null || text === null ? null : { kind, path, text } + } + case 'create-note': { + const body = stringField(record, 'body') + return path === null || body === null ? null : { kind, path, body } + } + case 'apply-template': { + const template = stringField(record, 'template') + return path === null || template === null ? null : { kind, path, template } + } + case 'archive': + case 'trash': + return path === null ? null : { kind, path } + case 'notify': { + const message = stringField(record, 'message') + return message === null ? null : { kind, message } + } + case 'clipboard': { + const text = stringField(record, 'text') + return text === null ? null : { kind, text } + } + default: + return null + } +} + +function parseWorkflowOps(values: unknown[]): WorkflowOp[] { + return values.map((value, index) => { + const op = parseWorkflowOp(value) + if (!op) throw new Error(`Workflow op ${index} is not a valid operation`) + return op + }) +} + +function opTargets(op: WorkflowOp, dirs: SystemFolderDirs): string[] { + switch (op.kind) { + case 'notify': + case 'clipboard': + return [] + case 'move': + return [op.path, moveTarget(op.path, op.to)] + case 'rename': + return [op.path, renameTarget(op.path, op.to)] + case 'archive': + return [op.path, folderTarget('archive', op.path, dirs)] + case 'trash': + return [op.path, folderTarget('trash', op.path, dirs)] + default: + return [op.path] + } +} + +function assertNotePath(path: string): void { + const problem = notePathProblem(path) + if (problem) throw new Error(`Workflow op path is invalid (${problem}): ${path}`) +} + +/** + * Resolve a browser-planned run into one optimistic server transaction. + * + * All transformations happen against an in-memory view first. Nothing is + * written until the server has received every pre-run and post-run byte, so it + * can verify that the vault did not change underneath the dry run, journal the + * originals, and either keep every change or roll all of them back. + */ +export async function prepareWorkflowRun( + input: ApplyWorkflowInput, + source: WorkflowRunSource +): Promise { + const workflowId = input.workflowId.trim() || 'unknown' + const ops = parseWorkflowOps(input.ops) + + for (const op of ops) { + for (const target of opTargets(op, source.systemFolderDirs)) assertNotePath(target) + } + + const live = new Map() + const journal = new Map() + const redirects = new Map() + + const read = async (path: string): Promise => { + const normalized = normalizeRel(path) + if (live.has(normalized)) return live.get(normalized) ?? null + const value = await source.read(normalized) + live.set(normalized, value) + return value + } + + const touch = async (path: string): Promise => { + const normalized = normalizeRel(path) + if (journal.has(normalized)) return + journal.set(normalized, { path: normalized, before: await read(normalized) }) + } + + const resolveLivePath = async (path: string): Promise => { + const normalized = normalizeRel(path) + const redirected = redirects.get(normalized) + if (redirected === undefined || (await read(normalized)) !== null) return normalized + return redirected + } + + const uniquePath = async (path: string): Promise => { + const ext = noteExtensionOf(path) + const stem = joinRel(relDirname(path), stripNoteExtension(relBasename(path))) + let candidate = normalizeRel(path) + for (let suffix = 2; suffix < 1000; suffix += 1) { + if ((await read(candidate)) === null) return candidate + candidate = `${stem} ${suffix}${ext}` + } + throw new Error(`Cannot find a free destination for ${path}`) + } + + const applyText = async (op: Exclude): Promise => { + const path = await resolveLivePath(op.path) + const before = await read(path) + if (op.kind === 'create-note' && before !== null) { + throw new Error( + `create-note would replace the existing note ${path}. Use write to replace a note on purpose.` + ) + } + if (before === null && PER_NOTE_TEXT_OPS.has(op.kind)) { + throw new Error( + `Cannot ${op.kind} ${path}: the note is missing (moved or removed earlier in this run?)` + ) + } + const after = applyTextOp(before ?? '', op) + if (after === null) throw new Error(`Workflow op ${op.kind} is not a text op`) + if (before !== null && after === before) return + await touch(path) + live.set(path, after) + } + + const move = async ( + kind: 'move' | 'rename' | 'archive' | 'trash', + fromPath: string, + nominalPath: string, + promisedPath: string + ): Promise => { + const from = normalizeRel(fromPath) + const body = await read(from) + if (body === null) throw new Error(`Cannot ${kind} ${from}: the note is missing`) + const nominal = normalizeRel(nominalPath) + if (nominal === from) return + const destination = await uniquePath(nominal) + await touch(from) + await touch(destination) + live.set(from, null) + live.set(destination, body) + const promised = normalizeRel(promisedPath) + if (destination !== promised) redirects.set(promised, destination) + } + + let applied = 0 + for (const op of ops) { + switch (op.kind) { + case 'notify': + case 'clipboard': + continue + case 'move': { + const from = await resolveLivePath(op.path) + await move('move', from, moveTarget(from, op.to), moveTarget(normalizeRel(op.path), op.to)) + break + } + case 'rename': { + const from = await resolveLivePath(op.path) + await move( + 'rename', + from, + renameTarget(from, op.to), + renameTarget(normalizeRel(op.path), op.to) + ) + break + } + case 'archive': { + const from = await resolveLivePath(op.path) + await move( + 'archive', + from, + folderTarget('archive', from, source.systemFolderDirs), + folderTarget('archive', normalizeRel(op.path), source.systemFolderDirs) + ) + break + } + case 'trash': { + const from = await resolveLivePath(op.path) + await move( + 'trash', + from, + folderTarget('trash', from, source.systemFolderDirs), + folderTarget('trash', normalizeRel(op.path), source.systemFolderDirs) + ) + break + } + default: + await applyText(op) + } + applied += 1 + } + + const changes: WorkflowRunFileChange[] = [] + for (const entry of journal.values()) { + changes.push({ ...entry, after: await read(entry.path) }) + } + + return { + workflowId, + ops, + applied, + irreversible: ops.filter((op) => IRREVERSIBLE_OP_KINDS.has(op.kind)).length, + changes + } +} diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index f16dfbd2..71922bf0 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.28.2", + "version": "2.29.0", "type": "module", "exports": { ".": "./src/index.ts" diff --git a/packaging/nix/package-desktop.nix b/packaging/nix/package-desktop.nix index c4375470..73e3542c 100644 --- a/packaging/nix/package-desktop.nix +++ b/packaging/nix/package-desktop.nix @@ -3,7 +3,7 @@ stdenv, fetchurl, autoPatchelfHook, - makeWrapper, + makeShellWrapper, wrapGAppsHook3, copyDesktopItems, makeDesktopItem, @@ -24,6 +24,7 @@ gtk3, libdrm, libGL, + libglvnd, libgbm, libnotify, libpulseaudio, @@ -58,7 +59,7 @@ stdenv.mkDerivation (finalAttrs: { nativeBuildInputs = [ autoPatchelfHook - makeWrapper + makeShellWrapper wrapGAppsHook3 copyDesktopItems ]; @@ -100,9 +101,12 @@ stdenv.mkDerivation (finalAttrs: { ]; # dlopen'd at runtime (not in DT_NEEDED), so keep them on the wrapper's path. + # libglvnd provides libEGL.so.1, which ANGLE dlopens; without it the GPU + # process crash-loops and the window never maps. runtimeDependencies = [ (lib.getLib systemd) libGL + libglvnd libnotify libpulseaudio wayland @@ -110,7 +114,10 @@ stdenv.mkDerivation (finalAttrs: { dontConfigure = true; dontBuild = true; - # We invoke makeWrapper manually and splice in gappsWrapperArgs ourselves. + # We invoke the wrapper manually and splice in gappsWrapperArgs ourselves. + # Must be makeShellWrapper: wrapGAppsHook3 propagates makeBinaryWrapper, + # whose binary wrapper can't expand ''${NIXOS_OZONE_WL:+…} at runtime, so + # the flag reaches Electron as a literal string instead. dontWrapGApps = true; installPhase = '' @@ -129,8 +136,9 @@ stdenv.mkDerivation (finalAttrs: { install -Dm644 "$icon" "$out/share/icons/hicolor/$size/apps/${finalAttrs.pname}.png" done - makeWrapper $out/share/zennotes/ZenNotes $out/bin/${finalAttrs.pname} \ + makeShellWrapper $out/share/zennotes/ZenNotes $out/bin/${finalAttrs.pname} \ "''${gappsWrapperArgs[@]}" \ + --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libglvnd ]}" \ --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto}}" \ ${lib.optionalString (commandLineArgs != "") "--add-flags ${lib.escapeShellArg commandLineArgs}"}