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 @@ -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.67.0-core.hb828dd60259bc376](https://github.com/ZenNotes/zennotes/releases/tag/core-2.67.0-core.hb828dd60259bc376)
(desktop commit `58ba24ac`, clean tree).
[core-2.68.0-core.h6e3db1e19bc2b662](https://github.com/ZenNotes/zennotes/releases/tag/core-2.68.0-core.h6e3db1e19bc2b662)
(desktop commit `881baa6a`, clean tree).

`npm run boundaries:check` verifies the pins, installed versions, singleton
React/CodeMirror peers, and public export usage. `npm run core:adopt -- <core-tag>`
Expand Down
24 changes: 12 additions & 12 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -604,12 +604,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ZenWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.23.0;
MARKETING_VERSION = 1.24.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -733,12 +733,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = WYY7PK57DM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.23.0;
MARKETING_VERSION = 1.24.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -755,12 +755,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = WYY7PK57DM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.23.0;
MARKETING_VERSION = 1.24.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Expand All @@ -775,12 +775,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.23.0;
MARKETING_VERSION = 1.24.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand All @@ -797,12 +797,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ZenWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.23.0;
MARKETING_VERSION = 1.24.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -835,12 +835,12 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = WYY7PK57DM;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.23.0;
MARKETING_VERSION = 1.24.0;
PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down
44 changes: 29 additions & 15 deletions package-lock.json

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

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,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.67.0-core.hb828dd60259bc376.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.67.0-boundaries.h53f4d533587f4290.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.67.0-boundaries.h53f4d533587f4290.tgz",
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.68.0-core.h6e3db1e19bc2b662.tgz",
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.68.0-boundaries.hd2ce4427b284c18e.tgz",
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.68.0-boundaries.hd2ce4427b284c18e.tgz",
"@lezer/common": "^1.5.3"
},
"devDependencies": {
Expand Down
112 changes: 112 additions & 0 deletions src/bridge/demo-tour.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { loadMobileModule } from '../../tooling/load-mobile-module.ts'

// Before core 2.68.0 the demo tour named its notes "NN <U+2014> Title"; it now
// writes "NN Title". Desktop's vault.ts removes and replaces the notes under
// either name, and a tour seeded by an older build (on this phone, or synced
// from a desktop) must not survive Remove or come back doubled by Regenerate.
// Both delete what they match, so the match is exact: only the old separator,
// and only a name that becomes one of the tour's own notes.
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } })
const { MobileVault, DEMO_TOUR_NOTES, DEMO_TOUR_ASSETS } = await loadMobileModule([
'./src/bridge/vault-fs', '@zennotes/shared-domain/demo-tour-data'
])

/** The NativeFs surface the tour uses. Like the providers, a delete of a
* missing file fails. */
class MemoryFs {
files = new Map<string, string>()
directories = new Set<string>()
async mkdir(path: string): Promise<void> { this.directories.add(path) }
async writeText(path: string, body: string): Promise<void> { this.files.set(path, body) }
async deleteFile(path: string): Promise<void> {
if (!this.files.delete(path)) throw new Error(`File does not exist: ${path}`)
}
async rmdir(path: string): Promise<void> {
for (const key of [...this.files.keys()]) if (key === path || key.startsWith(`${path}/`)) this.files.delete(key)
for (const key of [...this.directories]) if (key === path || key.startsWith(`${path}/`)) this.directories.delete(key)
}
async readdir(path: string): Promise<{ name: string; type: 'file' | 'directory' }[]> {
const names = new Map<string, 'file' | 'directory'>()
for (const key of this.files.keys()) {
if (!key.startsWith(`${path}/`)) continue
const rest = key.slice(path.length + 1)
const slash = rest.indexOf('/')
names.set(slash < 0 ? rest : rest.slice(0, slash), slash < 0 ? 'file' : 'directory')
}
return [...names].map(([name, type]) => ({ name, type }))
}
}

const currentPaths: string[] = DEMO_TOUR_NOTES.map((note: { path: string }) => note.path)
const olderName = (path: string): string => path.replace(/^(inbox\/demo\/\d{2}b?) /, '$1 \u2014 ')

/** The user's own notes beside the tour. Each looks related, and none is the
* tour's: a tour title behind a middle dot or an en dash instead of the old
* separator, the old separator in front of a title the tour never had, and a
* plain note. Remove and Regenerate must leave every one byte for byte. */
const USER_NOTES = new Map<string, string>([
['inbox/demo/00 \u00b7 Start Here.md', '# Middle dot\n'],
['inbox/demo/01 \u2013 Markdown Basics.md', '# En dash\n'],
['inbox/demo/99 \u2014 Not from the tour.md', '# Old separator, own title\n'],
['inbox/demo/My own note.md', '# Mine\n']
])

function vaultWith(paths: (path: string) => string) {
const fs = new MemoryFs()
for (const note of DEMO_TOUR_NOTES) fs.files.set(paths(note.path), note.body)
for (const asset of DEMO_TOUR_ASSETS) fs.files.set(asset.path, asset.body)
for (const [path, body] of USER_NOTES) fs.files.set(path, body)
const vault = new MobileVault('Fixture')
Object.assign(vault, { fs, ensureVaultLayout: async () => {} })
return { fs, vault }
}

const tourNotesOnDisk = (fs: MemoryFs): string[] =>
[...fs.files.keys()].filter((key) => key.startsWith('inbox/demo/') && !USER_NOTES.has(key)).sort()

function assertUserNotesKept(fs: MemoryFs): void {
for (const [path, body] of USER_NOTES) assert.equal(fs.files.get(path), body, `${path} was touched`)
}

test('the older names are the tour notes behind the old separator', () => {
assert.equal(currentPaths.length, 17)
for (const path of currentPaths) assert.notEqual(olderName(path), path)
})

test('removing the tour deletes the notes it wrote under their older names', async () => {
const { fs, vault } = vaultWith(olderName)

const result = await vault.removeDemoTour()

assert.deepEqual(tourNotesOnDisk(fs), [])
for (const asset of DEMO_TOUR_ASSETS) assert.equal(fs.files.has(asset.path), false)
assertUserNotesKept(fs)
// The core unlinks every reported path, so the older names must be in it.
assert.deepEqual(
[...result.notePaths].sort(),
[...currentPaths, ...currentPaths.map(olderName)].sort()
)
})

test('regenerating replaces a tour seeded under the older names instead of adding a second copy', async () => {
const { fs, vault } = vaultWith(olderName)

const result = await vault.generateDemoTour()

assert.deepEqual(tourNotesOnDisk(fs), [...currentPaths].sort())
for (const note of DEMO_TOUR_NOTES) assert.equal(fs.files.get(note.path), note.body)
assertUserNotesKept(fs)
assert.deepEqual(result.notePaths, currentPaths)
})

test('a tour under the current names is removed as before', async () => {
const { fs, vault } = vaultWith((path) => path)

const result = await vault.removeDemoTour()

assert.deepEqual(tourNotesOnDisk(fs), [])
assertUserNotesKept(fs)
assert.deepEqual(result.notePaths, currentPaths)
})
28 changes: 24 additions & 4 deletions src/bridge/vault-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { bytesToBase64 } from './base64'
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 @@ -1253,8 +1254,23 @@ export class MobileVault {
emitVaultChange({ kind: 'add', path: WELCOME_NOTE_PATH, folder: 'inbox', scope: 'content' })
}

/** Tour notes still on disk under their older names (see
* currentDemoTourPath), found the way desktop's vault.ts finds them. */
private async olderDemoTourNotePaths(): Promise<string[]> {
const current = new Set(DEMO_TOUR_NOTES.map((note) => note.path))
return (await this.fs.readdir(DEMO_TOUR_DIR))
.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 @@ -1274,9 +1290,13 @@ 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)
const notePaths = [
...DEMO_TOUR_NOTES.map((note) => note.path),
...(await this.olderDemoTourNotePaths())
]
for (const rel of notePaths) {
await this.fs.deleteFile(rel).catch(() => {})
this.invalidateMeta(rel)
}
for (const asset of DEMO_TOUR_ASSETS) {
await this.fs.deleteFile(asset.path).catch(() => {})
Expand All @@ -1285,7 +1305,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
Loading
Loading