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.67.0-core.hb828dd60259bc376](https://github.com/ZenNotes/zennotes/releases/tag/core-2.67.0-core.hb828dd60259bc376)
(desktop commit `58ba24ac`, clean tree). Run `npm run
[core-2.68.0-core.h6e3db1e19bc2b662](https://github.com/ZenNotes/zennotes/releases/tag/core-2.68.0-core.h6e3db1e19bc2b662)
(desktop commit `881baa6a`, 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 40
versionName "1.1.37"
versionCode 41
versionName "1.1.38"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
48 changes: 31 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.37",
"version": "1.1.38",
"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.5",
"@replit/codemirror-vim": "^6.3.0",
"@xyflow/react": "^12.12.0",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.67.0-core.hb828dd60259bc376.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.67.0-boundaries.h53f4d533587f4290.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.67.0-boundaries.h53f4d533587f4290.tgz",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.68.0-core.h6e3db1e19bc2b662.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.68.0-boundaries.hd2ce4427b284c18e.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.68.0-boundaries.hd2ce4427b284c18e.tgz",
"codemirror": "^6.0.1",
"dompurify": "^3.4.16",
"function-plot": "^1.25.3",
Expand Down
131 changes: 131 additions & 0 deletions src/bridge/demo-tour.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { loadMobileModule } from '../../tooling/load-mobile-module.ts'

// Core 2.68.0 renamed the demo tour's notes: "00 Start Here.md" used to carry
// a long dash between number and title. Desktop's generateDemoTour and
// removeDemoTour (apps/desktop/src/main/vault.ts) take the notes still under
// the older names with them; this shell writes and removes the tour itself,
// so it has to as well. Both delete whatever the older-name match takes, so
// it must take nothing of the user's.

// Built from its code point so this file never contains the character itself.
const LONG_DASH = String.fromCharCode(0x2014)

Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } })
const { MobileVault, DEMO_TOUR_NOTES, DEMO_TOUR_ASSETS, DEMO_TOUR_START_PATH } = await loadMobileModule([
'./src/bridge/vault-fs', '@zennotes/shared-domain/demo-tour', '@zennotes/shared-domain/demo-tour-data'
])

const olderStart = `inbox/demo/00 ${LONG_DASH} Start Here.md`
const olderMath = `inbox/demo/05b ${LONG_DASH} Math Diagrams.md`
/** The user's notes: in the tour's folder or named like the tour, and not
* the tour. Another separator, a long-dash name the tour never shipped, or
* the old name outside the tour's folder all stay. */
const USER_NOTES = [
'inbox/demo/My own note.md',
'inbox/demo/00 · Start Here.md',
'inbox/demo/01 - Markdown Basics.md',
`inbox/demo/42 ${LONG_DASH} My Plans.md`,
`inbox/00 ${LONG_DASH} Start Here.md`
]
const body = (path: string): string => `kept: ${path}\n`

/** The NativeFs surface the tour uses. Like NativeFs, a listing of a missing
* folder is empty and a delete of a missing file fails. */
class MemoryFs {
files = new Map<string, string>()
directories = new Set<string>()
async mkdir(path: string): Promise<void> {
const parts = path.split('/')
for (let i = 1; i <= parts.length; i++) this.directories.add(parts.slice(0, i).join('/'))
}
async writeText(path: string, text: string): Promise<void> { this.files.set(path, text) }
async deleteFile(path: string): Promise<void> {
if (!this.files.delete(path)) throw new Error(`File does not exist: ${path}`)
}
async readdir(path: string): Promise<{ name: string; type: 'file' | 'directory' }[]> {
const prefix = path ? `${path}/` : ''
const entries = new Map<string, 'file' | 'directory'>()
for (const key of this.files.keys()) {
if (!key.startsWith(prefix)) continue
const [name, ...nested] = key.slice(prefix.length).split('/')
entries.set(name!, nested.length ? 'directory' : 'file')
}
for (const key of this.directories) {
if (!key.startsWith(prefix)) continue
const [name] = key.slice(prefix.length).split('/')
if (name && !entries.has(name)) entries.set(name, 'directory')
}
return [...entries].map(([name, type]) => ({ name, type }))
}
async rmdir(path: string): Promise<void> {
for (const key of [...this.files.keys()]) if (key.startsWith(`${path}/`)) this.files.delete(key)
for (const key of [...this.directories]) if (key === path || key.startsWith(`${path}/`)) this.directories.delete(key)
}
namesIn(path: string): string[] {
return [...this.files.keys()]
.filter((key) => key.startsWith(`${path}/`) && !key.slice(path.length + 1).includes('/'))
.map((key) => key.slice(path.length + 1))
.sort()
}
}

function vaultWith(paths: string[]) {
const fs = new MemoryFs()
for (const path of paths) {
void fs.mkdir(path.slice(0, path.lastIndexOf('/')))
fs.files.set(path, body(path))
}
const vault = new MobileVault('Fixture')
Object.assign(vault, { fs, ensureVaultLayout: async () => {}, invalidateMeta: () => {} })
return { fs, vault }
}

const tourPaths = (): string[] => DEMO_TOUR_NOTES.map((note: { path: string }) => note.path)
const userNamesInDemo = USER_NOTES.filter((path) => path.startsWith('inbox/demo/'))
.map((path) => path.slice('inbox/demo/'.length))

test('the older names in these fixtures belong to notes the tour ships today', () => {
assert.ok(tourPaths().includes(DEMO_TOUR_START_PATH))
assert.ok(tourPaths().includes('inbox/demo/05b Math Diagrams.md'))
assert.ok(!tourPaths().includes('inbox/demo/42 My Plans.md'))
})

test('regenerating replaces a tour under its older names instead of writing a second one', async () => {
const { fs, vault } = vaultWith([olderStart, olderMath, ...USER_NOTES])
const result = await vault.generateDemoTour()

assert.equal(fs.files.has(olderStart), false)
assert.equal(fs.files.has(olderMath), false)
for (const note of DEMO_TOUR_NOTES) assert.equal(fs.files.get(note.path), note.body)
for (const asset of DEMO_TOUR_ASSETS) assert.equal(fs.files.get(asset.path), asset.body)
for (const path of USER_NOTES) assert.equal(fs.files.get(path), body(path), `${path} is the user's`)
// One tour: the folder holds the tour's current notes and the user's, nothing else.
const tourNames = tourPaths().map((path) => path.slice('inbox/demo/'.length))
assert.deepEqual(fs.namesIn('inbox/demo'), [...tourNames, ...userNamesInDemo].sort())
assert.deepEqual(result.notePaths, tourPaths())
})

