Skip to content

Commit bef8a0f

Browse files
committed
Merge release/v2.11.0 into main for v2.11.0 release
2 parents e269443 + 7aca3d7 commit bef8a0f

79 files changed

Lines changed: 6449 additions & 3179 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/desktop",
33
"productName": "ZenNotes",
4-
"version": "2.10.0",
4+
"version": "2.11.0",
55
"description": "ZenNotes desktop shell",
66
"private": true,
77
"main": "./out/main/index.js",
@@ -119,6 +119,10 @@
119119
"!**/*.map"
120120
],
121121
"extraResources": [
122+
{
123+
"from": "../../node_modules/@excalidraw/excalidraw/dist/prod/fonts",
124+
"to": "excalidraw-fonts"
125+
},
122126
{
123127
"from": "build/icon.png",
124128
"to": "icon.png"

apps/desktop/src/main/app-config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
119119
comment: 'editor + preview font size (px)'
120120
},
121121
editorLineHeight: { section: 'editor', tomlKey: 'line_height', comment: 'line-height multiplier' },
122+
editorScrollOff: {
123+
section: 'editor',
124+
tomlKey: 'scroll_off',
125+
comment: 'vim scrolloff — lines kept above/below the cursor (0 = off)'
126+
},
127+
timeFormat: {
128+
section: 'editor',
129+
tomlKey: 'time_format',
130+
comment: 'clock format for the @time macro (12h or 24h)'
131+
},
122132
previewMaxWidth: {
123133
section: 'editor',
124134
tomlKey: 'preview_max_width',

apps/desktop/src/main/index.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { randomUUID } from 'node:crypto'
1919
import { promises as fsp } from 'node:fs'
2020
import path from 'node:path'
2121
import { fileURLToPath } from 'node:url'
22+
import { createRequire } from 'node:module'
2223
import { IPC } from '@shared/ipc'
2324
import type {
2425
NoteMeta,
@@ -80,6 +81,9 @@ import {
8081
renameAsset,
8182
removeDemoTour,
8283
restoreDeletedAsset,
84+
listDeletedAssets,
85+
purgeDeletedAsset,
86+
emptyDeletedAssets,
8387
restoreFromTrash,
8488
searchVaultTextCapabilities,
8589
searchVaultText,
@@ -192,8 +196,10 @@ import {
192196
} from './file-open'
193197

194198
const __dirname = path.dirname(fileURLToPath(import.meta.url))
199+
const nodeRequire = createRequire(import.meta.url)
195200
const LOCAL_ASSET_SCHEME = 'zen-asset'
196201
const THEME_ASSET_SCHEME = 'zen-theme'
202+
const EXCALIDRAW_ASSET_SCHEME = 'zen-excalidraw'
197203

198204
const PRIVILEGED_ASSET_PRIVILEGES = {
199205
standard: true,
@@ -205,7 +211,8 @@ const PRIVILEGED_ASSET_PRIVILEGES = {
205211

206212
protocol.registerSchemesAsPrivileged([
207213
{ scheme: LOCAL_ASSET_SCHEME, privileges: PRIVILEGED_ASSET_PRIVILEGES },
208-
{ scheme: THEME_ASSET_SCHEME, privileges: PRIVILEGED_ASSET_PRIVILEGES }
214+
{ scheme: THEME_ASSET_SCHEME, privileges: PRIVILEGED_ASSET_PRIVILEGES },
215+
{ scheme: EXCALIDRAW_ASSET_SCHEME, privileges: PRIVILEGED_ASSET_PRIVILEGES }
209216
])
210217

211218
let mainWindow: BrowserWindow | null = null
@@ -2550,6 +2557,28 @@ function registerIpc(): void {
25502557
return await restoreDeletedAsset(v.root, deleted)
25512558
})
25522559

2560+
handle(IPC.VAULT_LIST_DELETED_ASSETS, async () => {
2561+
if (isRemoteWorkspaceActive()) return []
2562+
const v = requireVault()
2563+
return await listDeletedAssets(v.root)
2564+
})
2565+
2566+
handle(IPC.VAULT_PURGE_DELETED_ASSET, async (_e, undoToken: string) => {
2567+
if (isRemoteWorkspaceActive()) {
2568+
throw new Error('Asset deletion is only available for local vaults right now.')
2569+
}
2570+
const v = requireVault()
2571+
await purgeDeletedAsset(v.root, undoToken)
2572+
})
2573+
2574+
handle(IPC.VAULT_EMPTY_DELETED_ASSETS, async () => {
2575+
if (isRemoteWorkspaceActive()) {
2576+
throw new Error('Asset deletion is only available for local vaults right now.')
2577+
}
2578+
const v = requireVault()
2579+
await emptyDeletedAssets(v.root)
2580+
})
2581+
25532582
handle(
25542583
IPC.VAULT_CREATE_FOLDER,
25552584
async (_e, folder: NoteFolder, subpath: string) => {
@@ -3430,6 +3459,47 @@ app.whenReady().then(async () => {
34303459
})
34313460
})
34323461

3462+
// Excalidraw's bundled fonts (dist/prod/fonts), served locally so the font
3463+
// picker works offline. With EXCALIDRAW_ASSET_PATH unset Excalidraw fetches its
3464+
// fonts from esm.sh, which the renderer CSP blocks, so nothing applied (#324). A
3465+
// packaged build ships only out/**, so the fonts are copied to
3466+
// resources/excalidraw-fonts (extraResources); dev reads them from node_modules.
3467+
const excalidrawFontsDir = (): string => {
3468+
if (app.isPackaged) return path.join(process.resourcesPath, 'excalidraw-fonts')
3469+
// The package `exports` map blocks resolving package.json, so derive the
3470+
// fonts dir from the main entry (.../dist/prod/index.js -> .../dist/prod/fonts).
3471+
const entry = nodeRequire.resolve('@excalidraw/excalidraw')
3472+
return path.join(path.dirname(entry), 'fonts')
3473+
}
3474+
const excalidrawFontMime = (p: string): string =>
3475+
/\.woff2$/i.test(p)
3476+
? 'font/woff2'
3477+
: /\.woff$/i.test(p)
3478+
? 'font/woff'
3479+
: /\.otf$/i.test(p)
3480+
? 'font/otf'
3481+
: /\.ttf$/i.test(p)
3482+
? 'font/ttf'
3483+
: 'application/octet-stream'
3484+
protocol.handle(EXCALIDRAW_ASSET_SCHEME, async (request) => {
3485+
// zen-excalidraw://assets/fonts/<Family>/<file> -> <fontsDir>/<Family>/<file>
3486+
const rel = decodeURIComponent(new URL(request.url).pathname)
3487+
.replace(/^\/+/, '')
3488+
.replace(/^fonts\//, '')
3489+
const root = path.resolve(excalidrawFontsDir())
3490+
const abs = path.resolve(root, rel)
3491+
if ((abs !== root && !abs.startsWith(root + path.sep)) || !/\.(woff2?|otf|ttf)$/i.test(abs)) {
3492+
throw new Error(`Invalid Excalidraw font URL: ${request.url}`)
3493+
}
3494+
const data = await fsp.readFile(abs)
3495+
return new Response(data, {
3496+
headers: {
3497+
'content-type': excalidrawFontMime(abs),
3498+
'cache-control': 'public, max-age=31536000, immutable'
3499+
}
3500+
})
3501+
})
3502+
34333503
// Permissions this app grants to its own renderer (deny everything else —
34343504
// it's our app talking to our own vault, no third-party surface):
34353505
// - 'local-fonts' → queryLocalFonts() for the font picker

apps/desktop/src/main/vault.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,19 @@ import {
88
archiveNote,
99
deleteAsset,
1010
duplicateAsset,
11+
emptyDeletedAssets,
1112
ensureVaultLayout,
1213
forgetLocalVault,
1314
getVaultSettings,
1415
importPastedImage,
1516
invalidateNoteMetaCache,
17+
listDeletedAssets,
1618
listNotes,
1719
listFolders,
1820
moveAsset,
1921
moveToTrash,
2022
rememberLocalVault,
23+
purgeDeletedAsset,
2124
renameAsset,
2225
renameFolder,
2326
restoreDeletedAsset,
@@ -277,6 +280,47 @@ describe('deleteAsset', () => {
277280
await expect(readFile(path.join(root, rel), 'utf8')).resolves.toBe('image-bytes')
278281
})
279282

283+
it('lists deleted assets so they are restorable without the in-session undo (#330)', async () => {
284+
const root = await makeTempDir('zennotes-list-deleted-assets-')
285+
await ensureVaultLayout(root)
286+
await writeFile(path.join(root, 'One.png'), 'one-bytes', 'utf8')
287+
await writeFile(path.join(root, 'Two.pdf'), 'two-bytes', 'utf8')
288+
289+
await deleteAsset(root, 'One.png')
290+
await deleteAsset(root, 'Two.pdf')
291+
292+
const listed = await listDeletedAssets(root)
293+
expect(listed).toHaveLength(2)
294+
expect(listed.map((d) => d.name).sort()).toEqual(['One.png', 'Two.pdf'])
295+
for (const d of listed) {
296+
expect(typeof d.undoToken).toBe('string')
297+
expect(typeof d.deletedAt).toBe('string')
298+
}
299+
300+
// Restore straight from the listed record — no in-memory undo entry needed.
301+
const entry = listed.find((d) => d.name === 'One.png')
302+
expect(entry).toBeTruthy()
303+
const restored = await restoreDeletedAsset(root, entry!)
304+
expect(restored.path).toBe('One.png')
305+
await expect(readFile(path.join(root, 'One.png'), 'utf8')).resolves.toBe('one-bytes')
306+
expect(await listDeletedAssets(root)).toHaveLength(1)
307+
})
308+
309+
it('purges a single deleted asset and empties them all (#330)', async () => {
310+
const root = await makeTempDir('zennotes-purge-deleted-assets-')
311+
await ensureVaultLayout(root)
312+
await writeFile(path.join(root, 'A.png'), 'a', 'utf8')
313+
await writeFile(path.join(root, 'B.png'), 'b', 'utf8')
314+
const a = await deleteAsset(root, 'A.png')
315+
await deleteAsset(root, 'B.png')
316+
317+
await purgeDeletedAsset(root, a.undoToken)
318+
expect((await listDeletedAssets(root)).map((d) => d.name)).toEqual(['B.png'])
319+
320+
await emptyDeletedAssets(root)
321+
expect(await listDeletedAssets(root)).toHaveLength(0)
322+
})
323+
280324
it('does not delete markdown notes through the asset path', async () => {
281325
const root = await makeTempDir('zennotes-delete-note-as-asset-')
282326
await ensureVaultLayout(root)

0 commit comments

Comments
 (0)