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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under
`vendor/zennotes/` with their source identity and checksums (`manifest.json`),
and `package-lock.json` pins the complete install. No source checkout is used.
The vendored set is the core release
[core-2.68.0-core.h6e3db1e19bc2b662](https://github.com/ZenNotes/zennotes/releases/tag/core-2.68.0-core.h6e3db1e19bc2b662)
(desktop commit `881baa6a`, clean tree). Run `npm run
[core-2.69.0-core.h27a2c5df8414d1dd](https://github.com/ZenNotes/zennotes/releases/tag/core-2.69.0-core.h27a2c5df8414d1dd)
(desktop commit `d1344076`, clean tree). Run `npm run
boundaries:check` to verify archives, installed versions, singleton
editor/React peers, and imports; it refuses an archive built from a dirty
upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out.
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
applicationId "md.zennotes"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 41
versionName "1.1.38"
versionCode 42
versionName "1.1.39"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
34 changes: 17 additions & 17 deletions package-lock.json

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

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
"version": "1.1.38",
"version": "1.1.39",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
Expand Down Expand Up @@ -43,9 +43,9 @@
"@lezer/highlight": "^1.2.5",
"@replit/codemirror-vim": "^6.3.0",
"@xyflow/react": "^12.12.0",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.68.0-core.h6e3db1e19bc2b662.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.68.0-boundaries.hd2ce4427b284c18e.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.68.0-boundaries.hd2ce4427b284c18e.tgz",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.69.0-core.h27a2c5df8414d1dd.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.69.0-boundaries.h240b8e06d33b0cdc.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.69.0-boundaries.h240b8e06d33b0cdc.tgz",
"codemirror": "^6.0.1",
"dompurify": "^3.4.16",
"function-plot": "^1.25.3",
Expand Down
2 changes: 1 addition & 1 deletion src/bridge/cloud-sync-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ function itemFromCache(path: string, cached: ScanCacheEntry): CloudSyncLocalItem
enumerable: true,
get(): string {
throw new Error(
`Cloud sync tried to push ${path} from the scan cache without reading it — the engine's hash-equal items must never need content.`
`Cloud sync tried to push ${path} from the scan cache without reading it: the engine's hash-equal items must never need content.`
)
}
})
Expand Down
2 changes: 1 addition & 1 deletion src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ import {
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
import { isPhoneViewport } from '../viewport'

let appVersion = '1.1.38'
let appVersion = '1.1.39'

export async function loadNativeAppVersion(): Promise<string> {
try {
Expand Down
64 changes: 50 additions & 14 deletions src/bridge/vault-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { VaultTask } from '@zennotes/shared-domain/tasks'
import { parseTaskFile, parseTasksFromBody } from '@zennotes/shared-domain/tasks'
import { normalizeHarperVaultState } from '@zennotes/shared-domain/harper-settings'
import { normalizeNoteComments } from '@zennotes/shared-domain/note-comments'
import { AGENT_EDITS_DIR, agentEditSidecarPath } from '@zennotes/shared-domain/agent-edits'
import { isFormDirName, isDatabaseInternalPath } from '@zennotes/shared-domain/databases'
import { emptyExcalidrawDocument } from '@zennotes/shared-domain/excalidraw'
import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@zennotes/shared-domain/demo-tour-data'
Expand Down Expand Up @@ -660,6 +661,7 @@ export class MobileVault {
if (dir) await this.fs.mkdir(dir)
const finalTitle = await this.uniqueTitle(dir, base)
const rel = joinPath(dir, `${finalTitle}.md`)
await this.removeOrphanAgentEdit(rel)
await this.fs.writeText(rel, `# ${finalTitle}\n\n`)
emitVaultChange({ kind: 'add', path: rel, folder, scope: 'content' })
return await this.metaForPath(rel)
Expand All @@ -673,6 +675,7 @@ export class MobileVault {
if (dir) await this.fs.mkdir(dir)
const finalTitle = await this.uniqueTitle(dir, base, '.excalidraw')
const rel = joinPath(dir, `${finalTitle}.excalidraw`)
await this.removeOrphanAgentEdit(rel)
await this.fs.writeText(rel, JSON.stringify(emptyExcalidrawDocument(), null, 2))
emitVaultChange({ kind: 'add', path: rel, folder, scope: 'content' })
return await this.metaForPath(rel)
Expand All @@ -687,36 +690,48 @@ export class MobileVault {
}

/**
* Move a note with its comments and its creation date, as desktop's
* note-sidecars.ts `relocateNote` does; a sidecar the note lacks is not
* moved. A creation date already at a destination with no note beside it
* belongs to nobody (its note left outside ZenNotes) and is discarded rather
* than inherited (ZenNotes#839). Leftover comments there are another note's
* discussion: taking it over and deleting it are both wrong, so the move is
* refused.
* Move a note with its comments, its creation date and an assistant's
* unreviewed edit record, as desktop's note-sidecars.ts `relocateNote` does;
* a sidecar the note lacks is not moved. A creation date or an edit record
* already at a destination with no note beside it belongs to nobody (its
* note left outside ZenNotes) and is discarded rather than inherited
* (ZenNotes#839). Leftover comments there are another note's discussion:
* taking it over and deleting it are both wrong, so the move is refused.
*/
private async relocateNote(oldPath: string, newPath: string): Promise<void> {
const comments = this.commentsPathFor(newPath)
const metadata = this.metadataPathFor(newPath)
const agentEdit = this.agentEditPathFor(newPath)
if (await this.fs.statVerified(newPath) === null) {
if (await this.fs.statVerified(comments) !== null) throw new Error(leftoverCommentsMessage(newPath))
if (await this.fs.statVerified(metadata) === 'file') await this.fs.deleteFile(metadata)
if (await this.fs.statVerified(agentEdit) === 'file') await this.fs.deleteFile(agentEdit)
}
await relocateVaultEntries(this.relocationIO(), [
{ from: oldPath, to: newPath, required: true },
{ from: this.commentsPathFor(oldPath), to: comments },
{ from: this.metadataPathFor(oldPath), to: metadata }
{ from: this.metadataPathFor(oldPath), to: metadata },
{ from: this.agentEditPathFor(oldPath), to: agentEdit }
])
this.invalidateMeta(oldPath)
}

/** Detach the content and both sidecar trees before cleanup so a failed move can restore the original. */
private async detachContent(path: string, comments: string, metadata: string, required = true): Promise<void> {
/** Detach the content and its sidecar trees (comments, creation dates,
* assistant edit records) before cleanup so a failed move can restore the
* original. A deleted note has nothing left to review. */
private async detachContent(
path: string,
comments: string,
metadata: string,
agentEdits: string,
required = true
): Promise<void> {
const temporary = `${INTERNAL_VAULT_DIR}/delete-${uuid()}`
const moves: VaultRelocation[] = [
{ from: path, to: `${temporary}/content`, required },
{ from: comments, to: `${temporary}/comments` },
{ from: metadata, to: `${temporary}/metadata` }
{ from: metadata, to: `${temporary}/metadata` },
{ from: agentEdits, to: `${temporary}/agent-edits` }
]
await relocateVaultEntries(this.relocationIO(), moves)
try {
Expand Down Expand Up @@ -830,6 +845,7 @@ export class MobileVault {
trashDir,
`${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${trashDir}`,
`${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${trashDir}`,
`${INTERNAL_VAULT_DIR}/${AGENT_EDITS_DIR}/${trashDir}`,
false
)
for (const key of [...this.metaCache.keys()]) {
Expand All @@ -841,7 +857,7 @@ export class MobileVault {
async deleteNote(relPath: string): Promise<void> {
const rel = resolveSafeRel(relPath)
const folder = (await this.folderOf(rel)) ?? 'trash'
await this.detachContent(rel, this.commentsPathFor(rel), this.metadataPathFor(rel))
await this.detachContent(rel, this.commentsPathFor(rel), this.metadataPathFor(rel), this.agentEditPathFor(rel))
this.invalidateMeta(rel)
emitVaultChange({ kind: 'unlink', path: rel, folder, scope: 'content' })
}
Expand All @@ -854,6 +870,7 @@ export class MobileVault {
const copyTitle = await this.uniqueTitle(dir, `${stemName(rel)} copy`, ext)
const destRel = joinPath(dir, `${copyTitle}${ext}`)
const body = await this.fs.readText(rel)
await this.removeOrphanAgentEdit(destRel)
await this.fs.writeText(destRel, body)
const comments = await this.readNoteComments(rel)
if (comments.length > 0) {
Expand Down Expand Up @@ -940,7 +957,9 @@ export class MobileVault {
{ from: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${oldRel}`,
to: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${newRel}` },
{ from: `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${oldRel}`,
to: `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${newRel}` }
to: `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${newRel}` },
{ from: `${INTERNAL_VAULT_DIR}/${AGENT_EDITS_DIR}/${oldRel}`,
to: `${INTERNAL_VAULT_DIR}/${AGENT_EDITS_DIR}/${newRel}` }
], async () => {
try {
await this.setVaultSettings({
Expand Down Expand Up @@ -975,7 +994,8 @@ export class MobileVault {
await this.detachContent(
rel,
`${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${rel}`,
`${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${rel}`
`${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${rel}`,
`${INTERNAL_VAULT_DIR}/${AGENT_EDITS_DIR}/${rel}`
)
for (const key of [...this.metaCache.keys()]) {
if (key.startsWith(`${rel}/`)) this.invalidateMeta(key)
Expand Down Expand Up @@ -1016,6 +1036,22 @@ export class MobileVault {
return `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${rel}${NOTE_METADATA_SUFFIX}`
}

/** `.zennotes/agent-edits/<rel>.json`, what the note said before an
* assistant edited it through MCP, kept until the edit is reviewed in the
* desktop app (shared-domain agent-edits). This app does not review edits,
* but the record travels with its note and goes when the note does, so a
* move here leaves the review waiting on the note in the desktop app. */
private agentEditPathFor(rel: string): string {
return agentEditSidecarPath(INTERNAL_VAULT_DIR, rel)
}

/** A record with no note beside it belongs to nobody (its note left outside
* ZenNotes); a new note at that path must not inherit its review. */
private async removeOrphanAgentEdit(rel: string): Promise<void> {
const path = this.agentEditPathFor(rel)
if (await this.fs.statVerified(path) === 'file') await this.fs.deleteFile(path)
}

async readNoteComments(relPath: string): Promise<NoteComment[]> {
const rel = resolveSafeRel(relPath)
const raw = await this.fs.readTextOrNull(this.commentsPathFor(rel))
Expand Down
49 changes: 49 additions & 0 deletions src/bridge/vault-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,52 @@ test('a vault display name written by desktop survives a phone settings save (Ze
assert.equal(written.displayName, 'Acme API docs')
assert.deepEqual(written.favorites, ['Notes/Work/One.md'])
})

// Assistant edit records (.zennotes/agent-edits/<note>.json), kept until the
// desktop app reviews them, travel with their note and go when it goes.
const editRecord = (path: string) => `.zennotes/agent-edits/${path}.json`
const editRecords = (fs: MemoryFs) => [...fs.files.keys()].filter(key => key.startsWith('.zennotes/agent-edits/')).sort()

test('an assistant edit record follows its note through rename, move and Trash, and goes with Empty Trash', async () => {
const s = fixture()
s.fs.files.set(editRecord(s.note), '{"record":"One"}')
// An orphaned record at the destination is nobody's review: the note's own replaces it.
s.fs.files.set(editRecord('Notes/Work/Two.md'), '{"record":"orphan"}')
const renamed = await s.vault.renameNote(s.note, 'Two')
assert.equal(renamed.path, 'Notes/Work/Two.md')
assert.deepEqual(editRecords(s.fs), [editRecord('Notes/Work/Two.md')])
assert.equal(s.fs.files.get(editRecord('Notes/Work/Two.md')), '{"record":"One"}')
const moved = await s.vault.moveNote(renamed.path, 'inbox', '')
assert.deepEqual(editRecords(s.fs), [editRecord(moved.path)])
const trashed = await s.vault.moveToTrash(moved.path)
assert.deepEqual(editRecords(s.fs), [editRecord(trashed.path)])
const restored = await s.vault.restoreFromTrash(trashed.path)
assert.deepEqual(editRecords(s.fs), [editRecord(restored.path)])
await s.vault.moveToTrash(restored.path)
await s.vault.emptyTrash()
assert.deepEqual(editRecords(s.fs), [])
})

test('folder rename carries nested edit records; deleting the note or the folder removes them', async () => {
const s = fixture()
s.fs.files.set(editRecord(s.note), '{"record":"One"}')
await s.vault.renameFolder('inbox', 'Work', 'Moved')
assert.deepEqual(editRecords(s.fs), [editRecord('Notes/Moved/One.md')])
await s.vault.deleteNote('Notes/Moved/One.md')
assert.deepEqual(editRecords(s.fs), [])
s.fs.files.set('Notes/Moved/Three.md', '# Three\n')
s.fs.files.set(editRecord('Notes/Moved/Three.md'), '{"record":"Three"}')
await s.vault.deleteFolder('inbox', 'Moved')
assert.deepEqual(editRecords(s.fs), [])
})

test('a new note does not inherit an orphaned edit record at its path', async () => {
const s = fixture()
s.fs.files.set(editRecord('Notes/Untitled.md'), '{"record":"orphan"}')
s.fs.files.set(editRecord('Notes/Work/One copy.md'), '{"record":"orphan copy"}')
const created = await s.vault.createNote('inbox')
assert.equal(created.path, 'Notes/Untitled.md')
const copy = await s.vault.duplicateNote(s.note)
assert.equal(copy.path, 'Notes/Work/One copy.md')
assert.deepEqual(editRecords(s.fs), [])
})
2 changes: 1 addition & 1 deletion src/bridge/welcome-note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Every note is a plain Markdown file: **bold**, *italic*, lists, and checkboxes a

Type \`#\` in any note to tag it, like #ideas on this one. Tags appear under **Browse → Tags** the moment you write them.

Folders work the way you expect: create one in **Browse → New folder**, and move any note with the circle → **More** → **Move to…**.
Folders work the way you expect: create one in **Browse → New folder**, start a note inside the folder you are in with **New note** just above it, and move any note with the circle → **More** → **Move to…**.

## Math that renders live

Expand Down
Loading
Loading