Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
23 changes: 14 additions & 9 deletions apps/desktop/src/cli/commands/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -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}`)
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/main/note-docx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
convertInchesToTwip
} from 'docx'
import { withExportTitle } from '@shared/export-title'
import { stripBlockAnchorMarkers } from '@shared/block-anchors'

/* -------------------------------------------------------------------------- */
/* The intermediate representation */
Expand Down Expand Up @@ -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) ?? [])
}

Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/workflow-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,11 @@ function stringField(record: Record<string, unknown>, 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
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/main/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/main/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/mcp/vault-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1381,6 +1387,7 @@ function parseTasksFromBody(
checked,
cancelled,
inProgress,
forwarded,
due: due ?? defaults.due,
priority: priority ?? defaults.priority,
waiting,
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
20 changes: 20 additions & 0 deletions apps/server/internal/httpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions apps/server/internal/httpserver/workflows.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading