Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.55.0-core.h0743bd3af2a4204a](https://github.com/ZenNotes/zennotes/releases/tag/core-2.55.0-core.h0743bd3af2a4204a)
(desktop commit `00db7036`, clean tree). Run `npm run
[core-2.56.1-core.hc872c08b6872aec4](https://github.com/ZenNotes/zennotes/releases/tag/core-2.56.1-core.hc872c08b6872aec4)
(desktop commit `f8b24c09`, 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.
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
applicationId "md.zennotes"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 29
versionName "1.1.26"
versionCode 30
versionName "1.1.27"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
34 changes: 17 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
"version": "1.1.26",
"version": "1.1.27",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
Expand Down Expand Up @@ -43,9 +43,9 @@
"@lezer/highlight": "^1.2.1",
"@replit/codemirror-vim": "^6.3.0",
"@xyflow/react": "^12.11.6",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.55.0-core.h0743bd3af2a4204a.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.55.0-boundaries.hd2f617179eb16ef2.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.55.0-boundaries.hd2f617179eb16ef2.tgz",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.56.1-core.hc872c08b6872aec4.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.56.1-boundaries.h3434125ac49b85c7.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.56.1-boundaries.h3434125ac49b85c7.tgz",
"codemirror": "^6.0.1",
"dompurify": "^3.4.15",
"function-plot": "^1.25.3",
Expand Down
93 changes: 78 additions & 15 deletions src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
VaultTextSearchMatch
} from '@zennotes/shared-domain/ipc'
import { createDatabaseOps } from '@zennotes/shared-domain/database-ops'
import { normalizeVaultDisplayName, resolveVaultName } from '@zennotes/shared-domain/vault-display-name'
import type {
CustomCodeLanguage,
CustomCodeLanguageInstallInput,
Expand All @@ -58,7 +59,14 @@ import type {
McpServerRuntime
} from '@zennotes/shared-domain/mcp-clients'
import { MobileVault } from './vault-fs'
import { listVaultDirs, VAULTS_DIR, vaultsRoot, initVaultsRoot } from './native-fs'
import {
listVaultDirs,
readVaultDisplayName,
readVaultDisplayNameAtUrl,
VAULTS_DIR,
vaultsRoot,
initVaultsRoot
} from './native-fs'
import { Filesystem } from '@capacitor/filesystem'
import {
getStoragePref,
Expand Down Expand Up @@ -132,7 +140,7 @@ import {
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
import { isPhoneViewport } from '../viewport'

let appVersion = '1.1.26'
let appVersion = '1.1.27'

export async function loadNativeAppVersion(): Promise<string> {
try {
Expand Down Expand Up @@ -175,7 +183,12 @@ export function currentExternalVaultRoot(): string | null {

export interface MobileVaultEntry {
root: string
/** The folder's name on disk: what rename and delete act on, and what the
* open vault's `folderName` is compared with to mark it current. */
name: string
/** The name the vault goes by when its vault.json carries one (ZenNotes
* #692), normalized; the sheet shows it in place of the folder name. */
displayName?: string
tier: 'local' | 'icloud' | 'external'
}

Expand Down Expand Up @@ -219,17 +232,25 @@ async function looksLikeVaultDir(url: string): Promise<boolean> {
export async function listSwitchableVaults(): Promise<MobileVaultEntry[]> {
const out: MobileVaultEntry[] = []
for (const d of await listVaultDirs()) {
out.push({ root: `${VAULT_ROOT_PREFIX}${d.name}`, name: d.name, tier: 'local' })
const displayName = normalizeVaultDisplayName(await readVaultDisplayName(d.name))
out.push({
root: `${VAULT_ROOT_PREFIX}${d.name}`,
name: d.name,
tier: 'local',
...(displayName ? { displayName } : {})
})
}
const status = await icloudStatus().catch(() => null)
if (status?.available && status.rootUrl) {
for (const name of filterCloudVaultNames(status.vaults ?? [])) {
const url = `${status.rootUrl}/${encodeURIComponent(name)}`
if (!(await looksLikeVaultDir(url))) continue
const displayName = normalizeVaultDisplayName(await readVaultDisplayNameAtUrl(url))
out.push({
root: `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(name)}`,
name,
tier: 'icloud'
tier: 'icloud',
...(displayName ? { displayName } : {})
})
}
}
Expand Down Expand Up @@ -433,7 +454,45 @@ function currentVaultInfo(): VaultInfo | null {
const remote = remoteVaultInfo()
if (remote) return remote
if (!vault) return null
return { root: friendlyVaultRoot(vault), name: vault.name }
// The root is a label, not a path, so the core cannot read the folder's
// name off it; folderName is what the display name falls back to and what
// the Vault name field shows as its placeholder (ZenNotes #692).
return { root: friendlyVaultRoot(vault), name: vault.name, folderName: vault.name }
}

/**
* The open vault as the app names it: the display name from its vault.json
* when it has one (ZenNotes #692), else the folder name. Desktop resolves
* this in main (describeVault) before the renderer ever sees a VaultInfo,
* and the renderer only re-derives the name after a settings save or an
* external vault.json change, so a shell that handed over the folder name
* would show it until the first save. Every bridge method that returns the
* opened vault goes through here; a remote workspace's name comes from its
* server and passes untouched. The settings read is cached after the first
* open, and a vault whose vault.json cannot be read keeps its folder name,
* the way it always did.
*/
/**
* The open local vault's folder name, null in a remote workspace or before
* boot. The Vaults sheet lists folders and must mark the one that is open
* whatever the vault is called (rename and delete act on the folder), and
* the core's shell snapshot carries the vault's shown name, not its folder,
* so the sheet asks the bridge, which holds the MobileVault by folder name.
*/
export function currentVaultFolderName(): string | null {
if (remoteVaultInfo()) return null
return vault?.name ?? null
}

async function describeCurrentVault(): Promise<VaultInfo | null> {
const info = currentVaultInfo()
if (!info || remoteVaultInfo() || !vault) return info
try {
const settings = await vault.getVaultSettings()
return { ...info, name: resolveVaultName(settings.displayName, info.name) }
} catch {
return info
}
}

/** Whether app-core's note-index request has completed for the active vault. */
Expand Down Expand Up @@ -462,7 +521,7 @@ async function openVaultByName(name: string, cloudRootUri: string | null = null)
await next.open()
vault = next
localStorage.setItem(CURRENT_VAULT_KEY, name)
return currentVaultInfo() as VaultInfo
return (await describeCurrentVault()) as VaultInfo
}

/**
Expand Down Expand Up @@ -910,25 +969,29 @@ export const mobileBridge: ZenBridge = {
await disconnectRemote()
// Reopen the remembered local tier so the app lands somewhere real.
await openLocalVaultTier()
return currentVaultInfo()
return describeCurrentVault()
},
// Mobile never boots into a broken workspace (an unreachable remote falls
// back to the local vault in bootVault), so "retry" simply reports where
// the app already landed.
retryWorkspaceBoot: async () => currentVaultInfo(),
retryWorkspaceBoot: () => describeCurrentVault(),
listRemoteWorkspaceProfiles: () => listProfiles(),
saveRemoteWorkspaceProfile: (input) => saveProfile(input),
deleteRemoteWorkspaceProfile: (id) => deleteProfile(id),
connectRemoteWorkspaceProfile: (id) => connectRemoteProfile(id),

getCurrentVault: async () => currentVaultInfo(),
getCurrentVault: () => describeCurrentVault(),
listLocalVaults: async (): Promise<LocalVaultEntry[]> => {
const dirs = await listVaultDirs()
return dirs.map((d) => ({
root: `${VAULT_ROOT_PREFIX}${d.name}`,
name: d.name,
lastOpenedAt: d.mtime
}))
// The switcher shows each vault by the name it goes by (#692): one small
// vault.json read per folder, the folder name when there is none.
return Promise.all(
dirs.map(async (d) => ({
root: `${VAULT_ROOT_PREFIX}${d.name}`,
name: resolveVaultName(await readVaultDisplayName(d.name), d.name),
lastOpenedAt: d.mtime
}))
)
},
openLocalVault: async (root: string) => {
// One entry point for switching to any device-reachable vault: local
Expand Down Expand Up @@ -958,7 +1021,7 @@ export const mobileBridge: ZenBridge = {
setStoragePref('local')
return await openVaultByName(vaultNameFromRoot(root))
},
closeVault: async () => currentVaultInfo(),
closeVault: () => describeCurrentVault(),
pickVault: async () => {
const picked = await pickExternalVault()
if (!picked) return null
Expand Down
35 changes: 35 additions & 0 deletions src/bridge/native-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,41 @@ export async function listVaultDirs(): Promise<{ name: string; mtime: number }[]
}
}

/**
* The raw `displayName` a local vault carries in its own vault.json
* (ZenNotes #692), or null when the vault has none or the file cannot be
* read. For the vault switcher, which lists vaults that are not open and so
* have no MobileVault to ask; the caller normalizes and falls back to the
* folder name, the way desktop's describeVault does.
*/
export async function readVaultDisplayName(vaultName: string): Promise<string | null> {
return readDisplayNameFrom({
path: `${VAULTS_DIR}/${vaultName}/.zennotes/vault.json`,
directory: vaultsRoot()
})
}

/**
* The same for a vault addressed by a file URL, which is how the cloud tier
* lists its vaults (see looksLikeVaultDir). One read attempt, no download
* wait: an evicted vault.json answers null and the vault keeps its folder
* name in the switcher, rather than every listing waiting on the cloud.
*/
export async function readVaultDisplayNameAtUrl(url: string): Promise<string | null> {
return readDisplayNameFrom({ path: `${url}/.zennotes/vault.json` })
}

async function readDisplayNameFrom(loc: { path: string; directory?: Directory }): Promise<string | null> {
try {
const res = await Filesystem.readFile({ ...loc, encoding: Encoding.UTF8 })
const parsed: unknown = JSON.parse(typeof res.data === 'string' ? res.data : '')
const name = (parsed as { displayName?: unknown } | null)?.displayName
return typeof name === 'string' ? name : null
} catch {
return null
}
}

function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
const chunkSize = 32_768
Expand Down
17 changes: 17 additions & 0 deletions src/bridge/vault-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,20 @@ test('native absent-file reads propagate provider failures instead of allowing s
const permission = new Error('Permission denied')
await assert.rejects(NativeFs.prototype.readTextOrNull.call({ readText: async () => { throw permission } }, 'schema.json'), permission)
})
// ZenNotes #692: the desktop writes the vault's display name into vault.json.
// The phone's settings pass every key through, so a save from this device
// (toggling a favorite is enough) must keep the name, and a read must hand
// it to the core, which names the vault by it.
test('a vault display name written by desktop survives a phone settings save (ZenNotes #692)', async () => {
const { fs, vault } = fixture()
fs.files.set('.zennotes/vault.json', JSON.stringify({
...structuredClone(DEFAULT_VAULT_SETTINGS), displayName: 'Acme API docs'
}))
Object.assign(vault, { settingsCache: null })
const settings = await vault.getVaultSettings()
assert.equal(settings.displayName, 'Acme API docs')
await vault.setVaultSettings({ ...settings, favorites: ['Notes/Work/One.md'] })
const written = JSON.parse(fs.files.get('.zennotes/vault.json')!)
assert.equal(written.displayName, 'Acme API docs')
assert.deepEqual(written.favorites, ['Notes/Work/One.md'])
})
Loading
Loading