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
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ name: Release
on:
push:
tags:
- "v*"
# A digit after the v: the boundary packages publish tags such as
# viewer-2.69.0-viewer.h5e7e..., which "v*" also matched, and every one
# built and uploaded a whole desktop release onto its pre-release.
- "v[0-9]*"
# Rebuild one platform of a release that already has its tag. A re-run of the
# tag's own run can only repeat every failed job with the workflow as it was
# at the tag; this runs the workflow from the branch it is dispatched on,
Expand Down
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.69.0",
"version": "2.69.1",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/main/note-sidecars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ vi.mock('electron', () => ({

import * as desktop from './vault'
import * as mcp from '../mcp/vault-ops'
import { NEIGHBOUR_FILE_IDS, useWindowsFileIds } from './windows-file-ids.testing'

const roots: string[] = []

Expand Down Expand Up @@ -175,3 +176,43 @@ describe.each(clients)('%s: a note keeps its comments', (_name, client) => {
expect(await readFile(path.join(root, 'inbox/Keep.md'), 'utf8')).toBe('# Keep\n')
})
})

// A Windows file id is 64 bits, and two notes saved one after the other can
// share one id as a JS number. The guards that tell a case-only rename from a
// rename onto another note compared those numbers, so on Windows the rename
// went through and replaced the other note.
describe.each(clients)('%s: Windows file ids that round to one number', (_name, client) => {
it('a rename onto another note’s name is refused, and both notes stay', async () => {
const root = await makeVault()
await client.writeNote(root, 'inbox/One.md', 'First.\n')
await client.writeNote(root, 'inbox/Two.md', 'Second.\n')
const ids = useWindowsFileIds({
[path.join('inbox', 'One.md')]: NEIGHBOUR_FILE_IDS[0],
[path.join('inbox', 'Two.md')]: NEIGHBOUR_FILE_IDS[1]
})
try {
await expect(client.renameNote(root, 'inbox/One.md', 'Two')).rejects.toThrow(/A note named .Two. already exists/)
} finally {
ids.mockRestore()
}
expect(await readFile(path.join(root, 'inbox/One.md'), 'utf8')).toBe('First.\n')
expect(await readFile(path.join(root, 'inbox/Two.md'), 'utf8')).toBe('Second.\n')
})

it('a folder rename onto another folder is refused, and both folders stay', async () => {
const root = await makeVault()
await client.writeNote(root, 'inbox/Work/One.md', 'First.\n')
await mkdir(path.join(root, 'inbox', 'Empty'), { recursive: true })
const ids = useWindowsFileIds({
[path.join('inbox', 'Work')]: NEIGHBOUR_FILE_IDS[0],
[path.join('inbox', 'Empty')]: NEIGHBOUR_FILE_IDS[1]
})
try {
await expect(client.renameFolder(root, 'inbox', 'Work', 'Empty')).rejects.toThrow(/already exist/)
} finally {
ids.mockRestore()
}
expect(await readFile(path.join(root, 'inbox/Work/One.md'), 'utf8')).toBe('First.\n')
expect((await stat(path.join(root, 'inbox', 'Empty'))).isDirectory()).toBe(true)
})
})
7 changes: 4 additions & 3 deletions apps/desktop/src/main/note-sidecars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import path from 'node:path'
import { NOTE_COMMENTS_DIR, NOTE_COMMENTS_SUFFIX } from '@shared/note-comments'
import { noteMetadataPath, removeNoteCreation } from './note-creation-metadata'
import { agentEditPath, removeAgentEditRecord } from './agent-edit-files'
import { fileIdentity, isSameFile } from './same-file'

const INTERNAL_VAULT_DIR = '.zennotes'

Expand Down Expand Up @@ -162,13 +163,13 @@ export async function relocateFolderTrees(
for (const [from, to] of moves) {
let source
try {
source = await fs.stat(from)
source = await fileIdentity(from)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
try {
const target = await fs.stat(to)
if (!source || source.ino !== target.ino || source.dev !== target.dev)
const target = await fileIdentity(to)
if (!source || !isSameFile(source, target))
throw new Error('The destination folder or its comments already exist')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/main/same-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { promises as fs, type BigIntStats } from 'node:fs'

/**
* A file's identity for the guards that tell a case-only rename (the
* destination is the file itself, on a case-insensitive disk) from a rename
* onto another file. Always a bigint stat: a Windows file id is 64 bits, a
* reuse count above the record number, and as a JS number two files saved one
* after the other round to one value once that count passes 31. Read as
* numbers, renaming a note onto another note's name looked like a case-only
* rename, and the rename replaced the other note.
*/
export function fileIdentity(absolutePath: string): Promise<BigIntStats> {
return fs.stat(absolutePath, { bigint: true })
}

export function isSameFile(a: BigIntStats, b: BigIntStats): boolean {
return a.dev === b.dev && a.ino === b.ino
}
18 changes: 18 additions & 0 deletions apps/desktop/src/main/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
} from './vault'

import { registerEphemeralRoot, unregisterEphemeralRoot } from './ephemeral-vaults'
import { NEIGHBOUR_FILE_IDS, useWindowsFileIds } from './windows-file-ids.testing'

const tempDirs: string[] = []

Expand Down Expand Up @@ -633,6 +634,23 @@ describe('deleteAsset', () => {
await expect(readFile(path.join(root, duplicated.path), 'utf8')).resolves.toBe('image-bytes')
})

it('refuses a rename onto another asset whose Windows file id rounds to the same number', async () => {
const root = await makeTempDir('zennotes-asset-rename-taken-')
await ensureVaultLayout(root)
await writeFile(path.join(root, 'One.png'), 'first', 'utf8')
await writeFile(path.join(root, 'Two.png'), 'second', 'utf8')
const ids = useWindowsFileIds({ 'One.png': NEIGHBOUR_FILE_IDS[0], 'Two.png': NEIGHBOUR_FILE_IDS[1] })
try {
await expect(renameAsset(root, 'One.png', 'Two.png')).rejects.toThrow(
'An asset named "Two.png" already exists in this folder.'
)
} finally {
ids.mockRestore()
}
await expect(readFile(path.join(root, 'One.png'), 'utf8')).resolves.toBe('first')
await expect(readFile(path.join(root, 'Two.png'), 'utf8')).resolves.toBe('second')
})

it('rewrites every reference to a renamed asset, in the shapes people write them (#785)', async () => {
const root = await makeTempDir('zennotes-asset-rename-links-')
await ensureVaultLayout(root)
Expand Down
15 changes: 8 additions & 7 deletions apps/desktop/src/main/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { recordMainPerf } from './perf'
import { resolveCommandViaLoginShell } from './login-shell-path'
import { isEphemeralRoot } from './ephemeral-vaults'
import { atomicWriteTarget, renameWithRetry } from './atomic-write'
import { fileIdentity, isSameFile } from './same-file'

export { renameWithRetry }
import {
Expand Down Expand Up @@ -3622,13 +3623,13 @@ export async function renameNote(
// Said plainly, since the app shows this reason to whoever is renaming. A
// case-only rename finds the note itself on a case-insensitive disk.
const [source, taken] = await Promise.all([
fs.stat(abs),
fs.stat(target).catch((error: NodeJS.ErrnoException) => {
fileIdentity(abs),
fileIdentity(target).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null
throw error
})
])
if (taken && (taken.ino !== source.ino || taken.dev !== source.dev))
if (taken && !isSameFile(taken, source))
throw new Error(`A note named “${trimmed}” already exists in this folder`)
}
// Snapshot the vault before the rename so inbound [[wikilinks]] still
Expand Down Expand Up @@ -3832,8 +3833,8 @@ export async function renameAsset(
if (willRename) {
try {
await fs.access(destAbs)
const [srcStat, dstStat] = await Promise.all([fs.stat(source.abs), fs.stat(destAbs)])
if (srcStat.ino !== dstStat.ino) {
const [srcStat, dstStat] = await Promise.all([fileIdentity(source.abs), fileIdentity(destAbs)])
if (!isSameFile(srcStat, dstStat)) {
throw new Error(`An asset named "${cleanName}" already exists in this folder.`)
}
} catch (e) {
Expand Down Expand Up @@ -4107,8 +4108,8 @@ export async function renameFolder(
try {
await fs.access(newAbs)
// Check if old and new are the same file (case-only rename)
const [oldStat, newStat] = await Promise.all([fs.stat(oldAbs), fs.stat(newAbs)])
if (oldStat.ino !== newStat.ino) {
const [oldStat, newStat] = await Promise.all([fileIdentity(oldAbs), fileIdentity(newAbs)])
if (!isSameFile(oldStat, newStat)) {
throw new Error(`A folder already exists at "${newClean}"`)
}
} catch (e) {
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/main/windows-file-ids.testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { promises as fs, type PathLike, type StatOptions } from 'node:fs'
import { vi } from 'vitest'

/**
* Two files saved one after the other on Windows: neighbouring records with
* the same reuse count. A Windows file id is 64 bits (the reuse count above
* the record number), and once the count passes 31 these two round to one
* value as JS numbers. As bigints they stay apart.
*/
export const NEIGHBOUR_FILE_IDS = [0x0028000000037683n, 0x0028000000037684n] as const

/**
* Report Windows file ids for the paths ending in the given suffixes, the way
* Node does: the full id from a bigint stat, a rounded number otherwise.
* Call `mockRestore()` on the result.
*/
export function useWindowsFileIds(ids: Record<string, bigint>) {
const stat = fs.stat.bind(fs)
return vi.spyOn(fs, 'stat').mockImplementation((async (target: PathLike, options?: StatOptions) => {
const stats = await stat(target, options)
const id = Object.entries(ids).find(([suffix]) => String(target).endsWith(suffix))?.[1]
if (id === undefined) return stats
return Object.assign(stats, options?.bigint ? { ino: id, dev: 7n } : { ino: Number(id), dev: 7 })
}) as typeof fs.stat)
}
4 changes: 3 additions & 1 deletion apps/desktop/src/main/workflow-apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1644,6 +1644,8 @@ describe('ops after a path op follow the note', () => {
})

describe('one run at a time per vault', () => {
// Many journaled, fsynced writes in a row: on a slow Windows runner the
// file-system-heavy suites ran 4 to 6 times slower and this passed 5 s.
it('concurrent applies serialize instead of interleaving read-modify-write', async () => {
const root = await makeVault()
await seed(root, 'inbox/Log.md', 'start\n')
Expand All @@ -1660,7 +1662,7 @@ describe('one run at a time per vault', () => {
for (const receipt of receipts) expect(receipt.rolledBack).toBeUndefined()
const body = (await readOrNull(root, 'inbox/Log.md')) ?? ''
for (const text of lines) expect(body).toContain(text)
})
}, 30_000)

it('an undo queued behind a run sees the run completed, not half of it', async () => {
const root = await makeVault()
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promis
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NEIGHBOUR_FILE_IDS, useWindowsFileIds } from './windows-file-ids.testing'
import {
MAX_IMPORT_BYTES,
deleteWorkflowFile,
Expand Down Expand Up @@ -313,6 +314,22 @@ describe('writeWorkflowFile', () => {
expect(await readFile(path.join(workflowsDirOf(root), 'my-flow.md'), 'utf8')).toBe('new\n')
})

it('removes the old file on a rename, even where Windows file ids round to one number', async () => {
const root = await makeVault()
await writeWorkflowFile(root, { slug: 'old-name', raw: 'old\n' })
const ids = useWindowsFileIds({ 'old-name.md': NEIGHBOUR_FILE_IDS[0], 'new-name.md': NEIGHBOUR_FILE_IDS[1] })
try {
await writeWorkflowFile(root, {
slug: 'new-name',
raw: 'new\n',
previousSourcePath: '.zennotes/workflows/old-name.md'
})
} finally {
ids.mockRestore()
}
expect(await entriesOf(root)).toEqual(['new-name.md'])
})

it('tolerates a previousSourcePath that no longer exists', async () => {
const root = await makeVault()
const saved = await writeWorkflowFile(root, {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from 'node:path'
import type { WorkflowFile, WriteWorkflowInput } from '@zennotes/bridge-contract/workflows'
import { WORKFLOWS_REL_DIR } from '@shared/workflows-view'
import { writeFileAtomic } from './vault'
import { fileIdentity, isSameFile } from './same-file'

function workflowsDir(root: string): string {
return path.join(root, '.zennotes', 'workflows')
Expand Down Expand Up @@ -148,8 +149,8 @@ export async function writeWorkflowFile(
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
const [prevStat, newStat] = await Promise.all([fileIdentity(prevAbs), fileIdentity(abs)])
sameFile = isSameFile(prevStat, newStat)
} catch {
// Either side unstattable: the remove below treats a missing file as done.
}
Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/src/mcp/vault-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
searchText,
toggleTaskInBody
} from './vault-ops'
import { TASK_LINE_RE as SHARED_TASK_LINE_RE } from '@shared/tasklists'

// Every note-shaped MCP result carries `link`, the zennotes:// deep link a
// model renders as a markdown link so the user can click from chat straight
Expand Down Expand Up @@ -90,6 +91,34 @@ describe('mcp task states', () => {
expect(byContent.get('done')?.checked).toBe(true)
})

// The app's Tasks view reads task lines with @shared/tasklists; the MCP
// scanner kept its own copy, which missed ordered lists and quotes, so an
// assistant never saw tasks the app listed.
it('sees the task lines the app sees: ordered lists and quotes too', async () => {
const body = [
'- [ ] bullet',
'1. [ ] ordered',
'2) [x] ordered done',
'> - [ ] quoted',
'> > 3. [/] deeply quoted',
'-[ ] not a task',
'text [ ] not a task',
''
].join('\n')
await writeFile(path.join(root, 'inbox', 'Shapes.md'), `# Shapes\n\n${body}`)
const tasks = (await scanAllTasks(root)).filter((t) => t.sourcePath === 'inbox/Shapes.md')
expect(tasks.map((t) => t.content)).toEqual(['bullet', 'ordered', 'ordered done', 'quoted', 'deeply quoted'])
expect(tasks.map((t) => t.content)).toHaveLength(body.split('\n').filter((line) => SHARED_TASK_LINE_RE.test(line)).length)
expect(tasks.find((t) => t.content === 'ordered done')?.checked).toBe(true)
expect(tasks.find((t) => t.content === 'deeply quoted')?.inProgress).toBe(true)
})

it('toggles an ordered or quoted task in place', () => {
const body = '1. [ ] first\n> - [ ] quoted\n'
expect(toggleTaskInBody(body, 0)).toBe('1. [x] first\n> - [ ] quoted\n')
expect(toggleTaskInBody(body, 1)).toBe('1. [ ] first\n> - [x] quoted\n')
})

it('toggle follows the app rules: [/] checks off, records stay (#599)', () => {
const body = '- [ ] open\n- [/] started\n- [x] done\n- [-] scrapped\n- [>] gone\n'
expect(toggleTaskInBody(body, 0)).toContain('- [x] open')
Expand Down
15 changes: 9 additions & 6 deletions apps/desktop/src/mcp/vault-ops.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readNoteCreatedAt, prepareNoteCreation, removeNoteCreation, noteMetadataPath } from '../main/note-creation-metadata'
import { noteCommentsPath, noteCommentsRoot, relocateFolderTrees, relocateNote } from '../main/note-sidecars'
import { agentEditFolderPath, removeAgentEditRecord } from '../main/agent-edit-files'
import { fileIdentity, isSameFile } from '../main/same-file'
import { readSavedServerToken as readZnSavedServerToken } from '../main/remote/zn-credentials'
/**
* Vault operations used by the MCP server. Mirrors the filesystem
Expand Down Expand Up @@ -230,10 +231,12 @@ async function folderRoot(root: string, folder: NoteFolder): Promise<string> {
}

const FENCE_LINE_RE = /^(\s{0,3})(`{3,}|~{3,})/
// `>` = forwarded (#316), `-` = cancelled (#450), `/` = in progress (#512) —
// all recognized so those tasks aren't invisible to the MCP scanner. Kept in
// sync with @shared/tasklists.
const TASK_LINE_RE = /^\s*[-*+]\s+\[([ xX>/-])\](.*)$/
// The task lines @shared/tasklists' TASK_LINE_RE matches, so the MCP scanner
// lists the tasks the app lists and counts them the same way: bullets,
// ordered lists (`1.` and `1)`) and quoted tasks (`> - [ ]`). Every state the
// app knows is recognized: `>` forwarded (#316), `-` cancelled (#450) and `/`
// in progress (#512). Groups: the state char, then the text after `]`.
const TASK_LINE_RE = /^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\[([ xX>/-])\](.*)$/

export interface NoteMeta {
path: string
Expand Down Expand Up @@ -1021,8 +1024,8 @@ export async function renameNote(root: string, rel: string, nextTitle: string):
if (target !== abs) {
try {
await fs.access(target)
const [srcStat, dstStat] = await Promise.all([fs.stat(abs), fs.stat(target)])
if (srcStat.ino !== dstStat.ino) {
const [srcStat, dstStat] = await Promise.all([fileIdentity(abs), fileIdentity(target)])
if (!isSameFile(srcStat, dstStat)) {
throw new Error(`A note named "${trimmed}" already exists in ${folder}`)
}
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion apps/quicklook/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/quicklook",
"private": true,
"version": "2.69.0",
"version": "2.69.1",
"type": "module",
"description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it",
"homepage": "https://zennotes.org",
Expand Down
2 changes: 1 addition & 1 deletion apps/share-viewer/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/share-viewer",
"private": true,
"version": "2.69.0",
"version": "2.69.1",
"type": "module",
"description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website",
"homepage": "https://zennotes.org",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.69.0",
"version": "2.69.1",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
Loading
Loading