diff --git a/README.md b/README.md index d1f0c1a..d60b2e2 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.71.0-core.he08cc8dc11a66da8](https://github.com/ZenNotes/zennotes/releases/tag/core-2.71.0-core.he08cc8dc11a66da8) -(desktop commit `603fc6dc`, clean tree). Run `npm run +[core-2.72.0-core.h2a3462a340c755ce](https://github.com/ZenNotes/zennotes/releases/tag/core-2.72.0-core.h2a3462a340c755ce) +(desktop commit `3792c985`, 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 2d619d4..4b398b4 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 44 - versionName "1.1.41" + versionCode 45 + versionName "1.1.42" 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 1d954c2..9c82155 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.41", + "version": "1.1.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.41", + "version": "1.1.42", "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.71.0-core.he08cc8dc11a66da8.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.71.0-boundaries.h76db7cf027fc7d04.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.71.0-boundaries.h76db7cf027fc7d04.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.72.0-core.h2a3462a340c755ce.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -718,6 +718,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -734,6 +735,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -750,6 +752,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -766,6 +769,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -782,6 +786,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -798,6 +803,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -814,6 +820,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -830,6 +837,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -846,6 +854,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -862,6 +871,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -878,6 +888,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -894,6 +905,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -910,6 +922,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -926,6 +939,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -942,6 +956,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -958,6 +973,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -974,6 +990,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -990,6 +1007,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1006,6 +1024,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1022,6 +1041,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1038,6 +1058,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1054,6 +1075,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1070,6 +1092,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1086,6 +1109,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1102,6 +1126,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1118,6 +1143,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2876,6 +2902,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2892,6 +2919,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2908,6 +2936,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2924,6 +2953,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2940,6 +2970,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2956,6 +2987,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2972,6 +3004,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2988,6 +3021,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3004,6 +3038,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3020,6 +3055,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3036,6 +3072,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3052,6 +3089,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3068,6 +3106,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3084,6 +3123,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3100,6 +3140,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3446,7 +3487,7 @@ "version": "22.20.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3627,9 +3668,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.71.0-core.he08cc8dc11a66da8", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.71.0-core.he08cc8dc11a66da8.tgz", - "integrity": "sha512-JnisgSn8wgSYBIe4S635nf8ky4HdtZShZq4vg27GJ1PsyRNxdCucMMYYA1JLb+sfg3Y+1u/Np834+rbBiwBeew==", + "version": "2.72.0-core.h2a3462a340c755ce", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.72.0-core.h2a3462a340c755ce.tgz", + "integrity": "sha512-AJsVKaxRiG76yCZkij1NaDG2dGhk781Lpy4PyDdjhE0SaDtth8rHxVxS+er9XHqlwKuE3dsld/gWGR5nNJIaJw==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3659,8 +3700,8 @@ "@replit/codemirror-vim": "^6.3.0", "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.71.0-boundaries.h76db7cf027fc7d04", - "@zennotes/shared-domain": "2.71.0-boundaries.h76db7cf027fc7d04", + "@zennotes/bridge-contract": "2.72.0-boundaries.h69adf6c7c8b0ddc5", + "@zennotes/shared-domain": "2.72.0-boundaries.h69adf6c7c8b0ddc5", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3704,19 +3745,19 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.71.0-boundaries.h76db7cf027fc7d04", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.71.0-boundaries.h76db7cf027fc7d04.tgz", - "integrity": "sha512-ARyQMvmvh+BLarnS4474DiNQ648CicWqx+RxvVk4FuPiD4sr5TE90KkBRWzIb7/KpQBaj9Wj9aMfwg5OIOdYsw==", + "version": "2.72.0-boundaries.h69adf6c7c8b0ddc5", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz", + "integrity": "sha512-qUgAkVGpsWHEKZ50yYvoocfL+HVhzvGiKAnD9QT6oGbOrQhRH13E/jTuRDmvzKNBG8D20YjpE55e8eA/b++bEw==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.71.0-boundaries.h76db7cf027fc7d04", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.71.0-boundaries.h76db7cf027fc7d04.tgz", - "integrity": "sha512-Jc8xw3h91pXr0TTSxeI5njputLNCpGE/rCRtM3tWupj3nEDoAnnftlTAHqwBRsqKLyN+exZi3/mqsHtjpuKIbA==", + "version": "2.72.0-boundaries.h69adf6c7c8b0ddc5", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz", + "integrity": "sha512-hANY/7Ehbol6ZjyHYUmVTBmSqqEhwF0qmb32wDhJ8+jVKsuCg9zU3bakgAKODnv7KJzBzFCx+DdXEbH9u6RtGA==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.71.0-boundaries.h76db7cf027fc7d04", + "@zennotes/bridge-contract": "2.72.0-boundaries.h69adf6c7c8b0ddc5", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } @@ -5022,7 +5063,7 @@ "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5263,6 +5304,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5865,7 +5907,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -6035,6 +6077,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6055,6 +6098,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6075,6 +6119,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6095,6 +6140,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6115,6 +6161,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6135,6 +6182,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6155,6 +6203,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6175,6 +6224,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6195,6 +6245,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6215,6 +6266,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6235,6 +6287,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9051,7 +9104,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unified": { diff --git a/package.json b/package.json index f683244..b3c231d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.41", + "version": "1.1.42", "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.71.0-core.he08cc8dc11a66da8.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.71.0-boundaries.h76db7cf027fc7d04.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.71.0-boundaries.h76db7cf027fc7d04.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.72.0-core.h2a3462a340c755ce.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 2a104ec..d791d62 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -155,7 +155,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.41' +let appVersion = '1.1.42' export async function loadNativeAppVersion(): Promise { try { diff --git a/src/bridge/mobile-cloud-encryption.test.ts b/src/bridge/mobile-cloud-encryption.test.ts index 4f3f279..f38561f 100644 --- a/src/bridge/mobile-cloud-encryption.test.ts +++ b/src/bridge/mobile-cloud-encryption.test.ts @@ -112,13 +112,14 @@ it('sets up, locks, unlocks, changes the passphrase and replaces the recovery ke await encryption.changePassphrase({ passphrase: PASSPHRASE }, NEXT_PASSPHRASE) await encryption.lock() - await assert.rejects(encryption.unlock({ passphrase: PASSPHRASE }), /does not unlock/) + // The wording is the core's (2.72.0 rewrote it); the code is the contract. + await assert.rejects(encryption.unlock({ passphrase: PASSPHRASE }), { code: 'wrong-key' }) await encryption.unlock({ passphrase: NEXT_PASSPHRASE }) const replaced = await encryption.prepareNewRecoveryKey({ passphrase: NEXT_PASSPHRASE }) await encryption.finishNewRecoveryKey(replaced.setupId) await encryption.lock() - await assert.rejects(encryption.unlock({ recoveryWords: setup.recoveryWords.join(' ') }), /does not unlock/) + await assert.rejects(encryption.unlock({ recoveryWords: setup.recoveryWords.join(' ') }), { code: 'wrong-key' }) await encryption.unlock({ recoveryWords: replaced.recoveryWords.join(' ') }) assert.equal((await encryption.status()).unlocked, true) }) diff --git a/src/bridge/tasks-folders.test.ts b/src/bridge/tasks-folders.test.ts new file mode 100644 index 0000000..0746050 --- /dev/null +++ b/src/bridge/tasks-folders.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +// The vault's Tasks folder lists from vault.json, read by the on-device +// scanners: `tasks.excludedFolders` (#458) keeps folders out, and +// `tasks.includedFolders`, the Task folders (#962), narrows Tasks to the notes +// inside them, the excluded list still applying within. Same rule as desktop +// main, the Go server and the Go CLI. + +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() + async statVerified(path: string) { + return this.files.has(path) ? 'file' : this.directories.has(path) || [...this.files.keys()].some(key => key.startsWith(`${path}/`)) ? 'directory' : null + } + 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(tasks: unknown) { + const fs = new MemoryFs(), vault = new MobileVault('Fixture') + fs.files.set(SETTINGS, JSON.stringify({ primaryNotesLocation: 'inbox', ...(tasks === undefined ? {} : { tasks }) })) + fs.files.set('inbox/Tasks/Plan.md', '# Plan\n\n- [ ] in the Task folder\n') + fs.files.set('inbox/Tasks/Done/Old.md', '# Old\n\n- [ ] in a left-out folder inside it\n') + fs.files.set('inbox/Tasksheet.md', '# Tasksheet\n\n- [ ] beside it, sharing a name prefix\n') + fs.files.set('inbox/Projects/Site.md', '# Site\n\n- [ ] outside the Task folder\n') + fs.files.set('trash/Gone.md', '# Gone\n\n- [ ] in the trash\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 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('Task folders narrow Tasks to the notes inside them, and the excluded list still applies within', async () => { + const { fs, vault } = fixture({ includedFolders: ['inbox/Tasks'], excludedFolders: ['inbox/Tasks/Done'] }) + assert.deepEqual(await scanned(vault), ['inbox/Tasks/Plan.md']) + // A single-note rescan agrees, or an edit would bring a left-out note back. + assert.deepEqual(await rescanned(vault, fs), ['inbox/Tasks/Plan.md']) +}) + +test('no Task folders means the whole vault, less the excluded folders and the trash', async () => { + const { fs, vault } = fixture({ excludedFolders: ['inbox/Projects'] }) + const expected = ['inbox/Tasks/Done/Old.md', 'inbox/Tasks/Plan.md', 'inbox/Tasksheet.md'] + assert.deepEqual(await scanned(vault), expected) + assert.deepEqual(await rescanned(vault, fs), expected) +}) + +test('a vault.json without Tasks settings lists every live note', async () => { + const { vault } = fixture(undefined) + assert.deepEqual(await scanned(vault), [ + 'inbox/Projects/Site.md', 'inbox/Tasks/Done/Old.md', 'inbox/Tasks/Plan.md', 'inbox/Tasksheet.md' + ]) +}) + +test('the folder lists are validated on read, and a save from this device keeps the Task folders', async () => { + const { fs, vault } = fixture({ + includedFolders: ['inbox/Tasks/', '../escape', '', 'inbox/Tasks', 7], + excludedFolders: 'not a list', + mode: 'tagged' + }) + const settings = await vault.getVaultSettings() + assert.deepEqual(settings.tasks, { mode: 'tagged', includedFolders: ['inbox/Tasks'] }) + await vault.setVaultSettings({ ...settings, favorites: ['inbox/Tasks/Plan.md'] }) + const written = JSON.parse(fs.files.get(SETTINGS)!) + assert.deepEqual(written.tasks, { mode: 'tagged', includedFolders: ['inbox/Tasks'] }) + assert.deepEqual(written.favorites, ['inbox/Tasks/Plan.md']) +}) + +test('lists that end up empty leave no tasks stub in vault.json', async () => { + const { fs, vault } = fixture({ includedFolders: [], excludedFolders: ['', '..'] }) + const settings = await vault.getVaultSettings() + assert.equal('tasks' in settings, false) + await vault.setVaultSettings(settings) + assert.equal('tasks' in JSON.parse(fs.files.get(SETTINGS)!), false) +}) + +test('renaming a folder repoints both lists, so the Task folder keeps its tasks', async () => { + const { fs, vault } = fixture({ includedFolders: ['inbox/Tasks', 'inbox/Projects'], excludedFolders: ['inbox/Tasks/Done'] }) + await vault.renameFolder('inbox', 'Tasks', 'Todo') + assert.deepEqual(JSON.parse(fs.files.get(SETTINGS)!).tasks, { + excludedFolders: ['inbox/Todo/Done'], + includedFolders: ['inbox/Todo', 'inbox/Projects'] + }) + assert.deepEqual(await scanned(vault), ['inbox/Projects/Site.md', 'inbox/Todo/Plan.md']) + // A folder that is neither listed nor inside a listed one leaves the lists alone. + fs.files.set('inbox/Notes/Loose.md', '# Loose\n') + await vault.renameFolder('inbox', 'Notes', 'Kept') + assert.deepEqual(JSON.parse(fs.files.get(SETTINGS)!).tasks.includedFolders, ['inbox/Todo', 'inbox/Projects']) +}) diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index 3696d00..84315f1 100644 --- a/src/bridge/vault-fs.ts +++ b/src/bridge/vault-fs.ts @@ -28,8 +28,15 @@ 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 { + isPathExcludedFromTasks, + normalizeTasksExcludedFolders, + normalizeTasksIncludedFolders, + rewriteTasksFoldersForRename +} from '@zennotes/shared-domain/tasks-excluded-folders' import { isFormDirName, isDatabaseInternalPath } from '@zennotes/shared-domain/databases' import { emptyExcalidrawDocument } from '@zennotes/shared-domain/excalidraw' +import { pastedImageFilename } from '@zennotes/shared-domain/pasted-image' import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@zennotes/shared-domain/demo-tour-data' import { currentDemoTourPath, DEMO_TOUR_DIR, demoTourNestedDirs } from '@zennotes/shared-domain/demo-tour' import { WELCOME_NOTE_PATH, WELCOME_NOTE_BODY } from './welcome-note' @@ -140,6 +147,27 @@ function warnAboutDiscardedFolderPaths(parsed: unknown, settings: VaultSettings) } } +/** + * The Tasks folder lists validated the way desktop's normalizeTasksSettings + * validates them: the folders left out of Tasks (#458) and the Task folders + * it is narrowed to (#962). A hand-edited list that is not an array would + * otherwise reach a folder rename and refuse it. An empty list is left out, + * other keys under `tasks` pass through, and undefined comes back when + * nothing is left, so vault.json carries no empty stub. + */ +function normalizeTasksSettings(raw: unknown): VaultSettings['tasks'] | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const { excludedFolders, includedFolders, ...rest } = raw as Record + const excluded = normalizeTasksExcludedFolders(excludedFolders) + const included = normalizeTasksIncludedFolders(includedFolders) + const out = { + ...rest, + ...(excluded.length > 0 ? { excludedFolders: excluded } : {}), + ...(included.length > 0 ? { includedFolders: included } : {}) + } + return Object.keys(out).length > 0 ? out : undefined +} + export class MobileVault { readonly name: string readonly fs: NativeFs @@ -316,6 +344,9 @@ export class MobileVault { // sync erases what the user taught it. Phones never run Harper themselves. const harper = normalizeHarperVaultState(obj.harper) if (harper) out.harper = harper + const tasks = normalizeTasksSettings(obj.tasks) + if (tasks) out.tasks = tasks + else delete out.tasks return out } @@ -965,7 +996,11 @@ export class MobileVault { await this.setVaultSettings({ ...settings, folderIcons: rekey(settings.folderIcons as Record) as VaultSettings['folderIcons'], - folderColors: rekey(settings.folderColors as Record) as VaultSettings['folderColors'] + folderColors: rekey(settings.folderColors as Record) as VaultSettings['folderColors'], + // 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) }) } catch (error) { try { @@ -1197,11 +1232,28 @@ export class MobileVault { return fileTask ? [fileTask, ...inline] : inline } + /** 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. */ + private async tasksFolderLists(): Promise<{ excluded: string[]; included: string[] }> { + const tasks = (await this.getVaultSettings()).tasks + return { + excluded: normalizeTasksExcludedFolders(tasks?.excludedFolders), + included: normalizeTasksIncludedFolders(tasks?.includedFolders) + } + } + async scanTasks(): Promise { + // The Tasks folder lists travel in vault.json, so a folder the desktop + // left out (#458), or the Task folders it narrowed Tasks to (#962), apply + // here too; the frontmatter `tasks:` opt-out comes free from the shared + // parsers. + const { excluded, included } = await this.tasksFolderLists() const notes = await this.listNotes() const out: VaultTask[] = [] for (const note of notes) { if (note.folder === 'trash') continue + if (isPathExcludedFromTasks(note.path, excluded, included)) continue if (!isMarkdownPath(note.path)) continue const body = await this.fs.readTextOrNull(note.path) if (body === null) continue @@ -1216,6 +1268,10 @@ export class MobileVault { const rel = resolveSafeRel(relPath) const folder = await this.folderOf(rel) if (!folder || folder === 'trash') return [] + // Same folder lists the full scan applies (#458, #962), or a single-note + // rescan would resurrect a left-out folder's tasks on every edit. + const { excluded, included } = await this.tasksFolderLists() + if (isPathExcludedFromTasks(rel, excluded, included)) return [] if (!isMarkdownPath(rel)) return [] const body = await this.fs.readTextOrNull(rel) if (body === null) return [] @@ -1346,10 +1402,14 @@ export class MobileVault { async importPastedImage(input: PastedImageInput): Promise { const bytes = input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data as ArrayBuffer) - const ext = pastedImageExtension(input.mimeType, input.suggestedName ?? null) - const base = pastedImageBaseName(input.suggestedName ?? null) + // The shared naming, as on the iPhone and the desktop: a clipboard bitmap + // the WebView calls image.png gets the timestamped name (#775). + const name = pastedImageFilename( + { mimeType: input.mimeType, suggestedName: input.suggestedName }, + new Date() + ) await this.fs.mkdir(ASSETS_DIR) - const filename = await this.uniqueFilename(ASSETS_DIR, `${base}${ext}`) + const filename = await this.uniqueFilename(ASSETS_DIR, name) const rel = `${ASSETS_DIR}/${filename}` await this.fs.writeBase64(rel, bytesToBase64(bytes)) emitVaultChange({ kind: 'add', path: rel, folder: 'inbox', scope: 'content' }) @@ -1534,45 +1594,6 @@ export class MobileVault { // Pasted image naming — mirrors vault.ts pastedImage* helpers // --------------------------------------------------------------------------- -const PASTED_IMAGE_MIME_EXTENSIONS: Record = { - 'image/png': '.png', - 'image/jpeg': '.jpg', - 'image/jpg': '.jpg', - 'image/gif': '.gif', - 'image/webp': '.webp', - 'image/avif': '.avif', - 'image/svg+xml': '.svg' -} - -function pastedImageExtension(mimeType: string, suggestedName: string | null): string { - if (suggestedName) { - const ext = extName(suggestedName).toLowerCase() - if (ext && ['.apng', '.avif', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'].includes(ext)) { - return ext - } - } - const mapped = PASTED_IMAGE_MIME_EXTENSIONS[mimeType.toLowerCase()] - if (mapped) return mapped - if (mimeType.toLowerCase().startsWith('image/')) return '.png' - throw new Error(`Unsupported pasted image type: ${mimeType}`) -} - -function pastedImageTimestamp(now: Date): string { - const pad = (v: number): string => String(v).padStart(2, '0') - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` -} - -function pastedImageBaseName(suggestedName: string | null): string { - if (suggestedName) { - const base = stemName(suggestedName) - .replace(/[\\/:%*?"<>|[\]#^]/g, '-') - .replace(/\s+/g, ' ') - .trim() - if (base) return base - } - return `Pasted Image ${pastedImageTimestamp(new Date())}` -} - function bytesToBase64(bytes: Uint8Array): string { let binary = '' const chunk = 0x8000 diff --git a/vendor/zennotes/manifest.json b/vendor/zennotes/manifest.json index 1620de0..244df8d 100644 --- a/vendor/zennotes/manifest.json +++ b/vendor/zennotes/manifest.json @@ -1,12 +1,12 @@ { "name": "@zennotes/app-core", - "version": "2.71.0-core.he08cc8dc11a66da8", - "file": "zennotes-app-core-2.71.0-core.he08cc8dc11a66da8.tgz", - "sha256": "a299b79e12964cb02eb90bb60fbfe1ea13023e5fb37884f1b7fc3c3e2cc71122", - "integrity": "sha512-JnisgSn8wgSYBIe4S635nf8ky4HdtZShZq4vg27GJ1PsyRNxdCucMMYYA1JLb+sfg3Y+1u/Np834+rbBiwBeew==", - "sourceCommit": "603fc6dc996b2771b6cbccdb9055a9cd49d488af", + "version": "2.72.0-core.h2a3462a340c755ce", + "file": "zennotes-app-core-2.72.0-core.h2a3462a340c755ce.tgz", + "sha256": "7610c8a6e338e1e6c152d87ac98ce7f3fbfd48b5655139538c376cfde00a41f7", + "integrity": "sha512-AJsVKaxRiG76yCZkij1NaDG2dGhk781Lpy4PyDdjhE0SaDtth8rHxVxS+er9XHqlwKuE3dsld/gWGR5nNJIaJw==", + "sourceCommit": "3792c985eeb5c0d5f60c20be34898397901b5ae3", "workingTreeDirty": false, - "sourceLockSha256": "444ae047db12bb423c5ec0c6b41c72399e112dba848ad6429d02a339204ce860", + "sourceLockSha256": "af5b3d3e6058dd7e2c911aa297cc24b5da8e1d1b635b636ceb3c49da31f25963", "toolchain": { "node": "v22.23.3", "typescript": "5.9.3", @@ -15,20 +15,20 @@ "dependencies": [ { "name": "@zennotes/bridge-contract", - "version": "2.71.0-boundaries.h76db7cf027fc7d04", - "file": "zennotes-bridge-contract-2.71.0-boundaries.h76db7cf027fc7d04.tgz", - "sha256": "1509a999da9c281bd9320b29f44ff819abc3bb1bbb125e143b0c65148f430b7b", - "integrity": "sha512-ARyQMvmvh+BLarnS4474DiNQ648CicWqx+RxvVk4FuPiD4sr5TE90KkBRWzIb7/KpQBaj9Wj9aMfwg5OIOdYsw==", - "sourceCommit": "603fc6dc996b2771b6cbccdb9055a9cd49d488af", + "version": "2.72.0-boundaries.h69adf6c7c8b0ddc5", + "file": "zennotes-bridge-contract-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz", + "sha256": "f312dd5390f68aff04ed567e0cca49dcaf7b8323026018fc345d20af2011539a", + "integrity": "sha512-qUgAkVGpsWHEKZ50yYvoocfL+HVhzvGiKAnD9QT6oGbOrQhRH13E/jTuRDmvzKNBG8D20YjpE55e8eA/b++bEw==", + "sourceCommit": "3792c985eeb5c0d5f60c20be34898397901b5ae3", "workingTreeDirty": false }, { "name": "@zennotes/shared-domain", - "version": "2.71.0-boundaries.h76db7cf027fc7d04", - "file": "zennotes-shared-domain-2.71.0-boundaries.h76db7cf027fc7d04.tgz", - "sha256": "5339f395068e25ce2847c8f39c6e4e4ce1c426013d07063d5bbb97fad86ff240", - "integrity": "sha512-Jc8xw3h91pXr0TTSxeI5njputLNCpGE/rCRtM3tWupj3nEDoAnnftlTAHqwBRsqKLyN+exZi3/mqsHtjpuKIbA==", - "sourceCommit": "603fc6dc996b2771b6cbccdb9055a9cd49d488af", + "version": "2.72.0-boundaries.h69adf6c7c8b0ddc5", + "file": "zennotes-shared-domain-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz", + "sha256": "c8a8e41bc26200dd001cc77ed02872c1c730e447e76645465fdcea584ca53e19", + "integrity": "sha512-hANY/7Ehbol6ZjyHYUmVTBmSqqEhwF0qmb32wDhJ8+jVKsuCg9zU3bakgAKODnv7KJzBzFCx+DdXEbH9u6RtGA==", + "sourceCommit": "3792c985eeb5c0d5f60c20be34898397901b5ae3", "workingTreeDirty": false } ] diff --git a/vendor/zennotes/zennotes-app-core-2.71.0-core.he08cc8dc11a66da8.tgz b/vendor/zennotes/zennotes-app-core-2.71.0-core.he08cc8dc11a66da8.tgz deleted file mode 100644 index b56679b..0000000 Binary files a/vendor/zennotes/zennotes-app-core-2.71.0-core.he08cc8dc11a66da8.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-app-core-2.72.0-core.h2a3462a340c755ce.tgz b/vendor/zennotes/zennotes-app-core-2.72.0-core.h2a3462a340c755ce.tgz new file mode 100644 index 0000000..d68263f Binary files /dev/null and b/vendor/zennotes/zennotes-app-core-2.72.0-core.h2a3462a340c755ce.tgz differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.71.0-boundaries.h76db7cf027fc7d04.tgz b/vendor/zennotes/zennotes-bridge-contract-2.71.0-boundaries.h76db7cf027fc7d04.tgz deleted file mode 100644 index fa1cbb0..0000000 Binary files a/vendor/zennotes/zennotes-bridge-contract-2.71.0-boundaries.h76db7cf027fc7d04.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz b/vendor/zennotes/zennotes-bridge-contract-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz new file mode 100644 index 0000000..24d0295 Binary files /dev/null and b/vendor/zennotes/zennotes-bridge-contract-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.71.0-boundaries.h76db7cf027fc7d04.tgz b/vendor/zennotes/zennotes-shared-domain-2.71.0-boundaries.h76db7cf027fc7d04.tgz deleted file mode 100644 index ef1f12d..0000000 Binary files a/vendor/zennotes/zennotes-shared-domain-2.71.0-boundaries.h76db7cf027fc7d04.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz b/vendor/zennotes/zennotes-shared-domain-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz new file mode 100644 index 0000000..fc4f549 Binary files /dev/null and b/vendor/zennotes/zennotes-shared-domain-2.72.0-boundaries.h69adf6c7c8b0ddc5.tgz differ