diff --git a/README.md b/README.md index d54cd2c..b0e5e98 100644 --- a/README.md +++ b/README.md @@ -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.79.0-core.h7896a01910a2ea0f](https://github.com/ZenNotes/zennotes/releases/tag/core-2.79.0-core.h7896a01910a2ea0f) -(desktop commit `47d26827`, clean tree). Run `npm run +[core-2.80.0-core.hc5a4589d040cd0da](https://github.com/ZenNotes/zennotes/releases/tag/core-2.80.0-core.hc5a4589d040cd0da) +(desktop commit `31520814`, 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. diff --git a/android/app/build.gradle b/android/app/build.gradle index 08066ba..7e9577a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 53 - versionName "1.1.50" + versionCode 54 + versionName "1.1.51" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/package-lock.json b/package-lock.json index 361048a..44f4edf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.50", + "version": "1.1.51", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.1", "@capacitor/android": "^8.5.3", @@ -32,9 +32,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.79.0-core.h7896a01910a2ea0f.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h28492494a5dfa837.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h28492494a5dfa837.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.80.0-core.hc5a4589d040cd0da.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -3668,9 +3668,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.79.0-core.h7896a01910a2ea0f", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.79.0-core.h7896a01910a2ea0f.tgz", - "integrity": "sha512-NUULX0/XabETj9gziIIglT465in8uWMfJU1FQDe7ilbTSeAKF4XtcVC5Zx/+8y1v3q1Xg+P868JE5U7tc35xGg==", + "version": "2.80.0-core.hc5a4589d040cd0da", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.80.0-core.hc5a4589d040cd0da.tgz", + "integrity": "sha512-pfj1PIqD3RaQOLEvtAP6DagOOqZdnWiBxSH+fckx+STFMTS859gQaTlf2arGaCaRypy0bWMLm+H3LYpGmEDUEw==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3700,8 +3700,8 @@ "@replit/codemirror-vim": "^6.3.0", "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.79.0-boundaries.h28492494a5dfa837", - "@zennotes/shared-domain": "2.79.0-boundaries.h28492494a5dfa837", + "@zennotes/bridge-contract": "2.80.0-boundaries.hc07ef9e2bef31a64", + "@zennotes/shared-domain": "2.80.0-boundaries.hc07ef9e2bef31a64", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3745,19 +3745,19 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.79.0-boundaries.h28492494a5dfa837", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h28492494a5dfa837.tgz", - "integrity": "sha512-1VGbTL3V0714Ppt8KBxzHYbYPBGbXXEZgDnN2Qk0xwNTCfpSEPVnUfvslnVB/qsPxFop3bI8/xBwkE9qIILnGw==", + "version": "2.80.0-boundaries.hc07ef9e2bef31a64", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", + "integrity": "sha512-Ia+UG7f1rU0ar6+lVdrNTrWpM03udlG6ehai9EN8R5yM3U05HZkFSaAA9shxMg9TrDvt9103FAep/U5XH6/mAg==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.79.0-boundaries.h28492494a5dfa837", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h28492494a5dfa837.tgz", - "integrity": "sha512-ZrSPk9MILWPB3Uo1N/8+jL8Im+GhZ2aFufmYY+6LTtTKxSjcjUaMq1gSWoTBWqcl1A1fzsSp/nSOwN/2j1ecmg==", + "version": "2.80.0-boundaries.hc07ef9e2bef31a64", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", + "integrity": "sha512-8qj/op680ka4qF8sIqGhZZfTd+ADjhneivst+T7dmS3HOjZaHQQskzjLRt1zSNngSXWqz9mxG03UCItYfDL2gA==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.79.0-boundaries.h28492494a5dfa837", + "@zennotes/bridge-contract": "2.80.0-boundaries.hc07ef9e2bef31a64", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } diff --git a/package.json b/package.json index a15cd92..5574a77 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.50", + "version": "1.1.51", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", @@ -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.79.0-core.h7896a01910a2ea0f.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h28492494a5dfa837.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h28492494a5dfa837.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.80.0-core.hc5a4589d040cd0da.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/bridge/cloud-sync-client.test.ts b/src/bridge/cloud-sync-client.test.ts index 50aa3a0..5e98c76 100644 --- a/src/bridge/cloud-sync-client.test.ts +++ b/src/bridge/cloud-sync-client.test.ts @@ -350,3 +350,47 @@ it('puts an object again when the store never answered, never when it refused', } } }) + +it('keeps which fields a 422 refused, so sync sets one path aside and sends the rest (#1004)', async () => { + const refused = 'This path is not eligible for vault sync.' + const requests: string[] = [] + const { createCloudSyncClient, cloudSyncPathRefusal, CloudSyncPathRefusedError } = await loadMobileModule([ + './src/bridge/cloud-sync-client.ts', '@zennotes/shared-domain/cloud-sync-refusal' + ], { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async ({ url, method }: { url: string; method: string }) => { + requests.push(`${method} ${new URL(url).pathname}`) + if (url.endsWith('/uploads')) { + return { status: 422, data: { error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' }, errors: { path: [refused] } } } + } + if (requests.length === 1) { + return { status: 422, data: { error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' }, errors: { 'mutations.1.path': [refused] } } } + } + return { status: 200, data: { acknowledged: [{ operation_id: 'kept', item_id: 'kept-item', revision: 1, sequence: 3 }], conflicts: [], cursor: 3 } } + } } + } + }) + const content = (bytes: number) => ({ encoding: 'base64', data: Buffer.alloc(bytes, 1).toString('base64'), sha256: 'a'.repeat(64), byte_length: bytes, media_type: 'text/markdown' }) + const upsert = (operation: string, path: string, bytes = 4) => + ({ type: 'upsert', operation_id: operation, item_id: `${operation}-item`, base_revision: null, path, kind: 'markdown', content: content(bytes) }) + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'refused-path' }) + + // One request: the transport keeps Cloud's `errors`, so the refusal names the path. + const inline = { mutations: [upsert('kept', 'inbox/Note.md'), upsert('agent', '.zennotes/agent-edits/inbox/Note.md.json')] } + const error = await client.mutate('vault-1', inline).catch((caught: unknown) => caught) + assert.equal(error.status, 422) + assert.equal(error.code, 'VALIDATION_FAILED') + assert.equal(error.message, refused) + assert.deepEqual(error.details, { errors: { 'mutations.1.path': [refused] } }) + assert.deepEqual(cloudSyncPathRefusal(error, inline)?.indices, [1]) + + // Several requests: a large file's upload refused for its path is named by + // its place in the batch, and carries what the inline request applied. + const mixed = { mutations: [upsert('kept', 'inbox/Note.md'), upsert('big', '.zennotes/agent-edits/big.json', 5 * 1024 * 1024 + 1)] } + const split = await client.mutate('vault-1', mixed).catch((caught: unknown) => caught) + assert.ok(split instanceof CloudSyncPathRefusedError) + assert.deepEqual(split.refusal.indices, [1]) + assert.deepEqual(split.refusal.applied.acknowledged.map((entry: { operation_id: string }) => entry.operation_id), ['kept']) + assert.deepEqual(cloudSyncPathRefusal(split, mixed)?.indices, [1]) +}) diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index 36bcbde..07d62b4 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -114,7 +114,7 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: M : `ZenNotes Cloud request failed (${response.status}).`), response.status, typeof error?.code === 'string' ? error.code : null, - isRecord(error?.details) ? error.details : null, + errorDetails(error?.details, response.data?.errors), response.headers ?? {} ) } @@ -264,6 +264,16 @@ function unansweredUpload(error: unknown): boolean { return error instanceof MobileDirectUploadError && error.status === 0 && error.code === 'DIRECT_UPLOAD_FAILED' } +/** The answer's error details, with a 422's field errors kept under + * `details.errors`: which field Cloud refused (`mutations.3.path`) is only in + * those keys, and sync needs it to set one refused path aside instead of + * stopping the whole run (#1004, core 2.80; desktop's asErrorPayload). */ +export function errorDetails(details: unknown, errors: unknown): Record | null { + const kept = isRecord(details) ? details : null + const fields = isRecord(errors) ? errors : null + return kept || fields ? { ...kept, ...(fields ? { errors: fields } : {}) } : null +} + function firstValidationMessage(errors: unknown): string | null { if (!errors || typeof errors !== 'object') return null diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 4878ad4..78c5b7e 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -157,7 +157,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.50' +let appVersion = '1.1.51' export async function loadNativeAppVersion(): Promise { try { @@ -406,6 +406,10 @@ const MOBILE_CAPABILITIES: ZenCapabilities = { supportsCloudSync: true, supportsCliInstall: false, supportsCustomTemplates: true, + // vault.json's template folder (#1024) survives a save from this device + // (vault-fs.ts normalizes it as desktop does) and a folder rename follows + // it. A self-hosted vault answers with its server's own flag instead. + supportsTemplateFolder: true, // TextMate grammar import is a desktop feature for now: grammars are // per-app (not per-vault), so nothing desyncs by gating this off. The // Settings section hides itself behind this flag. diff --git a/src/bridge/mobile-direct-upload.test.ts b/src/bridge/mobile-direct-upload.test.ts index 65ec515..dd2a815 100644 --- a/src/bridge/mobile-direct-upload.test.ts +++ b/src/bridge/mobile-direct-upload.test.ts @@ -18,6 +18,7 @@ import { type Zne1ChunkCipherParams } from '@zennotes/shared-domain/cloud-e2ee' import { CloudE2eeLockedError, CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' +import { CloudSyncPathRefusedError, cloudSyncPathRefusal } from '@zennotes/shared-domain/cloud-sync-refusal' import { CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES, MobileDirectUploadError, @@ -197,6 +198,79 @@ describe('mutateWithMobileDirectUploads', () => { }) }) +describe('a path Cloud refuses (#1004)', () => { + const refusedText = 'This path is not eligible for vault sync.' + const validation = (errors: Record) => + new MobileDirectUploadError(refusedText, 422, 'VALIDATION_FAILED', { errors }) + const small = (operation: string, path: string): CloudSyncUpsertMutation => + ({ ...upsertMutation(4, 'YWJjZA=='), operation_id: operation, item_id: `${operation}-item`, path }) + const large = (operation: string, path: string): CloudSyncUpsertMutation => { + const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1 + return { ...upsertMutation(byteLength, Buffer.alloc(byteLength, 5).toString('base64')), operation_id: operation, item_id: `${operation}-item`, path } + } + const ack = (mutation: CloudSyncUpsertMutation, sequence: number) => + ({ operation_id: mutation.operation_id, item_id: mutation.item_id, revision: 1, sequence }) + + it('names an inline mutation by its place in the whole batch, with the direct upload that went before', async () => { + const big = large('big', 'assets/photo.png') + const kept = small('kept', 'inbox/Note.md') + const refused = small('refused', '.zennotes/agent-edits/inbox/Note.md.json') + const body = { mutations: [big, kept, refused] } + const api = fakeApi({ + initiateUpload: async () => uploadInstruction(big, 'https://objects.example.test/upload'), + completeUpload: async () => ({ + data: { id: 'upload-1', operation_id: big.operation_id, status: 'completed' as const, result: { acknowledged: [ack(big, 4)], conflicts: [], cursor: 4 } } + }), + // The inline request carries kept and refused; Cloud names its own index 1. + mutate: async () => { throw validation({ 'mutations.1.path': [refusedText] }) } + }) + + const error = await mutateWithMobileDirectUploads(api, 'vault-1', body, async () => {}).catch((caught: unknown) => caught) + + assert.ok(error instanceof CloudSyncPathRefusedError) + assert.deepEqual(error.refusal.indices, [2]) + assert.deepEqual(error.refusal.applied, { acknowledged: [ack(big, 4)], conflicts: [], cursor: 4 }) + assert.deepEqual(cloudSyncPathRefusal(error, body)?.indices, [2]) + }) + + it('names a direct upload refused for its own path by its place in the batch, with what went before', async () => { + const kept = small('kept', 'inbox/Note.md') + const big = large('big', '.zennotes/agent-edits/assets/photo.png.json') + const body = { mutations: [kept, big] } + let uploaded = false + const api = fakeApi({ + mutate: async () => ({ acknowledged: [ack(kept, 3)], conflicts: [], cursor: 3 }), + initiateUpload: async () => { throw validation({ path: [refusedText] }) } + }) + + const error = await mutateWithMobileDirectUploads(api, 'vault-1', body, async () => { uploaded = true }) + .catch((caught: unknown) => caught) + + assert.ok(error instanceof CloudSyncPathRefusedError) + assert.deepEqual(error.refusal.indices, [1]) + assert.deepEqual(error.refusal.applied, { acknowledged: [ack(kept, 3)], conflicts: [], cursor: 3 }) + assert.equal(uploaded, false) + }) + + it('stops as before when Cloud refuses anything but a path', async () => { + const big = large('big', 'assets/photo.png') + const other = small('other', 'inbox/Note.md') + const original = validation({ 'mutations.0.base_revision': ['The base revision is invalid.'] }) + const api = fakeApi({ + initiateUpload: async () => uploadInstruction(big, 'https://objects.example.test/upload'), + completeUpload: async () => ({ + data: { id: 'upload-1', operation_id: big.operation_id, status: 'completed' as const, result: { acknowledged: [ack(big, 4)], conflicts: [], cursor: 4 } } + }), + mutate: async () => { throw original } + }) + + const error = await mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [big, other] }, async () => {}) + .catch((caught: unknown) => caught) + + assert.equal(error, original) + }) +}) + describe('encrypted vaults', () => { const SIGNED_URL = 'https://objects.example.test/upload?signature=signed' diff --git a/src/bridge/mobile-direct-upload.ts b/src/bridge/mobile-direct-upload.ts index eea609f..7906241 100644 --- a/src/bridge/mobile-direct-upload.ts +++ b/src/bridge/mobile-direct-upload.ts @@ -14,6 +14,11 @@ import type { CloudSyncUploadRequest } from '@zennotes/bridge-contract/cloud-sync' import { zne1CiphertextLength, type Zne1ChunkCipherParams } from '@zennotes/shared-domain/cloud-e2ee' +import { + CloudSyncPathRefusedError, + cloudSyncPathRefusal, + isCloudSyncUploadPathRefused +} from '@zennotes/shared-domain/cloud-sync-refusal' export const CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 @@ -157,22 +162,52 @@ export async function mutateWithMobileDirectUploads( const responses: CloudSyncMutationResponse[] = [] let inlineMutations: CloudSyncMutation[] = [] + let inlineIndices: number[] = [] + // The batch goes out as several requests, so a path Cloud refuses in one of + // them (#1004) comes back after the earlier ones were applied. The refusal + // names positions in the batch this was given and carries what was applied, + // so sync commits that and resends only what went unanswered. A port of + // desktop's DesktopCloudSyncApiClient.mutate (core 2.80). + const refused = (error: unknown, indices: number[]): never => { + const refusal = indices.length === 1 && isCloudSyncUploadPathRefused(error) + ? cloudSyncPathRefusal(withMutationField(error, 0), { mutations: [body.mutations[indices[0]]] }) + : cloudSyncPathRefusal(error, { mutations: indices.map((index) => body.mutations[index]) }) + if (!refusal) throw error + throw new CloudSyncPathRefusedError( + { ...refusal, indices: refusal.indices.map((index) => indices[index]), applied: merged(responses) }, + error + ) + } const flushInline = async (): Promise => { if (inlineMutations.length === 0) return - responses.push(await api.mutate(vaultId, { mutations: inlineMutations })) + try { + responses.push(await api.mutate(vaultId, { mutations: inlineMutations })) + } catch (error) { + refused(error, inlineIndices) + } inlineMutations = [] + inlineIndices = [] } - for (const mutation of body.mutations) { + for (const [index, mutation] of body.mutations.entries()) { if (direct(mutation)) { await flushInline() - responses.push(await directUpload(api, vaultId, mutation, uploadObject, cipher)) + try { + responses.push(await directUpload(api, vaultId, mutation, uploadObject, cipher)) + } catch (error) { + refused(error, [index]) + } } else { inlineMutations.push(mutation) + inlineIndices.push(index) } } await flushInline() + return merged(responses) +} + +function merged(responses: CloudSyncMutationResponse[]): CloudSyncMutationResponse { return { acknowledged: responses.flatMap((response) => response.acknowledged), conflicts: responses.flatMap((response) => response.conflicts), @@ -180,6 +215,20 @@ export async function mutateWithMobileDirectUploads( } } +/** A direct upload's refusal names its own request's `path`; as one mutation + * of a batch it is that mutation's path. The copy keeps the error's class and + * fields, only the refused field is renamed. */ +function withMutationField(error: unknown, index: number): unknown { + if (!isServiceError(error)) return error + const errors = error.details?.errors as Record | undefined + const copy = Object.assign(Object.create(Object.getPrototypeOf(error) as object) as Error, error) + // An Error's message is its own property but not an enumerable one. + copy.message = (error as unknown as Error).message + return Object.assign(copy, { + details: { ...error.details, errors: { [`mutations.${index}.path`]: errors?.path ?? [] } } + }) +} + function usesDirectUpload(mutation: CloudSyncMutation, encrypted: boolean): mutation is CloudSyncUpsertMutation { if (mutation.type !== 'upsert') return false const bytes = encrypted ? zne1CiphertextLength(mutation.content.byte_length) : mutation.content.byte_length diff --git a/src/bridge/template-folder.test.ts b/src/bridge/template-folder.test.ts new file mode 100644 index 0000000..fd7a767 --- /dev/null +++ b/src/bridge/template-folder.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +// Core 2.80.0 on the phone's own vault code. The template folder (#1024) is +// vault.json `templates.folder`: a save from this device keeps it, normalized +// the way desktop normalizes it, a folder rename carries it along, and its +// notes stay out of the task scan and text search, as on desktop and the +// self-hosted server. Subtasks (#1028) come from the shared parser this scan +// calls, so every task the phone lists carries the `parentId` the shared +// contract fixture expects, from a fresh read and from the scan's cache alike. + +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } }) +const { MobileVault } = await loadMobileModule(['./src/bridge/vault-fs']) + +const SETTINGS = '.zennotes/vault.json' + +class MemoryFs { + files = new Map() + directories = new Set() + private isDirectory(path: string): boolean { + return path === '' || this.directories.has(path) || [...this.files.keys()].some(key => key.startsWith(`${path}/`)) + } + async statVerified(path: string) { + return this.files.has(path) ? 'file' : this.isDirectory(path) ? 'directory' : null + } + async statOrNull(path: string) { + const body = this.files.get(path) + if (body !== undefined) return { type: 'file', size: body.length, mtime: 1, ctime: null, uri: path } + return this.isDirectory(path) ? { type: 'directory', size: 0, mtime: 0, ctime: null, uri: path } : null + } + async readdir(path: string) { + const prefix = path ? `${path}/` : '' + const entries = new Map() + for (const [key, body] of this.files) { + if (!key.startsWith(prefix)) continue + const [name, ...nested] = key.slice(prefix.length).split('/') + entries.set(name!, nested.length + ? { name: name!, type: 'directory', size: 0, mtime: 0 } + : { name: name!, type: 'file', size: body.length, mtime: 1 }) + } + return [...entries.values()] + } + async exists(path: string) { return await this.statVerified(path) !== null } + async mkdir(path: string) { this.directories.add(path) } + async readText(path: string) { if (!this.files.has(path)) throw new Error('ENOENT'); return this.files.get(path)! } + async readTextOrNull(path: string) { return this.files.get(path) ?? null } + async writeText(path: string, body: string) { this.files.set(path, body) } + async deleteFile(path: string) { this.files.delete(path) } + async rename(from: string, to: string) { + assert.ok(await this.exists(from)); assert.equal(await this.exists(to), false) + for (const [key, body] of [...this.files]) if (key === from || key.startsWith(`${from}/`)) { + this.files.set(to + key.slice(from.length), body); this.files.delete(key) + } + for (const key of [...this.directories]) if (key === from || key.startsWith(`${from}/`)) { + this.directories.add(to + key.slice(from.length)); this.directories.delete(key) + } + } +} + +function fixture(settings: Record) { + const fs = new MemoryFs(), vault = new MobileVault('Fixture') + fs.files.set(SETTINGS, JSON.stringify({ primaryNotesLocation: 'inbox', ...settings })) + fs.files.set('inbox/Templates/Meeting.md', '# Meeting\n\n- [ ] Agenda item\n- [ ] Follow-up\n\nRetrospective notes\n') + fs.files.set('inbox/Templates/Work/Standup.md', '# Standup\n\n- [ ] Yesterday\n') + fs.files.set('inbox/Templates Old/Kept.md', '# Kept\n\n- [ ] a folder that only shares a prefix\n') + fs.files.set('inbox/Projects/Site.md', '# Site\n\n- [ ] Launch\n - [x] Write copy\n - [ ] Ship\n\nRetrospective notes\n') + Object.assign(vault, { + fs, + invalidateMeta: () => {}, + // The scanners walk listNotes(); answer from the memory files so a rename + // shows up in the next scan. A fixed stamp lets the task cache serve. + listNotes: async () => [...fs.files.keys()] + .filter(path => path.endsWith('.md')) + .map(path => ({ + path, + title: path.split('/').pop()!.replace(/\.md$/, ''), + folder: 'inbox', + updatedAt: 1, + size: fs.files.get(path)!.length + })) + }) + return { fs, vault } +} + +const scanned = async (vault: any): Promise => + [...new Set((await vault.scanTasks()).map((task: { sourcePath: string }) => task.sourcePath))].sort() + +const rescanned = async (vault: any, fs: MemoryFs): Promise => { + const out: string[] = [] + for (const path of [...fs.files.keys()].filter(path => path.endsWith('.md')).sort()) { + if ((await vault.scanTasksForPath(path)).length > 0) out.push(path) + } + return out +} + +test('a save from this device keeps the template folder a desktop set, normalized like desktop', async () => { + const { fs, vault } = fixture({ templates: { folder: ' inbox//Templates/ ', note: 'dropped as desktop drops it' } }) + const settings = await vault.getVaultSettings() + assert.deepEqual(settings.templates, { folder: 'inbox/Templates' }) + await vault.setVaultSettings({ ...settings, favorites: ['inbox/Projects/Site.md'] }) + const written = JSON.parse(fs.files.get(SETTINGS)!) + assert.deepEqual(written.templates, { folder: 'inbox/Templates' }) + assert.deepEqual(written.favorites, ['inbox/Projects/Site.md']) +}) + +test('an unusable template folder leaves no templates stub in vault.json', async () => { + for (const templates of [{ folder: '../outside' }, { folder: 'inbox/.hidden' }, { folder: '' }, 'Templates', []]) { + const { fs, vault } = fixture({ templates }) + const settings = await vault.getVaultSettings() + assert.equal('templates' in settings, false, JSON.stringify(templates)) + await vault.setVaultSettings(settings) + assert.equal('templates' in JSON.parse(fs.files.get(SETTINGS)!), false, JSON.stringify(templates)) + } +}) + +test("the template folder's checkboxes stay out of Tasks, even inside a Task folder", async () => { + const { fs, vault } = fixture({ templates: { folder: 'inbox/Templates' } }) + const expected = ['inbox/Projects/Site.md', 'inbox/Templates Old/Kept.md'] + assert.deepEqual(await scanned(vault), expected) + // A single-note rescan agrees, or an edit to a template would list it again. + assert.deepEqual(await rescanned(vault, fs), expected) + + const narrowed = fixture({ templates: { folder: 'inbox/Templates' }, tasks: { includedFolders: ['inbox'] } }) + assert.deepEqual(await scanned(narrowed.vault), expected) +}) + +test('without a template folder its notes are ordinary notes with tasks', async () => { + const { vault } = fixture({}) + assert.deepEqual(await scanned(vault), [ + 'inbox/Projects/Site.md', 'inbox/Templates Old/Kept.md', 'inbox/Templates/Meeting.md', 'inbox/Templates/Work/Standup.md' + ]) +}) + +test('text search leaves the template folder out', async () => { + const { vault } = fixture({ templates: { folder: 'inbox/Templates' } }) + const hits = await vault.searchVaultText('Retrospective') + assert.deepEqual(hits.map((hit: { path: string }) => hit.path), ['inbox/Projects/Site.md']) + const plain = fixture({}) + const all = await plain.vault.searchVaultText('Retrospective') + assert.deepEqual(all.map((hit: { path: string }) => hit.path).sort(), ['inbox/Projects/Site.md', 'inbox/Templates/Meeting.md']) +}) + +test('renaming the template folder, or a folder around it, carries the setting along', async () => { + const { fs, vault } = fixture({ templates: { folder: 'inbox/Templates' } }) + await vault.renameFolder('inbox', 'Templates', 'Blueprints') + assert.deepEqual(JSON.parse(fs.files.get(SETTINGS)!).templates, { folder: 'inbox/Blueprints' }) + assert.deepEqual(await scanned(vault), ['inbox/Projects/Site.md', 'inbox/Templates Old/Kept.md']) + + const nested = fixture({ templates: { folder: 'inbox/Templates/Work' } }) + await nested.vault.renameFolder('inbox', 'Templates', 'Library/Templates') + assert.deepEqual(JSON.parse(nested.fs.files.get(SETTINGS)!).templates, { folder: 'inbox/Library/Templates/Work' }) + + // A folder that only shares a name prefix leaves it alone. + const other = fixture({ templates: { folder: 'inbox/Templates' } }) + await other.vault.renameFolder('inbox', 'Templates Old', 'Archive Old') + assert.deepEqual(JSON.parse(other.fs.files.get(SETTINGS)!).templates, { folder: 'inbox/Templates' }) +}) + +test('every scanned task carries the parentId of the shared subtasks fixture, cached or read', async () => { + // The installed core's own copy; the package exports no path to it. + const fixturePath = new URL('../../node_modules/@zennotes/bridge-contract/fixtures/task-subtasks.json', import.meta.url) + const contract = JSON.parse(readFileSync(fixturePath, 'utf8')) as { + cases: { id: string; note: { path: string }; body: string; expectedTasks: { id: string; parentId?: string }[] }[] + } + assert.ok(contract.cases.length > 0) + for (const contractCase of contract.cases) { + const { fs, vault } = fixture({}) + fs.files.clear() + fs.files.set(SETTINGS, JSON.stringify({ primaryNotesLocation: 'inbox' })) + fs.files.set(contractCase.note.path, contractCase.body) + for (const pass of ['read', 'cached']) { + const tasks = (await vault.scanTasks()) as { id: string; parentId?: string }[] + assert.deepEqual( + tasks.map(task => (task.parentId ? { id: task.id, parentId: task.parentId } : { id: task.id })), + contractCase.expectedTasks, + `${contractCase.id} (${pass})` + ) + } + } +}) diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index 2bf543d..a1a3a96 100644 --- a/src/bridge/vault-fs.ts +++ b/src/bridge/vault-fs.ts @@ -32,8 +32,15 @@ import { isPathExcludedFromTasks, normalizeTasksExcludedFolders, normalizeTasksIncludedFolders, - rewriteTasksFoldersForRename + rewriteTasksFoldersForRename, + tasksFolderListsForSettings } from '@zennotes/shared-domain/tasks-excluded-folders' +import { + isPathInTemplateFolder, + normalizeTemplateFolderSettings, + resolveTemplateFolder, + rewriteTemplateFolderForRename +} from '@zennotes/shared-domain/template-folder' import { isFormDirName, isDatabaseInternalPath } from '@zennotes/shared-domain/databases' import { emptyExcalidrawDocument } from '@zennotes/shared-domain/excalidraw' import { pastedImageFilename } from '@zennotes/shared-domain/pasted-image' @@ -377,6 +384,13 @@ export class MobileVault { const tasks = normalizeTasksSettings(obj.tasks) if (tasks) out.tasks = tasks else delete out.tasks + // The template folder (#1024), validated as desktop validates it. Passing + // the key through untouched would keep a desktop user's folder, but also a + // hand-edited `../x` or an empty stub; normalizing drops those the way + // every other host does. + const templates = normalizeTemplateFolderSettings(obj.templates) + if (templates) out.templates = templates + else delete out.templates return out } @@ -1071,7 +1085,10 @@ export class MobileVault { // The Tasks folder lists name on-disk paths, so they follow the folder // too, as on desktop: a renamed Task folder (#962) left behind would // match nothing and empty Tasks. - tasks: rewriteTasksFoldersForRename(settings.tasks, oldRel, newRel) + tasks: rewriteTasksFoldersForRename(settings.tasks, oldRel, newRel), + // So does the template folder (#1024), or its notes would turn back + // into ordinary notes, their checkboxes into tasks. + templates: rewriteTemplateFolderForRename(settings.templates, oldRel, newRel) }) } catch (error) { try { @@ -1219,14 +1236,18 @@ export class MobileVault { const candidates: SearchCandidate[] = [] const settings = await this.getVaultSettings() const hidden = hiddenPrimaryRootNames(settings.systemFolderPaths) + // Template notes (#1024) are placeholders nobody is searching for, so + // their folder is never walked, as desktop leaves them out of its ranking. + const templateFolder = resolveTemplateFolder(settings) for (const folder of SEARCHABLE_TEXT_FOLDERS) { const topRel = await this.folderRootRel(folder) const isPrimaryRoot = folder === 'inbox' && topRel === '' if (!isPrimaryRoot) { + if (isPathInTemplateFolder(topRel, templateFolder)) continue const stat = await this.fs.statOrNull(topRel) if (!stat || stat.type !== 'directory') continue } - await this.collectSearchCandidates(topRel, folder, isPrimaryRoot, hidden, candidates) + await this.collectSearchCandidates(topRel, folder, isPrimaryRoot, hidden, templateFolder, candidates) } const ranked: { score: number; c: SearchCandidate }[] = [] for (const c of candidates) { @@ -1252,15 +1273,17 @@ export class MobileVault { folder: NoteFolder, isPrimaryRoot: boolean, hidden: Set, + templateFolder: string | null, out: SearchCandidate[] ): Promise { const entries = await this.fs.readdir(dirRel) for (const entry of entries) { const childRel = dirRel ? `${dirRel}/${entry.name}` : entry.name + if (isPathInTemplateFolder(childRel, templateFolder)) continue if (entry.type === 'directory') { if (entry.name.startsWith('.')) continue if (isPrimaryRoot && hidden.has(entry.name)) continue - await this.collectSearchCandidates(childRel, folder, false, hidden, out) + await this.collectSearchCandidates(childRel, folder, false, hidden, templateFolder, out) continue } if (!isMarkdownPath(entry.name)) continue @@ -1305,13 +1328,11 @@ export class MobileVault { /** The vault's Tasks folder lists from vault.json: the folders left out of * Tasks (#458) and the Task folders it is narrowed to (#962), empty - * meaning the whole vault. */ + * meaning the whole vault, with the template folder (#1024) left out too, + * even inside a Task folder: a template's checkboxes are placeholders. + * The same helper desktop main, MCP and the Go server apply. */ private async tasksFolderLists(): Promise<{ excluded: string[]; included: string[] }> { - const tasks = (await this.getVaultSettings()).tasks - return { - excluded: normalizeTasksExcludedFolders(tasks?.excludedFolders), - included: normalizeTasksIncludedFolders(tasks?.includedFolders) - } + return tasksFolderListsForSettings(await this.getVaultSettings()) } async scanTasks(): Promise { diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index bb34a39..fb5d03b 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -11,7 +11,7 @@ import ReactDOM from 'react-dom/client' import { getShellSnapshot, useShellSnapshot, setNoteSortOrder, type NoteSortOrder } from '@zennotes/app-core/shell' import { getBrowseSnapshot, useBrowseSnapshot, getBrowseDirectory, requestCreateBrowseFolder, requestCreateBrowseNote, requestRenameBrowseFolder, requestRenameBrowseDatabase, requestMoveBrowseDirectory, - requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' + requestDeleteBrowseDirectory, openBrowseFolderGallery } from '@zennotes/app-core/browse' import { useWorkspaceSnapshot, openLocalVault, pickLocalVault, refreshRemoteProfiles, connectRemoteWorkspace, connectRemoteProfile, changeRemoteVaultPath, deleteRemoteProfile } from '@zennotes/app-core/workspace' import { openNote, openAppPage } from '@zennotes/app-core/navigation' @@ -85,6 +85,7 @@ const D = { notePlus: 'M14 3H6a2 2 0 00-2 2v14a2 2 0 002 2h12a2 2 0 002-2V9l-6-6zM14 3v6h6M12 12v6M9 15h6', move: 'M5 8V6a2 2 0 012-2h3l2 2h7a2 2 0 012 2v10a2 2 0 01-2 2H7a2 2 0 01-2-2v-4M2 13h9m0 0l-3-3m3 3l-3 3', rename: 'M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z', + gallery: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', link: 'M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71', files: 'M19 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5a2 2 0 00-2-2zM8.5 10a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM21 15l-5-5L5 21', @@ -1030,6 +1031,13 @@ function MobileDrawerBody(props: { setFolderMenu(null) void requestMoveBrowseDirectory(host, subpath).catch(reportActionError) } + // The desktop folder menu's Open as Gallery (core 2.80's + // openBrowseFolderGallery): the drawer closes onto the folder's cards. + const openFolderGallery = (subpath: string): void => { + const host = folderMenu?.host ?? captureMobileWorkspace() + setFolderMenu(null) + go(() => openBrowseFolderGallery(host, subpath).catch(reportActionError)) + } const deleteFolder = (subpath: string, _name: string): void => { const host = folderMenu?.host ?? captureMobileWorkspace() void requestDeleteBrowseDirectory(host, subpath).catch(reportActionError) @@ -1309,6 +1317,16 @@ function MobileDrawerBody(props: { {pinnedFolders.includes(folderMenu.subpath) ? 'Unpin' : 'Pin'} )} + {folderMenu.kind === 'folder' && ( + + )}