Compare commits

...
54 Commits
Author SHA1 Message Date
Yuri Karamian 2983e3db29 Merge branch 'main' into 'main'
Merge branch 'fix/sort-theme-list-alphabetically' into 'main'

Closes #250

See merge request ArkHost/HelixNotes!7
2026-09-17 12:30:52 +00:00
Shawn cc7c9e2b49 Merge branch HelixNotes:main into main 2026-09-15 16:09:56 +00:00
Shawn 2240231f61 Merge branch 'fix/sort-theme-list-alphabetically' into 'main'
fix: sort theme list alphabetically

See merge request locodarwin/HelixNotes!1
2026-09-15 15:36:44 +00:00
Yuri Karamian b2d0c7175e test: cover built-in theme preset ordering 2026-09-15 15:04:51 +02:00
Shawn Olson 1bc0026731 fix: sort theme list alphabetically 2026-09-14 14:42:06 -07:00
Yuri Karamian 41c0e4c993 fix: preserve Linux window title and configuration 2026-09-10 16:50:54 +02:00
Yuri Karamian 34a0f9efa3 fix: keep mobile navigation visible with collapsed desktop sidebar 2026-09-06 22:46:11 +02:00
Yuri Karamian 9d92489586 Merge branch 'fix/macos-escape-overlay-fullscreen' into 'main'
Prevent Escape from exiting fullscreen when closing overlays

See merge request ArkHost/HelixNotes!4
2026-09-04 13:00:47 +00:00
Vijay c45efb60ab Prevent Escape from exiting fullscreen when closing overlays 2026-09-04 13:00:46 +00:00
Yuri Karamian 0d11fd01dd fix: preserve hidden note title across editor modes 2026-09-04 14:46:30 +02:00
Yuri Karamian 0aaa05ce5c Merge branch 'fix/context-menu-paste-image' into 'main'
fix: paste images from the editor context menu

See merge request ArkHost/HelixNotes!6
2026-09-04 08:54:04 +00:00
Sridhar 77e92ef25c fix: paste images from the editor context menu 2026-09-04 08:54:04 +00:00
Yuri Karamian d7e4be7f3a Merge branch 'fix/webkitgtk-clipboard-image-paste' into 'main'
fix: paste images copied from a browser on WebKitGTK

Closes #245

