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.75.0",
"version": "2.76.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ describe('TOML serialization', () => {
savedTaskFilters: { 'Project alpha': '@project:alpha', Blocked: '@status:blocked' },
kanbanGroupBy: 'folder',
kanbanCardSort: 'due',
tasksListOrder: 'priority',
kanbanFolderRoot: 'Projects',
externalApplicationSchemes: ['zotero', 'obsidian'],
ignoredKeys: ['KanaMode', 'F24']
Expand Down Expand Up @@ -165,6 +166,8 @@ describe('TOML serialization', () => {
expect(round.kanbanGroupBy).toBe('folder')
expect(text).toContain('kanban_card_sort = "due"')
expect(round.kanbanCardSort).toBe('due')
expect(text).toContain('tasks_list_order = "priority"')
expect(round.tasksListOrder).toBe('priority')
expect(round.kanbanFolderRoot).toBe('Projects')
expect(text).toContain('ignored_keys = ["KanaMode", "F24"]')
expect(round.externalApplicationSchemes).toEqual(['zotero', 'obsidian'])
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
tomlKey: 'kanban_card_sort',
comment: 'manual | due: cards in a Kanban column keep the order you drag them into, or follow their due dates'
},
tasksListOrder: {
section: 'view',
tomlKey: 'tasks_list_order',
comment: 'note | priority: each Tasks list group keeps its notes\' line order, or puts high priority first'
},
kanbanFolderRoot: {
section: 'view',
tomlKey: 'kanban_folder_root',
Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/main/cloud-sync-filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,64 @@ describe('DesktopCloudSyncRepository: decisions and writes', () => {
expect(await readFile(path.join(root, 'Deck.pdf'), 'utf8')).toBe('cloud bytes')
}, 20_000)

it('puts the other device\'s version where it went and moves this device\'s different file beside it', async () => {
// Dave (2026-10-23): the computer trashed the note his phone edited, and
// the phone had trashed an Untitled.md of its own into the same slot.
const root = await temporaryRoot()
const note = 'inbox/Daily Notes/Untitled.md'
await mkdir(path.join(root, 'inbox/Daily Notes'), { recursive: true })
await mkdir(path.join(root, 'trash/Daily Notes'), { recursive: true })
await writeFile(path.join(root, note), 'edited here')
await writeFile(path.join(root, 'trash/Daily Notes/Untitled.md'), 'own scratch')
await writeFile(path.join(root, 'trash/Daily Notes/Untitled 2.md'), 'already second')
const cloud = upsert('trash/Daily Notes/Untitled.md', 'from the computer').content!
const { data, ...contentMetadata } = cloud
const reference = { ...contentMetadata, item_id: 'item-remote', revision: 2 }
const repository = new DesktopCloudSyncRepository(root, {
stagingDirectory: await temporaryRoot(),
fetchImplementation: async () => new Response(data)
})
const file = await repository.stageCloudContent({
reference,
previewLimitBytes: 0,
getInstruction: async () => ({
item_id: reference.item_id, revision: reference.revision, content: contentMetadata,
download: { method: 'GET', url: 'https://objects.example.test/immutable', headers: {}, expires_at: '2099-10-01T12:00:00Z' }
})
})
try {
await repository.resolveStagedCloudConflict({
expected_path: note, expected_sha256: hash('edited here'), cloud_path: 'trash/Daily Notes/Untitled.md', file
})
} finally {
await releaseCloudSyncStagedFile(file)
}

expect(await readFile(path.join(root, 'trash/Daily Notes/Untitled.md'), 'utf8')).toBe('from the computer')
expect(await readFile(path.join(root, 'trash/Daily Notes/Untitled 3.md'), 'utf8')).toBe('own scratch')
expect(await readFile(path.join(root, 'trash/Daily Notes/Untitled 2.md'), 'utf8')).toBe('already second')
await expect(readFile(path.join(root, note), 'utf8')).rejects.toThrow()
})

it('moves only for the other device\'s version: a chosen name that is taken still refuses', async () => {
const root = await temporaryRoot()
await writeFile(path.join(root, 'Plan.md'), 'edited here')
await writeFile(path.join(root, 'Taken.md'), 'someone else')
const repository = new DesktopCloudSyncRepository(root)
const cloud = upsert('Taken.md', 'cloud').content!

await expect(repository.applyConflictResolutionFiles({
expected_path: 'Plan.md', expected_sha256: hash('edited here'),
files: [{ path: 'Taken.md', content: cloud }]
})).rejects.toThrow('already exists')
await repository.applyConflictResolutionFiles({
expected_path: 'Plan.md', expected_sha256: hash('edited here'),
files: [{ path: 'Taken.md', content: cloud, displace_existing: true }]
})
expect(await readFile(path.join(root, 'Taken.md'), 'utf8')).toBe('cloud')
expect(await readFile(path.join(root, 'Taken 2.md'), 'utf8')).toBe('someone else')
})

it('refuses to write a snapshot that carries no bytes and no source', async () => {
const root = await temporaryRoot()
await writeFile(path.join(root, 'Deck.pdf'), 'agreed')
Expand Down
56 changes: 51 additions & 5 deletions apps/desktop/src/main/cloud-sync-filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import {
CLOUD_SYNC_SETTINGS_CONFLICT_PATH,
CLOUD_SYNC_VAULT_SETTINGS_PATH,
cloudSyncFreePath,
cloudSyncPathKey,
isCloudSyncVaultSettingsPath,
normalizeCloudSyncPath,
Expand Down Expand Up @@ -136,8 +137,11 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository {
const local = expected ? await this.localItemOrNull(expected) : null
if ((local?.content.sha256 ?? null) !== input.expected_sha256) throw new Error('This file changed on this device.')
const replacing = expected !== null && cloudSyncPathKey(expected) === cloudSyncPathKey(cloud)
if (!replacing && await exists(this.resolve(cloud))) throw new Error(`A file named “${cloud}” already exists. Choose another name.`)
// The other device's version goes where that device put it.
const displacement = replacing ? null
: await this.displacementFor(cloud, new Set([cloud, ...(keep ? [keep] : [])].map(cloudSyncPathKey)))
let keptCopy: Stats | undefined
let displaced = false
let cloudPublished = false
try {
if (keep) {
Expand All @@ -149,6 +153,10 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository {
if (expected && ((await this.localItemOrNull(expected))?.content.sha256 ?? null) !== input.expected_sha256) {
throw new Error('This file changed on this device.')
}
if (displacement) {
await renameWithRetry(this.resolve(displacement.from), this.resolve(displacement.to))
displaced = true
}
await this.publishFile(cloud, handle.path, input.file.reference, replacing ? input.expected_sha256 : null, handle.signal)
cloudPublished = true
if (expected && !replacing) {
Expand All @@ -162,10 +170,37 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository {
// intact. An edit, replacement or unverifiable file must survive.
await this.rollbackStagedConflictCopy(keep, expected, local.content, keptCopy).catch(() => {})
}
if (displacement && displaced && !cloudPublished) await this.undoDisplacement(displacement).catch(() => {})
throw error
}
}

/**
* A different file of this device where the other device's version has to
* go (see CloudSyncRepository.applyConflictResolutionFiles): the next free
* name beside it, or null when the path is free. Anything but a plain file
* there keeps the choice refused.
*/
private async displacementFor(relPath: string, reserved: Set<string>): Promise<{ from: string; to: string } | null> {
let stats: Stats
try {
stats = await fs.lstat(this.resolve(relPath))
} catch (error) {
if (isMissingFileError(error)) return null
throw error
}
if (!stats.isFile()) throw new Error(`A file named “${relPath}” already exists. Choose another name.`)
const to = await cloudSyncFreePath(relPath, async (candidate) =>
reserved.has(cloudSyncPathKey(candidate)) || await exists(this.resolve(candidate)))
reserved.add(cloudSyncPathKey(to))
return { from: relPath, to }
}

private async undoDisplacement(move: { from: string; to: string }): Promise<void> {
if (await exists(this.resolve(move.from))) return
await renameWithRetry(this.resolve(move.to), this.resolve(move.from))
}

private async rollbackStagedConflictCopy(relPath: string, originalPath: string, content: CloudSyncContent, created: Stats): Promise<void> {
const filename = this.resolve(relPath)
const before = await fs.lstat(filename)
Expand Down Expand Up @@ -370,7 +405,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository {
async applyConflictResolutionFiles(input: {
expected_path: string | null
expected_sha256: string | null
files: Array<{ path: string; content: CloudSyncContent }>
files: Array<{ path: string; content: CloudSyncContent; displace_existing?: boolean }>
}): Promise<void> {
const expectedPath = input.expected_path ? normalizeCloudSyncPath(input.expected_path) : null
const current = expectedPath ? await this.localItemOrNull(expectedPath) : null
Expand All @@ -382,9 +417,14 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository {

const files = normalizedResolutionFiles(input.files)
const expectedKey = expectedPath ? cloudSyncPathKey(expectedPath) : null
const reserved = new Set(files.map((file) => cloudSyncPathKey(file.path)))
const displacements: Array<{ from: string; to: string }> = []
for (const file of files) {
if (cloudSyncPathKey(file.path) === expectedKey) continue
if (await exists(this.resolve(file.path))) {
if (file.displace_existing) {
const displacement = await this.displacementFor(file.path, reserved)
if (displacement) displacements.push(displacement)
} else if (await exists(this.resolve(file.path))) {
throw new Error(`A file named “${file.path}” already exists. Choose another name.`)
}
}
Expand All @@ -399,7 +439,12 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository {
: 0
)
const newPaths: string[] = []
const displaced: Array<{ from: string; to: string }> = []
try {
for (const move of displacements) {
await renameWithRetry(this.resolve(move.from), this.resolve(move.to))
displaced.push(move)
}
for (const file of ordered) {
if (cloudSyncPathKey(file.path) !== expectedKey) newPaths.push(file.path)
await this.writeContent(file.path, file.content, cloudSyncPathKey(file.path) === expectedKey ? input.expected_sha256 : null)
Expand All @@ -412,6 +457,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository {
for (const createdPath of newPaths.reverse()) {
await fs.rm(this.resolve(createdPath), { force: true }).catch(() => undefined)
}
for (const move of displaced.reverse()) await this.undoDisplacement(move).catch(() => undefined)
throw error
}
}
Expand Down Expand Up @@ -579,8 +625,8 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository {
}

function normalizedResolutionFiles(
files: Array<{ path: string; content: CloudSyncContent }>
): Array<{ path: string; content: CloudSyncContent }> {
files: Array<{ path: string; content: CloudSyncContent; displace_existing?: boolean }>
): Array<{ path: string; content: CloudSyncContent; displace_existing?: boolean }> {
const normalized = files.map((file) => ({
...file,
path: normalizeCloudSyncPath(file.path)
Expand Down
20 changes: 10 additions & 10 deletions apps/desktop/terminal-release.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,24 @@
"release": {
"repository": "ZenNotes/tui",
"protocol": 1,
"version": "0.10.0",
"commit": "ced7926715549e85349e545f780a9e4bdca1585f",
"version": "0.11.0",
"commit": "881057116fa131212bf55c2d26f5eae42ef8d72d",
"artifacts": {
"darwin-arm64": {
"url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_darwin_arm64.tar.gz",
"sha256": "0ab6f5596079cadb1329161dd9a6716f253355465a4ec8e527838fbb8ec97ee6"
"url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_darwin_arm64.tar.gz",
"sha256": "4b3548577ece83dc311bb67d72d4389a0a48a24c3bd56b9ba28aaa705303d33f"
},
"darwin-x64": {
"url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_darwin_amd64.tar.gz",
"sha256": "dcbe9cfe7b116b1667e282dc81fcf13e8340b41d7f9575e0919481bd75908376"
"url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_darwin_amd64.tar.gz",
"sha256": "9dd6b9e19c3448ff947c7dd20eec511a6ffb4975616f0a78014c0e6b33047ddb"
},
"linux-arm64": {
"url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_linux_arm64.tar.gz",
"sha256": "b7bd5f8a1b22988240d612879b3194a07212ba8d7a51112df822ba935680d3b0"
"url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_linux_arm64.tar.gz",
"sha256": "132eb7755693cea9911783c2097b6a18964e18658a95d99d93353bc1e56ee6e8"
},
"linux-x64": {
"url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_linux_amd64.tar.gz",
"sha256": "d7aeca19b82c29b994cc0c2bcf6d3eff2767f3dea0842170462d21b6eda394f1"
"url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_linux_amd64.tar.gz",
"sha256": "44fd68b60dd82946a210dc715bea4d8d28363870ef7fcc335522e0a3e586d2bd"
}
}
}
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.75.0",
"version": "2.76.0",
"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.75.0",
"version": "2.76.0",
"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.75.0",
"version": "2.76.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
26 changes: 13 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-monorepo",
"private": true,
"version": "2.75.0",
"version": "2.76.0",
"description": "ZenNotes monorepo for desktop, web, and self-hosted server builds",
"packageManager": "npm@10.9.2",
"engines": {
Expand Down
Loading
Loading