diff --git a/README.md b/README.md index 186816c..38585e3 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. A clean checkout installs them with `npm ci`, without a source clone or sibling repository. 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). +[core-2.80.0-core.hc5a4589d040cd0da](https://github.com/ZenNotes/zennotes/releases/tag/core-2.80.0-core.hc5a4589d040cd0da) +(desktop commit `31520814`, clean tree). `npm run boundaries:check` verifies the pins, installed versions, singleton React/CodeMirror peers, and public export usage. `npm run core:adopt -- ` diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 9ea0594..5478548 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -624,12 +624,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.34.0; + MARKETING_VERSION = 1.35.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -753,12 +753,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.34.0; + MARKETING_VERSION = 1.35.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -775,12 +775,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.34.0; + MARKETING_VERSION = 1.35.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -795,12 +795,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.34.0; + MARKETING_VERSION = 1.35.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -817,12 +817,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.34.0; + MARKETING_VERSION = 1.35.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -855,12 +855,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.34.0; + MARKETING_VERSION = 1.35.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/package-lock.json b/package-lock.json index 6fbab0e..38355ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.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", @@ -3666,9 +3666,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", @@ -3698,8 +3698,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", @@ -3743,19 +3743,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 40ae67c..55bc3b1 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.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 9f0f994..6a25d79 100644 --- a/src/bridge/cloud-sync-client.test.ts +++ b/src/bridge/cloud-sync-client.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { it } from 'node:test' +import type { CloudSyncMutationRequest } from '@zennotes/bridge-contract/cloud-sync' import { generateCloudE2eeKey, toBase64 } from '@zennotes/shared-domain/cloud-e2ee' import { CloudE2eeKeyring } from '@zennotes/shared-domain/cloud-e2ee-account' import { CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' @@ -186,3 +187,36 @@ 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 instead of stopping (#1004)', async () => { + const refused = 'This path is not eligible for vault sync.' + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { + request: async () => ({ + status: 422, + data: { + error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' }, + errors: { 'mutations.1.path': [refused] } + } + }) + } + } + }) + const { cloudSyncPathRefusal } = await import('@zennotes/shared-domain/cloud-sync-refusal') + const content = { encoding: 'base64' as const, data: 'YWJjZA==', sha256: 'a'.repeat(64), byte_length: 4, media_type: 'text/markdown' } + const body: CloudSyncMutationRequest = { + mutations: [ + { type: 'upsert', operation_id: 'first', item_id: 'item-1', base_revision: null, path: 'inbox/Note.md', kind: 'text', content }, + { type: 'upsert', operation_id: 'second', item_id: 'item-2', base_revision: null, path: '.zennotes/agent-edits/inbox/Note.md.json', kind: 'text', content } + ] + } + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'account' }) + const error = await client.mutate('vault-1', body).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, body)?.indices, [1]) +}) diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index 5d9a9c6..a6c9aa0 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -119,7 +119,7 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: { : `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 ?? {} ) } @@ -263,6 +263,16 @@ function unansweredUpload(error: unknown): boolean { (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 +} + export function firstValidationMessage(errors: unknown): string | null { if (!errors || typeof errors !== 'object') return null diff --git a/src/bridge/mobile-direct-upload.test.ts b/src/bridge/mobile-direct-upload.test.ts index 62b738c..873067c 100644 --- a/src/bridge/mobile-direct-upload.test.ts +++ b/src/bridge/mobile-direct-upload.test.ts @@ -10,6 +10,7 @@ import type { } from '@zennotes/bridge-contract/cloud-sync' import { generateCloudE2eeKey, zne1CiphertextLength } from '@zennotes/shared-domain/cloud-e2ee' import { CloudE2eeLockedError, CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' +import { CloudSyncPathRefusedError } from '@zennotes/shared-domain/cloud-sync-refusal' import { CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES, MobileDirectUploadError, @@ -141,6 +142,74 @@ describe('mutateWithMobileDirectUploads', () => { assert.equal(uploads[0]?.headers.Authorization, undefined) }) + it('names a direct upload refused for its path by its place in the batch, with what went before (#1004)', async () => { + const refused = 'This path is not eligible for vault sync.' + const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1 + const small = { ...upsertMutation(4, 'YWJjZA=='), operation_id: 'small', item_id: 'small-item', path: 'inbox/Note.md' } + const large = upsertMutation(byteLength, Buffer.alloc(byteLength, 17).toString('base64')) + const after = { ...small, operation_id: 'after', item_id: 'after-item', path: 'inbox/After.md' } + const smallAck = { operation_id: 'small', item_id: 'small-item', revision: 1, sequence: 7 } + const calls: string[] = [] + const api = fakeApi({ + mutate: async (_vaultId, body) => { + calls.push(`mutate ${body.mutations.map((mutation) => mutation.operation_id).join(',')}`) + return { acknowledged: [smallAck], conflicts: [], cursor: 7 } + }, + initiateUpload: async () => { + calls.push('initiate') + throw serviceError(refused, 422, 'VALIDATION_FAILED', { errors: { path: [refused] } }) + } + }) + + const error = await mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [small, large, after] }, async () => { + throw new Error('Unexpected object upload') + }).catch((caught: unknown) => caught) + + assert.ok(error instanceof CloudSyncPathRefusedError) + assert.deepEqual(error.refusal.indices, [1]) + assert.deepEqual(error.refusal.applied, { acknowledged: [smallAck], conflicts: [], cursor: 7 }) + assert.equal(error.refusal.detail, `HTTP 422 · VALIDATION_FAILED · ${refused}`) + // After.md was never sent; sync sends it again without the refused file. + assert.deepEqual(calls, ['mutate small', 'initiate']) + }) + + it('names an inline path refusal by its place in the whole batch, after a direct upload', async () => { + const refused = 'This path is not eligible for vault sync.' + const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1 + const large = upsertMutation(byteLength, Buffer.alloc(byteLength, 3).toString('base64')) + const first = { ...upsertMutation(4, 'YWJjZA=='), operation_id: 'first', item_id: 'first-item', path: 'inbox/A.md' } + const second = { ...first, operation_id: 'second', item_id: 'second-item', path: '.zennotes/agent-edits/inbox/A.md.json' } + const largeAck = { operation_id: large.operation_id, item_id: large.item_id, revision: 2, sequence: 4 } + const api = fakeApi({ + initiateUpload: async () => uploadInstruction(large, 'https://objects.example.test/upload'), + completeUpload: async () => ({ + data: { id: 'upload-1', operation_id: large.operation_id, status: 'completed', result: { acknowledged: [largeAck], conflicts: [], cursor: 4 } } + }), + // The inline request holds the batch's mutations 1 and 2, so Cloud names + // its own positions; the refusal must name the batch's. + mutate: async () => { + throw serviceError(refused, 422, 'VALIDATION_FAILED', { errors: { 'mutations.1.path': [refused] } }) + } + }) + + const error = await mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [large, first, second] }, async () => {}) + .catch((caught: unknown) => caught) + + assert.ok(error instanceof CloudSyncPathRefusedError) + assert.deepEqual(error.refusal.indices, [2]) + assert.deepEqual(error.refusal.applied, { acknowledged: [largeAck], conflicts: [], cursor: 4 }) + }) + + it('still stops on a refusal that is not about a path', async () => { + const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1 + const large = upsertMutation(byteLength, Buffer.alloc(byteLength, 5).toString('base64')) + const failure = serviceError('Invalid.', 422, 'VALIDATION_FAILED', { errors: { sha256: ['Bad hash.'] } }) + const api = fakeApi({ initiateUpload: async () => { throw failure } }) + const error = await mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [large] }, async () => {}) + .catch((caught: unknown) => caught) + assert.equal(error, failure) + }) + it('aborts the reservation when object storage rejects the upload', async () => { const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1 const mutation = upsertMutation(byteLength, Buffer.alloc(byteLength, 11).toString('base64')) @@ -186,6 +255,12 @@ describe('mutateWithMobileDirectUploads', () => { }) }) +/** What cloud-sync-client's transport throws for an answer with an error + * status (CloudServiceRequestError, whose module needs Capacitor). */ +function serviceError(message: string, status: number, code: string, details: Record): Error { + return Object.assign(new Error(message), { name: 'CloudServiceRequestError', status, code, details }) +} + function fakeApi(overrides: Partial): MobileDirectUploadApi { return { mutate: async () => emptyResponse(), diff --git a/src/bridge/mobile-direct-upload.ts b/src/bridge/mobile-direct-upload.ts index 07d1468..34f5af2 100644 --- a/src/bridge/mobile-direct-upload.ts +++ b/src/bridge/mobile-direct-upload.ts @@ -14,6 +14,11 @@ import type { import { toBase64, zne1CiphertextLength } from '@zennotes/shared-domain/cloud-e2ee' import type { CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' import { cloudSendTimeoutMs } from '@zennotes/shared-domain/cloud-sync-stall' +import { + CloudSyncPathRefusedError, + cloudSyncPathRefusal, + isCloudSyncUploadPathRefused +} from '@zennotes/shared-domain/cloud-sync-refusal' export const CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 @@ -150,22 +155,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), @@ -173,6 +208,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..bf82acf --- /dev/null +++ b/src/bridge/template-folder.test.ts @@ -0,0 +1,197 @@ +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's template folder (ZenNotes/zennotes#1024) lives in vault.json +// as `templates.folder`. app-core reads it for every host (the picker, the +// Settings row, the tag surfaces), but the on-device vault writes vault.json +// and scans tasks and text itself, so it keeps the setting through a save, +// follows a folder rename, and leaves the folder out of Tasks and search, as +// desktop main and the Go server do. Its task scan also hands the Kanban board +// each subtask's parent (#1028), held to the core's contract fixture. + +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 type = await this.statVerified(path) + return type ? { type } : null + } + async readdir(path: string) { + const prefix = path ? `${path}/` : '' + const names = new Map() + for (const key of [...this.files.keys(), ...this.directories]) { + if (!key.startsWith(prefix) || key === path) continue + const [name, ...rest] = key.slice(prefix.length).split('/') + if (!name) continue + names.set(name, rest.length > 0 || !this.files.has(key) ? 'directory' : 'file') + } + return [...names].map(([name, type]) => ({ name, type })) + } + 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\nquarterly review\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 real task\nquarterly review\n') + fs.files.set('inbox/Plan.md', '# Plan\n\n- [ ] ship it\nquarterly review\n') + Object.assign(vault, { + fs, + invalidateMeta: () => {}, + // The scanners walk listNotes(); answer from the memory files so a rename + // shows up in the next scan. + listNotes: async () => [...fs.files.keys()] + .filter(path => path.endsWith('.md')) + .map(path => ({ + path, + title: path.split('/').pop()!.replace(/\.md$/, ''), + folder: path.startsWith('trash/') ? 'trash' : 'inbox' + })) + }) + return { fs, vault } +} + +const written = (fs: MemoryFs) => JSON.parse(fs.files.get(SETTINGS)!) + +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 a desktop user\'s template folder', async () => { + const { fs, vault } = fixture({ templates: { folder: 'inbox/Templates' } }) + const settings = await vault.getVaultSettings() + assert.deepEqual(settings.templates, { folder: 'inbox/Templates' }) + await vault.setVaultSettings({ ...settings, favorites: ['inbox/Plan.md'] }) + assert.deepEqual(written(fs).templates, { folder: 'inbox/Templates' }) + assert.deepEqual(written(fs).favorites, ['inbox/Plan.md']) +}) + +test('the folder is normalized as shared-domain normalizes it: cleaned, folder only, an unusable one dropped', async () => { + const cases: [unknown, unknown][] = [ + [{ folder: ' inbox//Templates/ ' }, { folder: 'inbox/Templates' }], + [{ folder: 'inbox\\Templates' }, { folder: 'inbox/Templates' }], + [{ folder: 'inbox/.hidden' }, undefined], + [{ folder: '../outside' }, undefined], + [{ folder: '' }, undefined], + [{ folder: 7 }, undefined], + ['Templates', undefined], + [['Templates'], undefined], + // Only the folder is kept, as desktop main and the Go server keep it. + [{ folder: 'inbox/Templates', later: true }, { folder: 'inbox/Templates' }], + [{ folder: '..', later: true }, undefined] + ] + for (const [raw, expected] of cases) { + const { fs, vault } = fixture({ templates: raw }) + const settings = await vault.getVaultSettings() + assert.deepEqual(settings.templates, expected, JSON.stringify(raw)) + await vault.setVaultSettings(settings) + // No empty stub in vault.json when nothing usable is left. + assert.deepEqual(written(fs).templates, expected, JSON.stringify(raw)) + assert.equal('templates' in written(fs), expected !== undefined, JSON.stringify(raw)) + } +}) + +test('the template folder stays out of Tasks, even inside a Task folder, and its neighbour stays in', async () => { + const { fs, vault } = fixture({ templates: { folder: 'inbox/Templates' } }) + const expected = ['inbox/Plan.md', 'inbox/Templates Old/Kept.md'] + assert.deepEqual(await scanned(vault), expected) + // A single-note rescan agrees, or an edit would bring a template's tasks back. + assert.deepEqual(await rescanned(vault, fs), expected) + + const inside = fixture({ templates: { folder: 'inbox/Templates' }, tasks: { includedFolders: ['inbox'] } }) + assert.deepEqual(await scanned(inside.vault), expected) + assert.deepEqual(await rescanned(inside.vault, inside.fs), expected) +}) + +test('a vault without a template folder keeps every note\'s tasks', async () => { + const { vault } = fixture({}) + assert.deepEqual(await scanned(vault), [ + 'inbox/Plan.md', 'inbox/Templates Old/Kept.md', 'inbox/Templates/Meeting.md', 'inbox/Templates/Work/Standup.md' + ]) +}) + +test('renaming the folder, or one it sits in, carries the setting along', async () => { + const { fs, vault } = fixture({ templates: { folder: 'inbox/Templates/Work' } }) + await vault.renameFolder('inbox', 'Templates/Work', 'Templates/Office') + assert.deepEqual(written(fs).templates, { folder: 'inbox/Templates/Office' }) + await vault.renameFolder('inbox', 'Templates', 'Library/Templates') + assert.deepEqual(written(fs).templates, { folder: 'inbox/Library/Templates/Office' }) + assert.deepEqual(await scanned(vault), [ + 'inbox/Library/Templates/Meeting.md', 'inbox/Plan.md', 'inbox/Templates Old/Kept.md' + ]) + // A folder that is neither the template folder nor holds it leaves it alone. + await vault.renameFolder('inbox', 'Templates Old', 'Archive Old') + assert.deepEqual(written(fs).templates, { folder: 'inbox/Library/Templates/Office' }) +}) + +test('text search leaves template notes out before ranking', async () => { + const { vault } = fixture({ templates: { folder: 'inbox/Templates' } }) + const paths = (await vault.searchVaultText('quarterly review')).map((match: { path: string }) => match.path).sort() + assert.deepEqual(paths, ['inbox/Plan.md', 'inbox/Templates Old/Kept.md']) + const none = fixture({}) + const all = (await none.vault.searchVaultText('quarterly review')).map((match: { path: string }) => match.path).sort() + assert.deepEqual(all, ['inbox/Plan.md', 'inbox/Templates Old/Kept.md', 'inbox/Templates/Meeting.md']) +}) + +interface SubtaskCase { + id: string + note: { path: string; title: string; folder: string } + body: string + expectedTasks: { id: string; parentId?: string }[] +} + +test('each scanned task names its parent the way the core contract fixture says (#1028)', async () => { + const fixtureUrl = new URL('../../node_modules/@zennotes/bridge-contract/fixtures/task-subtasks.json', import.meta.url) + const { cases } = JSON.parse(readFileSync(fixtureUrl, 'utf8')) as { cases: SubtaskCase[] } + assert.ok(cases.length > 0) + for (const testCase of cases) { + const fs = new MemoryFs(), vault = new MobileVault('Fixture') + fs.files.set(SETTINGS, JSON.stringify({ primaryNotesLocation: 'inbox' })) + fs.files.set(testCase.note.path, testCase.body) + Object.assign(vault, { + fs, + invalidateMeta: () => {}, + listNotes: async () => [{ path: testCase.note.path, title: testCase.note.title, folder: testCase.note.folder }] + }) + const links = (tasks: { id: string; parentId?: string }[]) => + tasks.map(task => ('parentId' in task ? { id: task.id, parentId: task.parentId } : { id: task.id })) + assert.deepEqual(links(await vault.scanTasksForPath(testCase.note.path)), testCase.expectedTasks, testCase.id) + assert.deepEqual(links(await vault.scanTasks()), testCase.expectedTasks, testCase.id) + } +}) diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index 0225de3..78eb8b7 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 { pastedImageFilename } from '@zennotes/shared-domain/pasted-image' import { randomUUID } from './uuid' import { bytesToBase64 } from './base64' @@ -380,6 +387,14 @@ export class MobileVault { const tasks = normalizeTasksSettings(obj.tasks) if (tasks) out.tasks = tasks else delete out.tasks + // A desktop user's template folder (#1024) rides through a save from this + // device, normalized exactly as desktop main normalizes it: a usable + // folder cleaned (`Templates/` reads `Templates`), only the folder kept, + // and no stub left when there is none. The pass-through above would carry + // it raw. + const templates = normalizeTemplateFolderSettings(obj.templates) + if (templates) out.templates = templates + else delete out.templates return out } @@ -1074,7 +1089,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 templates would turn back + // into notes and their checkboxes into tasks. + templates: rewriteTemplateFolderForRename(settings.templates, oldRel, newRel) }) } catch (error) { try { @@ -1231,8 +1249,12 @@ export class MobileVault { } await this.collectSearchCandidates(topRel, folder, isPrimaryRoot, hidden, candidates) } + // Template notes (#1024) are placeholders nobody is searching for. They + // go before ranking, as on desktop, so the limit is spent on real notes. + const templateFolder = resolveTemplateFolder(settings) const ranked: { score: number; c: SearchCandidate }[] = [] for (const c of candidates) { + if (isPathInTemplateFolder(c.path, templateFolder)) continue const bodyScore = scoreMatch(trimmed, c.lineText) if (bodyScore <= 0) continue const titleScore = scoreMatch(trimmed, c.title) * 0.18 @@ -1308,13 +1330,12 @@ 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 for + * the note it will make. The shared helper desktop main and the Go server + * apply, so every scanner agrees. */ 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 90f2e47..15d7d83 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' @@ -99,6 +99,7 @@ const D = { more: 'M6 12h.01M12 12h.01M18 12h.01', pencil: 'M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z', pin: 'M12 17v5M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z', + gallery: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', // The editor toolbar's "Attach file" glyph. file: 'M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l8.57-8.57A4 4 0 0118 8.84l-8.59 8.57a2 2 0 01-2.83-2.83l8.49-8.48' } @@ -1152,6 +1153,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 newFolderHere = (): void => { void requestCreateBrowseFolder(captureMobileWorkspace(), path).catch(reportActionError) } @@ -1433,6 +1441,16 @@ function MobileDrawerBody(props: { {pinnedFolders.includes(folderMenu.subpath) ? 'Unpin' : 'Pin'} )} + {folderMenu.kind === 'folder' && ( + + )}