See merge request ArkHost/HelixNotes!5
2026-09-03 19:07:34 +00:00
Sridhar 2aa572dcaa fix: paste images copied from a browser on WebKitGTK 2026-09-03 19:07:34 +00:00
Yuri Karamian 32f270ce9e Update v1.3.5 download documentation 2026-08-27 00:37:30 +02:00
Yuri Karamian a15b267614 Add scroll font size preference and bump v1.3.5 2026-08-26 17:16:16 +02:00
Yuri Karamian ce48d77cd8 fix: stop wiki links extending after autocomplete 2026-08-23 15:38:57 +02:00
Yuri Karamian 221625aabd fix: override vulnerable cookie dependency 2026-08-17 21:17:53 +02:00
Yuri Karamian 1bd4cd8138 chore: set Rust MSRV to 1.88 2026-08-17 21:15:11 +02:00
Yuri Karamian dd748776b2 test: add local verification command 2026-08-17 21:08:31 +02:00
Yuri Karamian 0391a60c1b fix: block private image proxy requests 2026-08-17 21:06:50 +02:00
Yuri Karamian 33d77cf2fd fix: restrict external file access 2026-08-17 21:02:05 +02:00
Yuri Karamian 81e4e26290 fix: restrict asset protocol to vaults 2026-08-17 20:14:34 +02:00
Yuri Karamian 111d819f92 fix: improve editor accessibility 2026-08-17 19:55:00 +02:00
Yuri Karamian c37818de1a fix: improve settings accessibility 2026-08-17 19:49:05 +02:00
Yuri Karamian 95dab8cf74 fix: improve note list accessibility 2026-08-17 19:46:50 +02:00
Yuri Karamian 62365f52b1 fix: mark resize handles presentational 2026-08-17 19:45:19 +02:00
Yuri Karamian 6eb1ec6eb2 fix: improve sidebar interactions 2026-08-17 19:44:35 +02:00
Yuri Karamian 527c6a7a91 fix: label storage location choices 2026-08-17 19:43:31 +02:00
Yuri Karamian 45e3c389e4 fix: handle info overlay clicks 2026-08-17 19:42:47 +02:00
Yuri Karamian 9ccbf3aff5 fix: make layout references reactive 2026-08-17 19:31:21 +02:00
Yuri Karamian 695664913a fix: make graph state reactive 2026-08-17 19:28:48 +02:00
Yuri Karamian d0fb87cf3a style: format Rust code 2026-08-17 19:27:05 +02:00
Yuri Karamian ac7c79af04 fix: resolve Rust lints 2026-08-17 19:24:32 +02:00
Yuri Karamian 1ebf2a9843 fix: resolve type errors 2026-08-17 19:04:21 +02:00
Yuri Karamian 44dcfbe24a fix: tighten app permissions 2026-08-17 18:57:56 +02:00
Yuri Karamian 00196ecedb fix: protect config files 2026-08-17 18:53:53 +02:00
Yuri Karamian c4d2d1b2d1 fix: validate version paths 2026-08-17 18:52:40 +02:00
Yuri Karamian 3a9a5129c4 fix: restrict backup paths 2026-08-17 18:50:57 +02:00
Yuri Karamian a964ee1930 fix: restrict vault paths 2026-08-17 18:49:32 +02:00
Yuri Karamian 2d57577338 fix: update Rust dependencies 2026-08-17 18:43:11 +02:00
Yuri Karamian 206faed113 fix: update frontend dependencies 2026-08-17 18:37:57 +02:00
Yuri Karamian b3c292d6e2 test(paths): cover cross-platform asset resolution 2026-08-17 18:35:26 +02:00
Yuri Karamian 43f15962f7 Revert "chore(release): prepare v1.3.5"
This reverts commit ade146f9f3.
2026-08-17 17:03:01 +02:00
Yuri Karamian ade146f9f3 chore(release): prepare v1.3.5 2026-08-17 16:50:14 +02:00
Yuri Karamian b253ec4121 fix(paths): preserve cross-platform asset paths 2026-08-17 15:58:20 +02:00
Yuri Karamian c4a91b8f1c fix(sorting): sort numeric names naturally 2026-08-15 16:18:22 +02:00
Yuri Karamian 87c879bcac fix(notebooks): normalize icon paths across platforms 2026-08-15 01:40:14 +02:00
Yuri Karamian 27609fb4f3 Fix Windows internal note links 2026-08-11 02:41:39 +02:00
Yuri Karamian b0555fe648 Add Clear Formatting action to editor context menu 2026-08-08 23:53:27 +02:00
Yuri Karamian 32ef51161d Scroll code block into view after fence input rule 2026-08-07 21:50:34 +02:00
Yuri Karamian 23abd0a19a Add Maintenance tab for vault statistics and cleanup 2026-08-07 17:12:15 +02:00
Yuri Karamian 5f726eb01f Move orphaned attachment cleanup from About to Settings 2026-08-07 17:01:41 +02:00
Yuri Karamian 73f7604187 Speed up note switcher and sync window scaling 2026-08-07 16:51:06 +02:00
57 changed files with 4116 additions and 2216 deletions
+24 -14
View File
@@ -1,7 +1,7 @@
# HelixNotes # HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.3.4-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4) [![Latest Release](https://img.shields.io/badge/release-v1.3.5-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com) [![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]() [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.3.4) ## Download (v1.3.5)
### Linux ### Linux
@@ -18,7 +18,7 @@ No cloud, no lock-in.
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64). The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.AppImage)
#### Distro-specific packages #### Distro-specific packages
@@ -108,26 +108,28 @@ sudo eopkg it helixnotes
#### Manual package downloads #### Manual package downloads
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+) - [.deb](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.deb) (Ubuntu 22.04+)
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm) - [.rpm](https://download.helixnotes.com/releases/v1.3.5/HelixNotes-1.3.5-1.x86_64.rpm)
### Windows ### Windows
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11) [Download Installer](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs) [Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_aarch64.dmg) (M-series Macs)
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update): [Download .dmg (Intel)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64.dmg)
>
> ```bash The Apple Silicon build is signed and notarized by Apple. The Intel build may still trigger a Gatekeeper warning. If macOS reports that the Intel build is damaged, run this once after installing or updating it:
> xattr -cr /Applications/HelixNotes.app
> ``` ```bash
xattr -cr /Applications/HelixNotes.app
```
### Android ### Android
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk) [Download APK](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_android.apk)
--- ---
@@ -166,7 +168,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
### Prerequisites ### Prerequisites
- [Rust](https://rustup.rs/) (1.77+) - [Rust](https://rustup.rs/) (1.88+)
- [Node.js](https://nodejs.org/) (18+) - [Node.js](https://nodejs.org/) (18+)
- [pnpm](https://pnpm.io/) - [pnpm](https://pnpm.io/)
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) - System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
@@ -178,6 +180,14 @@ pnpm install
pnpm tauri dev pnpm tauri dev
``` ```
### Verification
Run the frontend checks and tests, Rust tests and lints, and production frontend build with one command:
```bash
pnpm verify
```
### Production Build ### Production Build
```bash ```bash
+14 -10
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.3.4", "version": "1.3.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -11,6 +11,10 @@
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
"tauri": "tauri", "tauri": "tauri",
"tauri:dev": "tauri dev", "tauri:dev": "tauri dev",
"tauri:build": "tauri build" "tauri:build": "tauri build"
@@ -18,22 +22,22 @@
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2.10.0", "@tauri-apps/cli": "^2.10.0",
"svelte": "^5.49.2", "@types/node": "26.2.0",
"svelte": "^5.56.9",
"svelte-check": "^4.3.6", "svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.3.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1" "vite": "^7.3.6"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.10.1", "@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-updater": "^2.10.0", "@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.19.0", "@tiptap/core": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0", "@tiptap/extension-code-block-lowlight": "^3.19.0",
@@ -62,11 +66,11 @@
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.3.0",
"markdown-it-mark": "^4.0.0", "markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0", "markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0", "markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1", "markdown-it-task-lists": "^2.1.1",
"mermaid": "^11.14.0" "mermaid": "^11.16.1"
} }
} }
+598 -692
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,2 +1,4 @@
allowBuilds: allowBuilds:
esbuild: true esbuild: true
overrides:
cookie: 0.7.2
+67 -275
View File
@@ -8,17 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -138,12 +127,6 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -346,18 +329,6 @@ dependencies = [
"crunchy", "crunchy",
] ]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -389,29 +360,6 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "borsh"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
dependencies = [
"borsh-derive",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
dependencies = [
"once_cell",
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.2" version = "8.0.2"
@@ -439,40 +387,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byte-unit"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
dependencies = [
"rust_decimal",
"schemars 1.2.1",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.0" version = "1.25.0"
@@ -624,6 +538,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -738,6 +663,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -768,9 +702,9 @@ dependencies = [
[[package]] [[package]]
name = "crossbeam-epoch" name = "crossbeam-epoch"
version = "0.9.18" version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
@@ -1314,12 +1248,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "futf" name = "futf"
version = "0.1.5" version = "0.1.5"
@@ -1590,11 +1518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1604,10 +1530,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"rand_core 0.10.1",
"wasip2", "wasip2",
"wasip3", "wasip3",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1796,9 +1725,6 @@ name = "hashbrown"
version = "0.12.3" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -1806,7 +1732,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [ dependencies = [
"ahash 0.8.12", "ahash",
"allocator-api2", "allocator-api2",
] ]
@@ -1850,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.3.4" version = "1.3.5"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1862,7 +1788,7 @@ dependencies = [
"notify", "notify",
"objc2-app-kit", "objc2-app-kit",
"png 0.17.16", "png 0.17.16",
"quick-xml 0.36.2", "quick-xml",
"rayon", "rayon",
"regex", "regex",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -2554,9 +2480,6 @@ name = "log"
version = "0.4.29" version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]] [[package]]
name = "lru" name = "lru"
@@ -2575,9 +2498,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lz4_flex" name = "lz4_flex"
version = "0.11.5" version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
[[package]] [[package]]
name = "mac" name = "mac"
@@ -3417,13 +3340,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]] [[package]]
name = "plist" name = "plist"
version = "1.8.0" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.0", "indexmap 2.13.0",
"quick-xml 0.38.4", "quick-xml",
"serde", "serde",
"time", "time",
] ]
@@ -3576,26 +3499,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "pure-rust-locales" name = "pure-rust-locales"
version = "0.8.2" version = "0.8.2"
@@ -3619,18 +3522,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.36.2" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -3657,14 +3551,15 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.13" version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.4.1",
"lru-slab", "lru-slab",
"rand 0.9.2", "rand 0.10.2",
"rand_pcg 0.10.2",
"ring", "ring",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
"rustls", "rustls",
@@ -3705,12 +3600,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.7.3" version = "0.7.3"
@@ -3722,7 +3611,7 @@ dependencies = [
"rand_chacha 0.2.2", "rand_chacha 0.2.2",
"rand_core 0.5.1", "rand_core 0.5.1",
"rand_hc", "rand_hc",
"rand_pcg", "rand_pcg 0.2.1",
] ]
[[package]] [[package]]
@@ -3738,12 +3627,13 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [ dependencies = [
"rand_chacha 0.9.0", "chacha20",
"rand_core 0.9.5", "getrandom 0.4.1",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -3766,16 +3656,6 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@@ -3796,12 +3676,9 @@ dependencies = [
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_distr" name = "rand_distr"
@@ -3831,6 +3708,15 @@ dependencies = [
"rand_core 0.5.1", "rand_core 0.5.1",
] ]
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3935,15 +3821,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@@ -4063,35 +3940,6 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "rust-stemmers" name = "rust-stemmers"
version = "1.2.0" version = "1.2.0"
@@ -4102,22 +3950,6 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "rust_decimal"
version = "1.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@@ -4231,9 +4063,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.9" version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -4327,12 +4159,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@@ -4574,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
@@ -4600,12 +4426,6 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "0.3.11" version = "0.3.11"
@@ -5007,17 +4827,11 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.44" version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
@@ -5214,12 +5028,11 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-log" name = "tauri-plugin-log"
version = "2.8.0" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"byte-unit",
"fern", "fern",
"log", "log",
"objc2", "objc2",
@@ -5964,12 +5777,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -5988,12 +5795,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -6246,12 +6047,12 @@ dependencies = [
[[package]] [[package]]
name = "wayland-scanner" name = "wayland-scanner"
version = "0.31.8" version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml 0.38.4", "quick-xml",
"quote", "quote",
] ]
@@ -7007,15 +6808,6 @@ dependencies = [
"x11-dl", "x11-dl",
] ]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]] [[package]]
name = "x11" name = "x11"
version = "2.21.0" version = "2.21.0"
+10 -3
View File
@@ -1,11 +1,15 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.3.4" version = "1.3.5"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
edition = "2021" edition = "2021"
rust-version = "1.77.2" rust-version = "1.88"
[lints.clippy]
# This style lint is version-dependent and produces over 100 legacy-only diagnostics on Rust 1.88.
uninlined_format_args = "allow"
[lib] [lib]
name = "app_lib" name = "app_lib"
@@ -14,6 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] } tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
@@ -38,7 +45,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3" futures = "0.3"
rayon = "1" rayon = "1"
quick-xml = "0.36" quick-xml = "0.41"
urlencoding = "2" urlencoding = "2"
sha2 = "0.10" sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }
+1 -27
View File
@@ -24,32 +24,6 @@
"dialog:allow-message", "dialog:allow-message",
"dialog:allow-ask", "dialog:allow-ask",
"dialog:allow-confirm", "dialog:allow-confirm",
"fs:default", "fs:allow-read-file"
"fs:allow-read",
"fs:allow-write",
"fs:allow-exists",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-copy-file",
"fs:allow-stat",
"fs:allow-read-dir",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-watch",
"opener:default",
{
"identifier": "opener:allow-open-path",
"allow": [{ "path": "/**" }]
},
{
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "https://**" },
{ "url": "http://**" },
{ "url": "mailto:*" }
]
},
"opener:allow-reveal-item-in-dir"
] ]
} }
+28 -16
View File
@@ -8,6 +8,7 @@ const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions"; const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434"; const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
#[allow(clippy::too_many_arguments)]
pub fn ai_request( pub fn ai_request(
app: AppHandle, app: AppHandle,
provider: String, provider: String,
@@ -22,7 +23,11 @@ pub fn ai_request(
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { rt.block_on(async {
// Handle all API keys as optional; ollama and v1 completions doesnt always require it. // Handle all API keys as optional; ollama and v1 completions doesnt always require it.
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) }; let key_opt = if api_key.is_empty() {
None
} else {
Some(api_key.as_str())
};
let result = match provider.as_str() { let result = match provider.as_str() {
"openai" => { "openai" => {
stream_openai( stream_openai(
@@ -98,7 +103,10 @@ pub fn ai_request(
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`). /// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
fn normalize_openai_base(base: &str) -> String { fn normalize_openai_base(base: &str) -> String {
let b = base.trim().trim_end_matches('/'); let b = base.trim().trim_end_matches('/');
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string() b.strip_suffix("/v1")
.unwrap_or(b)
.trim_end_matches('/')
.to_string()
} }
async fn stream_anthropic( async fn stream_anthropic(
@@ -268,9 +276,7 @@ async fn stream_openai(
body["temperature"] = json!(0.7); body["temperature"] = json!(0.7);
} }
let mut req = client let mut req = client.post(url).header("content-type", "application/json");
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key { if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key)); req = req.header("Authorization", format!("Bearer {}", key));
@@ -380,7 +386,11 @@ pub async fn test_connection(
model: &str, model: &str,
base_url: Option<&str>, base_url: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let key_opt = if api_key.is_empty() { None } else { Some(api_key) }; let key_opt = if api_key.is_empty() {
None
} else {
Some(api_key)
};
match provider { match provider {
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await, "openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
"ollama" => { "ollama" => {
@@ -434,14 +444,18 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
} }
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> { async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new(); let client = Client::new();
let is_gpt5 = model.starts_with("gpt-5"); let is_gpt5 = model.starts_with("gpt-5");
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" }; let token_key = if is_gpt5 {
"max_completion_tokens"
} else {
"max_tokens"
};
let body = json!({ let body = json!({
"model": model, "model": model,
token_key: 20, token_key: 20,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
"content": "Hi" "content": "Hi"
@@ -449,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
] ]
}); });
let mut req = client let mut req = client.post(url).header("content-type", "application/json");
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key { if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key)); req = req.header("Authorization", format!("Bearer {}", key));
+111
View File
@@ -0,0 +1,111 @@
use std::path::Path;
use tauri::{AppHandle, Manager, Runtime};
/// Grants asset-protocol access only to a vault selected by the user.
/// `.helixnotes` is added explicitly because Unix glob matching excludes hidden paths.
pub fn allow_vault_assets<R: Runtime>(app: &AppHandle<R>, vault_path: &Path) -> Result<(), String> {
// Keep the canonical native path: Tauri's scope normalizes Unix paths plus
// Windows drive, verbatim, and UNC prefixes when matching asset requests.
let vault = std::fs::canonicalize(vault_path).map_err(|error| {
format!(
"Failed to resolve vault asset path '{}': {error}",
vault_path.display()
)
})?;
let scope = app.asset_protocol_scope();
scope
.allow_directory(&vault, true)
.map_err(|error| error.to_string())?;
scope
.allow_directory(vault.join(".helixnotes"), true)
.map_err(|error| error.to_string())
}
/// Grants access to relative assets next to a Markdown file explicitly opened by the user.
pub fn allow_external_note_assets<R: Runtime>(
app: &AppHandle<R>,
note_path: &Path,
) -> Result<(), String> {
let note = std::fs::canonicalize(note_path).map_err(|error| {
format!(
"Failed to resolve external note path '{}': {error}",
note_path.display()
)
})?;
let parent = note
.parent()
.ok_or_else(|| "External note has no parent directory".to_string())?;
app.asset_protocol_scope()
.allow_directory(parent, true)
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn test_directory(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("helixnotes-{name}-{}", uuid::Uuid::new_v4()))
}
#[test]
fn static_asset_scope_does_not_expose_the_filesystem() {
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let scope = &config["app"]["security"]["assetProtocol"]["scope"];
assert_eq!(scope["allow"], serde_json::json!([]));
assert_eq!(scope["deny"], serde_json::json!([]));
assert_eq!(scope["requireLiteralLeadingDot"], true);
}
#[test]
fn runtime_scope_allows_only_the_selected_vault() {
let root = test_directory("asset-scope");
let vault = root.join("Vault [Cross Platform]");
let attachments = vault.join(".helixnotes").join("attachments");
fs::create_dir_all(&attachments).unwrap();
let note_image = vault.join("images").join("note image.png");
fs::create_dir_all(note_image.parent().unwrap()).unwrap();
fs::write(&note_image, b"image").unwrap();
let attachment = attachments.join("attachment.png");
fs::write(&attachment, b"attachment").unwrap();
let outside = root.join("outside.png");
fs::write(&outside, b"outside").unwrap();
let app = tauri::test::mock_app();
allow_vault_assets(app.handle(), &vault).unwrap();
let scope = app.asset_protocol_scope();
assert!(scope.is_allowed(&note_image));
assert!(scope.is_allowed(&attachment));
assert!(!scope.is_allowed(&outside));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn external_note_scope_allows_relative_assets_only_below_its_directory() {
let root = test_directory("external-note-assets");
let note_dir = root.join("shared note");
let assets = note_dir.join("images");
fs::create_dir_all(&assets).unwrap();
let note = note_dir.join("note.md");
let image = assets.join("image.png");
let outside = root.join("outside.png");
fs::write(&note, b"note").unwrap();
fs::write(&image, b"image").unwrap();
fs::write(&outside, b"outside").unwrap();
let app = tauri::test::mock_app();
allow_external_note_assets(app.handle(), &note).unwrap();
let scope = app.asset_protocol_scope();
assert!(scope.is_allowed(&note));
assert!(scope.is_allowed(&image));
assert!(!scope.is_allowed(&outside));
fs::remove_dir_all(root).unwrap();
}
}
+48 -15
View File
@@ -134,7 +134,7 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
let entry = entry.map_err(|e| e.to_string())?; let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path(); let path = entry.path();
if path.extension().map_or(false, |ext| ext == "zip") { if path.extension().is_some_and(|ext| ext == "zip") {
let filename = path let filename = path
.file_name() .file_name()
.unwrap_or_default() .unwrap_or_default()
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
Ok(entries) Ok(entries)
} }
/// Restore a backup by extracting the zip over the vault directory fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> { let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
let vault = Path::new(vault_path); let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
let backup = Path::new(backup_path); if backup.parent() != Some(backup_dir.as_path())
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
if !backup.exists() { || !backup.is_file()
return Err("Backup file does not exist".to_string()); {
return Err(
"Backup path must point to a ZIP file in the configured backup directory".to_string(),
);
} }
Ok(backup)
}
/// Restore a backup by extracting the zip over the vault directory
pub fn restore_backup(
vault_path: &str,
backup_dir: &Path,
backup_path: &str,
) -> Result<(), String> {
let vault = Path::new(vault_path);
let backup = validated_backup_file(backup_dir, backup_path)?;
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?; let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
let mut archive = let mut archive =
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
} }
/// Delete a single backup file /// Delete a single backup file
pub fn delete_backup(backup_path: &str) -> Result<(), String> { pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
let path = Path::new(backup_path); let path = validated_backup_file(backup_dir, backup_path)?;
if path.exists() { fs::remove_file(path).map_err(|error| error.to_string())
fs::remove_file(path).map_err(|e| e.to_string())?;
}
Ok(())
} }
/// Remove old backups keeping only the newest `max_count` /// Remove old backups keeping only the newest `max_count`
@@ -261,9 +272,31 @@ pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), Stri
if backups.len() as u32 > max_count { if backups.len() as u32 > max_count {
let to_remove = backups.split_off(max_count as usize); let to_remove = backups.split_off(max_count as usize);
for entry in to_remove { for entry in to_remove {
delete_backup(&entry.path)?; delete_backup(backup_dir, &entry.path)?;
} }
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::delete_backup;
use std::fs;
use uuid::Uuid;
#[test]
fn delete_backup_rejects_files_outside_backup_directory() {
let root = std::env::temp_dir().join(format!("helixnotes-backup-test-{}", Uuid::new_v4()));
let backup_dir = root.join("backups");
let outside = root.join("outside.zip");
fs::create_dir_all(&backup_dir).unwrap();
fs::write(&outside, "must survive").unwrap();
let result = delete_backup(&backup_dir, &outside.to_string_lossy());
assert!(result.is_err());
assert!(outside.exists());
fs::remove_dir_all(root).unwrap();
}
}
+592 -231
View File
File diff suppressed because it is too large Load Diff
+60 -22
View File
@@ -1,22 +1,36 @@
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use crate::types::VersionEntry; use crate::types::VersionEntry;
fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
let mut components = Path::new(value).components();
if value.is_empty()
|| !matches!(components.next(), Some(Component::Normal(_)))
|| components.next().is_some()
{
return Err(format!("Invalid {label}"));
}
Ok(value)
}
/// Directory: .helixnotes/history/<note-id>/ /// Directory: .helixnotes/history/<note-id>/
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf { fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
Path::new(vault_path) Ok(Path::new(vault_path)
.join(".helixnotes") .join(".helixnotes")
.join("history") .join("history")
.join(note_id) .join(safe_path_component(note_id, "note ID")?))
} }
/// Save a version snapshot if enough time has passed since the last one. /// Save a version snapshot if enough time has passed since the last one.
/// Minimum interval: 5 minutes between snapshots. /// Minimum interval: 5 minutes between snapshots.
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) { pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
let dir = history_dir(vault_path, note_id); let Ok(dir) = history_dir(vault_path, note_id) else {
log::warn!("Skipping history snapshot with an invalid note ID");
return;
};
// Check if we should create a snapshot (5 min cooldown) // Check if we should create a snapshot (5 min cooldown)
if let Ok(entries) = fs::read_dir(&dir) { if let Ok(entries) = fs::read_dir(&dir) {
@@ -68,31 +82,30 @@ pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_ve
} }
/// Force-create a version snapshot, bypassing the cooldown. /// Force-create a version snapshot, bypassing the cooldown.
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) { pub fn force_snapshot(
let dir = history_dir(vault_path, note_id); vault_path: &str,
note_id: &str,
if let Err(e) = fs::create_dir_all(&dir) { raw_content: &str,
eprintln!("Failed to create history dir: {}", e); max_versions: u32,
return; ) -> Result<(), String> {
} let dir = history_dir(vault_path, note_id)?;
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string(); let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
let filename = format!("{}.md", timestamp); let filename = format!("{}.md", timestamp);
let path = dir.join(&filename); let path = dir.join(&filename);
if let Err(e) = fs::write(&path, raw_content) { fs::write(&path, raw_content).map_err(|error| error.to_string())?;
eprintln!("Failed to write version snapshot: {}", e);
return;
}
if max_versions > 0 { if max_versions > 0 {
let _ = prune_versions(&dir, max_versions); prune_versions(&dir, max_versions)?;
} }
Ok(())
} }
/// List all version snapshots for a note, newest first. /// List all version snapshots for a note, newest first.
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> { pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
let dir = history_dir(vault_path, note_id); let dir = history_dir(vault_path, note_id)?;
if !dir.exists() { if !dir.exists() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -102,7 +115,7 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?; let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path(); let path = entry.path();
if path.extension().map_or(false, |ext| ext == "md") { if path.extension().is_some_and(|ext| ext == "md") {
let filename = path let filename = path
.file_stem() .file_stem()
.unwrap_or_default() .unwrap_or_default()
@@ -134,17 +147,18 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
/// Get the raw content of a specific version. /// Get the raw content of a specific version.
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> { pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
safe_path_component(timestamp, "version timestamp")?;
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md // Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
let filename = if let Some(t_pos) = timestamp.find('T') { let filename = if let Some(t_pos) = timestamp.find('T') {
let date_part = &timestamp[..t_pos]; let date_part = &timestamp[..t_pos];
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z'); let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
let time_dashes = time_part.replace(':', "-"); let time_dashes = time_part.replace(':', "-");
format!("{}.md", format!("{}T{}", date_part, time_dashes)) format!("{date_part}T{time_dashes}.md")
} else { } else {
format!("{}.md", timestamp) format!("{}.md", timestamp)
}; };
let path = history_dir(vault_path, note_id).join(&filename); let path = history_dir(vault_path, note_id)?.join(&filename);
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e)) fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
} }
@@ -154,7 +168,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.map(|e| e.path()) .map(|e| e.path())
.filter(|p| p.extension().map_or(false, |ext| ext == "md")) .filter(|p| p.extension().is_some_and(|ext| ext == "md"))
.collect(); .collect();
// Sort by name (timestamps sort lexicographically) - newest last // Sort by name (timestamps sort lexicographically) - newest last
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::{get_version, list_versions};
use std::fs;
use uuid::Uuid;
#[test]
fn rejects_history_path_traversal() {
let vault =
std::env::temp_dir().join(format!("helixnotes-history-test-{}", Uuid::new_v4()));
let metadata = vault.join(".helixnotes");
let escaped_history = metadata.join("escaped");
fs::create_dir_all(&escaped_history).unwrap();
fs::write(escaped_history.join("2026-01-01T00-00-00.md"), "escaped").unwrap();
fs::create_dir_all(metadata.join("history").join("safe")).unwrap();
fs::write(metadata.join("secret.md"), "secret").unwrap();
assert!(list_versions(&vault.to_string_lossy(), "../escaped").is_err());
assert!(get_version(&vault.to_string_lossy(), "safe", "../../secret").is_err());
fs::remove_dir_all(vault).unwrap();
}
}
+245
View File
@@ -0,0 +1,245 @@
use reqwest::{blocking::Client, redirect::Policy, Url};
use std::io::Read;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::time::Duration;
const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
pub struct ImageResponse {
pub content_type: String,
pub body: Vec<u8>,
}
pub enum ProxyError {
Invalid(String),
Blocked(String),
Fetch(String),
}
impl ProxyError {
pub fn status(&self) -> u16 {
match self {
Self::Invalid(_) => 400,
Self::Blocked(_) => 403,
Self::Fetch(_) => 502,
}
}
pub fn message(&self) -> &str {
match self {
Self::Invalid(message) | Self::Blocked(message) | Self::Fetch(message) => message,
}
}
}
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
let [a, b, c, _] = ip.octets();
!(a == 0
|| a == 10
|| a == 127
|| a >= 224
|| (a == 100 && (64..=127).contains(&b))
|| (a == 169 && b == 254)
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
|| (a == 192 && b == 0 && c == 0)
|| (a == 192 && b == 0 && c == 2)
|| (a == 198 && (b == 18 || b == 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113))
}
fn is_public_ipv6(ip: Ipv6Addr) -> bool {
if ip.is_unspecified() || ip.is_loopback() || ip.to_ipv4().is_some() {
return false;
}
let first = ip.segments()[0];
(0x2000..=0x3fff).contains(&first) && ip.segments()[..2] != [0x2001, 0x0db8]
}
fn is_public_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_public_ipv4(ip),
IpAddr::V6(ip) => is_public_ipv6(ip),
}
}
fn normalized_host(host: &str) -> String {
host.trim_start_matches('[')
.trim_end_matches(']')
.trim_end_matches('.')
.to_ascii_lowercase()
}
fn validate_url(raw_url: &str) -> Result<Url, ProxyError> {
let url = Url::parse(raw_url).map_err(|_| ProxyError::Invalid("Invalid image URL".into()))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(ProxyError::Invalid(
"Only HTTP and HTTPS images are supported".into(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(ProxyError::Invalid(
"Image URLs must not contain credentials".into(),
));
}
let expected_port = if url.scheme() == "https" { 443 } else { 80 };
if url.port_or_known_default() != Some(expected_port) {
return Err(ProxyError::Blocked("Non-standard image URL port".into()));
}
let host = url
.host_str()
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
if host.ends_with('.') {
return Err(ProxyError::Invalid(
"Image URL host must not have a trailing dot".into(),
));
}
let normalized = normalized_host(host);
if normalized == "localhost" || normalized.ends_with(".localhost") {
return Err(ProxyError::Blocked(
"Local image hosts are not allowed".into(),
));
}
if let Ok(ip) = normalized.parse::<IpAddr>() {
if !is_public_ip(ip) {
return Err(ProxyError::Blocked(
"Private image addresses are not allowed".into(),
));
}
}
Ok(url)
}
fn resolve_public_host(url: &Url) -> Result<(String, Vec<SocketAddr>), ProxyError> {
let host = url
.host_str()
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
let host = normalized_host(host);
let port = url
.port_or_known_default()
.ok_or_else(|| ProxyError::Invalid("Image URL has no port".into()))?;
let addresses: Vec<_> = (host.as_str(), port)
.to_socket_addrs()
.map_err(|error| ProxyError::Fetch(format!("Could not resolve image host: {error}")))?
.collect();
if addresses.is_empty() {
return Err(ProxyError::Fetch(
"Image host resolved to no addresses".into(),
));
}
if addresses.iter().any(|address| !is_public_ip(address.ip())) {
return Err(ProxyError::Blocked(
"Image host resolves to a private address".into(),
));
}
Ok((host, addresses))
}
fn image_client(host: &str, addresses: &[SocketAddr]) -> Result<Client, ProxyError> {
let redirect_host = host.to_string();
Client::builder()
.timeout(Duration::from_secs(30))
.redirect(Policy::custom(move |attempt| {
let redirected_elsewhere = match attempt.url().host_str() {
Some(host) => normalized_host(host) != redirect_host,
None => true,
};
if attempt.previous().len() >= 5
|| redirected_elsewhere
|| validate_url(attempt.url().as_str()).is_err()
{
attempt.stop()
} else {
attempt.follow()
}
}))
.resolve_to_addrs(host, addresses)
.build()
.map_err(|error| ProxyError::Fetch(format!("Could not create image client: {error}")))
}
pub fn fetch(raw_url: &str) -> Result<ImageResponse, ProxyError> {
let url = validate_url(raw_url)?;
let (host, addresses) = resolve_public_host(&url)?;
let client = image_client(&host, &addresses)?;
let mut response = client
.get(url)
.send()
.map_err(|error| ProxyError::Fetch(format!("Image request failed: {error}")))?;
if !response.status().is_success() {
return Err(ProxyError::Fetch(format!(
"Image server returned {}",
response.status()
)));
}
if response
.content_length()
.is_some_and(|length| length > MAX_IMAGE_BYTES)
{
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string();
if !content_type.to_ascii_lowercase().starts_with("image/") {
return Err(ProxyError::Blocked(
"Remote resource is not an image".into(),
));
}
let mut body = Vec::new();
response
.by_ref()
.take(MAX_IMAGE_BYTES + 1)
.read_to_end(&mut body)
.map_err(|error| ProxyError::Fetch(format!("Could not read image: {error}")))?;
if body.len() as u64 > MAX_IMAGE_BYTES {
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
}
Ok(ImageResponse { content_type, body })
}
#[cfg(test)]
mod tests {
use super::{is_public_ip, validate_url};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[test]
fn blocks_local_and_private_networks() {
for ip in [
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
IpAddr::V6(Ipv6Addr::LOCALHOST),
"fc00::1".parse().unwrap(),
"fe80::1".parse().unwrap(),
] {
assert!(!is_public_ip(ip), "{ip}");
}
assert!(is_public_ip("93.184.216.34".parse().unwrap()));
assert!(is_public_ip(
"2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()
));
}
#[test]
fn validates_only_public_web_image_urls() {
for url in [
"file:///etc/passwd",
"http://localhost/image.png",
"http://127.0.0.1/image.png",
"http://[::1]/image.png",
"http://example.com:8080/image.png",
"https://example.com./image.png",
"https://user:pass@example.com/image.png",
] {
assert!(validate_url(url).is_err(), "{url}");
}
assert!(validate_url("https://example.com/image.png").is_ok());
assert!(validate_url("http://93.184.216.34/image.png").is_ok());
}
}
+58 -65
View File
@@ -1,7 +1,9 @@
mod ai; mod ai;
mod asset_scope;
mod backup; mod backup;
mod commands; mod commands;
mod history; mod history;
mod image_proxy;
mod search; mod search;
mod state; mod state;
mod sync; mod sync;
@@ -11,6 +13,7 @@ mod vault;
use state::AppState; use state::AppState;
#[allow(unused_imports)] #[allow(unused_imports)]
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use tauri_plugin_fs::FsExt;
#[cfg(desktop)] #[cfg(desktop)]
use tauri::{ use tauri::{
@@ -56,6 +59,16 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.on_webview_event(|webview, event| {
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
let scope = webview.fs_scope();
for path in paths {
if path.is_file() {
let _ = scope.allow_file(path);
}
}
}
})
.on_page_load(|webview, payload| { .on_page_load(|webview, payload| {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if webview.label() == "main" if webview.label() == "main"
@@ -93,6 +106,20 @@ pub fn run() {
}); });
} }
let active_vault = app
.state::<AppState>()
.config
.lock()
.ok()
.and_then(|config| config.active_vault.clone());
if let Some(vault_path) = active_vault {
if let Err(error) =
asset_scope::allow_vault_assets(app.handle(), std::path::Path::new(&vault_path))
{
log::warn!("Failed to restore the vault asset scope: {error}");
}
}
#[cfg(desktop)] #[cfg(desktop)]
if show_tray { if show_tray {
setup_tray(app)?; setup_tray(app)?;
@@ -112,6 +139,14 @@ pub fn run() {
std::env::current_dir().unwrap_or_default().join(&path) std::env::current_dir().unwrap_or_default().join(&path)
}; };
if let Some(resolved_str) = resolved.to_str() { if let Some(resolved_str) = resolved.to_str() {
if resolved.is_file() {
let _ = app.fs_scope().allow_file(&resolved);
if let Some(parent) = resolved.parent() {
let _ = app.fs_scope().allow_directory(parent, true);
}
let _ =
asset_scope::allow_external_note_assets(app.handle(), &resolved);
}
let app_state = app.state::<AppState>(); let app_state = app.state::<AppState>();
let _ = app_state.pending_open_file.lock().map(|mut p| { let _ = app_state.pending_open_file.lock().map(|mut p| {
*p = Some(resolved_str.to_string()); *p = Some(resolved_str.to_string());
@@ -137,6 +172,7 @@ pub fn run() {
commands::export_custom_theme, commands::export_custom_theme,
commands::import_custom_themes, commands::import_custom_themes,
commands::set_font_size, commands::set_font_size,
commands::set_scroll_to_change_font_size,
commands::set_font_family, commands::set_font_family,
commands::set_line_height, commands::set_line_height,
commands::set_ui_scale, commands::set_ui_scale,
@@ -158,6 +194,7 @@ pub fn run() {
commands::move_note, commands::move_note,
commands::get_all_tags, commands::get_all_tags,
commands::get_all_note_titles, commands::get_all_note_titles,
commands::get_note_switcher_titles,
commands::get_graph_data, commands::get_graph_data,
commands::get_tasks, commands::get_tasks,
commands::set_task_done, commands::set_task_done,
@@ -189,6 +226,7 @@ pub fn run() {
commands::trash_orphaned_attachments, commands::trash_orphaned_attachments,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
commands::reveal_file,
commands::open_url, commands::open_url,
commands::copy_file_to, commands::copy_file_to,
commands::write_bytes_to, commands::write_bytes_to,
@@ -216,72 +254,21 @@ pub fn run() {
std::thread::spawn(move || { std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path); let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded); let external_url = percent_decode(encoded);
let response = match image_proxy::fetch(&external_url) {
if !external_url.starts_with("http://") && !external_url.starts_with("https://") { Ok(image) => tauri::http::Response::builder()
let _ = responder.respond( .status(200)
.header("Content-Type", image.content_type)
.header("Access-Control-Allow-Origin", "*")
.header("X-Content-Type-Options", "nosniff")
.body(image.body),
Err(error) => {
log::warn!("Blocked external image request: {}", error.message());
tauri::http::Response::builder() tauri::http::Response::builder()
.status(400) .status(error.status())
.body(Vec::new()) .body(Vec::new())
.unwrap(),
);
return;
}
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
return;
} }
}; };
responder.respond(response.expect("valid image proxy response"));
match client.get(&external_url).send() {
Ok(resp) => {
let content_type = resp
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let status = resp.status().as_u16();
match resp.bytes() {
Ok(bytes) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(status)
.header("Content-Type", &content_type)
.header("Access-Control-Allow-Origin", "*")
.body(bytes.to_vec())
.unwrap(),
);
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}); });
}); });
@@ -308,6 +295,13 @@ pub fn run() {
std::path::Path::new(&cwd).join(path) std::path::Path::new(&cwd).join(path)
}; };
if let Some(resolved_str) = resolved.to_str() { if let Some(resolved_str) = resolved.to_str() {
if resolved.is_file() {
let _ = app.fs_scope().allow_file(&resolved);
if let Some(parent) = resolved.parent() {
let _ = app.fs_scope().allow_directory(parent, true);
}
let _ = asset_scope::allow_external_note_assets(app, &resolved);
}
let _ = app.emit("open-file", resolved_str.to_string()); let _ = app.emit("open-file", resolved_str.to_string());
} }
} }
@@ -349,9 +343,9 @@ pub fn run() {
let _ = window.hide(); let _ = window.hide();
} }
} }
tauri::WindowEvent::Destroyed => { tauri::WindowEvent::Destroyed
// When main window is destroyed, close all note windows // When main window is destroyed, close all note windows
if window.label() == "main" { if window.label() == "main" => {
let app = window.app_handle(); let app = window.app_handle();
for (label, win) in app.webview_windows() { for (label, win) in app.webview_windows() {
if label.starts_with("note-") { if label.starts_with("note-") {
@@ -359,7 +353,6 @@ pub fn run() {
} }
} }
} }
}
_ => {} _ => {}
} }
}); });
+1 -1
View File
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
app_lib::run(); app_lib::run();
} }
+14 -8
View File
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(vault_path.as_bytes()); hasher.update(vault_path.as_bytes());
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect(); let key: String = hasher.finalize()[..8]
.iter()
.map(|b| format!("{:02x}", b))
.collect();
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key)) dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
} }
@@ -235,7 +238,9 @@ impl SearchIndex {
// before the writer is created, so both indexing and querying use it). // before the writer is created, so both indexing and querying use it).
index.tokenizers().register( index.tokenizers().register(
"cjk", "cjk",
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(), TextAnalyzer::builder(CjkTokenizer)
.filter(LowerCaser)
.build(),
); );
#[cfg(mobile)] #[cfg(mobile)]
@@ -290,7 +295,7 @@ impl SearchIndex {
doc.add_text(self.path_field, &path_str); doc.add_text(self.path_field, &path_str);
doc.add_text(self.title_field, &meta.title); doc.add_text(self.title_field, &meta.title);
doc.add_text(self.body_field, &content); doc.add_text(self.body_field, &content);
doc.add_text(self.tags_field, &meta.tags.join(" ")); doc.add_text(self.tags_field, meta.tags.join(" "));
let _ = writer.add_document(doc); let _ = writer.add_document(doc);
} }
} }
@@ -321,7 +326,7 @@ impl SearchIndex {
doc.add_text(self.path_field, path); doc.add_text(self.path_field, path);
doc.add_text(self.title_field, &meta.title); doc.add_text(self.title_field, &meta.title);
doc.add_text(self.body_field, &content); doc.add_text(self.body_field, &content);
doc.add_text(self.tags_field, &meta.tags.join(" ")); doc.add_text(self.tags_field, meta.tags.join(" "));
let _ = writer.add_document(doc); let _ = writer.add_document(doc);
writer.commit().map_err(|e| e.to_string())?; writer.commit().map_err(|e| e.to_string())?;
@@ -341,7 +346,7 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?; let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher(); let searcher = reader.searcher();
let fields = vec![self.title_field, self.body_field, self.tags_field]; let fields = [self.title_field, self.body_field, self.tags_field];
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a // Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs. // Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
// (For pure-ASCII queries this yields the same lowercased word tokens as before.) // (For pure-ASCII queries this yields the same lowercased word tokens as before.)
@@ -376,9 +381,10 @@ impl SearchIndex {
)); ));
vec![(Occur::Should, exact)] vec![(Occur::Should, exact)]
} else { } else {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new( let prefix: Box<dyn Query> =
vec![Term::from_field_text(field, term)], Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
)); field, term,
)]));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new( let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term), Term::from_field_text(field, term),
1, 1,
+31 -8
View File
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
} }
fn normalize_etag(s: &str) -> String { fn normalize_etag(s: &str) -> String {
s.trim().trim_start_matches("W/").trim_matches('"').to_string() s.trim()
.trim_start_matches("W/")
.trim_matches('"')
.to_string()
} }
/// Percent-encode each path segment, keeping the `/` separators. /// Percent-encode each path segment, keeping the `/` separators.
@@ -265,7 +268,11 @@ impl WebdavClient {
.send() .send()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16())); return Err(format!(
"GET {} failed: HTTP {}",
relpath,
resp.status().as_u16()
));
} }
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec()) Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
} }
@@ -279,7 +286,11 @@ impl WebdavClient {
.send() .send()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16())); return Err(format!(
"PUT {} failed: HTTP {}",
relpath,
resp.status().as_u16()
));
} }
Ok(resp Ok(resp
.headers() .headers()
@@ -453,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
} }
Ok(Event::Text(e)) => { Ok(Event::Text(e)) => {
if cap != Cap::None { if cap != Cap::None {
if let Ok(t) = e.unescape() { if let Ok(decoded) = e.decode() {
buf.push_str(&t); if let Ok(text) = quick_xml::escape::unescape(&decoded) {
buf.push_str(&text);
}
} }
} }
} }
@@ -515,8 +528,14 @@ fn apply_changes(
match (l, r) { match (l, r) {
(Some(lf), Some(re)) => { (Some(lf), Some(re)) => {
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash); let local_changed = match m {
let remote_changed = m.map_or(true, |me| &me.remote_etag != re); Some(entry) => entry.local_hash != lf.hash,
None => true,
};
let remote_changed = match m {
Some(entry) => &entry.remote_etag != re,
None => true,
};
if !local_changed && !remote_changed { if !local_changed && !remote_changed {
new_m.files.insert( new_m.files.insert(
key.clone(), key.clone(),
@@ -669,7 +688,11 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
/// Run a full sync. Mutes the file watcher while applying local writes, then /// Run a full sync. Mutes the file watcher while applying local writes, then
/// rebuilds the search index. Returns a summary of what changed. /// rebuilds the search index. Returns a summary of what changed.
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> { pub fn run_sync(
app: tauri::AppHandle,
vault: String,
cfg: WebdavConfig,
) -> Result<SyncSummary, String> {
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
let state = app.state::<AppState>(); let state = app.state::<AppState>();
+19 -4
View File
@@ -124,6 +124,8 @@ pub struct AppConfig {
pub accent_color: Option<String>, pub accent_color: Option<String>,
#[serde(default)] #[serde(default)]
pub font_size: Option<u32>, pub font_size: Option<u32>,
#[serde(default = "default_true")]
pub scroll_to_change_font_size: bool,
#[serde(default)] #[serde(default)]
pub font_family: Option<String>, pub font_family: Option<String>,
#[serde(default)] #[serde(default)]
@@ -296,6 +298,7 @@ impl Default for AppConfig {
system_dark_theme: default_system_dark_theme(), system_dark_theme: default_system_dark_theme(),
accent_color: None, accent_color: None,
font_size: None, font_size: None,
scroll_to_change_font_size: true,
font_family: None, font_family: None,
line_height: None, line_height: None,
ui_scale: None, ui_scale: None,
@@ -549,12 +552,24 @@ mod startup_view_tests {
assert!(!config.show_note_switcher); assert!(!config.show_note_switcher);
let mut value = serde_json::to_value(config).unwrap(); let mut value = serde_json::to_value(config).unwrap();
value value.as_object_mut().unwrap().remove("show_note_switcher");
.as_object_mut()
.unwrap()
.remove("show_note_switcher");
let config: AppConfig = serde_json::from_value(value).unwrap(); let config: AppConfig = serde_json::from_value(value).unwrap();
assert!(!config.show_note_switcher); assert!(!config.show_note_switcher);
} }
#[test]
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
let config = AppConfig::default();
assert!(config.scroll_to_change_font_size);
let mut value = serde_json::to_value(config).unwrap();
value
.as_object_mut()
.unwrap()
.remove("scroll_to_change_font_size");
let config: AppConfig = serde_json::from_value(value).unwrap();
assert!(config.scroll_to_change_font_size);
}
} }
+5 -5
View File
@@ -108,7 +108,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
"[{}]", "[{}]",
meta.tags meta.tags
.iter() .iter()
.map(|t| format!("{}", t)) .map(|t| t.to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
) )
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
chars.next(); // skip '[' chars.next(); // skip '['
let mut depth = 1; let mut depth = 1;
// Skip alt text // Skip alt text
while let Some(c) = chars.next() { for c in chars.by_ref() {
if c == '[' { if c == '[' {
depth += 1; depth += 1;
} }
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') { if chars.peek() == Some(&'(') {
chars.next(); chars.next();
let mut depth = 1; let mut depth = 1;
while let Some(c) = chars.next() { for c in chars.by_ref() {
if c == '(' { if c == '(' {
depth += 1; depth += 1;
} }
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if ch == '[' { if ch == '[' {
let mut link_text = String::new(); let mut link_text = String::new();
let mut depth = 1; let mut depth = 1;
while let Some(c) = chars.next() { for c in chars.by_ref() {
if c == '[' { if c == '[' {
depth += 1; depth += 1;
} }
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') { if chars.peek() == Some(&'(') {
chars.next(); chars.next();
let mut depth = 1; let mut depth = 1;
while let Some(c) = chars.next() { for c in chars.by_ref() {
if c == '(' { if c == '(' {
depth += 1; depth += 1;
} }
+52 -38
View File
@@ -112,13 +112,15 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf" "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
); );
if is_embeddable { if is_embeddable {
let alt = if is_dimension_spec(alt_param) { "" } else { alt_param }; let alt = if is_dimension_spec(alt_param) {
""
} else {
alt_param
};
result.links_converted += 1; result.links_converted += 1;
format!("![{}]({})", alt, link_target) format!("![{}]({})", alt, link_target)
} else { } else {
let display = if alt_param.is_empty() { let display = if alt_param.is_empty() || is_dimension_spec(alt_param) {
file_part.rsplit('/').next().unwrap_or(file_part)
} else if is_dimension_spec(alt_param) {
file_part.rsplit('/').next().unwrap_or(file_part) file_part.rsplit('/').next().unwrap_or(file_part)
} else { } else {
alt_param alt_param
@@ -137,7 +139,11 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
let (file_part, anchor) = split_anchor(note_ref); let (file_part, anchor) = split_anchor(note_ref);
if file_part.is_empty() { if file_part.is_empty() {
if let Some(a) = anchor { if let Some(a) = anchor {
let display = if display_param.is_empty() { a } else { display_param }; let display = if display_param.is_empty() {
a
} else {
display_param
};
result.links_converted += 1; result.links_converted += 1;
return format!("[{}](#{})", display, a); return format!("[{}](#{})", display, a);
} }
@@ -165,8 +171,22 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
.to_string(); .to_string();
content = after_links; content = after_links;
content = fix_md_image_refs(&content, &md_img_re, vault, note_dir, &file_index, &mut result.links_converted); content = fix_md_image_refs(
content = fix_md_link_refs(&content, &md_link_re, vault, note_dir, &file_index, &mut result.links_converted); &content,
&md_img_re,
vault,
note_dir,
&file_index,
&mut result.links_converted,
);
content = fix_md_link_refs(
&content,
&md_link_re,
vault,
note_dir,
&file_index,
&mut result.links_converted,
);
if result.links_converted > links_before { if result.links_converted > links_before {
changed = true; changed = true;
@@ -231,10 +251,8 @@ fn rename_deprecated_properties(raw: &str) -> String {
mapping.insert(new_key, val); mapping.insert(new_key, val);
renamed = true; renamed = true;
} }
} else { } else if mapping.remove(&old_key).is_some() {
if mapping.remove(&old_key).is_some() { renamed = true;
renamed = true;
}
} }
} }
@@ -267,7 +285,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
let tags = normalize_tags(&mapping); let tags = normalize_tags(&mapping);
let title = mapping let title = mapping
.get(&serde_yaml::Value::String("title".into())) .get(serde_yaml::Value::String("title".into()))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|| { .unwrap_or_else(|| {
@@ -283,7 +301,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
}); });
let id = mapping let id = mapping
.get(&serde_yaml::Value::String("id".into())) .get(serde_yaml::Value::String("id".into()))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
@@ -293,9 +311,9 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
.iter() .iter()
.find_map(|key| { .find_map(|key| {
mapping mapping
.get(&serde_yaml::Value::String((*key).into())) .get(serde_yaml::Value::String((*key).into()))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(|s| frontmatter::parse_date_flexible(s)) .and_then(frontmatter::parse_date_flexible)
}) })
.or_else(|| file_created(path)) .or_else(|| file_created(path))
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
@@ -304,15 +322,15 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
.iter() .iter()
.find_map(|key| { .find_map(|key| {
mapping mapping
.get(&serde_yaml::Value::String((*key).into())) .get(serde_yaml::Value::String((*key).into()))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(|s| frontmatter::parse_date_flexible(s)) .and_then(frontmatter::parse_date_flexible)
}) })
.or_else(|| file_modified(path)) .or_else(|| file_modified(path))
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
let pinned = mapping let pinned = mapping
.get(&serde_yaml::Value::String("pinned".into())) .get(serde_yaml::Value::String("pinned".into()))
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
@@ -332,7 +350,7 @@ fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec<String> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for key in &["tags", "tag"] { for key in &["tags", "tag"] {
if let Some(val) = mapping.get(&serde_yaml::Value::String((*key).into())) { if let Some(val) = mapping.get(serde_yaml::Value::String((*key).into())) {
for raw in yaml_value_to_strings(val) { for raw in yaml_value_to_strings(val) {
let cleaned = raw.trim().trim_start_matches('#').trim().to_string(); let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) { if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
@@ -389,9 +407,7 @@ fn file_created(path: &Path) -> Option<chrono::DateTime<Utc>> {
.ok() .ok()
.and_then(|m| m.created().ok()) .and_then(|m| m.created().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| { .and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
})
} }
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> { fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
@@ -399,9 +415,7 @@ fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
.ok() .ok()
.and_then(|m| m.modified().ok()) .and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| { .and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
})
} }
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String { fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String {
@@ -513,7 +527,9 @@ fn fix_md_link_refs(
let display = &caps[1]; let display = &caps[1];
let href = &caps[2]; let href = &caps[2];
let decoded = percent_decode(href); let decoded = percent_decode(href);
if decoded.starts_with("http") || decoded.starts_with('/') || decoded.starts_with("data:") if decoded.starts_with("http")
|| decoded.starts_with('/')
|| decoded.starts_with("data:")
|| decoded.starts_with('#') || decoded.starts_with('#')
{ {
return format!("[{}]({})", display, href); return format!("[{}]({})", display, href);
@@ -549,9 +565,7 @@ fn fix_md_link_refs(
.to_string() .to_string()
} }
fn move_attachments( fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
vault: &Path,
) -> Result<HashMap<String, String>, String> {
let attachments_dir = vault.join(".helixnotes").join("attachments"); let attachments_dir = vault.join(".helixnotes").join("attachments");
let _ = std::fs::create_dir_all(&attachments_dir); let _ = std::fs::create_dir_all(&attachments_dir);
@@ -601,10 +615,7 @@ fn move_attachments(
Ok(moved) Ok(moved)
} }
fn rewrite_attachment_refs( fn rewrite_attachment_refs(vault: &Path, moved: &HashMap<String, String>) -> Result<(), String> {
vault: &Path,
moved: &HashMap<String, String>,
) -> Result<(), String> {
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?; let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
let md_files: Vec<_> = walkdir::WalkDir::new(vault) let md_files: Vec<_> = walkdir::WalkDir::new(vault)
@@ -686,7 +697,7 @@ fn cleanup_empty_dirs(root: &Path) {
.map(|e| e.path().to_path_buf()) .map(|e| e.path().to_path_buf())
.collect(); .collect();
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count())); dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
for dir in dirs { for dir in dirs {
let dir_str = dir.to_string_lossy(); let dir_str = dir.to_string_lossy();
@@ -719,7 +730,8 @@ fn is_dimension_spec(s: &str) -> bool {
if s.is_empty() { if s.is_empty() {
return false; return false;
} }
s.chars().all(|c| c.is_ascii_digit() || c == 'x' || c == 'X') s.chars()
.all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
} }
fn resolve_wiki_ref(file_index: &HashMap<String, String>, reference: &str) -> String { fn resolve_wiki_ref(file_index: &HashMap<String, String>, reference: &str) -> String {
@@ -907,7 +919,10 @@ mod tests {
extract_heading_title("# My Title\n\nBody"), extract_heading_title("# My Title\n\nBody"),
Some("My Title".to_string()) Some("My Title".to_string())
); );
assert_eq!(extract_heading_title("\n\n# Spaced Title"), Some("Spaced Title".to_string())); assert_eq!(
extract_heading_title("\n\n# Spaced Title"),
Some("Spaced Title".to_string())
);
assert_eq!(extract_heading_title("Body without heading"), None); assert_eq!(extract_heading_title("Body without heading"), None);
assert_eq!(extract_heading_title("## Subheading"), None); assert_eq!(extract_heading_title("## Subheading"), None);
assert_eq!(extract_heading_title(""), None); assert_eq!(extract_heading_title(""), None);
@@ -947,8 +962,7 @@ mod tests {
#[test] #[test]
fn test_normalize_tags_strips_hash() { fn test_normalize_tags_strips_hash() {
let mapping: serde_yaml::Mapping = let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
let tags = normalize_tags(&mapping); let tags = normalize_tags(&mapping);
assert_eq!(tags, vec!["hashed"]); assert_eq!(tags, vec!["hashed"]);
} }
+568 -108
View File
@@ -1,10 +1,15 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState}; use crate::types::{
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents,
TrashNotebookEntry, VaultState,
};
use crate::vault::frontmatter; use crate::vault::frontmatter;
use chrono::{DateTime, Local, Locale, Utc}; use chrono::{DateTime, Local, Locale, Utc};
use rayon::prelude::*; use rayon::prelude::*;
use std::cmp::Ordering;
use std::collections::HashSet;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use uuid::Uuid; use uuid::Uuid;
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -12,6 +17,107 @@ pub fn helixnotes_dir(vault_path: &str) -> PathBuf {
Path::new(vault_path).join(".helixnotes") Path::new(vault_path).join(".helixnotes")
} }
fn canonicalize_path(path: &Path, label: &str) -> Result<PathBuf, String> {
fs::canonicalize(path).map_err(|error| format!("Invalid {label}: {error}"))
}
fn ensure_vault_content_path(
vault_path: &str,
requested_path: &Path,
allow_root: bool,
) -> Result<PathBuf, String> {
let vault = canonicalize_path(Path::new(vault_path), "vault path")?;
let requested = canonicalize_path(requested_path, "vault item path")?;
let metadata = vault.join(".helixnotes");
if !requested.starts_with(&vault)
|| requested.starts_with(&metadata)
|| (!allow_root && requested == vault)
{
return Err("Path must stay inside the active vault".to_string());
}
Ok(requested_path.to_path_buf())
}
fn ensure_vault_content_dir(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
let requested = ensure_vault_content_path(vault_path, requested_path, true)?;
if !requested.is_dir() {
return Err("Vault destination is not a directory".to_string());
}
Ok(requested)
}
fn ensure_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
if !requested.is_file()
|| requested
.extension()
.and_then(|extension| extension.to_str())
!= Some("md")
{
return Err("Note path must point to a Markdown file".to_string());
}
Ok(requested)
}
fn ensure_readable_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
if let Ok(note) = ensure_note_path(vault_path, requested_path) {
return Ok(note);
}
let trashed_note = ensure_trash_entry(vault_path, requested_path)?;
if !trashed_note.is_file()
|| trashed_note
.extension()
.and_then(|extension| extension.to_str())
!= Some("md")
{
return Err("Note path must point to a Markdown file".to_string());
}
Ok(trashed_note)
}
fn ensure_notebook_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
if !requested.is_dir() {
return Err("Notebook path must point to a directory".to_string());
}
Ok(requested)
}
fn ensure_trash_entry(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
let trash = canonicalize_path(&helixnotes_dir(vault_path).join("trash"), "trash path")?;
let requested = canonicalize_path(requested_path, "trash item path")?;
if requested == trash || !requested.starts_with(&trash) {
return Err("Path must be an item inside the active vault trash".to_string());
}
Ok(requested_path.to_path_buf())
}
fn safe_relative_path(path: &str) -> Result<&Path, String> {
let relative = Path::new(path);
if relative.as_os_str().is_empty()
|| !relative
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
{
return Err("Path must be a safe vault-relative path".to_string());
}
Ok(relative)
}
fn safe_child_name(name: &str) -> Result<&str, String> {
let mut components = Path::new(name).components();
if name.trim().is_empty()
|| !matches!(components.next(), Some(Component::Normal(_)))
|| components.next().is_some()
{
return Err("Name must not contain path separators".to_string());
}
Ok(name)
}
pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> { pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
let hn_dir = helixnotes_dir(vault_path); let hn_dir = helixnotes_dir(vault_path);
fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?; fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?;
@@ -43,6 +149,59 @@ pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
Ok(()) Ok(())
} }
fn digit_run(value: &[u8], start: usize) -> (usize, &[u8]) {
let mut end = start;
while end < value.len() && value[end].is_ascii_digit() {
end += 1;
}
let mut significant = start;
while significant + 1 < end && value[significant] == b'0' {
significant += 1;
}
(end, &value[significant..end])
}
fn compare_natural_names(left: &str, right: &str) -> Ordering {
let left_lower = left.to_lowercase();
let right_lower = right.to_lowercase();
let left = left_lower.as_bytes();
let right = right_lower.as_bytes();
let (mut left_index, mut right_index) = (0, 0);
let mut leading_zero_order = Ordering::Equal;
while left_index < left.len() && right_index < right.len() {
if left[left_index].is_ascii_digit() && right[right_index].is_ascii_digit() {
let (left_end, left_number) = digit_run(left, left_index);
let (right_end, right_number) = digit_run(right, right_index);
let number_order = left_number
.len()
.cmp(&right_number.len())
.then_with(|| left_number.cmp(right_number));
if number_order != Ordering::Equal {
return number_order;
}
if leading_zero_order == Ordering::Equal {
leading_zero_order = (left_end - left_index).cmp(&(right_end - right_index));
}
left_index = left_end;
right_index = right_end;
} else {
let character_order = left[left_index].cmp(&right[right_index]);
if character_order != Ordering::Equal {
return character_order;
}
left_index += 1;
right_index += 1;
}
}
match (left_index == left.len(), right_index == right.len()) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => leading_zero_order,
}
}
pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> { pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
let root = Path::new(vault_path); let root = Path::new(vault_path);
if !root.exists() { if !root.exists() {
@@ -69,10 +228,10 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.collect(); .collect();
dirs.sort_by(|a, b| { dirs.sort_by(|a, b| {
a.file_name() compare_natural_names(
.to_string_lossy() &a.file_name().to_string_lossy(),
.to_lowercase() &b.file_name().to_string_lossy(),
.cmp(&b.file_name().to_string_lossy().to_lowercase()) )
}); });
// Scan sibling notebooks in parallel: each subtree is independent, so // Scan sibling notebooks in parallel: each subtree is independent, so
@@ -82,7 +241,11 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
paths paths
.par_iter() .par_iter()
.map(|path| { .map(|path| {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let relative = path let relative = path
.strip_prefix(root) .strip_prefix(root)
.unwrap_or(path) .unwrap_or(path)
@@ -123,17 +286,21 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
} }
subdirs.sort_by(|a, b| { subdirs.sort_by(|a, b| {
a.file_name() compare_natural_names(
.to_string_lossy() &a.file_name().to_string_lossy(),
.to_lowercase() &b.file_name().to_string_lossy(),
.cmp(&b.file_name().to_string_lossy().to_lowercase()) )
}); });
let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect(); let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect();
let entries: Vec<NotebookEntry> = paths let entries: Vec<NotebookEntry> = paths
.par_iter() .par_iter()
.map(|path| { .map(|path| {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let relative = path let relative = path
.strip_prefix(root) .strip_prefix(root)
.unwrap_or(path) .unwrap_or(path)
@@ -185,9 +352,15 @@ pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> { pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
let scan_path = notebook_path.unwrap_or(vault_path); let scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path); let root = Path::new(scan_path);
ensure_vault_content_dir(vault_path, root)?;
let vault_root = Path::new(vault_path); let vault_root = Path::new(vault_path);
log::info!("scan_notes: vault={}, scan={}, exists={}", vault_path, scan_path, root.exists()); log::info!(
"scan_notes: vault={}, scan={}, exists={}",
vault_path,
scan_path,
root.exists()
);
if !root.exists() { if !root.exists() {
return Err("Path does not exist".to_string()); return Err("Path does not exist".to_string());
@@ -227,7 +400,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
.collect(); .collect();
log::info!("scan_notes: mobile scan found {} notes", notes.len()); log::info!("scan_notes: mobile scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
return Ok(notes); return Ok(notes);
} }
@@ -243,7 +416,9 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
} else { } else {
WalkDir::new(root) WalkDir::new(root)
.into_iter() .into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path))) .filter_entry(|e| {
!is_hidden(e.path()) && !e.path().starts_with(helixnotes_dir(vault_path))
})
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.map(|e| e.path().to_path_buf()) .map(|e| e.path().to_path_buf())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md")) .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
@@ -255,7 +430,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
.filter_map(|path| read_note_entry_fast(path, vault_root).ok()) .filter_map(|path| read_note_entry_fast(path, vault_root).ok())
.collect(); .collect();
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
Ok(notes) Ok(notes)
} }
} }
@@ -365,8 +540,18 @@ fn read_note_entry_from_str(
}) })
} }
pub fn read_note(path: &str) -> Result<NoteContent, String> { pub fn read_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
let p = Path::new(path); let validated = ensure_readable_note_path(vault_path, Path::new(path))?;
read_note_content(&validated, path)
}
pub fn read_vault_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
let validated = ensure_note_path(vault_path, Path::new(path))?;
read_note_content(&validated, path)
}
fn read_note_content(validated: &Path, reported_path: &str) -> Result<NoteContent, String> {
let p = validated;
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?; let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p let filename = p
.file_name() .file_name()
@@ -396,14 +581,15 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
} }
Ok(NoteContent { Ok(NoteContent {
path: path.to_string(), path: reported_path.to_string(),
meta, meta,
content, content,
raw, raw,
}) })
} }
pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> { pub fn save_note(vault_path: &str, path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
let path = ensure_note_path(vault_path, Path::new(path))?;
let mut updated_meta = meta.clone(); let mut updated_meta = meta.clone();
updated_meta.modified = Utc::now(); updated_meta.modified = Utc::now();
@@ -413,7 +599,7 @@ pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String>
} }
// Read existing file to preserve unknown frontmatter fields // Read existing file to preserve unknown frontmatter fields
let existing = fs::read_to_string(path).unwrap_or_default(); let existing = fs::read_to_string(&path).unwrap_or_default();
let raw = if existing.is_empty() { let raw = if existing.is_empty() {
frontmatter::update_note_raw(&updated_meta, body) frontmatter::update_note_raw(&updated_meta, body)
} else { } else {
@@ -429,14 +615,11 @@ pub fn create_note(
notebook_relative: Option<&str>, notebook_relative: Option<&str>,
title: &str, title: &str,
) -> Result<NoteEntry, String> { ) -> Result<NoteEntry, String> {
let dir = match notebook_relative { let requested_dir = match notebook_relative {
Some(rel) => Path::new(vault_path).join(rel), Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
None => PathBuf::from(vault_path), None => PathBuf::from(vault_path),
}; };
let dir = ensure_vault_content_dir(vault_path, &requested_dir)?;
if !dir.exists() {
return Err("Notebook directory does not exist".to_string());
}
let filename = sanitize_filename(title); let filename = sanitize_filename(title);
let mut file_path = dir.join(format!("{}.md", filename)); let mut file_path = dir.join(format!("{}.md", filename));
@@ -477,10 +660,8 @@ pub fn create_note(
} }
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> { pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
let src = Path::new(path); let validated = ensure_note_path(vault_path, Path::new(path))?;
if !src.is_file() { let src = validated.as_path();
return Err("Note does not exist".to_string());
}
let parent = src let parent = src
.parent() .parent()
@@ -614,7 +795,9 @@ pub fn create_daily_note(
"eu" => target_date.format("%d/%m/%Y").to_string(), "eu" => target_date.format("%d/%m/%Y").to_string(),
_ => { _ => {
let locale = get_system_locale(); let locale = get_system_locale();
target_date.format_localized("%B %d, %Y", locale).to_string() target_date
.format_localized("%B %d, %Y", locale)
.to_string()
} }
}; };
@@ -661,12 +844,12 @@ pub fn create_notebook(
parent_relative: Option<&str>, parent_relative: Option<&str>,
name: &str, name: &str,
) -> Result<NotebookEntry, String> { ) -> Result<NotebookEntry, String> {
let parent = match parent_relative { let requested_parent = match parent_relative {
Some(rel) => Path::new(vault_path).join(rel), Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
None => PathBuf::from(vault_path), None => PathBuf::from(vault_path),
}; };
let parent = ensure_vault_content_dir(vault_path, &requested_parent)?;
let dir_path = parent.join(name); let dir_path = parent.join(safe_child_name(name)?);
if dir_path.exists() { if dir_path.exists() {
return Err("Notebook already exists".to_string()); return Err("Notebook already exists".to_string());
} }
@@ -690,10 +873,8 @@ pub fn create_notebook(
} }
pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> { pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
let src = Path::new(note_path); let validated = ensure_note_path(vault_path, Path::new(note_path))?;
if !src.exists() { let src = validated.as_path();
return Err("Note does not exist".to_string());
}
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?; fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
@@ -713,10 +894,8 @@ pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
} }
pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> { pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> {
let src = Path::new(notebook_path); let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
if !src.exists() { let src = validated.as_path();
return Err("Notebook does not exist".to_string());
}
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?; fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
@@ -745,10 +924,8 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
} }
pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> { pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
let src = Path::new(path); let validated = ensure_note_path(vault_path, Path::new(path))?;
if !src.exists() { let src = validated.as_path();
return Err("Note does not exist".to_string());
}
// Read old title before renaming // Read old title before renaming
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?; let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
@@ -781,7 +958,13 @@ pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<Stri
let new_path_str = new_path.to_string_lossy().to_string(); let new_path_str = new_path.to_string_lossy().to_string();
// Update wikilinks in other notes that reference this note // Update wikilinks in other notes that reference this note
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title); update_wikilinks_after_rename(
vault_path,
&old_path_str,
&new_path_str,
&old_title,
new_title,
);
Ok(new_path_str) Ok(new_path_str)
} }
@@ -840,11 +1023,17 @@ fn update_wikilinks_after_rename(
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ {
let path = entry.path(); let path = entry.path();
if !path.is_file() { continue; } if !path.is_file() {
continue;
}
let path_str = path.to_string_lossy(); let path_str = path.to_string_lossy();
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
// Skip the renamed note itself // Skip the renamed note itself
if *path_str == *new_path { continue; } if *path_str == *new_path {
continue;
}
let content = match fs::read_to_string(path) { let content = match fs::read_to_string(path) {
Ok(c) => c, Ok(c) => c,
@@ -860,22 +1049,13 @@ fn update_wikilinks_after_rename(
// If another note shares the same title, these would be ambiguous. // If another note shares the same title, these would be ambiguous.
if old_title != new_title && title_is_unique { if old_title != new_title && title_is_unique {
// 1. Short title ref: [[Old Title]] → [[New Title]] // 1. Short title ref: [[Old Title]] → [[New Title]]
result = result.replace( result = result.replace(&format!("[[{}]]", old_title), &format!("[[{}]]", new_title));
&format!("[[{}]]", old_title),
&format!("[[{}]]", new_title),
);
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]] // 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
result = result.replace( result = result.replace(&format!("[[{}|", old_title), &format!("[[{}|", new_title));
&format!("[[{}|", old_title),
&format!("[[{}|", new_title),
);
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]] // 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
result = result.replace( result = result.replace(&format!("|{}]]", old_title), &format!("|{}]]", new_title));
&format!("|{}]]", old_title),
&format!("|{}]]", new_title),
);
} }
// Path-based rules are always safe (paths are unique). // Path-based rules are always safe (paths are unique).
@@ -908,13 +1088,11 @@ fn update_wikilinks_after_rename(
} }
} }
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> { pub fn rename_notebook(vault_path: &str, path: &str, new_name: &str) -> Result<String, String> {
let src = Path::new(path); let validated = ensure_notebook_path(vault_path, Path::new(path))?;
if !src.exists() { let src = validated.as_path();
return Err("Notebook does not exist".to_string());
}
let new_path = src.parent().unwrap().join(new_name); let new_path = src.parent().unwrap().join(safe_child_name(new_name)?);
if new_path.exists() { if new_path.exists() {
return Err("A notebook with that name already exists".to_string()); return Err("A notebook with that name already exists".to_string());
} }
@@ -923,13 +1101,12 @@ pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
Ok(new_path.to_string_lossy().to_string()) Ok(new_path.to_string_lossy().to_string())
} }
pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String> { pub fn move_note(vault_path: &str, note_path: &str, dest_notebook: &str) -> Result<String, String> {
let src = Path::new(note_path); let validated = ensure_note_path(vault_path, Path::new(note_path))?;
if !src.exists() { let src = validated.as_path();
return Err("Note does not exist".to_string());
}
let dest_dir = Path::new(dest_notebook); let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_notebook))?;
let dest_dir = validated_dest.as_path();
if !dest_dir.is_dir() { if !dest_dir.is_dir() {
return Err("Destination notebook does not exist".to_string()); return Err("Destination notebook does not exist".to_string());
} }
@@ -941,13 +1118,16 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String>
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
} }
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> { pub fn move_notebook(
let src = Path::new(notebook_path); vault_path: &str,
if !src.exists() || !src.is_dir() { notebook_path: &str,
return Err("Notebook does not exist".to_string()); dest_parent: &str,
} ) -> Result<String, String> {
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
let src = validated.as_path();
let dest_parent_path = Path::new(dest_parent); let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_parent))?;
let dest_parent_path = validated_dest.as_path();
if !dest_parent_path.is_dir() { if !dest_parent_path.is_dir() {
return Err("Destination does not exist".to_string()); return Err("Destination does not exist".to_string());
} }
@@ -990,7 +1170,10 @@ fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> { pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() { if !trash_dir.exists() {
return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() }); return Ok(TrashContents {
notes: Vec::new(),
notebooks: Vec::new(),
});
} }
let vault_root = Path::new(vault_path); let vault_root = Path::new(vault_path);
@@ -1009,7 +1192,10 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
.min_depth(1) .min_depth(1)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md")) .filter(|e| {
e.path().is_file()
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
.count(); .count();
let dirname = path.file_name().unwrap_or_default().to_string_lossy(); let dirname = path.file_name().unwrap_or_default().to_string_lossy();
// Strip timestamp prefix to get original notebook name // Strip timestamp prefix to get original notebook name
@@ -1022,7 +1208,7 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
}; };
let modified = fs::metadata(&path) let modified = fs::metadata(&path)
.and_then(|m| m.modified()) .and_then(|m| m.modified())
.map(|t| DateTime::<Utc>::from(t)) .map(DateTime::<Utc>::from)
.unwrap_or_else(|_| Utc::now()); .unwrap_or_else(|_| Utc::now());
notebooks.push(TrashNotebookEntry { notebooks.push(TrashNotebookEntry {
name, name,
@@ -1033,8 +1219,8 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
} }
} }
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified)); notebooks.sort_by_key(|notebook| std::cmp::Reverse(notebook.modified));
Ok(TrashContents { notes, notebooks }) Ok(TrashContents { notes, notebooks })
} }
@@ -1043,15 +1229,17 @@ pub fn restore_note(
trash_path: &str, trash_path: &str,
dest_notebook: Option<&str>, dest_notebook: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let src = Path::new(trash_path); let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
if !src.exists() { let src = validated.as_path();
if !src.is_file() {
return Err("Trashed note does not exist".to_string()); return Err("Trashed note does not exist".to_string());
} }
let dest_dir = match dest_notebook { let requested_dest = match dest_notebook {
Some(nb) => PathBuf::from(nb), Some(nb) => PathBuf::from(nb),
None => PathBuf::from(vault_path), None => PathBuf::from(vault_path),
}; };
let dest_dir = ensure_vault_content_dir(vault_path, &requested_dest)?;
// Strip timestamp prefix from trash filename (17-char with millis or 14-char legacy) // Strip timestamp prefix from trash filename (17-char with millis or 14-char legacy)
let filename = src.file_name().unwrap_or_default().to_string_lossy(); let filename = src.file_name().unwrap_or_default().to_string_lossy();
@@ -1074,15 +1262,18 @@ pub fn restore_note(
} }
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> { pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
let src = Path::new(trash_path); let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
if !src.exists() || !src.is_dir() { let src = validated.as_path();
if !src.is_dir() {
return Err("Trashed notebook does not exist".to_string()); return Err("Trashed notebook does not exist".to_string());
} }
let dirname = src.file_name().unwrap_or_default().to_string_lossy(); let dirname = src.file_name().unwrap_or_default().to_string_lossy();
// Try to read original path from sidecar .meta file // Try to read original path from sidecar .meta file
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname)); let meta_path = src
.with_extension("")
.with_file_name(format!("{}.meta", dirname));
let relative = if let Ok(original) = fs::read_to_string(&meta_path) { let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
original original
} else { } else {
@@ -1098,7 +1289,7 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
name.to_string() name.to_string()
}; };
let dest = Path::new(vault_path).join(&relative); let dest = Path::new(vault_path).join(safe_relative_path(&relative)?);
// Recreate parent directories if needed // Recreate parent directories if needed
if let Some(parent) = dest.parent() { if let Some(parent) = dest.parent() {
@@ -1112,7 +1303,8 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
} }
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> { pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
let p = Path::new(path); let validated = ensure_trash_entry(vault_path, Path::new(path))?;
let p = validated.as_path();
let parent = p.parent().map(|pp| pp.to_path_buf()); let parent = p.parent().map(|pp| pp.to_path_buf());
if p.is_dir() { if p.is_dir() {
fs::remove_dir_all(p).map_err(|e| e.to_string())?; fs::remove_dir_all(p).map_err(|e| e.to_string())?;
@@ -1214,16 +1406,25 @@ pub fn save_attachment(vault_path: &str, name: &str, data: &[u8]) -> Result<Stri
Ok(relative) Ok(relative)
} }
pub(crate) fn normalize_notebook_icon_key(path: &str) -> String {
path.replace('\\', "/")
}
pub fn load_notebook_icons( pub fn load_notebook_icons(
vault_path: &str, vault_path: &str,
) -> Result<std::collections::HashMap<String, String>, String> { ) -> Result<std::collections::HashMap<String, String>, String> {
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json"); let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
if icons_path.exists() { if !icons_path.exists() {
let data = fs::read_to_string(&icons_path).map_err(|e| e.to_string())?; return Ok(std::collections::HashMap::new());
serde_json::from_str(&data).map_err(|e| e.to_string())
} else {
Ok(std::collections::HashMap::new())
} }
let data = fs::read_to_string(&icons_path).map_err(|e| e.to_string())?;
let icons: std::collections::HashMap<String, String> =
serde_json::from_str(&data).map_err(|e| e.to_string())?;
Ok(icons
.into_iter()
.map(|(path, icon)| (normalize_notebook_icon_key(&path), icon))
.collect())
} }
pub fn set_notebook_icon( pub fn set_notebook_icon(
@@ -1232,12 +1433,13 @@ pub fn set_notebook_icon(
icon_relative: Option<&str>, icon_relative: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut icons = load_notebook_icons(vault_path)?; let mut icons = load_notebook_icons(vault_path)?;
let notebook_key = normalize_notebook_icon_key(notebook_relative);
match icon_relative { match icon_relative {
Some(icon) => { Some(icon) => {
icons.insert(notebook_relative.to_string(), icon.to_string()); icons.insert(notebook_key, icon.to_string());
} }
None => { None => {
icons.remove(notebook_relative); icons.remove(&notebook_key);
} }
} }
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json"); let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
@@ -1279,6 +1481,59 @@ pub fn remove_quick_access(vault_path: &str, note_relative: &str) -> Result<(),
Ok(()) Ok(())
} }
const NOTE_SWITCHER_RECENT_LIMIT: usize = 6;
pub fn get_note_switcher_titles(
vault_path: &str,
recent_paths: &[String],
) -> Result<Vec<NoteTitleEntry>, String> {
let vault_root = Path::new(vault_path);
if !vault_root.is_dir() {
return Err("Vault path does not exist".to_string());
}
let mut seen = HashSet::new();
let mut titles = Vec::with_capacity(NOTE_SWITCHER_RECENT_LIMIT);
for requested_path in recent_paths {
if titles.len() >= NOTE_SWITCHER_RECENT_LIMIT {
break;
}
let path = Path::new(requested_path);
let Ok(relative) = path.strip_prefix(vault_root) else {
continue;
};
let safe_relative = !relative.as_os_str().is_empty()
&& relative.components().all(|component| match component {
Component::Normal(name) => !is_hidden(Path::new(name)),
Component::CurDir => true,
_ => false,
});
if !safe_relative
|| path.extension().and_then(|extension| extension.to_str()) != Some("md")
|| !path.is_file()
{
continue;
}
let relative_path = relative.to_path_buf();
if !seen.insert(relative_path.clone()) {
continue;
}
let Ok(entry) = read_note_entry_fast(path, vault_root) else {
continue;
};
titles.push(NoteTitleEntry {
title: entry.meta.title,
path: relative_path.to_string_lossy().replace('\\', "/"),
});
}
Ok(titles)
}
pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> { pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
let list = load_quick_access(vault_path)?; let list = load_quick_access(vault_path)?;
let vault_root = Path::new(vault_path); let vault_root = Path::new(vault_path);
@@ -1287,7 +1542,7 @@ pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String
for relative in &list { for relative in &list {
let full_path = vault_root.join(relative); let full_path = vault_root.join(relative);
if full_path.exists() { if full_path.exists() {
if let Ok(entry) = read_note_entry(&full_path, vault_root) { if let Ok(entry) = read_note_entry_fast(&full_path, vault_root) {
notes.push(entry); notes.push(entry);
} }
} }
@@ -1309,10 +1564,57 @@ pub fn sanitize_filename(name: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{duplicate_note, helixnotes_dir, load_notebook_icons, set_notebook_icon}; use super::{
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
scan_notebooks, set_notebook_icon,
};
use std::fs; use std::fs;
use uuid::Uuid; use uuid::Uuid;
#[test]
fn compares_numeric_segments_anywhere_in_names() {
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
names.sort_by(|left, right| compare_natural_names(left, right));
assert_eq!(names, ["Class 2a", "Class 2b", "Class 10a", "Class 10b"]);
assert_eq!(
compare_natural_names("Class 02", "Class 2b"),
std::cmp::Ordering::Less
);
}
#[test]
fn sorts_notebooks_with_numeric_names_naturally() {
let vault =
std::env::temp_dir().join(format!("helixnotes-natural-sort-test-{}", Uuid::new_v4()));
let nested = vault.join("Classes");
fs::create_dir_all(&nested).unwrap();
let expected = ["5g", "6g", "7g", "8g", "9g", "10g", "11g", "12g"];
for name in ["10g", "11g", "12g", "5g", "6g", "7g", "8g", "9g"] {
fs::create_dir(vault.join(name)).unwrap();
fs::create_dir(nested.join(name)).unwrap();
}
let notebooks = scan_notebooks(&vault.to_string_lossy()).unwrap();
let root_names: Vec<_> = notebooks
.iter()
.filter(|notebook| notebook.name != "Classes")
.map(|notebook| notebook.name.as_str())
.collect();
assert_eq!(root_names, expected);
let nested_names: Vec<_> = notebooks
.iter()
.find(|notebook| notebook.name == "Classes")
.unwrap()
.children
.iter()
.map(|notebook| notebook.name.as_str())
.collect();
assert_eq!(nested_names, expected);
fs::remove_dir_all(vault).unwrap();
}
#[test] #[test]
fn persists_and_removes_builtin_notebook_icons() { fn persists_and_removes_builtin_notebook_icons() {
let vault = let vault =
@@ -1320,19 +1622,177 @@ mod tests {
let vault_path = vault.to_string_lossy(); let vault_path = vault.to_string_lossy();
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap(); fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
set_notebook_icon(&vault_path, "Projects", Some("builtin:briefcase")).unwrap(); set_notebook_icon(&vault_path, r"Projects\Client", Some("builtin:briefcase")).unwrap();
let stored: std::collections::HashMap<String, String> = serde_json::from_str(
&fs::read_to_string(helixnotes_dir(&vault_path).join("notebook_icons.json")).unwrap(),
)
.unwrap();
assert!(stored.contains_key("Projects/Client"));
assert!(!stored.contains_key(r"Projects\Client"));
let icons = load_notebook_icons(&vault_path).unwrap(); let icons = load_notebook_icons(&vault_path).unwrap();
assert_eq!( assert_eq!(
icons.get("Projects").map(String::as_str), icons.get("Projects/Client").map(String::as_str),
Some("builtin:briefcase") Some("builtin:briefcase")
); );
set_notebook_icon(&vault_path, "Projects", None).unwrap(); set_notebook_icon(&vault_path, "Projects/Client", None).unwrap();
assert!(load_notebook_icons(&vault_path).unwrap().is_empty()); assert!(load_notebook_icons(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap(); fs::remove_dir_all(vault).unwrap();
} }
#[test]
fn normalizes_legacy_notebook_icon_keys_when_loading() {
let vault =
std::env::temp_dir().join(format!("helixnotes-notebook-icon-test-{}", Uuid::new_v4()));
let vault_path = vault.to_string_lossy();
let icons_path = helixnotes_dir(&vault_path).join("notebook_icons.json");
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
fs::write(&icons_path, r#"{"Projects\\Client":"builtin:folder"}"#).unwrap();
let icons = load_notebook_icons(&vault_path).unwrap();
assert_eq!(
icons.get("Projects/Client").map(String::as_str),
Some("builtin:folder")
);
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn loads_only_requested_note_switcher_titles() {
let vault =
std::env::temp_dir().join(format!("helixnotes-note-switcher-test-{}", Uuid::new_v4()));
fs::create_dir_all(&vault).unwrap();
let mut note_paths = Vec::new();
for index in 0..8 {
let path = vault.join(format!("Note {index}.md"));
fs::write(
&path,
format!("---\ntitle: Note {index}\n---\n\nNote {index} body.\n"),
)
.unwrap();
note_paths.push(path);
}
fs::write(
vault.join("Unrelated.md"),
"---\ntitle: Unrelated\n---\n\nMust not be loaded.\n",
)
.unwrap();
let outside =
std::env::temp_dir().join(format!("helixnotes-outside-note-{}.md", Uuid::new_v4()));
fs::write(&outside, "---\ntitle: Outside\n---\n").unwrap();
let requested = vec![
note_paths[0].to_string_lossy().into_owned(),
note_paths[0].to_string_lossy().into_owned(),
vault.join("Missing.md").to_string_lossy().into_owned(),
outside.to_string_lossy().into_owned(),
note_paths[1].to_string_lossy().into_owned(),
note_paths[2].to_string_lossy().into_owned(),
note_paths[3].to_string_lossy().into_owned(),
note_paths[4].to_string_lossy().into_owned(),
note_paths[5].to_string_lossy().into_owned(),
note_paths[6].to_string_lossy().into_owned(),
note_paths[7].to_string_lossy().into_owned(),
];
let vault_path = vault.to_string_lossy();
let titles = get_note_switcher_titles(&vault_path, &requested).unwrap();
assert_eq!(titles.len(), 6);
assert_eq!(
titles
.iter()
.map(|entry| entry.path.as_str())
.collect::<Vec<_>>(),
vec![
"Note 0.md",
"Note 1.md",
"Note 2.md",
"Note 3.md",
"Note 4.md",
"Note 5.md",
]
);
assert!(titles.iter().all(|entry| entry.title != "Unrelated"));
assert!(titles.iter().all(|entry| entry.title != "Outside"));
fs::remove_dir_all(vault).unwrap();
fs::remove_file(outside).unwrap();
}
#[test]
fn reads_markdown_notes_from_trash_without_allowing_external_files() {
let test_root =
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
let vault = test_root.join("vault");
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
let trashed_note = trash.join("20240101000000000_Note.md");
let outside = test_root.join("outside.md");
fs::create_dir_all(&trash).unwrap();
fs::write(&trashed_note, "---\ntitle: Note\n---\n\ntrashed").unwrap();
fs::write(&outside, "outside").unwrap();
assert!(read_note(&vault.to_string_lossy(), &trashed_note.to_string_lossy()).is_ok());
assert!(read_note(&vault.to_string_lossy(), &outside.to_string_lossy()).is_err());
fs::remove_dir_all(test_root).unwrap();
}
#[test]
fn rejects_permanent_deletion_outside_trash() {
let test_root =
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
let vault = test_root.join("vault");
let outside = test_root.join("outside.md");
fs::create_dir_all(helixnotes_dir(&vault.to_string_lossy()).join("trash")).unwrap();
fs::write(&outside, "must survive").unwrap();
let result = permanent_delete(&vault.to_string_lossy(), &outside.to_string_lossy());
assert!(result.is_err());
assert_eq!(fs::read_to_string(&outside).unwrap(), "must survive");
fs::remove_dir_all(test_root).unwrap();
}
#[test]
fn rejects_notebook_creation_outside_vault() {
let test_root =
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
let vault = test_root.join("vault");
fs::create_dir_all(&vault).unwrap();
let result = create_notebook(&vault.to_string_lossy(), Some(".."), "escaped");
assert!(result.is_err());
assert!(!test_root.join("escaped").exists());
fs::remove_dir_all(test_root).unwrap();
}
#[test]
fn rejects_traversal_in_restored_notebook_metadata() {
let test_root =
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
let vault = test_root.join("vault");
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
let trashed_notebook = trash.join("20240101000000000_Notebook");
fs::create_dir_all(&trashed_notebook).unwrap();
fs::write(trash.join("20240101000000000_Notebook.meta"), "../escaped").unwrap();
let result = restore_notebook(
&vault.to_string_lossy(),
&trashed_notebook.to_string_lossy(),
);
assert!(result.is_err());
assert!(trashed_notebook.exists());
assert!(!test_root.join("escaped").exists());
fs::remove_dir_all(test_root).unwrap();
}
#[test] #[test]
fn duplicates_note_content_and_assigns_unique_identity_and_name() { fn duplicates_note_content_and_assigns_unique_identity_and_name() {
let vault = let vault =
@@ -1364,4 +1824,4 @@ mod tests {
fs::remove_dir_all(vault).unwrap(); fs::remove_dir_all(vault).unwrap();
} }
} }
+7 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.3.4", "version": "1.3.5",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
@@ -24,10 +24,14 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost", "csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"] "scope": {
"allow": [],
"deny": [],
"requireLiteralLeadingDot": true
}
} }
} }
}, },
+8
View File
@@ -2,7 +2,15 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "HelixNotes",
"label": "main", "label": "main",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 500,
"resizable": true,
"fullscreen": false,
"decorations": false,
"visible": false "visible": false
} }
] ]
+15 -1
View File
@@ -71,6 +71,10 @@ export async function setFontSize(size: number): Promise<void> {
return invoke("set_font_size", { size }); return invoke("set_font_size", { size });
} }
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
return invoke("set_scroll_to_change_font_size", { enabled });
}
export async function setFontFamily(family: string): Promise<void> { export async function setFontFamily(family: string): Promise<void> {
return invoke("set_font_family", { family }); return invoke("set_font_family", { family });
} }
@@ -179,7 +183,13 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
return invoke("get_all_note_titles"); return invoke("get_all_note_titles");
} }
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> { export async function getNoteSwitcherTitles(
recentPaths: string[],
): Promise<NoteTitleEntry[]> {
return invoke("get_note_switcher_titles", { recentPaths });
}
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number; bidirectional: boolean }[] }> {
return invoke("get_graph_data"); return invoke("get_graph_data");
} }
@@ -358,6 +368,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path }); return invoke("open_file", { path });
} }
export async function revealFile(path: string): Promise<void> {
return invoke("reveal_file", { path });
}
export async function openUrl(url: string): Promise<void> { export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url }); return invoke("open_url", { url });
} }
+9 -4
View File
@@ -81,9 +81,9 @@
return null; return null;
} }
let sidebar: Sidebar; let sidebar = $state<Sidebar>();
let noteList: NoteList; let noteList = $state<NoteList>();
let editor: Editor; let editor = $state<Editor>();
let unlistenFileChange: (() => void) | null = null; let unlistenFileChange: (() => void) | null = null;
async function applyStartupTarget(target: StartupTarget): Promise<boolean> { async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
if (target.mode === 'notebook') { if (target.mode === 'notebook') {
@@ -578,6 +578,11 @@
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
const dismissesAppUi = $showSettings || $showInfo || $focusMode || $showSearch || $showCommandPalette;
if (!dismissesAppUi) return;
e.preventDefault();
e.stopPropagation();
if ($showSettings) $showSettings = false; if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false; else if ($showInfo) $showInfo = false;
else if ($focusMode) $focusMode = false; else if ($focusMode) $focusMode = false;
@@ -829,7 +834,7 @@
<!-- Mobile Header --> <!-- Mobile Header -->
<div class="mobile-header"> <div class="mobile-header">
{#if $mobileView !== 'sidebar'} {#if $mobileView !== 'sidebar'}
<button class="mobile-header-btn" onclick={mobileBack}> <button class="mobile-header-btn" onclick={mobileBack} aria-label="Go back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/> <path d="M15 18l-6-6 6-6"/>
</svg> </svg>
+9 -4
View File
@@ -164,10 +164,15 @@
} }
}); });
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
$showCommandPalette = false;
}
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { if (e.key === 'ArrowDown') {
$showCommandPalette = false;
} else if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1); selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
@@ -194,7 +199,7 @@
{#if $showCommandPalette} {#if $showCommandPalette}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydown={handleKeydown}> <div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> <div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<div class="palette-input-wrapper"> <div class="palette-input-wrapper">
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -20,12 +20,12 @@
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; } interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
interface LabelRect { x: number; y: number; w: number; h: number; } interface LabelRect { x: number; y: number; w: number; h: number; }
let nodes: GraphNode[] = []; let nodes = $state.raw<GraphNode[]>([]);
let edges: GraphEdge[] = []; let edges = $state.raw<GraphEdge[]>([]);
let nodeIndexMap: Map<string, number> = new Map(); let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set(); let connectedSet: Set<number> = new Set();
let nodeDegree: number[] = []; let nodeDegree: number[] = [];
let searchMatchSet: Set<number> | null = null; let searchMatchSet = $state.raw<Set<number> | null>(null);
let folderColorMap = new Map<string, string>(); let folderColorMap = new Map<string, string>();
let pan = { x: 0, y: 0 }; let pan = { x: 0, y: 0 };
@@ -40,7 +40,7 @@
let hoveredNeighborSet: Set<number> = new Set(); let hoveredNeighborSet: Set<number> = new Set();
let hoveredEdgeSet: Set<number> = new Set(); let hoveredEdgeSet: Set<number> = new Set();
let glowPhase = 0; let glowPhase = 0;
let activeNodeIdx = -1; let activeNodeIdx = $state(-1);
let navigatedFromGraph = false; let navigatedFromGraph = false;
// Combined animation loop // Combined animation loop
+13 -182
View File
@@ -1,10 +1,7 @@
<script lang="ts"> <script lang="ts">
import { showInfo } from '$lib/stores/app'; import { showInfo } from '$lib/stores/app';
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import type { OrphanAttachment } from '$lib/api';
import { openUrl } from '$lib/api'; import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types';
import { import {
ACTIONS, ACTIONS,
keybindings, keybindings,
@@ -84,13 +81,8 @@
if (capturingId === id) stopCapture(); if (capturingId === id) stopCapture();
} }
let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts'); let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...'); let appVersion = $state('...');
let orphans = $state<OrphanAttachment[] | null>(null);
let scanning = $state(false);
let trashing = $state(false);
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0'); getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
@@ -99,49 +91,28 @@
activeTab = isMobile ? 'about' : 'shortcuts'; activeTab = isMobile ? 'about' : 'shortcuts';
} }
function closeFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) close();
}
function handleEscape(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
close();
}
function openLink(url: string) { function openLink(url: string) {
openUrl(url).catch(console.error); openUrl(url).catch(console.error);
} }
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
$effect(() => {
if ($showInfo) {
getVaultStats().then((s) => { stats = s; }).catch(console.error);
} else {
stats = null;
orphans = null;
}
});
async function scanOrphans() {
scanning = true;
try { orphans = await findOrphanedAttachments(); }
catch (e) { console.error(e); }
finally { scanning = false; }
}
async function trashOrphans() {
if (!orphans || orphans.length === 0) return;
trashing = true;
try {
await trashOrphanedAttachments(orphans.map((o) => o.name));
orphans = null;
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
} catch (e) { console.error(e); }
finally { trashing = false; }
}
</script> </script>
{#if $showInfo} {#if $showInfo}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="info-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}> <div class="info-overlay" onclick={closeFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="info-panel">
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
<div class="info-header"> <div class="info-header">
<h2>Info</h2> <h2>Info</h2>
<button class="close-btn" onclick={close} aria-label="Close info"> <button class="close-btn" onclick={close} aria-label="Close info">
@@ -176,49 +147,7 @@
<p class="app-version">v{appVersion}</p> <p class="app-version">v{appVersion}</p>
<p class="app-description">A local markdown note-taking app.</p> <p class="app-description">A local markdown note-taking app.</p>
{#if stats}
<div class="info-stats">
<div class="stat-row">
<span class="stat-label">Notes</span>
<span class="stat-value">{stats.total_notes}</span>
</div>
<div class="stat-row">
<span class="stat-label">Attachments</span>
<span class="stat-value">{stats.total_attachments}</span>
</div>
<div class="stat-row">
<span class="stat-label">Notes size</span>
<span class="stat-value">{formatSize(stats.notes_size)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Attachments size</span>
<span class="stat-value">{formatSize(stats.attachments_size)}</span>
</div>
<div class="stat-row stat-total">
<span class="stat-label">Total vault size</span>
<span class="stat-value">{formatSize(stats.total_size)}</span>
</div>
</div>
{/if}
{#if stats}
<div class="info-cleanup">
{#if orphans === null}
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
{:else if orphans.length === 0}
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
{:else}
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
<div class="cleanup-list">
{#each orphans as o}
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
{/each}
</div>
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
{/if}
</div>
{/if}
<div class="info-credits"> <div class="info-credits">
<p>Created by <strong>Yuri Karamian</strong></p> <p>Created by <strong>Yuri Karamian</strong></p>
@@ -417,39 +346,6 @@
margin-top: 4px; margin-top: 4px;
} }
.info-stats {
width: 100%;
margin-top: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 4px 0;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 7px 16px;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
}
.stat-value {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.stat-total {
border-top: 1px solid var(--border-light);
}
.stat-total .stat-value {
color: var(--accent);
}
.info-credits { .info-credits {
margin-top: 20px; margin-top: 20px;
@@ -481,71 +377,6 @@
.info-link:hover { .info-link:hover {
background: var(--accent-light); background: var(--accent-light);
} }
.info-cleanup {
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cleanup-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
padding: 7px 14px;
cursor: pointer;
}
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
.cleanup-msg {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.cleanup-list {
width: 100%;
max-height: 140px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.cleanup-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 12px;
font-size: 12px;
}
.cleanup-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cleanup-size {
color: var(--text-tertiary);
flex-shrink: 0;
}
.cleanup-hint {
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.shortcuts-section { .shortcuts-section {
width: 100%; width: 100%;
+26 -39
View File
@@ -37,12 +37,12 @@
reorderQuickAccess, reorderQuickAccess,
moveNote, moveNote,
getAllTags, getAllTags,
createDailyNote createDailyNote,
revealFile
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time'; import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { encodeNoteDragPaths } from '$lib/utils/note-drag'; import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte'; import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte'; import TagSuggestInput from './TagSuggestInput.svelte';
@@ -966,11 +966,21 @@
await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e)))); await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e))));
} }
function handleWindowClick() { function handleWindowClick(event: MouseEvent) {
const target = event.target as Element | null;
if (target?.closest('.context-menu, .sort-menu')) return;
if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; } if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; }
if (sortMenu) sortMenu = null; if (sortMenu) sortMenu = null;
} }
function closeBatchMoveOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) batchMovePicker = false;
}
function closeBatchTagOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) batchTagEdit = false;
}
function openSortMenu(e: MouseEvent) { function openSortMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -1076,12 +1086,11 @@
{#if batchMovePicker} {#if batchMovePicker}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchMovePicker = false}> <div class="batch-move-overlay" onclick={closeBatchMoveOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchMovePicker = false; }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-move-title" tabindex="-1">
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-header"> <div class="batch-move-header">
<span>Move {selectedPaths.size} notes to...</span> <span id="batch-move-title">Move {selectedPaths.size} notes to...</span>
<button class="selection-close" onclick={() => batchMovePicker = false}> <button class="selection-close" onclick={() => batchMovePicker = false} aria-label="Close move picker">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
@@ -1105,12 +1114,11 @@
{#if batchTagEdit && !contextMenu} {#if batchTagEdit && !contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}> <div class="batch-move-overlay" onclick={closeBatchTagOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchTagEdit = false; }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-tags-title" tabindex="-1">
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-header"> <div class="batch-move-header">
<span>Tags for {selectedPaths.size} notes</span> <span id="batch-tags-title">Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false}> <button class="selection-close" onclick={() => batchTagEdit = false} aria-label="Close tag editor">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
@@ -1408,7 +1416,7 @@
</div> </div>
{#if contextMenu} {#if contextMenu}
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu"> <div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" role="group" aria-label="Note actions">
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)} {#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
<!-- Batch context menu --> <!-- Batch context menu -->
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
@@ -1559,7 +1567,7 @@
Open in New Window Open in New Window
</button> </button>
{#if !isMobile} {#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealItemInDir(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}> <button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealFile(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg> </svg>
@@ -1611,8 +1619,7 @@
{/if} {/if}
{#if sortMenu} {#if sortMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="sort-menu" class:mobile={isMobile} style="left: {sortMenu.x}px; top: {sortMenu.y}px">
<div class="sort-menu" style="left: {sortMenu.x}px; top: {sortMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="sort-menu-title">Sort by</div> <div class="sort-menu-title">Sort by</div>
<button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}> <button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -2335,13 +2342,13 @@
border-radius: 8px; border-radius: 8px;
} }
.note-list.mobile .sort-menu { .sort-menu.mobile {
min-width: 220px; min-width: 220px;
border-radius: 12px; border-radius: 12px;
padding: 6px; padding: 6px;
} }
.note-list.mobile .sort-menu button { .sort-menu.mobile button {
padding: 12px 16px; padding: 12px 16px;
font-size: 15px; font-size: 15px;
min-height: 44px; min-height: 44px;
@@ -2365,26 +2372,6 @@
min-height: 44px; min-height: 44px;
} }
/* Mobile selection checkboxes */
.mobile-select-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 4px;
transition: all 0.15s ease;
}
.mobile-select-check.checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Mobile create note button (round, accent-colored) */ /* Mobile create note button (round, accent-colored) */
.mobile-create-btn { .mobile-create-btn {
background: var(--accent); background: var(--accent);
+29 -5
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from 'svelte';
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app'; import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
import { getAllNoteTitles, getQuickAccess } from '$lib/api'; import { getNoteSwitcherTitles, getQuickAccess } from '$lib/api';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { import {
buildNoteSwitcherRequestPaths,
buildNoteSwitcherSections, buildNoteSwitcherSections,
type NoteSwitcherNote, type NoteSwitcherNote,
type NoteSwitcherRow, type NoteSwitcherRow,
@@ -23,6 +24,7 @@
let selectingPath = $state<string | null>(null); let selectingPath = $state<string | null>(null);
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] }); let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
let loadGeneration = 0; let loadGeneration = 0;
let loadedVaultPath = $state<string | null | undefined>(undefined);
function normalizedPath(path: string): string { function normalizedPath(path: string): string {
return path.replace(/\\/g, '/').replace(/\/$/, ''); return path.replace(/\\/g, '/').replace(/\/$/, '');
@@ -71,13 +73,15 @@
const vaultPath = $appConfig?.active_vault; const vaultPath = $appConfig?.active_vault;
if (!vaultPath) { if (!vaultPath) {
sections = { recent: [], quickAccess: [] }; sections = { recent: [], quickAccess: [] };
loadedVaultPath = null;
loading = false; loading = false;
await focusInitialRow(); await focusInitialRow();
return; return;
} }
const requestPaths = buildNoteSwitcherRequestPaths($activeNotePath, $navHistory.stack);
const [titlesResult, quickAccessResult] = await Promise.allSettled([ const [titlesResult, quickAccessResult] = await Promise.allSettled([
getAllNoteTitles(), getNoteSwitcherTitles(requestPaths),
getQuickAccess() getQuickAccess()
]); ]);
if (!open || generation !== loadGeneration) return; if (!open || generation !== loadGeneration) return;
@@ -111,6 +115,11 @@
.map((entry) => quickAccessEntryToNote(entry, vaultPath)) .map((entry) => quickAccessEntryToNote(entry, vaultPath))
.filter((entry): entry is NoteSwitcherNote => entry !== null) .filter((entry): entry is NoteSwitcherNote => entry !== null)
: []; : [];
for (const note of quickAccessNotes) {
if (!knownNotes.some((knownNote) => normalizedPath(knownNote.path) === normalizedPath(note.path))) {
knownNotes.push(note);
}
}
sections = buildNoteSwitcherSections({ sections = buildNoteSwitcherSections({
currentPath, currentPath,
@@ -118,22 +127,37 @@
knownNotes, knownNotes,
quickAccessNotes quickAccessNotes
}); });
loadedVaultPath = vaultPath;
loading = false; loading = false;
await focusInitialRow(); await focusInitialRow();
} }
function refreshAfterPopoverPaint(generation: number) {
requestAnimationFrame(() => {
window.setTimeout(() => {
if (!open || generation !== loadGeneration) return;
void refreshSections(generation);
}, 0);
});
}
function toggleSwitcher() { function toggleSwitcher() {
if (open) { if (open) {
open = false; open = false;
loadGeneration += 1; loadGeneration += 1;
return; return;
} }
const vaultPath = $appConfig?.active_vault ?? null;
const hasCachedSections = loadedVaultPath === vaultPath;
open = true; open = true;
loading = true; loading = !hasCachedSections;
selectingPath = null; selectingPath = null;
sections = { recent: [], quickAccess: [] }; if (!hasCachedSections) sections = { recent: [], quickAccess: [] };
loadGeneration += 1; loadGeneration += 1;
void refreshSections(loadGeneration); const generation = loadGeneration;
if (hasCachedSections) void focusInitialRow();
refreshAfterPopoverPaint(generation);
} }
async function closeSwitcher(restoreTriggerFocus: boolean) { async function closeSwitcher(restoreTriggerFocus: boolean) {
+18
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import Editor from './Editor.svelte'; import Editor from './Editor.svelte';
import { import {
appConfig,
activeNote, activeNote,
activeNotePath, activeNotePath,
editorDirty, editorDirty,
@@ -17,9 +19,11 @@
let { notePath }: { notePath: string } = $props(); let { notePath }: { notePath: string } = $props();
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const appWebview = getCurrentWebview();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
let editor = $state<Editor>(null!); let editor = $state<Editor>(null!);
let unlistenFileChange: (() => void) | null = null; let unlistenFileChange: (() => void) | null = null;
let unlistenUiScale: (() => void) | null = null;
let maximized = $state(false); let maximized = $state(false);
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
@@ -67,7 +71,20 @@
} }
} }
async function applyUiScale(scale: number) {
try {
await appWebview.setZoom(scale);
} catch (e) {
console.error('Failed to apply interface scale to note window:', e);
}
}
onMount(async () => { onMount(async () => {
unlistenUiScale = await listen<number>('ui-scale-changed', (event) => {
void applyUiScale(event.payload);
});
await applyUiScale($appConfig?.ui_scale ?? 1);
try { try {
const content = await readNote(notePath); const content = await readNote(notePath);
$activeNote = content; $activeNote = content;
@@ -99,6 +116,7 @@
onDestroy(() => { onDestroy(() => {
unlistenFileChange?.(); unlistenFileChange?.();
unlistenUiScale?.();
}); });
</script> </script>
+8 -8
View File
@@ -30,14 +30,14 @@
{#if appWindow && !maximized} {#if appWindow && !maximized}
<div class="resize-layer" aria-hidden="true"> <div class="resize-layer" aria-hidden="true">
<div class="rz rz-n" onmousedown={(e) => start(e, 'North')}></div> <div class="rz rz-n" role="presentation" onmousedown={(e) => start(e, 'North')}></div>
<div class="rz rz-s" onmousedown={(e) => start(e, 'South')}></div> <div class="rz rz-s" role="presentation" onmousedown={(e) => start(e, 'South')}></div>
<div class="rz rz-w" onmousedown={(e) => start(e, 'West')}></div> <div class="rz rz-w" role="presentation" onmousedown={(e) => start(e, 'West')}></div>
<div class="rz rz-e" onmousedown={(e) => start(e, 'East')}></div> <div class="rz rz-e" role="presentation" onmousedown={(e) => start(e, 'East')}></div>
<div class="rz rz-nw" onmousedown={(e) => start(e, 'NorthWest')}></div> <div class="rz rz-nw" role="presentation" onmousedown={(e) => start(e, 'NorthWest')}></div>
<div class="rz rz-ne" onmousedown={(e) => start(e, 'NorthEast')}></div> <div class="rz rz-ne" role="presentation" onmousedown={(e) => start(e, 'NorthEast')}></div>
<div class="rz rz-sw" onmousedown={(e) => start(e, 'SouthWest')}></div> <div class="rz rz-sw" role="presentation" onmousedown={(e) => start(e, 'SouthWest')}></div>
<div class="rz rz-se" onmousedown={(e) => start(e, 'SouthEast')}></div> <div class="rz rz-se" role="presentation" onmousedown={(e) => start(e, 'SouthEast')}></div>
</div> </div>
{/if} {/if}
+9 -4
View File
@@ -44,10 +44,15 @@
if (item) item.scrollIntoView({ block: 'nearest' }); if (item) item.scrollIntoView({ block: 'nearest' });
} }
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
$showSearch = false;
}
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { if (e.key === 'ArrowDown') {
$showSearch = false;
} else if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1); selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
scrollToSelected(); scrollToSelected();
@@ -128,7 +133,7 @@
{#if $showSearch} {#if $showSearch}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="search-overlay" onclick={close} onkeydown={handleKeydown}> <div class="search-overlay" onclick={close} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> <div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<div class="search-input-wrapper"> <div class="search-input-wrapper">
+321 -46
View File
@@ -1,18 +1,19 @@
<script lang="ts"> <script lang="ts">
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app'; import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api'; import { setTheme, setSystemThemes, setAccentColor, setFontSize, setScrollToChangeFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { darkThemes, isMobile, isAndroid } from '$lib/platform'; import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import { getCurrentWebview } from '@tauri-apps/api/webview'; import { getCurrentWebview } from '@tauri-apps/api/webview';
import { openUrl } from '$lib/api'; import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView } from '$lib/types'; import type { OrphanAttachment } from '$lib/api';
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView, VaultStats } from '$lib/types';
import { normalizeStartupView } from '$lib/utils/startup-view'; import { normalizeStartupView } from '$lib/utils/startup-view';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates'; type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'maintenance' | 'ai' | 'sync' | 'updates';
let activeTab = $state<Tab>('styling'); let activeTab = $state<Tab>('styling');
// Updates state // Updates state
@@ -116,6 +117,67 @@
} }
} }
// Vault maintenance state
let vaultStats = $state<VaultStats | null>(null);
let vaultStatsError = $state(false);
let orphanedAttachments = $state<OrphanAttachment[] | null>(null);
let scanningOrphanedAttachments = $state(false);
let trashingOrphanedAttachments = $state(false);
const orphanedAttachmentTotal = $derived((orphanedAttachments ?? []).reduce((total, attachment) => total + attachment.size, 0));
$effect(() => {
if (!$showSettings) {
vaultStats = null;
vaultStatsError = false;
orphanedAttachments = null;
return;
}
if (activeTab === 'maintenance') void loadVaultStats();
});
async function loadVaultStats() {
vaultStatsError = false;
try {
vaultStats = await getVaultStats();
} catch (error) {
vaultStats = null;
vaultStatsError = true;
console.error('Failed to load vault statistics:', error);
}
}
function formatVaultSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
async function scanOrphanedAttachments() {
scanningOrphanedAttachments = true;
try {
orphanedAttachments = await findOrphanedAttachments();
} catch (error) {
console.error(error);
} finally {
scanningOrphanedAttachments = false;
}
}
async function trashFoundOrphanedAttachments() {
if (!orphanedAttachments || orphanedAttachments.length === 0) return;
trashingOrphanedAttachments = true;
try {
await trashOrphanedAttachments(orphanedAttachments.map((attachment) => attachment.name));
orphanedAttachments = null;
void loadVaultStats();
} catch (error) {
console.error(error);
} finally {
trashingOrphanedAttachments = false;
}
}
// Backup state // Backup state
let backups = $state<BackupEntry[]>([]); let backups = $state<BackupEntry[]>([]);
let backupLoading = $state(false); let backupLoading = $state(false);
@@ -459,6 +521,17 @@
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' }, { id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
]; ];
// Keep the default themes pinned at the top and sort the rest alphabetically.
const priorityThemeIds = ['system', 'light', 'dark'];
themePresets.sort((a, b) => {
const pa = priorityThemeIds.indexOf(a.id);
const pb = priorityThemeIds.indexOf(b.id);
if (pa !== -1 && pb !== -1) return pa - pb;
if (pa !== -1) return -1;
if (pb !== -1) return 1;
return a.label.localeCompare(b.label);
});
const accentPresets = [ const accentPresets = [
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' }, { name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' }, { name: 'Rose', light: '#e11d48', dark: '#d4768a' },
@@ -521,6 +594,7 @@
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo'); let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf'); let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
let activeFontSize = $state($appConfig?.font_size ?? 14); let activeFontSize = $state($appConfig?.font_size ?? 14);
let scrollToChangeFontSize = $state($appConfig?.scroll_to_change_font_size ?? true);
let activeFontFamily = $state($appConfig?.font_family ?? 'system'); let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6); let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1); let activeUiScale = $state($appConfig?.ui_scale ?? 1);
@@ -682,6 +756,10 @@
closeCustomThemeEditor(); closeCustomThemeEditor();
} }
function cancelCustomThemeFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) cancelCustomThemeEditor();
}
async function selectCustomTheme(ct: CustomTheme) { async function selectCustomTheme(ct: CustomTheme) {
$theme = ct.id; $theme = ct.id;
setTheme(ct.id); setTheme(ct.id);
@@ -921,6 +999,13 @@
setFontSize(size).catch((e) => console.error('Failed to save font size:', e)); setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
} }
function toggleScrollToChangeFontSize() {
scrollToChangeFontSize = !scrollToChangeFontSize;
if ($appConfig) $appConfig.scroll_to_change_font_size = scrollToChangeFontSize;
setScrollToChangeFontSize(scrollToChangeFontSize)
.catch((e) => console.error('Failed to save scroll font size setting:', e));
}
function applyFontSize(size: number) { function applyFontSize(size: number) {
document.documentElement.style.setProperty('--editor-font-size', `${size}px`); document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
} }
@@ -990,6 +1075,23 @@
$showSettings = false; $showSettings = false;
} }
function closeSettingsFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) close();
}
function handleEscape(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
if (restoreConfirm) restoreConfirm = null;
else if (customThemeEditorOpen) cancelCustomThemeEditor();
else close();
}
function dismissRestoreConfirm(event: MouseEvent) {
if (event.target === event.currentTarget) restoreConfirm = null;
}
// Apply saved settings on mount // Apply saved settings on mount
$effect(() => { $effect(() => {
const savedAccent = $appConfig?.accent_color; const savedAccent = $appConfig?.accent_color;
@@ -1006,6 +1108,7 @@
} }
} }
} }
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
const savedSize = $appConfig?.font_size; const savedSize = $appConfig?.font_size;
if (savedSize) { if (savedSize) {
activeFontSize = savedSize; activeFontSize = savedSize;
@@ -1065,12 +1168,11 @@
{#if $showSettings} {#if $showSettings}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-overlay" class:mobile={isMobile} onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}> <div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
<div class="settings-panel" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<div class="settings-header"> <div class="settings-header">
<h2>Settings</h2> <h2 id="settings-title">Settings</h2>
<button class="close-btn" onclick={close}> <button class="close-btn" onclick={close} aria-label="Close settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
@@ -1114,6 +1216,12 @@
Backup Backup
</button> </button>
{/if} {/if}
<button class="tab-btn" class:active={activeTab === 'maintenance'} onclick={() => activeTab = 'maintenance'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>
</svg>
Maintenance
</button>
<button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}> <button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/> <path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
@@ -1146,7 +1254,7 @@
<span class="setting-name">Compact mode</span> <span class="setting-name">Compact mode</span>
<span class="setting-desc">Show notes in a denser layout without preview</span> <span class="setting-desc">Show notes in a denser layout without preview</span>
</span> </span>
<button class="toggle-switch" class:on={compactNotes} onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={compactNotes} role="switch" aria-checked={compactNotes} aria-label="Compact notes" onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1155,7 +1263,7 @@
<span class="setting-name">Show dates</span> <span class="setting-name">Show dates</span>
<span class="setting-desc">Show the date next to each note in the list</span> <span class="setting-desc">Show the date next to each note in the list</span>
</span> </span>
<button class="toggle-switch" class:on={showNoteDates} onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showNoteDates} role="switch" aria-checked={showNoteDates} aria-label="Show note dates" onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1163,12 +1271,12 @@
<div class="settings-section"> <div class="settings-section">
<h3>Notebooks</h3> <h3>Notebooks</h3>
<label class="setting-toggle"> <div class="setting-toggle">
<span class="setting-label"> <span class="setting-label">
<span class="setting-name">Sort order</span> <span class="setting-name">Sort order</span>
<span class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span> <span class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span>
</span> </span>
</label> </div>
<div class="setting-options" style="margin-top: 8px;"> <div class="setting-options" style="margin-top: 8px;">
<button class="option-btn" class:active={$notebookSortMode === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button> <button class="option-btn" class:active={$notebookSortMode === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button>
<button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button> <button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
@@ -1181,31 +1289,31 @@
<p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p> <p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">All Notes</span></span> <span class="setting-label"><span class="setting-name">All Notes</span></span>
<button class="toggle-switch" class:on={showAllNotes} onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showAllNotes} role="switch" aria-checked={showAllNotes} aria-label="Show All Notes" onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Quick Access</span></span> <span class="setting-label"><span class="setting-name">Quick Access</span></span>
<button class="toggle-switch" class:on={showQuickAccess} onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showQuickAccess} role="switch" aria-checked={showQuickAccess} aria-label="Show Quick Access" onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Tasks</span></span> <span class="setting-label"><span class="setting-name">Tasks</span></span>
<button class="toggle-switch" class:on={showTasks} onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showTasks} role="switch" aria-checked={showTasks} aria-label="Show Tasks" onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Daily Notes</span></span> <span class="setting-label"><span class="setting-name">Daily Notes</span></span>
<button class="toggle-switch" class:on={showDailyNotes} onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showDailyNotes} role="switch" aria-checked={showDailyNotes} aria-label="Show Daily Notes" onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Trash</span></span> <span class="setting-label"><span class="setting-name">Trash</span></span>
<button class="toggle-switch" class:on={showTrash} onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showTrash} role="switch" aria-checked={showTrash} aria-label="Show Trash" onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1220,7 +1328,7 @@
<span class="setting-name">Show note switcher in title bar</span> <span class="setting-name">Show note switcher in title bar</span>
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span> <span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
</span> </span>
<button class="toggle-switch" class:on={showNoteSwitcher} onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showNoteSwitcher} role="switch" aria-checked={showNoteSwitcher} aria-label="Show note switcher in title bar" onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1244,7 +1352,7 @@
<span class="setting-name">Restore last session on launch</span> <span class="setting-name">Restore last session on launch</span>
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span> <span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
</span> </span>
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={restoreLastSession} role="switch" aria-checked={restoreLastSession} aria-label="Restore last session on launch" onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1291,6 +1399,7 @@
</div> </div>
{/if} {/if}
{#if !isMobile} {#if !isMobile}
<div class="settings-section"> <div class="settings-section">
<h3>Performance</h3> <h3>Performance</h3>
@@ -1299,7 +1408,7 @@
<span class="setting-name">GPU Acceleration</span> <span class="setting-name">GPU Acceleration</span>
<span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span> <span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span>
</span> </span>
<button class="toggle-switch" class:on={gpuAcceleration} onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={gpuAcceleration} role="switch" aria-checked={gpuAcceleration} aria-label="GPU acceleration" onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1312,7 +1421,7 @@
<span class="setting-name">Start at system startup</span> <span class="setting-name">Start at system startup</span>
<span class="setting-desc">Launch HelixNotes when your computer starts</span> <span class="setting-desc">Launch HelixNotes when your computer starts</span>
</span> </span>
<button class="toggle-switch" class:on={autostart} onclick={() => { autostart = !autostart; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={autostart} role="switch" aria-checked={autostart} aria-label="Start at system startup" onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1321,7 +1430,7 @@
<span class="setting-name">Show in system tray</span> <span class="setting-name">Show in system tray</span>
<span class="setting-desc">Show an icon in the notification area (requires restart)</span> <span class="setting-desc">Show an icon in the notification area (requires restart)</span>
</span> </span>
<button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showTrayIcon} role="switch" aria-checked={showTrayIcon} aria-label="Show in system tray" onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1331,7 +1440,7 @@
<span class="setting-name">Close to tray</span> <span class="setting-name">Close to tray</span>
<span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span> <span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
</span> </span>
<button class="toggle-switch" class:on={closeToTray} onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={closeToTray} role="switch" aria-checked={closeToTray} aria-label="Close to tray" onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1339,6 +1448,78 @@
</div> </div>
{/if} {/if}
</div> </div>
{:else if activeTab === 'maintenance'}
<div class="tab-content">
<div class="settings-section">
<h3>Vault Overview</h3>
{#if vaultStats}
<div class="maintenance-stats">
<div class="maintenance-stat-row">
<span class="maintenance-stat-label">Notes</span>
<span class="maintenance-stat-value">{vaultStats.total_notes}</span>
</div>
<div class="maintenance-stat-row">
<span class="maintenance-stat-label">Attachments</span>
<span class="maintenance-stat-value">{vaultStats.total_attachments}</span>
</div>
<div class="maintenance-stat-row">
<span class="maintenance-stat-label">Notes size</span>
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.notes_size)}</span>
</div>
<div class="maintenance-stat-row">
<span class="maintenance-stat-label">Attachments size</span>
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.attachments_size)}</span>
</div>
<div class="maintenance-stat-row maintenance-stat-total">
<span class="maintenance-stat-label">Total vault size</span>
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.total_size)}</span>
</div>
</div>
{:else if vaultStatsError}
<p class="setting-desc">Vault statistics could not be loaded.</p>
{:else}
<p class="setting-desc">Loading vault statistics…</p>
{/if}
</div>
<div class="settings-section">
<h3>Orphaned Attachments</h3>
<div class="vault-maintenance">
<p class="setting-desc">Find files in the vault's attachments folder that are not referenced by any note.</p>
{#if orphanedAttachments === null}
<button class="import-btn cleanup-action" onclick={scanOrphanedAttachments} disabled={scanningOrphanedAttachments}>
{#if scanningOrphanedAttachments}
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
Scanning…
{:else}
Find orphaned attachments
{/if}
</button>
{:else if orphanedAttachments.length === 0}
<p class="cleanup-message">No orphaned attachments. Nothing to clean up.</p>
{:else}
<p class="cleanup-message">{orphanedAttachments.length} {orphanedAttachments.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatVaultSize(orphanedAttachmentTotal)}).</p>
<div class="cleanup-list">
{#each orphanedAttachments as attachment}
<div class="cleanup-row">
<span class="cleanup-name" title={attachment.name}>{attachment.name}</span>
<span class="cleanup-size">{formatVaultSize(attachment.size)}</span>
</div>
{/each}
</div>
<button class="import-btn cleanup-action" onclick={trashFoundOrphanedAttachments} disabled={trashingOrphanedAttachments}>
{#if trashingOrphanedAttachments}
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
Moving…
{:else}
Move {orphanedAttachments.length} to Trash
{/if}
</button>
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
{/if}
</div>
</div>
</div>
{:else if activeTab === 'editor'} {:else if activeTab === 'editor'}
<div class="tab-content"> <div class="tab-content">
<div class="settings-section"> <div class="settings-section">
@@ -1348,7 +1529,7 @@
<span class="setting-name">Hide title in note body</span> <span class="setting-name">Hide title in note body</span>
<span class="setting-desc">Hide the first heading when it matches the note title</span> <span class="setting-desc">Hide the first heading when it matches the note title</span>
</span> </span>
<button class="toggle-switch" class:on={hideTitleInBody} onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={hideTitleInBody} role="switch" aria-checked={hideTitleInBody} aria-label="Hide title in note body" onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1357,7 +1538,7 @@
<span class="setting-name">Show link arrows</span> <span class="setting-name">Show link arrows</span>
<span class="setting-desc">Display the arrow icon after external links</span> <span class="setting-desc">Display the arrow icon after external links</span>
</span> </span>
<button class="toggle-switch" class:on={showLinkArrows} onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}> <button class="toggle-switch" class:on={showLinkArrows} role="switch" aria-checked={showLinkArrows} aria-label="Show link arrows" onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1367,7 +1548,7 @@
<span class="setting-name">Show line numbers</span> <span class="setting-name">Show line numbers</span>
<span class="setting-desc">Display line numbers in the markdown source editor</span> <span class="setting-desc">Display line numbers in the markdown source editor</span>
</span> </span>
<button class="toggle-switch" class:on={showLineNumbers} onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showLineNumbers} role="switch" aria-checked={showLineNumbers} aria-label="Show line numbers" onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1377,7 +1558,7 @@
<span class="setting-name">Open notes in View Mode</span> <span class="setting-name">Open notes in View Mode</span>
<span class="setting-desc">Notes open as read-only by default. Click the eye icon to switch to editing.</span> <span class="setting-desc">Notes open as read-only by default. Click the eye icon to switch to editing.</span>
</span> </span>
<button class="toggle-switch" class:on={defaultViewMode} onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={defaultViewMode} role="switch" aria-checked={defaultViewMode} aria-label="Open notes in View Mode" onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1386,7 +1567,7 @@
<span class="setting-name">Open new notes in source mode</span> <span class="setting-name">Open new notes in source mode</span>
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span> <span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
</span> </span>
<button class="toggle-switch" class:on={newNotesInSourceMode} onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={newNotesInSourceMode} role="switch" aria-checked={newNotesInSourceMode} aria-label="Open new notes in source mode" onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1400,7 +1581,7 @@
<span class="setting-name">Enable wiki links</span> <span class="setting-name">Enable wiki links</span>
<span class="setting-desc">Link notes with [[Note Title]] syntax and visualize connections in a graph view</span> <span class="setting-desc">Link notes with [[Note Title]] syntax and visualize connections in a graph view</span>
</span> </span>
<button class="toggle-switch" class:on={enableWikiLinks} onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={enableWikiLinks} role="switch" aria-checked={enableWikiLinks} aria-label="Enable wiki links" onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1415,7 +1596,7 @@
<span class="setting-name">Inline PDF preview</span> <span class="setting-name">Inline PDF preview</span>
<span class="setting-desc">Render embedded PDF files as inline previews inside notes</span> <span class="setting-desc">Render embedded PDF files as inline previews inside notes</span>
</span> </span>
<button class="toggle-switch" class:on={pdfPreview} onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={pdfPreview} role="switch" aria-checked={pdfPreview} aria-label="Inline PDF preview" onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1652,24 +1833,23 @@
<!-- Custom Theme Editor Modal --> <!-- Custom Theme Editor Modal -->
{#if customThemeEditorOpen && customThemeEditing} {#if customThemeEditorOpen && customThemeEditing}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeEditor} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}> <div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={handleEscape}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
<div class="custom-theme-modal" onclick={(e) => e.stopPropagation()}>
<div class="custom-theme-modal-header"> <div class="custom-theme-modal-header">
<h3>{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3> <h3 id="custom-theme-title">{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
<button class="close-btn" onclick={cancelCustomThemeEditor}> <button class="close-btn" onclick={cancelCustomThemeEditor} aria-label="Close theme editor">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
<div class="custom-theme-modal-body"> <div class="custom-theme-modal-body">
<div class="ct-field"> <div class="ct-field">
<label class="ct-label">Theme Name</label> <label class="ct-label" for="custom-theme-name">Theme Name</label>
<input class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} /> <input id="custom-theme-name" class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
</div> </div>
<div class="ct-field"> <div class="ct-field">
<label class="ct-label">Mode</label> <span class="ct-label">Mode</span>
<div class="setting-options"> <div class="setting-options">
<button class="option-btn" class:active={!customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = false; previewCustomTheme(); } }}>Light</button> <button class="option-btn" class:active={!customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = false; previewCustomTheme(); } }}>Light</button>
<button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button> <button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button>
@@ -1759,6 +1939,17 @@
</button> </button>
{/each} {/each}
</div> </div>
{#if !isMobile}
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Scroll to change font size</span>
<span class="setting-desc">Allow Cmd/Ctrl + scroll to resize editor text</span>
</span>
<button class="toggle-switch" class:on={scrollToChangeFontSize} role="switch" aria-checked={scrollToChangeFontSize} aria-label="Scroll to change font size" onclick={toggleScrollToChangeFontSize}>
<span class="toggle-knob"></span>
</button>
</label>
{/if}
</div> </div>
{#if !isMobile} {#if !isMobile}
@@ -1836,7 +2027,7 @@
<label class="setting-toggle"> <label class="setting-toggle">
<span>Enable automatic backup</span> <span>Enable automatic backup</span>
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}> <button class="toggle-switch" class:on={$appConfig?.backup_enabled} role="switch" aria-checked={$appConfig?.backup_enabled ?? false} aria-label="Enable automatic backup" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1864,7 +2055,7 @@
<span class="setting-name">Include attachments</span> <span class="setting-name">Include attachments</span>
<span class="setting-desc">Include images and files in backups (increases size significantly)</span> <span class="setting-desc">Include images and files in backups (increases size significantly)</span>
</span> </span>
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}> <button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} role="switch" aria-checked={$appConfig?.backup_include_attachments ?? false} aria-label="Include attachments in backups" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1950,10 +2141,9 @@
{#if restoreConfirm} {#if restoreConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="restore-confirm-overlay" onclick={() => restoreConfirm = null}> <div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={handleEscape}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
<div class="restore-confirm" onclick={(e) => e.stopPropagation()}> <h4 id="restore-confirm-title">Restore Backup?</h4>
<h4>Restore Backup?</h4>
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p> <p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
<div class="restore-confirm-actions"> <div class="restore-confirm-actions">
<button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button> <button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button>
@@ -2302,14 +2492,14 @@
<span class="setting-name">Sync when a note changes</span> <span class="setting-name">Sync when a note changes</span>
<span class="setting-desc">Sync shortly after you edit a note.</span> <span class="setting-desc">Sync shortly after you edit a note.</span>
</span> </span>
<button class="toggle-switch" class:on={syncOnChange} onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button> <button class="toggle-switch" class:on={syncOnChange} role="switch" aria-checked={syncOnChange} aria-label="Sync when a note changes" onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
</label> </label>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"> <span class="setting-label">
<span class="setting-name">Sync when the vault opens</span> <span class="setting-name">Sync when the vault opens</span>
<span class="setting-desc">Sync once when the app starts.</span> <span class="setting-desc">Sync once when the app starts.</span>
</span> </span>
<button class="toggle-switch" class:on={syncOnOpen} onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button> <button class="toggle-switch" class:on={syncOnOpen} role="switch" aria-checked={syncOnOpen} aria-label="Sync when the vault opens" onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
</label> </label>
</div> </div>
{/if} {/if}
@@ -2962,6 +3152,91 @@
cursor: not-allowed; cursor: not-allowed;
} }
.maintenance-stats {
width: 100%;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 4px 0;
}
.maintenance-stat-row {
display: flex;
justify-content: space-between;
padding: 7px 16px;
}
.maintenance-stat-label {
font-size: 13px;
color: var(--text-secondary);
}
.maintenance-stat-value {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.maintenance-stat-total {
border-top: 1px solid var(--border-light);
}
.maintenance-stat-total .maintenance-stat-value {
color: var(--accent);
}
.vault-maintenance {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.cleanup-action {
min-height: 44px;
}
.cleanup-message {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.cleanup-list {
width: 100%;
max-height: 140px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.cleanup-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 12px;
font-size: 12px;
}
.cleanup-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cleanup-size {
color: var(--text-tertiary);
flex-shrink: 0;
}
.cleanup-hint {
margin: 0;
font-size: 11px;
color: var(--text-tertiary);
}
.spinner-icon { .spinner-icon {
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
+28 -20
View File
@@ -33,6 +33,7 @@
NOTEBOOK_ICON_OPTIONS, NOTEBOOK_ICON_OPTIONS,
decodeBuiltinNotebookIcon, decodeBuiltinNotebookIcon,
encodeBuiltinNotebookIcon, encodeBuiltinNotebookIcon,
normalizeNotebookIconKey,
type NotebookIconId type NotebookIconId
} from '$lib/utils/notebook-icons'; } from '$lib/utils/notebook-icons';
@@ -334,6 +335,10 @@
} }
} }
function dismissDeleteConfirm(event: MouseEvent) {
if (event.target === event.currentTarget) deleteConfirm = null;
}
async function confirmDelete(nb: NotebookEntry) { async function confirmDelete(nb: NotebookEntry) {
deleteConfirm = null; deleteConfirm = null;
try { try {
@@ -587,9 +592,10 @@
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) { async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
try { try {
const key = normalizeNotebookIconKey(nb.relative_path);
const value = encodeBuiltinNotebookIcon(icon); const value = encodeBuiltinNotebookIcon(icon);
await setNotebookIcon(nb.relative_path, value); await setNotebookIcon(key, value);
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: value }; $notebookIcons = { ...$notebookIcons, [key]: value };
iconPickerNotebook = null; iconPickerNotebook = null;
} catch (e) { } catch (e) {
console.error('Failed to set notebook icon:', e); console.error('Failed to set notebook icon:', e);
@@ -608,8 +614,9 @@
const data = await readFile(filePath); const data = await readFile(filePath);
const fileName = baseOf(filePath) || 'icon.png'; const fileName = baseOf(filePath) || 'icon.png';
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data)); const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
await setNotebookIcon(nb.relative_path, iconRelative); const key = normalizeNotebookIconKey(nb.relative_path);
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: iconRelative }; await setNotebookIcon(key, iconRelative);
$notebookIcons = { ...$notebookIcons, [key]: iconRelative };
} catch (e) { } catch (e) {
console.error('Failed to set notebook icon:', e); console.error('Failed to set notebook icon:', e);
} }
@@ -619,9 +626,10 @@
contextMenu = null; contextMenu = null;
iconPickerNotebook = null; iconPickerNotebook = null;
try { try {
await setNotebookIcon(nb.relative_path, null); const key = normalizeNotebookIconKey(nb.relative_path);
await setNotebookIcon(key, null);
const icons = { ...$notebookIcons }; const icons = { ...$notebookIcons };
delete icons[nb.relative_path]; delete icons[key];
$notebookIcons = icons; $notebookIcons = icons;
} catch (e) { } catch (e) {
console.error('Failed to remove notebook icon:', e); console.error('Failed to remove notebook icon:', e);
@@ -629,7 +637,7 @@
} }
function getNotebookIconSrc(nb: NotebookEntry): string | null { function getNotebookIconSrc(nb: NotebookEntry): string | null {
const iconPath = $notebookIcons[nb.relative_path]; const iconPath = $notebookIcons[normalizeNotebookIconKey(nb.relative_path)];
if (!iconPath || iconPath.startsWith('builtin:')) return null; if (!iconPath || iconPath.startsWith('builtin:')) return null;
const vaultRoot = $appConfig?.active_vault; const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot) return null; if (!vaultRoot) return null;
@@ -692,7 +700,7 @@
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} /> <svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}> <aside class="sidebar" class:collapsed={!isMobile && $sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
{#if !isMobile} {#if !isMobile}
<div class="sidebar-header"> <div class="sidebar-header">
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar"> <button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
@@ -713,7 +721,7 @@
</div> </div>
{/if} {/if}
{#if !$sidebarCollapsed} {#if isMobile || !$sidebarCollapsed}
{#if anyNavItem} {#if anyNavItem}
<nav class="sidebar-nav"> <nav class="sidebar-nav">
{#if $appConfig?.show_all_notes !== false} {#if $appConfig?.show_all_notes !== false}
@@ -955,7 +963,7 @@
</button> </button>
<button onclick={() => openIconPicker(contextMenu!.notebook)}> <button onclick={() => openIconPicker(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
{$notebookIcons[contextMenu.notebook.relative_path] ? 'Change Icon...' : 'Set Icon...'} {$notebookIcons[normalizeNotebookIconKey(contextMenu.notebook.relative_path)] ? 'Change Icon...' : 'Set Icon...'}
</button> </button>
<button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}> <button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg>
@@ -981,7 +989,7 @@
{#each NOTEBOOK_ICON_OPTIONS as option} {#each NOTEBOOK_ICON_OPTIONS as option}
<button <button
class="icon-picker-option" class="icon-picker-option"
class:active={$notebookIcons[iconPickerNotebook.relative_path] === encodeBuiltinNotebookIcon(option.id)} class:active={$notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)] === encodeBuiltinNotebookIcon(option.id)}
aria-label={option.label} aria-label={option.label}
title={option.label} title={option.label}
onclick={() => handleBuiltinIcon(iconPickerNotebook!, option.id)} onclick={() => handleBuiltinIcon(iconPickerNotebook!, option.id)}
@@ -996,7 +1004,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
Custom image... Custom image...
</button> </button>
{#if $notebookIcons[iconPickerNotebook.relative_path]} {#if $notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)]}
<button class="remove" onclick={() => handleRemoveIcon(iconPickerNotebook!)}> <button class="remove" onclick={() => handleRemoveIcon(iconPickerNotebook!)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
Use default Use default
@@ -1025,10 +1033,9 @@
{#if deleteConfirm} {#if deleteConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}> <div class="delete-confirm-overlay" onclick={dismissDeleteConfirm} onkeydown={(e) => { if (e.key === 'Escape') deleteConfirm = null; }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="delete-confirm" class:mobile={isMobile} role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" tabindex="-1">
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}> <h4 id="delete-confirm-title">Delete "{deleteConfirm.name}"?</h4>
<h4>Delete "{deleteConfirm.name}"?</h4>
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p> <p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
<div class="delete-confirm-actions"> <div class="delete-confirm-actions">
<button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button> <button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
@@ -1042,7 +1049,7 @@
{@const hasChildren = nb.children.length > 0} {@const hasChildren = nb.children.length > 0}
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)} {@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
{@const iconSrc = getNotebookIconSrc(nb)} {@const iconSrc = getNotebookIconSrc(nb)}
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[nb.relative_path])} {@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[normalizeNotebookIconKey(nb.relative_path)])}
{#if editingNotebook === nb.path} {#if editingNotebook === nb.path}
<div class="notebook-item" style="padding-left: {4 + depth * 16}px"> <div class="notebook-item" style="padding-left: {4 + depth * 16}px">
<input <input
@@ -1067,7 +1074,9 @@
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'} class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
style="padding-left: {4 + depth * 16}px" style="padding-left: {4 + depth * 16}px"
data-nb-path={nb.path} data-nb-path={nb.path}
onclick={() => selectNotebook(nb)} onclick={(e) => {
if (!(e.target as Element).closest('.nb-drag-handle')) selectNotebook(nb);
}}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'F2') { if (e.key === 'F2') {
e.preventDefault(); e.preventDefault();
@@ -1149,12 +1158,11 @@
{/if} {/if}
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span> <span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
{#if $notebookSortMode === 'manual'} {#if $notebookSortMode === 'manual'}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<span <span
class="nb-drag-handle" class="nb-drag-handle"
title="Drag to reorder" title="Drag to reorder"
onpointerdown={(e) => nbHandleDown(e, nb)} onpointerdown={(e) => nbHandleDown(e, nb)}
onclick={(e) => e.stopPropagation()}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
</span> </span>
+4 -2
View File
@@ -214,19 +214,21 @@
{/if} {/if}
{#if isAndroid} {#if isAndroid}
<div class="location-selector"> <div class="location-selector" role="group" aria-labelledby="storage-location-label">
<label class="location-label">Location</label> <span id="storage-location-label" class="location-label">Location</span>
<div class="location-options"> <div class="location-options">
{#each storageLocations as loc} {#each storageLocations as loc}
<button <button
class="location-option" class="location-option"
class:active={selectedLocation === loc.label} class:active={selectedLocation === loc.label}
aria-pressed={selectedLocation === loc.label}
onclick={() => selectedLocation = loc.label} onclick={() => selectedLocation = loc.label}
>{loc.label}</button> >{loc.label}</button>
{/each} {/each}
<button <button
class="location-option" class="location-option"
class:active={selectedLocation === 'Custom'} class:active={selectedLocation === 'Custom'}
aria-pressed={selectedLocation === 'Custom'}
onclick={() => selectedLocation = 'Custom'} onclick={() => selectedLocation = 'Custom'}
>Custom</button> >Custom</button>
</div> </div>
+81
View File
@@ -0,0 +1,81 @@
import type { Editor } from '@tiptap/core';
import type { Schema } from '@tiptap/pm/model';
import type { Transaction } from '@tiptap/pm/state';
import { liftTarget } from '@tiptap/pm/transform';
// Marks that carry content (URLs, note references) rather than visual
// formatting. Clearing formatting must never destroy these.
const CONTENT_MARKS: Record<string, true> = { link: true, wikiLink: true };
// Block types reset to plain paragraphs. Lists, callouts, collapsible
// sections and tables keep their structure so no state (checkboxes,
// nesting, cells) is lost.
const DEMOTE_BLOCKS: Record<string, true> = { heading: true, codeBlock: true };
// Outermost-first position of the first blockquote intersecting [from, to].
function findBlockquote(tr: Transaction, from: number, to: number): number | null {
let found: number | null = null;
tr.doc.nodesBetween(from, to, (node, pos) => {
if (found !== null) return false;
if (node.type.name === 'blockquote') {
found = pos;
return false;
}
return true;
});
return found;
}
/**
* Strips inline formatting and demotes heading/code-block/blockquote
* structure over the transaction's selection. An empty (cursor) selection
* is expanded to the whole current text block, so a single invocation can
* reset one messy line without highlighting it first.
*/
export function applyClearFormatting(schema: Schema, tr: Transaction): void {
const { selection } = tr;
const { $from } = selection;
const expandToBlock = selection.empty && $from.parent.isTextblock;
const from = expandToBlock ? $from.start() : selection.from;
const to = expandToBlock ? $from.end() : selection.to;
// 1. Strip formatting marks, keep content-bearing ones.
for (const mark of Object.values(schema.marks)) {
if (!CONTENT_MARKS[mark.name]) tr.removeMark(from, to, mark);
}
// 2. Demote headings and code blocks to paragraphs. Mark removal and
// retyping never move content, so the original positions stay valid.
tr.doc.nodesBetween(from, to, (node, pos) => {
if (DEMOTE_BLOCKS[node.type.name]) tr.setNodeMarkup(pos, schema.nodes.paragraph);
return true;
});
// 3. Lift blockquote contents out of the quote wrapper, one pass per
// nesting level. Lifts move positions, so remap and rescan each pass.
for (let guard = 0; guard < 20; guard++) {
const quotePos = findBlockquote(tr, tr.mapping.map(from, 1), tr.mapping.map(to, -1));
if (quotePos === null) break;
// The range must span the blockquote's content: a collapsed
// blockRange starts one depth up and wraps the quote itself.
const quote = tr.doc.nodeAt(quotePos);
if (!quote) break;
const range = tr.doc.resolve(quotePos + 1).blockRange(tr.doc.resolve(quotePos + quote.nodeSize - 1));
if (!range) break;
const target = liftTarget(range);
if (target === null) break;
tr.lift(range, target);
}
}
/** Clears formatting for the editor's current selection (or current line). */
export function clearFormatting(editor: Editor): boolean {
return editor
.chain()
.focus()
.command(({ state, tr }) => {
applyClearFormatting(state.schema, tr);
return true;
})
.run();
}
@@ -0,0 +1,49 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
const CODE_FENCE_PATTERN = /^(?:```|~~~)(?:[a-z]+)?$/;
function isInputRulesPlugin(plugin: Plugin) {
return 'isInputRules' in plugin.spec && plugin.spec.isInputRules === true;
}
export function createCodeBlockInputScrollPlugin() {
return new Plugin({
key: new PluginKey('codeBlockInputScroll'),
appendTransaction(transactions, oldState, newState) {
const inputRulesPlugin = oldState.plugins.find(isInputRulesPlugin);
const wasKeyboardInputRule =
inputRulesPlugin !== undefined &&
transactions.some((transaction) => {
const metadata: unknown = transaction.getMeta(inputRulesPlugin);
return (
typeof metadata === 'object' &&
metadata !== null &&
'transform' in metadata &&
metadata.transform === transaction &&
'text' in metadata &&
metadata.text === '\n'
);
});
const { selection } = oldState;
const wasFenceParagraph =
selection.empty &&
selection.$from.parent.type.name === 'paragraph' &&
CODE_FENCE_PATTERN.test(selection.$from.parent.textContent);
const isCodeBlock = newState.selection.$from.parent.type.name === 'codeBlock';
return wasKeyboardInputRule && wasFenceParagraph && isCodeBlock
? newState.tr.scrollIntoView()
: null;
},
});
}
export const CodeBlockInputScroll = Extension.create({
name: 'codeBlockInputScroll',
addProseMirrorPlugins() {
return [createCodeBlockInputScrollPlugin()];
},
});
+46
View File
@@ -0,0 +1,46 @@
export type HiddenTitleHeading = {
headingPrefix: string;
title: string;
};
type TitleHeadingResult = {
markdown: string;
hiddenTitle: HiddenTitleHeading | null;
};
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/[\s\-_\u2014]+/g, ' ');
}
export function stripTitleHeading(
markdown: string,
title: string | undefined,
hideTitle: boolean,
): TitleHeadingResult {
if (!hideTitle || !title) return { markdown, hiddenTitle: null };
const lines = markdown.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '') continue;
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match && normalizeTitle(match[2]) === normalizeTitle(title)) {
const hiddenTitle = { headingPrefix: match[1], title: title.trim() };
lines.splice(i, 1);
if (i < lines.length && lines[i].trim() === '') lines.splice(i, 1);
return { markdown: lines.join('\n'), hiddenTitle };
}
break;
}
return { markdown, hiddenTitle: null };
}
export function restoreTitleHeading(
markdown: string,
hiddenTitle: HiddenTitleHeading | null,
): string {
if (!hiddenTitle) return markdown;
return `${hiddenTitle.headingPrefix} ${hiddenTitle.title}\n\n${markdown}`;
}
+22
View File
@@ -0,0 +1,22 @@
import { TextSelection, type EditorState, type Transaction } from '@tiptap/pm/state';
export type WikiLinkAttributes = {
title: string;
path: string;
aliased: boolean;
};
export function replaceWithWikiLink(
state: EditorState,
from: number,
to: number,
text: string,
attrs: WikiLinkAttributes
): Transaction {
const wikiLinkMark = state.schema.marks.wikiLink.create(attrs);
const textNode = state.schema.text(text, [wikiLinkMark]);
const transaction = state.tr.replaceWith(from, to, textNode);
transaction.setSelection(TextSelection.create(transaction.doc, from + text.length));
transaction.setStoredMarks([]);
return transaction;
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { writable, derived, get } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import { isMobile } from "$lib/platform"; import { isMobile } from "$lib/platform";
import { compareNaturalNames } from "$lib/utils/natural-sort";
import type { import type {
AppConfig, AppConfig,
CustomTheme, CustomTheme,
@@ -239,7 +240,7 @@ export const sortedNotes = derived(
return a.meta.title.localeCompare(b.meta.title); return a.meta.title.localeCompare(b.meta.title);
} }
case "title": case "title":
return a.meta.title.localeCompare(b.meta.title); return compareNaturalNames(a.meta.title, b.meta.title);
case "created": case "created":
return ( return (
new Date(b.meta.created).getTime() - new Date(b.meta.created).getTime() -
+1
View File
@@ -92,6 +92,7 @@ export interface AppConfig {
system_dark_theme: string; system_dark_theme: string;
accent_color: string | null; accent_color: string | null;
font_size: number | null; font_size: number | null;
scroll_to_change_font_size: boolean;
font_family: string | null; font_family: string | null;
line_height: number | null; line_height: number | null;
ui_scale: number | null; ui_scale: number | null;
+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const { getWheelFontSizeAction } = await import(
new URL('./editor-zoom.ts', import.meta.url)
);
test('blocks modified wheel input when scroll font sizing is disabled', () => {
assert.equal(
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, false),
'block'
);
assert.equal(
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, false),
'block'
);
});
test('changes font size from modified wheel input when enabled', () => {
assert.equal(
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, true),
'increase'
);
assert.equal(
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, true),
'decrease'
);
});
test('ignores unmodified wheel input', () => {
assert.equal(
getWheelFontSizeAction({ ctrlKey: false, metaKey: false, deltaY: -1 }, false),
'ignore'
);
});
+16
View File
@@ -0,0 +1,16 @@
export type WheelFontSizeAction = 'ignore' | 'block' | 'increase' | 'decrease';
interface WheelModifierInput {
ctrlKey: boolean;
metaKey: boolean;
deltaY: number;
}
export function getWheelFontSizeAction(
event: WheelModifierInput,
scrollToChangeFontSize: boolean,
): WheelFontSizeAction {
if (!event.ctrlKey && !event.metaKey) return 'ignore';
if (!scrollToChangeFontSize) return 'block';
return event.deltaY < 0 ? 'increase' : 'decrease';
}
+5
View File
@@ -0,0 +1,5 @@
const naturalNameCollator = new Intl.Collator(undefined, { numeric: true });
export function compareNaturalNames(left: string, right: string): number {
return naturalNameCollator.compare(left, right);
}
+24 -3
View File
@@ -28,6 +28,27 @@ function pathKey(path: string): string {
return path.replace(/\\/g, '/'); return path.replace(/\\/g, '/');
} }
export function buildNoteSwitcherRequestPaths(
currentPath: string | null,
historyPaths: readonly string[]
): string[] {
const paths: string[] = [];
const seen = new Set<string>();
const addPath = (path: string | null) => {
if (!path) return;
const key = pathKey(path);
if (seen.has(key)) return;
seen.add(key);
paths.push(path);
};
addPath(currentPath);
for (let index = historyPaths.length - 1; index >= 0; index -= 1) {
addPath(historyPaths[index]);
}
return paths;
}
function folderLabel(relativePath: string): string { function folderLabel(relativePath: string): string {
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean); const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
parts.pop(); parts.pop();
@@ -66,9 +87,9 @@ export function buildNoteSwitcherSections({
recent.push(toRow(note, currentPathKey)); recent.push(toRow(note, currentPathKey));
}; };
addRecent(currentPath); for (const path of buildNoteSwitcherRequestPaths(currentPath, historyPaths)) {
for (let index = historyPaths.length - 1; index >= 0 && recent.length < limit; index -= 1) { if (recent.length >= limit) break;
addRecent(historyPaths[index]); addRecent(path);
} }
const quickAccess: NoteSwitcherRow[] = []; const quickAccess: NoteSwitcherRow[] = [];
+4
View File
@@ -39,6 +39,10 @@ const BUILTIN_IDS: Record<NotebookIconId, true> = {
palette: true palette: true
}; };
export function normalizeNotebookIconKey(path: string): string {
return path.replace(/\\/g, '/');
}
export function encodeBuiltinNotebookIcon(icon: NotebookIconId): string { export function encodeBuiltinNotebookIcon(icon: NotebookIconId): string {
return `${BUILTIN_PREFIX}${icon}`; return `${BUILTIN_PREFIX}${icon}`;
} }
+99
View File
@@ -26,3 +26,102 @@ export function relativePath(fromDirectory: string, targetPath: string): string
const result = [...Array(from.length - common).fill('..'), ...target.slice(common)]; const result = [...Array(from.length - common).fill('..'), ...target.slice(common)];
return result.join('/') || '.'; return result.join('/') || '.';
} }
export function normalizeLocalAssetPath(path: string): string {
return path
.replace(/\\/g, '/')
.replace(/^\/([A-Za-z]:\/)/, '$1');
}
export function assetUrlToLocalPath(source: string): string | null {
if (!source.startsWith('asset:') && !source.startsWith('http://asset.localhost') && !source.startsWith('https://asset.localhost')) {
return null;
}
try {
const path = normalizeLocalAssetPath(decodeURIComponent(new URL(source).pathname));
// Tauri prepends a URL-path slash to POSIX and UNC paths.
return path.startsWith('//') ? path.substring(1) : path;
} catch {
return null;
}
}
export function resolveVaultFilePath(
targetPath: string,
notePath: string | null,
vaultRoot: string | null,
): string {
const target = normalizeLocalAssetPath(targetPath);
if (target.startsWith('/') || /^[A-Za-z]:\//.test(target)) return target;
const root = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '') ?? '';
if (target.startsWith('.helixnotes/') && root) {
return resolvePathFromFile(`${root}/.vault-root`, target);
}
if (notePath) return resolvePathFromFile(notePath, target);
if (root) return resolvePathFromFile(`${root}/.vault-root`, target);
return target;
}
export function assetSourceToMarkdown(
source: string,
notePath: string | null,
vaultRoot: string | null,
): string {
if (source.startsWith('blob:')) return '';
if (source.startsWith('imgproxy:') || source.startsWith('http://imgproxy.localhost') || source.startsWith('https://imgproxy.localhost')) {
try {
return decodeURIComponent(new URL(source).pathname.substring(1));
} catch {
return source;
}
}
const absolutePath = assetUrlToLocalPath(source);
if (absolutePath === null) return source;
const normalizedRoot = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '');
if (!normalizedRoot || !absolutePath.startsWith(normalizedRoot + '/')) return absolutePath;
const vaultRelative = absolutePath.substring(normalizedRoot.length + 1);
if (vaultRelative.startsWith('.helixnotes/')) return vaultRelative;
if (!notePath) return vaultRelative;
const normalizedNotePath = notePath.replace(/\\/g, '/');
const noteDirectory = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
return relativePath(noteDirectory, absolutePath);
}
export function resolvePathFromFile(filePath: string, targetPath: string): string {
const normalizedFile = filePath.replace(/\\/g, '/');
const normalizedTarget = targetPath.replace(/\\/g, '/');
const lastSeparator = normalizedFile.lastIndexOf('/');
const fileDirectory = lastSeparator >= 0 ? normalizedFile.slice(0, lastSeparator) : '';
const combined = normalizedTarget.startsWith('/') || /^[A-Za-z]:\//.test(normalizedTarget)
? normalizedTarget
: `${fileDirectory}/${normalizedTarget}`;
const drive = combined.match(/^([A-Za-z]:)\//);
const unc = combined.startsWith('//');
const absolute = !unc && combined.startsWith('/');
let prefix = '';
let remainder = combined;
if (drive) {
prefix = `${drive[1]}/`;
remainder = combined.slice(drive[0].length);
} else if (unc) {
prefix = '//';
remainder = combined.slice(2);
} else if (absolute) {
prefix = '/';
remainder = combined.slice(1);
}
const resolved: string[] = [];
for (const segment of remainder.split('/')) {
if (!segment || segment === '.') continue;
if (segment === '..') {
resolved.pop();
} else {
resolved.push(segment);
}
}
return prefix + resolved.join('/');
}
+6 -27
View File
@@ -7,6 +7,7 @@
import { darkThemes, isMobile, isAndroid } from '$lib/platform'; import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import type { CustomTheme } from '$lib/types'; import type { CustomTheme } from '$lib/types';
import ResizeHandles from '$lib/components/ResizeHandles.svelte'; import ResizeHandles from '$lib/components/ResizeHandles.svelte';
import { resolveVaultFilePath } from '$lib/utils/paths';
let { children } = $props(); let { children } = $props();
@@ -73,16 +74,6 @@
} }
} }
function normalizePath(p: string): string {
const parts = p.split('/');
const resolved: string[] = [];
for (const seg of parts) {
if (seg === '..') resolved.pop();
else if (seg !== '.') resolved.push(seg);
}
return resolved.join('/');
}
function openLocalFile(path: string) { function openLocalFile(path: string) {
if (isAndroid) { if (isAndroid) {
const bridge = (window as any).Android; const bridge = (window as any).Android;
@@ -98,24 +89,12 @@
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) { if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err)); openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) { } else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig); const config = get(appConfig);
const vaultRoot = config?.active_vault; const absPath = resolveVaultFilePath(
let absPath = decoded; decodeURIComponent(href),
if (!decoded.startsWith('/') && vaultRoot) { get(activeNotePath),
// .helixnotes/ paths are always relative to vault root, not the note's directory config?.active_vault ?? null,
if (decoded.startsWith('.helixnotes/')) { );
absPath = normalizePath(`${vaultRoot}/${decoded}`);
} else {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
}
// Internal .md note link - navigate within the app // Internal .md note link - navigate within the app
if (absPath.endsWith('.md')) { if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => { readNote(absPath).then((content) => {
+13 -2
View File
@@ -3,6 +3,7 @@
import { appConfig, vaultReady, theme } from '$lib/stores/app'; import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api'; import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
import { darkThemes, isIOS, isMobile } from '$lib/platform'; import { darkThemes, isIOS, isMobile } from '$lib/platform';
import { getWheelFontSizeAction } from '$lib/utils/editor-zoom';
import { getCurrentWebview } from '@tauri-apps/api/webview'; import { getCurrentWebview } from '@tauri-apps/api/webview';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -16,6 +17,7 @@
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null; let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null; let removeEditorZoomShortcuts: (() => void) | null = null;
let removeFontSizeListener: (() => void) | null = null; let removeFontSizeListener: (() => void) | null = null;
let removeScrollFontSizeListener: (() => void) | null = null;
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null; let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
let startupWindowRevealed = false; let startupWindowRevealed = false;
@@ -121,10 +123,15 @@
}; };
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return; const action = getWheelFontSizeAction(
event,
$appConfig?.scroll_to_change_font_size ?? true
);
if (action === 'ignore') return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
zoomEditor(event.deltaY < 0 ? 1 : -1); if (action === 'increase') zoomEditor(1);
else if (action === 'decrease') zoomEditor(-1);
}; };
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
@@ -153,6 +160,9 @@
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => { removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload); if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
}); });
removeScrollFontSizeListener = await listen<boolean>('scroll-to-change-font-size-changed', (event) => {
if ($appConfig) $appConfig.scroll_to_change_font_size = event.payload;
});
$theme = config.theme || 'system'; $theme = config.theme || 'system';
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve // Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
@@ -287,6 +297,7 @@
onDestroy(() => { onDestroy(() => {
removeEditorZoomShortcuts?.(); removeEditorZoomShortcuts?.();
removeFontSizeListener?.(); removeFontSizeListener?.();
removeScrollFontSizeListener?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer); if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
if (startupRevealTimer) clearTimeout(startupRevealTimer); if (startupRevealTimer) clearTimeout(startupRevealTimer);
}); });
+115
View File
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { createContext, Script } from 'node:vm';
import { compile, parse } from 'svelte/compiler';
import { get, writable } from 'svelte/store';
const source = await readFile(
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
'utf8'
);
const ast = parse(source, { modern: true });
function elementWithClass(nodes, className) {
return nodes.find((node) => node.attributes?.some((attribute) =>
attribute.name === 'class' && attribute.value[0]?.data === className
));
}
const aside = elementWithClass(ast.fragment.nodes, 'sidebar');
const blocks = aside.fragment.nodes.filter((node) => node.type === 'IfBlock');
const content = blocks.find((node) => elementWithClass(node.consequent.nodes, 'section'));
const header = blocks.find((node) => elementWithClass(node.consequent.nodes, 'sidebar-header'));
const toggle = elementWithClass(
elementWithClass(header.consequent.nodes, 'sidebar-header').fragment.nodes,
'collapse-btn'
);
function expressionScript(expression) {
assert.ok(expression, 'expected a component expression');
return new Script(`(${source.slice(expression.start, expression.end)})`);
}
// Execute the actual template conditions, not a copy of the collapse logic.
const expressions = {
contentVisible: expressionScript(content.test),
collapsedClass: expressionScript(aside.attributes.find((attribute) =>
attribute.type === 'ClassDirective' && attribute.name === 'collapsed'
).expression),
toggleVisible: expressionScript(header.test)
};
const toggleScript = expressionScript(
toggle.attributes.find((attribute) => attribute.name === 'onclick').value.expression
);
function sidebarContext(isMobile, stored) {
const sidebarCollapsed = writable(stored);
const context = createContext({
isMobile,
get $sidebarCollapsed() { return get(sidebarCollapsed); },
set $sidebarCollapsed(value) { sidebarCollapsed.set(value); }
});
return { context, sidebarCollapsed };
}
function evaluate(context) {
return Object.fromEntries(Object.entries(expressions).map(([name, script]) =>
[name, script.runInContext(context)]
));
}
const scenarios = [
{
platform: 'desktop', isMobile: false,
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: true },
collapsed: { contentVisible: false, collapsedClass: true, toggleVisible: true }
},
{
platform: 'mobile', isMobile: true,
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: false },
collapsed: { contentVisible: true, collapsedClass: false, toggleVisible: false }
}
];
for (const scenario of scenarios) {
for (const stored of [false, true]) {
test(`${scenario.platform} navigation with stored sidebar_collapsed=${stored}`, () => {
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, stored);
assert.deepEqual(evaluate(context), stored ? scenario.collapsed : scenario.expanded);
assert.equal(get(sidebarCollapsed), stored, 'rendering must preserve the stored preference');
});
}
test(`${scenario.platform} navigation follows late false -> true -> false store updates`, () => {
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, false);
assert.deepEqual(evaluate(context), scenario.expanded);
sidebarCollapsed.set(true);
assert.deepEqual(evaluate(context), scenario.collapsed);
assert.equal(get(sidebarCollapsed), true, 'rendering must not reset the loaded preference');
sidebarCollapsed.set(false);
assert.deepEqual(evaluate(context), scenario.expanded);
assert.equal(get(sidebarCollapsed), false);
});
}
test('desktop collapse toggle still collapses and expands the sidebar', () => {
const { context, sidebarCollapsed } = sidebarContext(false, false);
const toggleSidebar = toggleScript.runInContext(context);
toggleSidebar();
assert.equal(get(sidebarCollapsed), true);
assert.deepEqual(evaluate(context), scenarios[0].collapsed);
toggleSidebar();
assert.equal(get(sidebarCollapsed), false);
assert.deepEqual(evaluate(context), scenarios[0].expanded);
});
test('Sidebar compiles for the client', () => {
assert.doesNotThrow(() => compile(source, {
filename: 'src/lib/components/Sidebar.svelte',
generate: 'client'
}));
});
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const { compareNaturalNames } = await import(
new URL('../src/lib/utils/natural-sort.ts', import.meta.url)
);
test('sorts numeric name segments by numeric value', () => {
const names = ['10g', '11g', '12g', '5g', '6g', '7g', '8g', '9g'];
assert.deepEqual(names.sort(compareNaturalNames), [
'5g',
'6g',
'7g',
'8g',
'9g',
'10g',
'11g',
'12g'
]);
});
test('sorts numbers naturally wherever they appear in a name', () => {
const names = ['Class 10b', 'Class 2b', 'Class 10a', 'Class 2a'];
assert.deepEqual(names.sort(compareNaturalNames), [
'Class 2a',
'Class 2b',
'Class 10a',
'Class 10b'
]);
});
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { transformWithEsbuild } from 'vite';
const source = await readFile(
new URL('../src/lib/utils/paths.ts', import.meta.url),
'utf8'
);
const { code } = await transformWithEsbuild(source, 'paths.ts', {
loader: 'ts',
format: 'esm',
target: 'esnext'
});
const {
assetSourceToMarkdown,
assetUrlToLocalPath,
normalizeLocalAssetPath,
resolvePathFromFile,
resolveVaultFilePath
} = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
test('resolves note links relative to Windows note paths', () => {
assert.equal(
resolvePathFromFile('C:\\Vault\\Current.md', './Target.md'),
'C:/Vault/Target.md'
);
assert.equal(
resolvePathFromFile('C:\\Vault\\Folder\\Current.md', '../Target.md'),
'C:/Vault/Target.md'
);
assert.equal(
resolvePathFromFile('\\\\server\\share\\Folder\\Current.md', '../Target.md'),
'//server/share/Target.md'
);
});
test('preserves Linux relative note-link resolution', () => {
assert.equal(
resolvePathFromFile('/vault/folder/Current.md', '../Target.md'),
'/vault/Target.md'
);
});
test('converts Windows asset URLs back to portable attachment paths', () => {
assert.equal(
normalizeLocalAssetPath('/C:\\Users\\user\\Vault\\image.png'),
'C:/Users/user/Vault/image.png'
);
assert.equal(
assetSourceToMarkdown(
'http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5C.helixnotes%5Cattachments%5Cimage.png',
'C:\\Users\\user\\Vault\\Note.md',
'C:\\Users\\user\\Vault'
),
'.helixnotes/attachments/image.png'
);
});
test('preserves portable asset paths on Linux and macOS', () => {
assert.equal(
assetSourceToMarkdown(
'asset://localhost/%2Fhome%2Fuser%2FVault%2F.helixnotes%2Fattachments%2Fimage.png',
'/home/user/Vault/Note.md',
'/home/user/Vault'
),
'.helixnotes/attachments/image.png'
);
assert.equal(
assetSourceToMarkdown(
'asset://localhost/%2FUsers%2Fuser%2FVault%2Fassets%2Fimage.png',
'/Users/user/Vault/notes/Note.md',
'/Users/user/Vault'
),
'../assets/image.png'
);
assert.equal(
assetSourceToMarkdown('../assets/image.png', '/vault/notes/Note.md', '/vault'),
'../assets/image.png'
);
});
test('decodes local asset URLs without damaging platform roots', () => {
assert.equal(
assetUrlToLocalPath('http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5Cimage.png'),
'C:/Users/user/Vault/image.png'
);
assert.equal(
assetUrlToLocalPath('asset://localhost/%2Fhome%2Fuser%2FVault%2Fimage.png'),
'/home/user/Vault/image.png'
);
assert.equal(
assetUrlToLocalPath('http://asset.localhost/%5C%5Cserver%5Cshare%5CVault%5Cimage.png'),
'//server/share/Vault/image.png'
);
});
test('resolves vault files consistently across platforms', () => {
assert.equal(
resolveVaultFilePath('../assets/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
'C:/Vault/assets/image.png'
);
assert.equal(
resolveVaultFilePath('.helixnotes/attachments/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
'C:/Vault/.helixnotes/attachments/image.png'
);
assert.equal(
resolveVaultFilePath('../assets/image.png', '/vault/notes/Note.md', '/vault'),
'/vault/assets/image.png'
);
assert.equal(
resolveVaultFilePath('../assets/image.png', '\\\\server\\share\\Vault\\notes\\Note.md', '\\\\server\\share\\Vault'),
'//server/share/Vault/assets/image.png'
);
});
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = await readFile(
new URL('../src/lib/components/SettingsPanel.svelte', import.meta.url),
'utf8'
);
const initialization = source.match(
/(const\s+themePresets\s*=\s*\[[\s\S]*?\];)[\s\S]*?(?=\bconst\s+accentPresets\s*=)/
);
assert.ok(initialization, 'theme presets initialization was not found');
// Evaluate only the trusted repository declaration and its initialization code.
const unsorted = new Function(`${initialization[1]}\nreturn themePresets;`)();
const presets = new Function(`${initialization[0]}\nreturn themePresets;`)();
test('pins System, Light, and Dark in that exact order', () => {
assert.deepEqual(presets.slice(0, 3).map(({ id, label }) => ({ id, label })), [
{ id: 'system', label: 'System' },
{ id: 'light', label: 'Light' },
{ id: 'dark', label: 'Dark' }
]);
});
test('orders remaining built-in labels alphabetically, including Rosé Pine', () => {
const labels = presets.slice(3).map((preset) => preset.label);
assert.ok(labels.includes('Rosé Pine'));
assert.deepEqual(labels, [...labels].sort());
});
test('preserves every theme ID, label, and color without duplicates', () => {
assert.equal(presets.length, unsorted.length);
for (const themes of [unsorted, presets]) {
assert.equal(new Set(themes.map((preset) => preset.id)).size, themes.length);
assert.equal(new Set(themes.map((preset) => preset.label)).size, themes.length);
}
assert.deepEqual(
Object.fromEntries(presets.map((preset) => [preset.id, preset])),
Object.fromEntries(unsorted.map((preset) => [preset.id, preset]))
);
});
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const { restoreTitleHeading, stripTitleHeading } = await import(
new URL('../src/lib/editor/titleVisibility.ts', import.meta.url)
);
const editorSource = await readFile(
new URL('../src/lib/components/Editor.svelte', import.meta.url),
'utf8'
);
test('rich to source transition strips the restored title before display', () => {
const start = editorSource.indexOf('if (isSource && !lastSourceMode) {');
const end = editorSource.indexOf('} else if (!isSource && lastSourceMode)', start);
assert.notEqual(start, -1, 'rich to source transition was not found');
assert.notEqual(end, -1, 'rich to source transition boundary was not found');
assert.match(
editorSource.slice(start, end),
/sourceContent\s*=\s*stripTitleH1\(\s*editor\s*\?\s*editorToMarkdown\(\)\s*:\s*\(\$activeNote\?\.content\s*\?\?\s*''\)\s*\)\s*;/
);
});
test('source to rich transition preserves the hidden title on desktop and mobile', () => {
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
const transition = editorSource.slice(start, end);
assert.notEqual(start, -1, 'source to rich transition was not found');
assert.notEqual(end, -1, 'source to rich transition boundary was not found');
assert.match(
transition,
/editor\.commands\.setContent\(\s*markdownToHtml\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*\)\s*;/
);
assert.match(transition, /createEditor\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*;/);
});
test('source to rich transition preserves an empty source body on desktop and mobile', () => {
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
const transition = editorSource.slice(start, end);
const contentAssignments = [...transition.matchAll(/const content = ([^;]+);/g)]
.map((match) => match[1].trim());
assert.deepEqual(contentAssignments, ['srcText', 'srcText']);
assert.doesNotMatch(transition, /srcText\s*\|\|/);
});
test('keeps a hidden title through source to rich to source and save', () => {
const persisted = '# Note title\n\n## Something else\n\nBody\n';
const body = '## Something else\n\nBody\n';
const initialSource = stripTitleHeading(persisted, 'Note title', true);
const rich = stripTitleHeading(
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
'Note title',
true
);
const toggledSource = stripTitleHeading(
restoreTitleHeading(rich.markdown, rich.hiddenTitle),
'Note title',
true
);
assert.equal(rich.markdown, body);
assert.equal(toggledSource.markdown, body);
assert.equal(restoreTitleHeading(toggledSource.markdown, toggledSource.hiddenTitle), persisted);
});
test('keeps a title-only note visually empty and saves one title heading', () => {
const initialSource = stripTitleHeading('# Note title\n', 'Note title', true);
const rich = stripTitleHeading(
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
'Note title',
true
);
const saved = restoreTitleHeading(rich.markdown, rich.hiddenTitle);
assert.equal(initialSource.markdown, '');
assert.equal(rich.markdown, '');
assert.equal((saved.match(/^# Note title$/gm) ?? []).length, 1);
});
test('replaces hidden title state when an unrelated note loads', () => {
const markdown = '## Something else\n\nOther body\n';
const start = editorSource.indexOf('function stripTitleH1(md: string): string {');
const end = editorSource.indexOf('function restoreTitleH1(md: string): string {', start);
assert.deepEqual(stripTitleHeading(markdown, 'Another note', true), {
markdown,
hiddenTitle: null
});
assert.match(editorSource.slice(start, end), /hiddenTitleHeading\s*=\s*result\.hiddenTitle\s*;/);
});
test('leaves the title visible when title hiding is disabled', () => {
const persisted = '# Note title\n\n## Something else\n';
assert.deepEqual(stripTitleHeading(persisted, 'Note title', false), {
markdown: persisted,
hiddenTitle: null
});
});