test('removing takes the older names too, reports them, and keeps the user notes', async () => {
const { fs, vault } = vaultWith([olderStart, olderMath, ...tourPaths(), ...USER_NOTES])
const result = await vault.removeDemoTour()

assert.deepEqual(fs.namesIn('inbox/demo'), [...userNamesInDemo].sort())
for (const path of USER_NOTES) {
assert.equal(fs.files.get(path), body(path), `${path} is the user's`)
assert.ok(!result.notePaths.includes(path), `${path} is not reported as removed`)
}
assert.ok(result.notePaths.includes(olderStart))
assert.ok(result.notePaths.includes(olderMath))
assert.ok(result.notePaths.includes(DEMO_TOUR_START_PATH))
assert.ok(fs.directories.has('inbox/demo'), 'the folder stays while the user has notes in it')
})

test('removing a tour that only exists under its older names leaves no folder behind', async () => {
const { fs, vault } = vaultWith([olderStart, olderMath])
await vault.removeDemoTour()

assert.deepEqual(fs.namesIn('inbox/demo'), [])
assert.equal(fs.directories.has('inbox/demo'), false)
})
2 changes: 1 addition & 1 deletion src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ import {
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
import { isPhoneViewport } from '../viewport'

let appVersion = '1.1.37'
let appVersion = '1.1.38'

export async function loadNativeAppVersion(): Promise<string> {
try {
Expand Down
34 changes: 30 additions & 4 deletions src/bridge/vault-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { normalizeNoteComments } from '@zennotes/shared-domain/note-comments'
import { isFormDirName, isDatabaseInternalPath } from '@zennotes/shared-domain/databases'
import { emptyExcalidrawDocument } from '@zennotes/shared-domain/excalidraw'
import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@zennotes/shared-domain/demo-tour-data'
import { currentDemoTourPath, DEMO_TOUR_DIR } from '@zennotes/shared-domain/demo-tour'
import { WELCOME_NOTE_PATH, WELCOME_NOTE_BODY } from './welcome-note'
import {
rewriteWikilinksForRename,
Expand Down Expand Up @@ -1234,8 +1235,28 @@ export class MobileVault {
emitVaultChange({ kind: 'add', path: WELCOME_NOTE_PATH, folder: 'inbox', scope: 'content' })
}

/** Tour notes still on disk under their older names: desktop's
* olderDemoTourNotePaths. Core 2.68.0 renamed the tour's notes, and
* Regenerate and Remove delete what this returns, so it takes only a file
* that shared-domain's exact older-name match maps onto one of the tour's
* own notes. Any other note in the folder is the user's and stays. */
private async olderDemoTourNotePaths(): Promise<string[]> {
const current = new Set(DEMO_TOUR_NOTES.map((note) => note.path))
const entries = await this.fs.readdir(DEMO_TOUR_DIR)
return entries
.filter((entry) => entry.type === 'file')
.map((entry) => `${DEMO_TOUR_DIR}/${entry.name}`)
.filter((rel) => rel !== currentDemoTourPath(rel) && current.has(currentDemoTourPath(rel)))
}

async generateDemoTour(): Promise<VaultDemoTourResult> {
await this.ensureVaultLayout()
// Regenerating replaces a tour seeded under the older names rather than
// writing a second copy beside it.
for (const rel of await this.olderDemoTourNotePaths()) {
await this.fs.deleteFile(rel).catch(() => {})
this.invalidateMeta(rel)
}
for (const note of DEMO_TOUR_NOTES) {
const dir = dirName(note.path)
if (dir) await this.fs.mkdir(dir)
Expand All @@ -1255,9 +1276,14 @@ export class MobileVault {
}

async removeDemoTour(): Promise<VaultDemoTourResult> {
for (const note of DEMO_TOUR_NOTES) {
await this.fs.deleteFile(note.path).catch(() => {})
this.invalidateMeta(note.path)
// The older names are reported too, so app-core drops them from the list.
const notePaths = [
...DEMO_TOUR_NOTES.map((n) => n.path),
...(await this.olderDemoTourNotePaths())
]
for (const path of notePaths) {
await this.fs.deleteFile(path).catch(() => {})
this.invalidateMeta(path)
}
for (const asset of DEMO_TOUR_ASSETS) {
await this.fs.deleteFile(asset.path).catch(() => {})
Expand All @@ -1266,7 +1292,7 @@ export class MobileVault {
if (remaining.length === 0) await this.fs.rmdir('inbox/demo').catch(() => {})
emitVaultChange({ kind: 'unlink', path: 'inbox/demo', folder: 'inbox', scope: 'content' })
return {
notePaths: DEMO_TOUR_NOTES.map((n) => n.path),
notePaths,
assetPaths: DEMO_TOUR_ASSETS.map((a) => a.path)
}
}
Expand Down
9 changes: 8 additions & 1 deletion src/ui-mobile/MobileShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1321,7 +1321,14 @@ const DESKTOP_ONLY_COMMAND_TITLES = [
'Reset Zoom',
'Open in Floating Window',
'Export Note as PDF',
'Show Onboarding Wizard'
'Show Onboarding Wizard',
// 2.68.0 gates these desktop features itself: What's New on a desktop
// host, and the agent edit review rows on bridge methods this shell leaves
// out. Named here as well, so neither reaches the phone's palette if a later
// core relaxes its gate (this shell reports runtime 'web' and inherits
// whatever the web client is given).
'What\u2019s New in ZenNotes',
'Review Agent Changes'
]

/** Additionally hidden on phones: splits/panes and the (replaced) sidebar
Expand Down
Loading
Loading