diff --git a/README.md b/README.md index b0e5e98..0291f22 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.80.0-core.hc5a4589d040cd0da](https://github.com/ZenNotes/zennotes/releases/tag/core-2.80.0-core.hc5a4589d040cd0da) -(desktop commit `31520814`, clean tree). Run `npm run +[core-2.81.0-core.h4e6a38a3361b4748](https://github.com/ZenNotes/zennotes/releases/tag/core-2.81.0-core.h4e6a38a3361b4748) +(desktop commit `bfab9fdc`, 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 7e9577a..794b301 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 54 - versionName "1.1.51" + versionCode 55 + versionName "1.1.52" 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 44f4edf..52dfb12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.51", + "version": "1.1.52", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.51", + "version": "1.1.52", "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.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", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.81.0-core.h4e6a38a3361b4748.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.81.0-boundaries.h697bb3192aa5970b.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.81.0-boundaries.h697bb3192aa5970b.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -3668,9 +3668,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.80.0-core.hc5a4589d040cd0da", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.80.0-core.hc5a4589d040cd0da.tgz", - "integrity": "sha512-pfj1PIqD3RaQOLEvtAP6DagOOqZdnWiBxSH+fckx+STFMTS859gQaTlf2arGaCaRypy0bWMLm+H3LYpGmEDUEw==", + "version": "2.81.0-core.h4e6a38a3361b4748", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.81.0-core.h4e6a38a3361b4748.tgz", + "integrity": "sha512-0cEPF3+zsswihP+JCcxqfij+p0ssYxPHpt58EaGDgEx8LiYFdnTP9dnu67WliUNprIdHTKAdkrwmZ6Kc7hJFCQ==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3700,8 +3700,8 @@ "@replit/codemirror-vim": "^6.3.0", "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.80.0-boundaries.hc07ef9e2bef31a64", - "@zennotes/shared-domain": "2.80.0-boundaries.hc07ef9e2bef31a64", + "@zennotes/bridge-contract": "2.81.0-boundaries.h697bb3192aa5970b", + "@zennotes/shared-domain": "2.81.0-boundaries.h697bb3192aa5970b", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3745,19 +3745,19 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.80.0-boundaries.hc07ef9e2bef31a64", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", - "integrity": "sha512-Ia+UG7f1rU0ar6+lVdrNTrWpM03udlG6ehai9EN8R5yM3U05HZkFSaAA9shxMg9TrDvt9103FAep/U5XH6/mAg==", + "version": "2.81.0-boundaries.h697bb3192aa5970b", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.81.0-boundaries.h697bb3192aa5970b.tgz", + "integrity": "sha512-Aukkw1ZsDrTaSZ3L1fUjulNJHFpN8G/uFSeNFYpXxx5NziyL1ZYKlcO+6Mcc47qLKvHUryMhehqILZFJsXcnwQ==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "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==", + "version": "2.81.0-boundaries.h697bb3192aa5970b", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.81.0-boundaries.h697bb3192aa5970b.tgz", + "integrity": "sha512-sS4/C0A/SSC8GN7cqm8wLUcBWCqenJM/BhfPFmHCKyle24EMJFbs3KPhy/4auLOErXi7Qf+wC0r+kVZmbLaciQ==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.80.0-boundaries.hc07ef9e2bef31a64", + "@zennotes/bridge-contract": "2.81.0-boundaries.h697bb3192aa5970b", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } diff --git a/package.json b/package.json index 5574a77..764f2b7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.51", + "version": "1.1.52", "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.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", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.81.0-core.h4e6a38a3361b4748.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.81.0-boundaries.h697bb3192aa5970b.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.81.0-boundaries.h697bb3192aa5970b.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/bridge/database-open.test.ts b/src/bridge/database-open.test.ts new file mode 100644 index 0000000..0d35c17 --- /dev/null +++ b/src/bridge/database-open.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' +import { missingDatabaseAsNull } from './database-open.ts' + +const doc = { relPath: 'inbox/Board.base/data.csv' } as never + +describe('Opening a database whose folder is gone', () => { + it('answers null for a missing database, so core forgets it and closes its tab', async () => { + const open = missingDatabaseAsNull(async (rel) => { + throw new Error(`Database not found: ${rel}`) + }) + assert.equal(await open('inbox/Projects/Untitled Kanban.base/data.csv'), null) + }) + + it('passes a database through, and every other failure still throws', async () => { + assert.equal(await missingDatabaseAsNull(async () => doc)('x'), doc) + const unreadable = missingDatabaseAsNull(async () => { + throw new Error('EACCES: permission denied') + }) + await assert.rejects(unreadable('x'), /permission denied/) + }) + + it('is what the bridge hands core', () => { + const bridge = readFileSync(new URL('./mobile-bridge.ts', import.meta.url), 'utf8') + assert.match(bridge, /openDatabase: openDatabaseOrThrow,/) + assert.match(bridge, /const openDatabase = missingDatabaseAsNull\(openDatabaseOrThrow\)/) + }) + + it('reads the contract core and the shared ops keep', () => { + const pkg = (path: string): string => + readFileSync(new URL(`../../node_modules/@zennotes/${path}`, import.meta.url), 'utf8') + // The shared ops throw for an absent data.csv ... + assert.match(pkg('shared-domain/dist/database-ops.js'), /if \(csvText === null\)\n\s+throw new Error\(`Database not found: \$\{rel\}`\);/) + // ... core forgets a database the bridge answers null for, and toasts a + // throw once, recording it so the grid does not ask again (core 2.81.0). + const store = pkg('app-core/dist/store.js') + assert.match(store, /if \(!doc\) \{[\s\S]{0,240}await get\(\)\.forgetDatabase\(csvPath\);/) + assert.match(store, /const message = humanIpcError\(err, 'Could not open database'\);[\s\S]{0,240}databasesLoadError[\s\S]{0,240}\.addToast\(message, 'error'\);/) + assert.match(pkg('bridge-contract/dist/bridge.d.ts'), /openDatabase\(relPath: string\): Promise;/) + }) +}) diff --git a/src/bridge/database-open.ts b/src/bridge/database-open.ts new file mode 100644 index 0000000..fcc99b8 --- /dev/null +++ b/src/bridge/database-open.ts @@ -0,0 +1,26 @@ +import type { DatabaseDoc } from '@zennotes/bridge-contract/databases' + +/** + * A database's tab can outlive its folder: deleted in a file manager, by sync + * from another device, or by another app sharing the vault. The bridge + * contract answers null for a missing database, as desktop's main process + * does, and core then forgets the database and closes its tab. + * + * The shared database ops throw "Database not found" for it instead, and + * core's grid toasted that error and asked again on its next render: one + * relaunch with such a tab open stacked 780 "Database not found" toasts over + * the app. Only that error becomes null; anything else (a file that cannot + * be read, a schema that cannot be parsed) still throws. + */ +export function missingDatabaseAsNull( + open: (relPath: string) => Promise +): (relPath: string) => Promise { + return async (relPath) => { + try { + return await open(relPath) + } catch (err) { + if (err instanceof Error && err.message.startsWith('Database not found')) return null + throw err + } + } +} diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 78c5b7e..13d30f0 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -40,6 +40,7 @@ import type { VaultTextSearchMatch } from '@zennotes/shared-domain/ipc' import { createDatabaseOps } from '@zennotes/shared-domain/database-ops' +import { missingDatabaseAsNull } from './database-open' import { normalizeVaultDisplayName, resolveVaultName } from '@zennotes/shared-domain/vault-display-name' import type { CustomCodeLanguage, @@ -157,7 +158,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.51' +let appVersion = '1.1.52' export async function loadNativeAppVersion(): Promise { try { @@ -737,7 +738,7 @@ const dbOps = createDatabaseOps({ }) const { - openDatabase, + openDatabase: openDatabaseOrThrow, writeDatabaseRows, writeDatabaseSchema, createDatabase, @@ -745,6 +746,8 @@ const { renameDatabase, listDatabases } = dbOps +// A tab whose database is gone answers null (database-open.ts). +const openDatabase = missingDatabaseAsNull(openDatabaseOrThrow) // -------------------------------------------------------------------- // Asset URL resolution (WebView-loadable file URLs) diff --git a/src/main.tsx b/src/main.tsx index 66c308a..3cd659d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,7 @@ import { installHomeGuard } from './ui-mobile/nav' import { refreshVault } from './ui-mobile/refresh' import { isPhoneViewport, watchPhoneClass } from './viewport' import { waitAtBoot } from './boot-wait' +import { KeyboardLift } from './ui-mobile/keyboard-lift' import './ui-mobile/mobile.css' function wireKeyboard(): void { @@ -58,13 +59,22 @@ function wireKeyboard(): void { // innerWidth-at-boot check did. watchPhoneClass(applyResizeMode) applyResizeMode(isPhoneViewport()) + // The tablet lift: only the part of the keyboard the viewport has not + // already given up, since the Android plugin cannot actually turn the + // resize off (keyboard-lift.ts). Phones never read it. + const lift = new KeyboardLift(window.innerWidth, window.innerHeight) + const setLift = (px: number): void => html.style.setProperty('--zn-kb-height', `${px}px`) + window.addEventListener('resize', () => setLift(lift.resized(window.innerWidth, window.innerHeight))) void Keyboard.addListener('keyboardWillShow', (info) => { html.classList.add('zn-kb-open') - html.style.setProperty('--zn-kb-height', `${info.keyboardHeight}px`) + setLift(lift.shown(info.keyboardHeight, window.innerHeight)) + }).catch(() => {}) + void Keyboard.addListener('keyboardDidShow', (info) => { + setLift(lift.shown(info.keyboardHeight, window.innerHeight)) }).catch(() => {}) void Keyboard.addListener('keyboardWillHide', () => { html.classList.remove('zn-kb-open') - html.style.setProperty('--zn-kb-height', '0px') + setLift(lift.hidden()) }).catch(() => {}) } diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index fb5d03b..1b45820 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, openBrowseFolderGallery } from '@zennotes/app-core/browse' + requestDeleteBrowseDirectory, openBrowseFolderGallery, createBrowseKanban } 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' @@ -86,6 +86,8 @@ const D = { move: 'M5 8V6a2 2 0 012-2h3l2 2h7a2 2 0 012 2v10a2 2 0 01-2 2H7a2 2 0 01-2-2v-4M2 13h9m0 0l-3-3m3 3l-3 3', rename: 'M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z', gallery: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', + // The New sheet's New Kanban glyph (MobileShell ICONS.tabs). + kanban: 'M4 6h16M4 6v12h16V6M9 6v12', link: 'M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71', files: 'M19 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5a2 2 0 00-2-2zM8.5 10a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM21 15l-5-5L5 21', @@ -1005,6 +1007,20 @@ function MobileDrawerBody(props: { } }, [refreshing]) + // System back closes the folder sheet or the sort menu before the drawer + // under it (MobileShell's useSystemBackClose). Neither has an Escape of its + // own, so the cascade's menu step left them open. + useEffect(() => { + const onBack = (e: Event): void => { + if (folderMenu) setFolderMenu(null) + else if (sortOpen) setSortOpen(false) + else return + e.preventDefault() + } + window.addEventListener('zn:android-back', onBack) + return () => window.removeEventListener('zn:android-back', onBack) + }, [folderMenu, sortOpen]) + const renameFolderFromDrawer = (subpath: string, _name: string): void => { const host = folderMenu?.host ?? captureMobileWorkspace() const rename = folderMenu?.kind === 'database' ? requestRenameBrowseDatabase : requestRenameBrowseFolder @@ -1038,6 +1054,15 @@ function MobileDrawerBody(props: { setFolderMenu(null) go(() => openBrowseFolderGallery(host, subpath).catch(reportActionError)) } + // The desktop folder menu's New Kanban (core 2.81's createBrowseKanban): a + // board seeded with To do, Doing and Done lands in this folder and opens on + // its Board. The New sheet's row puts one where new databases go instead. + // Core refuses a folder inside a database, which the drawer never lists. + const newKanbanIn = (subpath: string): void => { + const host = folderMenu?.host ?? captureMobileWorkspace() + setFolderMenu(null) + go(() => createBrowseKanban(host, subpath).catch(reportActionError)) + } const deleteFolder = (subpath: string, _name: string): void => { const host = folderMenu?.host ?? captureMobileWorkspace() void requestDeleteBrowseDirectory(host, subpath).catch(reportActionError) @@ -1327,6 +1352,16 @@ function MobileDrawerBody(props: { Open as Gallery )} + {folderMenu.kind === 'folder' && ( + + )} +