diff --git a/README.md b/README.md index 6e8fa4c..bfbd5dd 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.60.3-core.h44232ae9fc126112](https://github.com/ZenNotes/zennotes/releases/tag/core-2.60.3-core.h44232ae9fc126112) -(desktop commit `e5e34f84`, clean tree). +[core-2.60.4-core.hae0e49f9e5397fc2](https://github.com/ZenNotes/zennotes/releases/tag/core-2.60.4-core.hae0e49f9e5397fc2) +(desktop commit `c507caa3`, 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 b2aee1f..51c46ad 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -604,12 +604,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.17.0; + MARKETING_VERSION = 1.17.1; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -733,12 +733,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.17.0; + MARKETING_VERSION = 1.17.1; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -755,12 +755,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.17.0; + MARKETING_VERSION = 1.17.1; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -775,12 +775,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.17.0; + MARKETING_VERSION = 1.17.1; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -797,12 +797,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.17.0; + MARKETING_VERSION = 1.17.1; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -835,12 +835,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.17.0; + MARKETING_VERSION = 1.17.1; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/package-lock.json b/package-lock.json index 530028d..b45de6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.4", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.60.3-core.h44232ae9fc126112.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.60.3-boundaries.he9b8fc8befc70b6e.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.60.3-boundaries.he9b8fc8befc70b6e.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.60.4-core.hae0e49f9e5397fc2.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.60.4-boundaries.h787dd8dec256d6bf.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.60.4-boundaries.h787dd8dec256d6bf.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.16", "function-plot": "^1.25.3", @@ -3351,9 +3351,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.60.3-core.h44232ae9fc126112", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.60.3-core.h44232ae9fc126112.tgz", - "integrity": "sha512-7F/EEgj8AOF0Mx6rupO2h6jc35jbei5Hq2fI6SX1lOQkM32dPeNCz3M3Izm873h8kUHHOD4ubMF/x6fPWgiqDw==", + "version": "2.60.4-core.hae0e49f9e5397fc2", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.60.4-core.hae0e49f9e5397fc2.tgz", + "integrity": "sha512-myfhQXcnJYLP0UNxYwYeyYtrPSXqWgA5zdK9CwVGJDl47GZWFjz9BOIzhbMUk4QkiJ0Pude1rtfDBOLMdAXNWQ==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3381,8 +3381,8 @@ "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.60.3-boundaries.he9b8fc8befc70b6e", - "@zennotes/shared-domain": "2.60.3-boundaries.he9b8fc8befc70b6e", + "@zennotes/bridge-contract": "2.60.4-boundaries.h787dd8dec256d6bf", + "@zennotes/shared-domain": "2.60.4-boundaries.h787dd8dec256d6bf", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3426,18 +3426,18 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.60.3-boundaries.he9b8fc8befc70b6e", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.60.3-boundaries.he9b8fc8befc70b6e.tgz", - "integrity": "sha512-XfD+CutriaT/o/Zp3ftuLH9MYZbbjI57gxGuL7s9ncXp28If/8SKFIuB3+0Cqzf6UxPxazPc/Mi2lmg6qU3sMQ==", + "version": "2.60.4-boundaries.h787dd8dec256d6bf", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.60.4-boundaries.h787dd8dec256d6bf.tgz", + "integrity": "sha512-Qw3LkQ14AzebK1gwjtEOajEaOPjRmaQ9Ku/JQkiQx5oHOVT3wT36MYb8jN1S+ro8aF6/oH48xnFLt+qMZAseZA==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.60.3-boundaries.he9b8fc8befc70b6e", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.60.3-boundaries.he9b8fc8befc70b6e.tgz", - "integrity": "sha512-vLG6OYNc3bxfaGZAp5VxkliaHKFFEBGgYTUMi89lEOSFQjpyn9dit/vIZR4I1SUDT52eRAwKAIPeEvDgfKv16g==", + "version": "2.60.4-boundaries.h787dd8dec256d6bf", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.60.4-boundaries.h787dd8dec256d6bf.tgz", + "integrity": "sha512-f5luMeQt4S8b2BD43yzoFpQSdWbve31n23DBGcNmfF89qxiKsLU/Mm+XaZOHRWraPCOSdYiBUb3qn3sVu9b+Sg==", "license": "MIT", "dependencies": { - "@zennotes/bridge-contract": "2.60.3-boundaries.he9b8fc8befc70b6e", + "@zennotes/bridge-contract": "2.60.4-boundaries.h787dd8dec256d6bf", "lz-string": "^1.5.0" } }, diff --git a/package.json b/package.json index 85c87ea..bbb300d 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,9 @@ "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.2", "zustand": "^5.0.15", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.60.3-core.h44232ae9fc126112.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.60.3-boundaries.he9b8fc8befc70b6e.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.60.3-boundaries.he9b8fc8befc70b6e.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.60.4-core.hae0e49f9e5397fc2.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.60.4-boundaries.h787dd8dec256d6bf.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.60.4-boundaries.h787dd8dec256d6bf.tgz", "@lezer/common": "^1.5.3" }, "devDependencies": { diff --git a/src/bridge/folder-picker.test.ts b/src/bridge/folder-picker.test.ts new file mode 100644 index 0000000..4d24aad --- /dev/null +++ b/src/bridge/folder-picker.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +// The real folder-picker.ts, with the native FolderPicker and the storage +// tier replaced, so each test sees exactly when the Files picker would open. +const store = new Map() +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key) + } +}) + +type PickResult = { cancelled: boolean; url?: string; name?: string; bookmark?: string } +const notes: PickResult = { cancelled: false, url: 'file:///Notes', name: 'Notes', bookmark: 'bookmark-notes' } +let nextPick: PickResult = notes +let picks = 0 +const tiers: string[] = [] + +const picker = await loadMobileModule('./src/bridge/folder-picker.ts', { + '@capacitor/core': { + registerPlugin: () => ({ + pickFolder: async () => { + picks += 1 + return nextPick + }, + resolveBookmark: async () => { + throw new Error('not reached by a pick') + } + }) + }, + './icloud': { setStoragePref: (tier: string) => void tiers.push(tier) } +}) + +const documents = { name: 'Documents', bookmark: 'bookmark-documents' } + +function start(current: typeof documents | null): void { + store.clear() + picker.setExternalVaultRef(current) + nextPick = notes + picks = 0 + tiers.length = 0 +} + +test('Cancel on the question never opens the Files picker and keeps the folder vault', async () => { + start(documents) + const asked: string[] = [] + const result = await picker.pickExternalVault(async (notice: { body: string }) => { + asked.push(notice.body) + return false + }) + assert.equal(result, null) + assert.equal(picks, 0) + assert.deepEqual(picker.getExternalVaultRef(), documents) + assert.deepEqual(tiers, []) + assert.equal(asked.length, 1) + assert.match(asked[0]!, /removes “Documents” from your vaults list/) +}) + +test('Choose New Folder opens the picker, and the new folder replaces the old one', async () => { + start(documents) + const result = await picker.pickExternalVault(async () => true) + assert.deepEqual(result, { url: 'file:///Notes', name: 'Notes' }) + assert.equal(picks, 1) + assert.deepEqual(picker.getExternalVaultRef(), { name: 'Notes', bookmark: 'bookmark-notes' }) + assert.deepEqual(tiers, ['external']) +}) + +test('the first folder vault is picked without a question', async () => { + start(null) + let asked = 0 + const result = await picker.pickExternalVault(async () => { + asked += 1 + return false + }) + assert.deepEqual(result, { url: 'file:///Notes', name: 'Notes' }) + assert.equal(asked, 0) + assert.equal(picks, 1) +}) + +test('the New Vault sheet’s yes is not asked again, and it covers one pick only', async () => { + start(documents) + nextPick = { cancelled: true } + let asked = 0 + const decline = async (): Promise => { + asked += 1 + return false + } + const release = picker.answerExternalVaultReplace(documents.bookmark) + assert.equal(await picker.pickExternalVault(decline), null) + release() + assert.equal(asked, 0) + assert.equal(picks, 1) + assert.deepEqual(picker.getExternalVaultRef(), documents) + + assert.equal(await picker.pickExternalVault(decline), null) + assert.equal(asked, 1) + assert.equal(picks, 1) +}) + +test('a yes that no pick used is dropped by its release', async () => { + start(documents) + let asked = 0 + picker.answerExternalVaultReplace(documents.bookmark)() + await picker.pickExternalVault(async () => { + asked += 1 + return false + }) + assert.equal(asked, 1) + assert.equal(picks, 0) +}) + +test('a yes about another folder does not cover the one that is set', async () => { + start(documents) + let asked = 0 + const release = picker.answerExternalVaultReplace('bookmark-elsewhere') + await picker.pickExternalVault(async () => { + asked += 1 + return false + }) + release() + assert.equal(asked, 1) + assert.equal(picks, 0) +}) diff --git a/src/bridge/folder-picker.ts b/src/bridge/folder-picker.ts index 75184eb..474034e 100644 --- a/src/bridge/folder-picker.ts +++ b/src/bridge/folder-picker.ts @@ -2,10 +2,13 @@ * External-folder vault tier (spec 03 "advanced" tier): the user picks any * Files-app folder (iCloud Drive, On My iPhone, Working Copy, ...) via the * native document picker; a security-scoped bookmark keeps it accessible - * across launches. + * across launches. Only one bookmark is kept, so a pick replaces the folder + * vault that was there, and the person is asked first + * (folder-vault-replace.ts). */ import { registerPlugin } from '@capacitor/core' import { setStoragePref } from './icloud' +import { pickAfterReplaceNotice, type FolderVaultReplaceNotice } from './folder-vault-replace' interface FolderPickerPlugin { pickFolder(): Promise<{ @@ -44,13 +47,37 @@ export function setExternalVaultRef(ref: ExternalVaultRef | null): void { else localStorage.removeItem(EXTERNAL_KEY) } -/** Present the picker; on selection persist the bookmark + flip storage. */ -export async function pickExternalVault(): Promise<{ url: string; name: string } | null> { - const result = await FolderPicker.pickFolder() - if (result.cancelled || !result.url || !result.bookmark) return null - setExternalVaultRef({ name: result.name ?? 'Vault', bookmark: result.bookmark }) - setStoragePref('external') - return { url: result.url, name: result.name ?? 'Vault' } +// The New Vault sheet asks in place, before the store starts a vault change, +// so its Cancel leaves the workspace exactly as it was. Its yes still has to +// cross the store to reach the picker (pickLocalVault takes no arguments), +// and it covers one pick of the folder it named; anything else asks here. +let replaceAnsweredFor: string | null = null + +/** Records the New Vault sheet's yes for the folder vault it named. The + * returned release drops an answer no pick used (a refused vault change). */ +export function answerExternalVaultReplace(bookmark: string): () => void { + replaceAnsweredFor = bookmark + return () => { + if (replaceAnsweredFor === bookmark) replaceAnsweredFor = null + } +} + +/** Present the picker; on selection persist the bookmark + flip storage. + * With a folder vault already set, `confirm` is asked before the picker + * opens, and a no leaves the bookmark and the storage tier untouched. */ +export async function pickExternalVault( + confirm: (notice: FolderVaultReplaceNotice) => Promise +): Promise<{ url: string; name: string } | null> { + const current = getExternalVaultRef() + const answered = current !== null && current.bookmark === replaceAnsweredFor + replaceAnsweredFor = null + return await pickAfterReplaceNotice(answered ? null : current, confirm, async () => { + const result = await FolderPicker.pickFolder() + if (result.cancelled || !result.url || !result.bookmark) return null + setExternalVaultRef({ name: result.name ?? 'Vault', bookmark: result.bookmark }) + setStoragePref('external') + return { url: result.url, name: result.name ?? 'Vault' } + }) } /** Re-open the bookmarked folder at boot (refreshing a stale bookmark). */ diff --git a/src/bridge/folder-vault-replace.test.ts b/src/bridge/folder-vault-replace.test.ts new file mode 100644 index 0000000..46f2827 --- /dev/null +++ b/src/bridge/folder-vault-replace.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + FOLDER_VAULT_HINT, + folderVaultReplaceNotice, + pickAfterReplaceNotice +} from './folder-vault-replace.ts' + +test('with no Files folder vault set a pick replaces nothing, so nothing is asked', () => { + assert.equal(folderVaultReplaceNotice(null), null) +}) + +test('the question names the folder vault, what stays in Files, and the way back', () => { + assert.deepEqual(folderVaultReplaceNotice({ name: 'Documents' }), { + title: 'Replace your Files folder vault?', + body: + 'ZenNotes keeps one Files folder vault at a time. Choosing a new folder removes “Documents” ' + + 'from your vaults list. Its notes stay in Files, and you can choose that folder again anytime.', + confirmLabel: 'Choose New Folder', + cancelLabel: 'Cancel' + }) + assert.equal(FOLDER_VAULT_HINT, 'One Files folder at a time') +}) + +test('the copy carries no em dash (house style)', () => { + const notice = folderVaultReplaceNotice({ name: 'Documents' }) + assert.ok(notice) + for (const text of [...Object.values(notice), FOLDER_VAULT_HINT]) { + assert.equal(text.includes(String.fromCodePoint(0x2014)), false) + } +}) + +test('Cancel never opens the picker', async () => { + const asked: string[] = [] + let picks = 0 + const result = await pickAfterReplaceNotice( + { name: 'Documents' }, + async (notice) => { + asked.push(notice.title) + return false + }, + async () => { + picks += 1 + return 'Notes' + } + ) + assert.equal(result, null) + assert.equal(picks, 0) + assert.deepEqual(asked, ['Replace your Files folder vault?']) +}) + +test('Choose New Folder opens the picker once and hands back what was picked', async () => { + let picks = 0 + const result = await pickAfterReplaceNotice( + { name: 'Documents' }, + async () => true, + async () => { + picks += 1 + return 'Notes' + } + ) + assert.equal(result, 'Notes') + assert.equal(picks, 1) +}) + +test('the first folder vault opens the picker without a question', async () => { + let asked = 0 + let picks = 0 + const result = await pickAfterReplaceNotice( + null, + async () => { + asked += 1 + return false + }, + async () => { + picks += 1 + return 'Documents' + } + ) + assert.equal(result, 'Documents') + assert.equal(asked, 0) + assert.equal(picks, 1) +}) diff --git a/src/bridge/folder-vault-replace.ts b/src/bridge/folder-vault-replace.ts new file mode 100644 index 0000000..afa15c4 --- /dev/null +++ b/src/bridge/folder-vault-replace.ts @@ -0,0 +1,50 @@ +/** + * One Files folder vault at a time: folder-picker.ts keeps a single + * security-scoped bookmark, so choosing another folder replaces the current + * one, and the replaced folder drops out of the Vaults sheet while its notes + * stay in Files. The picker used to do that without a word, and a vault the + * person had been using simply vanished from the list. Every path to the + * picker asks first with this copy, and the Vaults sheet states the rule + * under Folders. + * + * No Capacitor imports, so the copy and the ask-then-pick order load in + * node:test as they are. + */ + +export interface FolderVaultReplaceNotice { + title: string + body: string + confirmLabel: string + cancelLabel: string +} + +/** The rule, stated once under the Vaults sheet's Folders section. */ +export const FOLDER_VAULT_HINT = 'One Files folder at a time' + +/** What to ask before the picker opens, or null when no folder vault is set + * and a pick replaces nothing. Names the folder as the Vaults sheet lists it. */ +export function folderVaultReplaceNotice( + current: { name: string } | null +): FolderVaultReplaceNotice | null { + if (!current) return null + return { + title: 'Replace your Files folder vault?', + body: + `ZenNotes keeps one Files folder vault at a time. Choosing a new folder removes “${current.name}” ` + + 'from your vaults list. Its notes stay in Files, and you can choose that folder again anytime.', + confirmLabel: 'Choose New Folder', + cancelLabel: 'Cancel' + } +} + +/** Asks when there is a folder vault to replace, then picks. A declined + * notice resolves null and never opens the picker. */ +export async function pickAfterReplaceNotice( + current: { name: string } | null, + confirm: (notice: FolderVaultReplaceNotice) => Promise, + pick: () => Promise +): Promise { + const notice = folderVaultReplaceNotice(current) + if (notice && !(await confirm(notice))) return null + return await pick() +} diff --git a/src/bridge/imported-assets.test.ts b/src/bridge/imported-assets.test.ts index 9f03c43..319501b 100644 --- a/src/bridge/imported-assets.test.ts +++ b/src/bridge/imported-assets.test.ts @@ -44,9 +44,15 @@ describe('importedAssetMarkdown', () => { assert.ok(!markdown.includes('../')) }) - it('angle-brackets a non-image link so a space cannot break it', () => { - const rel = importedAssetRelPath('year end.pdf') - assert.equal(importedAssetMarkdown(rel, 'year end.pdf', 'pdf'), '[year end.pdf]()') + it('embeds a video, audio file, or PDF so the note shows it in place', () => { + assert.equal(importedAssetMarkdown(importedAssetRelPath('IMG_2709.mov'), 'IMG_2709.mov', 'video'), '![[assets/IMG_2709.mov]]') + assert.equal(importedAssetMarkdown(importedAssetRelPath('talk.mp3'), 'talk.mp3', 'audio'), '![[assets/talk.mp3]]') + assert.equal(importedAssetMarkdown(importedAssetRelPath('year end.pdf'), 'year end.pdf', 'pdf'), '![[assets/year end.pdf]]') + }) + + it('angle-brackets a plain file link so a space cannot break it', () => { + const rel = importedAssetRelPath('year end.zip') + assert.equal(importedAssetMarkdown(rel, 'year end.zip', 'file'), '[year end.zip]()') }) it('escapes a closing angle bracket in the path', () => { diff --git a/src/bridge/imported-assets.ts b/src/bridge/imported-assets.ts index 6c0cd60..aa72f27 100644 --- a/src/bridge/imported-assets.ts +++ b/src/bridge/imported-assets.ts @@ -35,16 +35,20 @@ export function importedAssetRelPath(filename: string): string { return `${ASSETS_DIR}/${filename}` } -/** The link written into the note, by vault-relative path. Images take the - * wikilink form, matching pasted images and the cloud vault; anything else - * gets an angle-bracketed markdown link so a space in the name cannot break - * it. Never a note-relative `../` path: the old attach path hand-built one - * from the note's depth, which pointed outside the vault from a nested note. */ +/** The link written into the note, by vault-relative path. Images, PDFs, audio + * and video take the embed form, matching pasted images and the desktop, so + * the note shows the picture, the document, or a player in place (a video + * attached here used to show up as its bare file name). Anything else gets an + * angle-bracketed markdown link so a space in the name cannot break it. Never + * a note-relative `../` path: the old attach path hand-built one from the + * note's depth, which pointed outside the vault from a nested note. */ export function importedAssetMarkdown( relPath: string, filename: string, kind: ImportedAssetKind ): string { - if (kind === 'image') return `![[${relPath}]]` + if (EMBEDDED_ASSET_KINDS.has(kind)) return `![[${relPath}]]` return `[${filename}](<${relPath.replace(/>/g, '%3E')}>)` } + +const EMBEDDED_ASSET_KINDS: ReadonlySet = new Set(['image', 'pdf', 'audio', 'video']) diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index f1cbd86..661daf9 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -1,4 +1,5 @@ import { relocateLocalVault } from '@zennotes/app-core/workspace' +import { confirm as confirmApp } from '@zennotes/app-core/dialogs' /** * The mobile `window.zen` — third ZenBridge implementation (after Electron IPC * and the web HTTP bridge). Vault operations run against the on-device vault @@ -100,6 +101,7 @@ import { getMobileCloudVaultLink, linkMobileCloudVault, listMobileCloudBackupItems, + listMobileCloudBackupItemsPage, listMobileCloudBackups, restoreMobileCloudBackup, restoreMobileCloudBackupNote, @@ -935,6 +937,8 @@ export const mobileBridge: ZenBridge = { updateMobileCloudBackupSchedule(activeMobileVault(), enabled), listCloudBackupItems: (backupId) => listMobileCloudBackupItems(activeMobileVault(), backupId), + listCloudBackupItemsPage: (backupId, query) => + listMobileCloudBackupItemsPage(activeMobileVault(), backupId, query), createCloudBackup: (label) => createMobileCloudBackup(activeMobileVault(), label), downloadCloudBackup: (backupId) => downloadMobileCloudBackup(activeMobileVault(), backupId), deleteCloudBackup: (backupId) => deleteMobileCloudBackup(activeMobileVault(), backupId), @@ -1017,7 +1021,18 @@ export const mobileBridge: ZenBridge = { }, closeVault: () => describeCurrentVault(), pickVault: async () => { - const picked = await pickExternalVault() + // The command palette's Open Vault… and Settings' Change… (iPad) reach + // the picker only through here, so this is where they ask, with the + // core's confirm dialog (a bottom sheet on the phone). The New Vault + // sheet has already asked in place and is not asked twice. + const picked = await pickExternalVault((notice) => + confirmApp({ + title: notice.title, + description: notice.body, + confirmLabel: notice.confirmLabel, + cancelLabel: notice.cancelLabel + }) + ) if (!picked) return null await disconnectRemote() return await openVaultByName(picked.name, picked.url) diff --git a/src/bridge/mobile-cloud-sync.integration.test.ts b/src/bridge/mobile-cloud-sync.integration.test.ts index d21d7c6..902d21c 100644 --- a/src/bridge/mobile-cloud-sync.integration.test.ts +++ b/src/bridge/mobile-cloud-sync.integration.test.ts @@ -25,6 +25,7 @@ async function fixture(initial: Record = { 'note.md': 'Original' const revisions = new Map() const feed: CloudSyncChange[] = [] const reads: string[] = [] + const createdDirectories: string[] = [] const refreshes: Record[] = [] const uploaded: CloudSyncMutation[] = [] const manifestRequests: unknown[] = [] @@ -147,7 +148,7 @@ async function fixture(initial: Record = { 'note.md': 'Original' if (path === failWritePath) throw new Error('Native write failed after writing') }, async deleteFile(path: string) { files.delete(path) }, - async mkdir(_path: string) {}, + async mkdir(path: string) { createdDirectories.push(path) }, async rename(from: string, to: string) { const file = files.get(from) assert.ok(file) @@ -186,7 +187,7 @@ async function fixture(initial: Record = { 'note.md': 'Original' await api.linkMobileCloudVault(vault, 'vault-1') const stateKey = () => [...persisted.keys()].find((path) => path.includes('/states/')) return { - api, vault, files, reads, uploaded, refreshes, remoteText, put, manifestRequests, remote, persisted, + api, vault, files, reads, uploaded, refreshes, remoteText, put, manifestRequests, remote, persisted, createdDirectories, setAccountStatus: (value: typeof accountStatus) => { accountStatus = value }, sync: () => api.syncMobileCloudVault(vault), setFailWrite: (path: string | null) => { failWritePath = path }, @@ -206,6 +207,19 @@ async function fixture(initial: Record = { 'note.md': 'Original' } describe('mobile Cloud adapter wiring', () => { + for (const [path, directories] of [ + ['Welcome.md', []], + ['notes/Welcome.md', ['notes']] + ] as const) { + it(`creates only actual parent directories when pulling ${path}`, async () => { + const h = await fixture({}) + h.remoteText(path, 'Content from desktop') + await h.sync() + assert.equal(h.files.get(path)?.bytes.toString(), 'Content from desktop') + assert.deepEqual(h.createdDirectories, directories) + }) + } + it('detects remote changes from a one-item metadata manifest without scanning local files', async () => { const h = await fixture() assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), true) diff --git a/src/bridge/mobile-cloud-sync.ts b/src/bridge/mobile-cloud-sync.ts index c322be9..9b8d3ae 100644 --- a/src/bridge/mobile-cloud-sync.ts +++ b/src/bridge/mobile-cloud-sync.ts @@ -5,6 +5,8 @@ import type { CloudBackupRestoreResult, CloudBackupSchedule, CloudBackupSnapshot, + CloudBackupItemsPage, + CloudBackupItemsQuery, CloudBackupSnapshotItem, CloudSyncRunSummary, CloudSyncPendingConflictDetails, @@ -236,6 +238,16 @@ export async function listMobileCloudBackupItems( return service.listBackupItems(hostVault(vault), backupId) } +/** One page of a backup's notes, searched by path on the service, so the + * backup browser can reach notes past the first 50. */ +export async function listMobileCloudBackupItemsPage( + vault: MobileVault, + backupId: string, + query: CloudBackupItemsQuery +): Promise { + return service.listBackupItemsPage(hostVault(vault), backupId, query) +} + export async function createMobileCloudBackup( vault: MobileVault, label?: string @@ -319,7 +331,7 @@ function hostVault(vault: MobileVault, cacheScan = false): CloudSyncHostVault { rename: async (from, to) => { // A pulled move carries no write that would create the destination // folder, and Capacitor's rename fails on a missing parent. - const parent = to.slice(0, to.lastIndexOf('/')) + const parent = to.includes('/') ? to.slice(0, to.lastIndexOf('/')) : '' if (parent) await vault.fs.mkdir(parent) await vault.fs.rename(from, to) } diff --git a/src/bridge/remote-vault.ts b/src/bridge/remote-vault.ts index 9a9af24..3057697 100644 --- a/src/bridge/remote-vault.ts +++ b/src/bridge/remote-vault.ts @@ -36,7 +36,8 @@ import type { ImportedAsset } from '@zennotes/shared-domain/ipc' import { createAbsenceAwareReader } from '@zennotes/shared-domain/remote-absence' import { pastedImageFilename } from '@zennotes/shared-domain/pasted-image' import { emitVaultChange } from './events' -import { importedAssetFilename } from './imported-assets' +import { importedAssetFilename, importedAssetMarkdown } from './imported-assets' +import { classifyImportedAsset } from './vault-core' import { RemoteClient, RemoteRequestError } from './remote-client' function remoteOnly(what: string): never { @@ -347,13 +348,14 @@ export class RemoteVault { bytesToBase64(bytes), 'assets' ) - const isImage = /\.(png|jpe?g|gif|webp|svg|heic)$/i.test(meta.name) + // The same rule as a local vault, so a remote vault embeds a video too. + const kind = classifyImportedAsset(meta.name) emitVaultChange({ kind: 'add', path: meta.path, folder: 'inbox', scope: 'content' }) return { name: meta.name, path: meta.path, - markdown: isImage ? `![[${meta.path}]]` : `[${meta.name}](<${meta.path}>)`, - kind: isImage ? 'image' : 'file' + markdown: importedAssetMarkdown(meta.path, meta.name, kind), + kind } } diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index ec5ee54..1ce1b2f 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -28,6 +28,12 @@ import { archiveNote, openNoteMenu, trashNote } from './note-actions' import { refreshVault } from './refresh' import { SwipeRow } from './SwipeRow' import { getStoragePref, icloudStatus } from '../bridge/icloud' +import { answerExternalVaultReplace, getExternalVaultRef } from '../bridge/folder-picker' +import { + FOLDER_VAULT_HINT, + folderVaultReplaceNotice, + type FolderVaultReplaceNotice +} from '../bridge/folder-vault-replace' import { ICLOUD_VAULT_ROOT_PREFIX, VAULT_ROOT_PREFIX, @@ -142,6 +148,14 @@ function NewVaultSheet({ const [cloudOk, setCloudOk] = useState(null) const [busy, setBusy] = useState<'create' | 'pick' | null>(null) const [error, setError] = useState('') + // Set while the sheet asks before a pick replaces the Files folder vault. + // The form stays mounted under the question, hidden, so the name and the + // location survive a Cancel, and the autofocused name field cannot come + // back and raise the keyboard over the Files picker. + const [replacing, setReplacing] = useState<{ + notice: FolderVaultReplaceNotice + bookmark: string + } | null>(null) useEffect(() => { let alive = true @@ -189,13 +203,15 @@ function NewVaultSheet({ // Escape hatch to the real file manager: the native Files picker (any // provider — iCloud Drive folders, On My iPhone, Working Copy, …). The // picked/created folder itself becomes the vault, so the name field does - // not apply; a cancelled picker returns to this sheet. - const chooseFolder = (): void => { - if (busy) return + // not apply; a cancelled picker returns to this sheet. `answered` carries + // the yes this sheet already got, so the bridge does not ask again. + const pickFolder = (answered: string | null): void => { + setReplacing(null) setBusy('pick') setError('') dismissKeyboard() const before = getShellSnapshot().vault?.root ?? null + const release = answered === null ? null : answerExternalVaultReplace(answered) pickLocalVault() .then(() => { const after = getShellSnapshot().vault?.root ?? null @@ -206,14 +222,49 @@ function NewVaultSheet({ setError(String((err as Error)?.message ?? err)) setBusy(null) }) + .finally(() => release?.()) + } + + // A pick replaces the Files folder vault that is already set (one + // bookmark), so the sheet asks first. Asking here, before the store starts + // a vault change, is what lets Cancel leave everything as it was. + const chooseFolder = (): void => { + if (busy) return + setError('') + dismissKeyboard() + const current = getExternalVaultRef() + const notice = folderVaultReplaceNotice(current) + if (current && notice) setReplacing({ notice, bookmark: current.bookmark }) + else pickFolder(null) } return ( <>
-
-
New Vault
-
+
+
{replacing?.notice.title ?? 'New Vault'}
+ {replacing && ( +
+

{replacing.notice.body}

+
+ + +
+
+ )} + + {tier === 'external' && ( +

{FOLDER_VAULT_HINT}

+ )} ) })} diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 050f594..4587389 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -1552,7 +1552,9 @@ function mobilizeSettingsPanel(panel: HTMLElement): void { // Everything below is the phone-only paged treatment. if (!isPhoneWidth()) return - panel.dataset.znView = 'nav' + // Opened for a page (Review on the Cloud status row asks for Cloud): start + // on that page. The section list made people find the page a second time. + panel.dataset.znView = panel.dataset.settingsTarget ? 'detail' : 'nav' const aside = panel.querySelector('aside') if (!aside) return diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index c3bf167..28b73e4 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -50,7 +50,7 @@ /* Balanced against the home-indicator zone: 20px in from the right, just above the indicator — hugging the edge with a tall bottom gap reads off. */ right: calc(1.25rem + env(safe-area-inset-right)); - bottom: calc(0.75rem + env(safe-area-inset-bottom)); + bottom: calc(0.75rem + env(safe-area-inset-bottom) + var(--zn-cloud-footer-height, 0px)); z-index: 41; display: none; align-items: center; @@ -97,7 +97,7 @@ .zn-mobile-fab-menu { position: fixed; right: calc(1.25rem + env(safe-area-inset-right)); - bottom: calc(0.75rem + 54px + 0.875rem + env(safe-area-inset-bottom)); + bottom: calc(0.75rem + 54px + 0.875rem + env(safe-area-inset-bottom) + var(--zn-cloud-footer-height, 0px)); z-index: 41; display: none; flex-direction: column; @@ -142,7 +142,7 @@ .zn-mobile-fab-hint { position: fixed; right: calc(1.25rem + env(safe-area-inset-right)); - bottom: calc(0.75rem + 54px + 0.875rem + env(safe-area-inset-bottom)); + bottom: calc(0.75rem + 54px + 0.875rem + env(safe-area-inset-bottom) + var(--zn-cloud-footer-height, 0px)); z-index: 41; display: none; flex-direction: column; @@ -363,6 +363,15 @@ padding-top: 0.25rem; } +/* The Folders group's one-folder rule: a quiet line under the group, like an + iOS table footer, so it reads as a note about the group and not a row. */ +.zn-mobile-sheet-footnote { + padding: 0.4375rem 0.625rem 0; + font-size: 0.75rem; + line-height: 1.4; + color: rgb(var(--z-grey-1)); +} + /* A row split into the main (switch) button and a trailing ⋯ manage button — buttons can't nest, so the pair shares one visual row. */ .zn-mobile-sheet-rowline { @@ -616,13 +625,43 @@ /* Word-count status bar → gone (fits the ••• sheet's note info instead). Matched on its layout classes, not its padding: app-core 2.44 changed `px-6` to `px-3 sm:px-6` and the old `px-6` hook silently stopped - matching, which put the desktop bar back under the circle nav. The Cloud - conflict queue it hosts ("Review now") stays reachable on the phone from - Settings → Cloud and the command palette. */ + matching, which put the desktop bar back under the circle nav. */ .zn-mobile.zn-phone div.h-8.shrink-0.justify-between.text-xs { display: none; } +/* ...except while Cloud needs the user. Its "1 file needs review · Review + now" was the only sign that sync had paused a note, and hiding it left a + paused note with nothing on the phone to say so. The bar comes back as one + 44px row in an attention or error phase, and for as long as a decision + waits: every run passes through ready and syncing, so a rule keyed on the + phase alone would blink the row and bounce the FAB on each sync. Keyed on + the core's data-cloud-sync-* hooks, never on its Tailwind tone classes. + Same geometry as the Android shell's Cloud row: 44px row, 44px action, FAB + lifted by --zn-cloud-footer-height (see .zn-mobile-fab). While the + keyboard is up the format toolbar owns that strip, so the row stays out. */ +.zn-mobile.zn-phone:has( + [data-cloud-sync-phase='attention'], + [data-cloud-sync-phase='error'], + [data-cloud-sync-review] + ) { + --zn-cloud-footer-height: 44px; +} + +.zn-mobile.zn-phone:not(.zn-kb-open) + div.h-8.shrink-0.justify-between.text-xs:has( + [data-cloud-sync-phase='attention'], + [data-cloud-sync-phase='error'], + [data-cloud-sync-review] + ) { + display: flex; + height: var(--zn-cloud-footer-height); +} + +.zn-mobile.zn-phone [data-cloud-sync-action] { + min-height: 44px; +} + /* Split view has no meaning on a phone. */ .zn-mobile.zn-phone button[aria-label^='Split mode'] { display: none; diff --git a/vendor/zennotes/manifest.json b/vendor/zennotes/manifest.json index 27c4f17..0302ff9 100644 --- a/vendor/zennotes/manifest.json +++ b/vendor/zennotes/manifest.json @@ -1,12 +1,12 @@ { "name": "@zennotes/app-core", - "version": "2.60.3-core.h44232ae9fc126112", - "file": "zennotes-app-core-2.60.3-core.h44232ae9fc126112.tgz", - "sha256": "d2150448992be71b60df5b63b8f56b6df8e6f4cb24aed68327c20274f45ead4f", - "integrity": "sha512-7F/EEgj8AOF0Mx6rupO2h6jc35jbei5Hq2fI6SX1lOQkM32dPeNCz3M3Izm873h8kUHHOD4ubMF/x6fPWgiqDw==", - "sourceCommit": "e5e34f84d4a7b3eaaa4ac319f9bfb2a5a0cb6656", + "version": "2.60.4-core.hae0e49f9e5397fc2", + "file": "zennotes-app-core-2.60.4-core.hae0e49f9e5397fc2.tgz", + "sha256": "ab27e3c8457b5fbdb892614dd20e83c103fd814cdf2d06be0bffe6459df91ff3", + "integrity": "sha512-myfhQXcnJYLP0UNxYwYeyYtrPSXqWgA5zdK9CwVGJDl47GZWFjz9BOIzhbMUk4QkiJ0Pude1rtfDBOLMdAXNWQ==", + "sourceCommit": "c507caa30492be7a6f8d00c0d67c1af47228056c", "workingTreeDirty": false, - "sourceLockSha256": "4f380efe2f52708bb3062b25751fb5a5207427865e88ae2e494b4f9a54c9c615", + "sourceLockSha256": "03925b7e9e3ffc1220e90d25fffd3f67f6f73a6badd8df97909b06b80785a7cf", "toolchain": { "node": "v22.23.3", "typescript": "5.9.3", @@ -15,20 +15,20 @@ "dependencies": [ { "name": "@zennotes/bridge-contract", - "version": "2.60.3-boundaries.he9b8fc8befc70b6e", - "file": "zennotes-bridge-contract-2.60.3-boundaries.he9b8fc8befc70b6e.tgz", - "sha256": "b5cab06655bcf3f2a85b57131fc6e2bdb4e312ca6d7c4b6b8f9edf4093b0055c", - "integrity": "sha512-XfD+CutriaT/o/Zp3ftuLH9MYZbbjI57gxGuL7s9ncXp28If/8SKFIuB3+0Cqzf6UxPxazPc/Mi2lmg6qU3sMQ==", - "sourceCommit": "e5e34f84d4a7b3eaaa4ac319f9bfb2a5a0cb6656", + "version": "2.60.4-boundaries.h787dd8dec256d6bf", + "file": "zennotes-bridge-contract-2.60.4-boundaries.h787dd8dec256d6bf.tgz", + "sha256": "4f03249745beb429f47075147f1d8c3a3b73b47e58f1c6525aa50736e480f351", + "integrity": "sha512-Qw3LkQ14AzebK1gwjtEOajEaOPjRmaQ9Ku/JQkiQx5oHOVT3wT36MYb8jN1S+ro8aF6/oH48xnFLt+qMZAseZA==", + "sourceCommit": "c507caa30492be7a6f8d00c0d67c1af47228056c", "workingTreeDirty": false }, { "name": "@zennotes/shared-domain", - "version": "2.60.3-boundaries.he9b8fc8befc70b6e", - "file": "zennotes-shared-domain-2.60.3-boundaries.he9b8fc8befc70b6e.tgz", - "sha256": "3dd1430c5bfe384ea77fd03475e829acb5131658f730c63bb81a909146e6173b", - "integrity": "sha512-vLG6OYNc3bxfaGZAp5VxkliaHKFFEBGgYTUMi89lEOSFQjpyn9dit/vIZR4I1SUDT52eRAwKAIPeEvDgfKv16g==", - "sourceCommit": "e5e34f84d4a7b3eaaa4ac319f9bfb2a5a0cb6656", + "version": "2.60.4-boundaries.h787dd8dec256d6bf", + "file": "zennotes-shared-domain-2.60.4-boundaries.h787dd8dec256d6bf.tgz", + "sha256": "185fc9fba85c8ceaf6c49de7cd4324857e470cdbacf9a3bf4e1c447330c1da74", + "integrity": "sha512-f5luMeQt4S8b2BD43yzoFpQSdWbve31n23DBGcNmfF89qxiKsLU/Mm+XaZOHRWraPCOSdYiBUb3qn3sVu9b+Sg==", + "sourceCommit": "c507caa30492be7a6f8d00c0d67c1af47228056c", "workingTreeDirty": false } ] diff --git a/vendor/zennotes/zennotes-app-core-2.60.3-core.h44232ae9fc126112.tgz b/vendor/zennotes/zennotes-app-core-2.60.3-core.h44232ae9fc126112.tgz deleted file mode 100644 index 5ac6286..0000000 Binary files a/vendor/zennotes/zennotes-app-core-2.60.3-core.h44232ae9fc126112.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-app-core-2.60.4-core.hae0e49f9e5397fc2.tgz b/vendor/zennotes/zennotes-app-core-2.60.4-core.hae0e49f9e5397fc2.tgz new file mode 100644 index 0000000..8effb6a Binary files /dev/null and b/vendor/zennotes/zennotes-app-core-2.60.4-core.hae0e49f9e5397fc2.tgz differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.60.3-boundaries.he9b8fc8befc70b6e.tgz b/vendor/zennotes/zennotes-bridge-contract-2.60.3-boundaries.he9b8fc8befc70b6e.tgz deleted file mode 100644 index eb548bb..0000000 Binary files a/vendor/zennotes/zennotes-bridge-contract-2.60.3-boundaries.he9b8fc8befc70b6e.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.60.4-boundaries.h787dd8dec256d6bf.tgz b/vendor/zennotes/zennotes-bridge-contract-2.60.4-boundaries.h787dd8dec256d6bf.tgz new file mode 100644 index 0000000..40e046f Binary files /dev/null and b/vendor/zennotes/zennotes-bridge-contract-2.60.4-boundaries.h787dd8dec256d6bf.tgz differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.60.3-boundaries.he9b8fc8befc70b6e.tgz b/vendor/zennotes/zennotes-shared-domain-2.60.3-boundaries.he9b8fc8befc70b6e.tgz deleted file mode 100644 index 3e78bc2..0000000 Binary files a/vendor/zennotes/zennotes-shared-domain-2.60.3-boundaries.he9b8fc8befc70b6e.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.60.4-boundaries.h787dd8dec256d6bf.tgz b/vendor/zennotes/zennotes-shared-domain-2.60.4-boundaries.h787dd8dec256d6bf.tgz new file mode 100644 index 0000000..920448c Binary files /dev/null and b/vendor/zennotes/zennotes-shared-domain-2.60.4-boundaries.h787dd8dec256d6bf.tgz differ