Compare commits

...
49 Commits
Author SHA1 Message Date
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
Yuri Karamian 7ca226d918 Merge remote-tracking branch 'origin/main' 2026-08-07 01:06:32 +02:00
Yuri Karamian 1cbdf704bd Add opt-in title bar note switcher 2026-08-07 00:56:54 +02:00
Yuri Karamian b5bd5ea151 Document Linux installation options 2026-08-07 00:48:40 +02:00
Yuri Karamian bdf94e2494 Keep Note Info open across note changes 2026-08-06 15:13:43 +02:00
Yuri Karamian 1950c4cc3c Fix system theme pair integration 2026-08-06 15:12:43 +02:00
Yuri Karamian aeddcc6e58 Merge branch 'system-theme-pair' into 'main' 2026-08-06 14:07:34 +02:00
Yuri Karamian bb9d01f57c Merge branch 'custom-theme-derived-vars' into 'main' 2026-08-06 14:03:37 +02:00
Yuri Karamian ac25dfd773 Move note trash action into Note Info 2026-08-06 14:01:47 +02:00
Hadrian Chio e61c66dc26 Add light and dark theme pair for the system appearance setting 2026-08-05 09:24:06 +02:00
Hadrian Chio 3e8aa7f10d Fix custom themes falling back to light divider and tertiary text colors 2026-08-05 08:57:55 +02:00
52 changed files with 4627 additions and 2279 deletions
+33 -16
View File
@@ -14,25 +14,41 @@ No cloud, no lock-in.
### Linux
#### Arch / Manjaro (AUR)
#### AppImage
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)
#### Distro-specific packages
##### Fedora 43+ (DNF)
```bash
yay -S helixnotes-appimage-bin
sudo dnf config-manager addrepo \
--from-repofile=https://repo.arkhost.com/helixnotes.repo
sudo dnf install helix-notes
```
#### Debian / Ubuntu / Mint (APT)
##### Debian / Ubuntu / Mint (APT)
```bash
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
```
#### Solus (EOPKG)
##### Arch / Manjaro (AUR)
```bash
yay -S helixnotes-appimage-bin
```
##### Solus (EOPKG)
```bash
sudo eopkg it helixnotes
```
#### NixOS
##### NixOS
<details>
<summary>flake.nix</summary>
@@ -90,17 +106,10 @@ sudo eopkg it helixnotes
```
</details>
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
#### Manual package downloads
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
#### .rpm (manual)
[Download .rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
### Windows
@@ -157,7 +166,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
### Prerequisites
- [Rust](https://rustup.rs/) (1.77+)
- [Rust](https://rustup.rs/) (1.88+)
- [Node.js](https://nodejs.org/) (18+)
- [pnpm](https://pnpm.io/)
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
@@ -169,6 +178,14 @@ pnpm install
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
```bash
+14 -10
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.3.4",
"version": "1.3.5",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -11,6 +11,10 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"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:dev": "tauri dev",
"tauri:build": "tauri build"
@@ -18,22 +22,22 @@
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2.10.0",
"svelte": "^5.49.2",
"@types/node": "26.2.0",
"svelte": "^5.56.9",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.6"
},
"dependencies": {
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0",
@@ -62,11 +66,11 @@
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"lowlight": "^3.3.0",
"markdown-it": "^14.1.0",
"markdown-it": "^14.3.0",
"markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"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:
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"
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]]
name = "ahash"
version = "0.8.12"
@@ -138,12 +127,6 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -346,18 +329,6 @@ dependencies = [
"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]]
name = "block-buffer"
version = "0.10.4"
@@ -389,29 +360,6 @@ dependencies = [
"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]]
name = "brotli"
version = "8.0.2"
@@ -439,40 +387,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "bytemuck"
version = "1.25.0"
@@ -624,6 +538,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "chrono"
version = "0.4.44"
@@ -738,6 +663,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -768,9 +702,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -1314,12 +1248,6 @@ dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1590,11 +1518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1604,10 +1530,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -1796,9 +1725,6 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
@@ -1806,7 +1732,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
"ahash",
"allocator-api2",
]
@@ -1850,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.3.4"
version = "1.3.5"
dependencies = [
"arboard",
"chrono",
@@ -1862,7 +1788,7 @@ dependencies = [
"notify",
"objc2-app-kit",
"png 0.17.16",
"quick-xml 0.36.2",
"quick-xml",
"rayon",
"regex",
"reqwest 0.12.28",
@@ -2554,9 +2480,6 @@ name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]]
name = "lru"
@@ -2575,9 +2498,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lz4_flex"
version = "0.11.5"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
[[package]]
name = "mac"
@@ -3417,13 +3340,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml 0.38.4",
"quick-xml",
"serde",
"time",
]
@@ -3576,26 +3499,6 @@ dependencies = [
"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]]
name = "pure-rust-locales"
version = "0.8.2"
@@ -3619,18 +3522,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.36.2"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
@@ -3657,14 +3551,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.1",
"lru-slab",
"rand 0.9.2",
"rand 0.10.2",
"rand_pcg 0.10.2",
"ring",
"rustc-hash 2.1.1",
"rustls",
@@ -3705,12 +3600,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3722,7 +3611,7 @@ dependencies = [
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
"rand_pcg",
"rand_pcg 0.2.1",
]
[[package]]
@@ -3738,12 +3627,13 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.2"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
"chacha20",
"getrandom 0.4.1",
"rand_core 0.10.1",
]
[[package]]
@@ -3766,16 +3656,6 @@ dependencies = [
"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]]
name = "rand_core"
version = "0.5.1"
@@ -3796,12 +3676,9 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
@@ -3831,6 +3708,15 @@ dependencies = [
"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]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3935,15 +3821,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -4063,35 +3940,6 @@ dependencies = [
"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]]
name = "rust-stemmers"
version = "1.2.0"
@@ -4102,22 +3950,6 @@ dependencies = [
"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]]
name = "rustc-hash"
version = "1.1.0"
@@ -4231,9 +4063,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4327,12 +4159,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4574,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -4600,12 +4426,6 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -5007,17 +4827,11 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.44"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
@@ -5214,12 +5028,11 @@ dependencies = [
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
@@ -5964,12 +5777,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5988,12 +5795,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -6246,12 +6047,12 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.8"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.38.4",
"quick-xml",
"quote",
]
@@ -7007,15 +6808,6 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
+10 -3
View File
@@ -1,11 +1,15 @@
[package]
name = "helixnotes"
version = "1.3.4"
version = "1.3.5"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
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]
name = "app_lib"
@@ -14,6 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
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"] }
futures = "0.3"
rayon = "1"
quick-xml = "0.36"
quick-xml = "0.41"
urlencoding = "2"
sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
+1 -27
View File
@@ -24,32 +24,6 @@
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"fs:default",
"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"
"fs:allow-read-file"
]
}
+22 -10
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 OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
#[allow(clippy::too_many_arguments)]
pub fn ai_request(
app: AppHandle,
provider: String,
@@ -22,7 +23,11 @@ pub fn ai_request(
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// 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() {
"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`).
fn normalize_openai_base(base: &str) -> String {
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(
@@ -268,9 +276,7 @@ async fn stream_openai(
body["temperature"] = json!(0.7);
}
let mut req = client
.post(url)
.header("content-type", "application/json");
let mut req = client.post(url).header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
@@ -380,7 +386,11 @@ pub async fn test_connection(
model: &str,
base_url: Option<&str>,
) -> 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 {
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
"ollama" => {
@@ -436,7 +446,11 @@ 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> {
let client = Client::new();
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!({
"model": model,
@@ -449,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
]
});
let mut req = client
.post(url)
.header("content-type", "application/json");
let mut req = client.post(url).header("content-type", "application/json");
if let Some(key) = api_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 path = entry.path();
if path.extension().map_or(false, |ext| ext == "zip") {
if path.extension().is_some_and(|ext| ext == "zip") {
let filename = path
.file_name()
.unwrap_or_default()
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
Ok(entries)
}
/// Restore a backup by extracting the zip over the vault directory
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let backup = Path::new(backup_path);
if !backup.exists() {
return Err("Backup file does not exist".to_string());
fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
if backup.parent() != Some(backup_dir.as_path())
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
|| !backup.is_file()
{
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 mut archive =
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
}
/// Delete a single backup file
pub fn delete_backup(backup_path: &str) -> Result<(), String> {
let path = Path::new(backup_path);
if path.exists() {
fs::remove_file(path).map_err(|e| e.to_string())?;
}
Ok(())
pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
let path = validated_backup_file(backup_dir, backup_path)?;
fs::remove_file(path).map_err(|error| error.to_string())
}
/// 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 {
let to_remove = backups.split_off(max_count as usize);
for entry in to_remove {
delete_backup(&entry.path)?;
delete_backup(backup_dir, &entry.path)?;
}
}
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();
}
}
+586 -181
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::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use chrono::{DateTime, Utc};
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>/
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
Path::new(vault_path)
fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
Ok(Path::new(vault_path)
.join(".helixnotes")
.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.
/// Minimum interval: 5 minutes between snapshots.
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)
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.
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
let dir = history_dir(vault_path, note_id);
if let Err(e) = fs::create_dir_all(&dir) {
eprintln!("Failed to create history dir: {}", e);
return;
}
pub fn force_snapshot(
vault_path: &str,
note_id: &str,
raw_content: &str,
max_versions: u32,
) -> 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 filename = format!("{}.md", timestamp);
let path = dir.join(&filename);
if let Err(e) = fs::write(&path, raw_content) {
eprintln!("Failed to write version snapshot: {}", e);
return;
}
fs::write(&path, raw_content).map_err(|error| error.to_string())?;
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.
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() {
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())? {
let entry = entry.map_err(|e| e.to_string())?;
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
.file_stem()
.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.
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
let filename = if let Some(t_pos) = timestamp.find('T') {
let date_part = &timestamp[..t_pos];
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
let time_dashes = time_part.replace(':', "-");
format!("{}.md", format!("{}T{}", date_part, time_dashes))
format!("{date_part}T{time_dashes}.md")
} else {
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))
}
@@ -154,7 +168,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.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();
// Sort by name (timestamps sort lexicographically) - newest last
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
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());
}
}
+59 -65
View File
@@ -1,7 +1,9 @@
mod ai;
mod asset_scope;
mod backup;
mod commands;
mod history;
mod image_proxy;
mod search;
mod state;
mod sync;
@@ -11,6 +13,7 @@ mod vault;
use state::AppState;
#[allow(unused_imports)]
use tauri::{Emitter, Manager};
use tauri_plugin_fs::FsExt;
#[cfg(desktop)]
use tauri::{
@@ -56,6 +59,16 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::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| {
#[cfg(target_os = "macos")]
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)]
if show_tray {
setup_tray(app)?;
@@ -112,6 +139,14 @@ pub fn run() {
std::env::current_dir().unwrap_or_default().join(&path)
};
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.pending_open_file.lock().map(|mut p| {
*p = Some(resolved_str.to_string());
@@ -130,12 +165,14 @@ pub fn run() {
commands::remove_vault,
commands::get_app_config,
commands::set_theme,
commands::set_system_themes,
commands::set_accent_color,
commands::save_custom_theme,
commands::delete_custom_theme,
commands::export_custom_theme,
commands::import_custom_themes,
commands::set_font_size,
commands::set_scroll_to_change_font_size,
commands::set_font_family,
commands::set_line_height,
commands::set_ui_scale,
@@ -157,6 +194,7 @@ pub fn run() {
commands::move_note,
commands::get_all_tags,
commands::get_all_note_titles,
commands::get_note_switcher_titles,
commands::get_graph_data,
commands::get_tasks,
commands::set_task_done,
@@ -188,6 +226,7 @@ pub fn run() {
commands::trash_orphaned_attachments,
commands::import_obsidian,
commands::open_file,
commands::reveal_file,
commands::open_url,
commands::copy_file_to,
commands::write_bytes_to,
@@ -215,72 +254,21 @@ pub fn run() {
std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded);
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
let _ = responder.respond(
let response = match image_proxy::fetch(&external_url) {
Ok(image) => tauri::http::Response::builder()
.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()
.status(400)
.status(error.status())
.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;
}
};
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(),
);
}
}
responder.respond(response.expect("valid image proxy response"));
});
});
@@ -307,6 +295,13 @@ pub fn run() {
std::path::Path::new(&cwd).join(path)
};
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());
}
}
@@ -348,9 +343,9 @@ pub fn run() {
let _ = window.hide();
}
}
tauri::WindowEvent::Destroyed => {
tauri::WindowEvent::Destroyed
// When main window is destroyed, close all note windows
if window.label() == "main" {
if window.label() == "main" => {
let app = window.app_handle();
for (label, win) in app.webview_windows() {
if label.starts_with("note-") {
@@ -358,7 +353,6 @@ pub fn run() {
}
}
}
}
_ => {}
}
});
+14 -8
View File
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
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))
}
@@ -235,7 +238,9 @@ impl SearchIndex {
// before the writer is created, so both indexing and querying use it).
index.tokenizers().register(
"cjk",
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
TextAnalyzer::builder(CjkTokenizer)
.filter(LowerCaser)
.build(),
);
#[cfg(mobile)]
@@ -290,7 +295,7 @@ impl SearchIndex {
doc.add_text(self.path_field, &path_str);
doc.add_text(self.title_field, &meta.title);
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);
}
}
@@ -321,7 +326,7 @@ impl SearchIndex {
doc.add_text(self.path_field, path);
doc.add_text(self.title_field, &meta.title);
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);
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 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
// 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.)
@@ -376,9 +381,10 @@ impl SearchIndex {
));
vec![(Occur::Should, exact)]
} else {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
vec![Term::from_field_text(field, term)],
));
let prefix: Box<dyn Query> =
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
field, term,
)]));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term),
1,
+31 -8
View File
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> 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.
@@ -265,7 +268,11 @@ impl WebdavClient {
.send()
.map_err(|e| e.to_string())?;
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())
}
@@ -279,7 +286,11 @@ impl WebdavClient {
.send()
.map_err(|e| e.to_string())?;
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
.headers()
@@ -453,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
}
Ok(Event::Text(e)) => {
if cap != Cap::None {
if let Ok(t) = e.unescape() {
buf.push_str(&t);
if let Ok(decoded) = e.decode() {
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
buf.push_str(&text);
}
}
}
}
@@ -515,8 +528,14 @@ fn apply_changes(
match (l, r) {
(Some(lf), Some(re)) => {
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
let local_changed = match m {
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 {
new_m.files.insert(
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
/// 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;
let state = app.state::<AppState>();
+63 -1
View File
@@ -114,10 +114,18 @@ pub struct AppConfig {
#[serde(default)]
pub active_bookmark_id: Option<String>,
pub theme: String,
/// Themes used when `theme` is "system": the frontend picks one by the OS color scheme.
/// Default to the plain "light"/"dark" schemes so existing configs keep their behavior.
#[serde(default = "default_system_light_theme")]
pub system_light_theme: String,
#[serde(default = "default_system_dark_theme")]
pub system_dark_theme: String,
#[serde(default)]
pub accent_color: Option<String>,
#[serde(default)]
pub font_size: Option<u32>,
#[serde(default = "default_true")]
pub scroll_to_change_font_size: bool,
#[serde(default)]
pub font_family: Option<String>,
#[serde(default)]
@@ -131,6 +139,8 @@ pub struct AppConfig {
#[serde(default = "default_true")]
pub show_note_dates: bool,
#[serde(default)]
pub show_note_switcher: bool,
#[serde(default)]
pub time_format: String,
#[serde(default = "default_week_start")]
pub week_start: String,
@@ -269,6 +279,14 @@ fn default_ai_model() -> String {
"claude-sonnet-4-6".to_string()
}
fn default_system_light_theme() -> String {
"light".to_string()
}
fn default_system_dark_theme() -> String {
"dark".to_string()
}
impl Default for AppConfig {
fn default() -> Self {
Self {
@@ -276,14 +294,18 @@ impl Default for AppConfig {
active_vault: None,
active_bookmark_id: None,
theme: "system".to_string(),
system_light_theme: default_system_light_theme(),
system_dark_theme: default_system_dark_theme(),
accent_color: None,
font_size: None,
scroll_to_change_font_size: true,
font_family: None,
line_height: None,
ui_scale: None,
content_width: None,
compact_notes: false,
show_note_dates: true,
show_note_switcher: false,
time_format: "relative".to_string(),
week_start: "monday".to_string(),
daily_title_format: "localized".to_string(),
@@ -489,7 +511,7 @@ pub struct TaskItem {
#[cfg(test)]
mod startup_view_tests {
use super::StartupView;
use super::{AppConfig, StartupView};
#[test]
fn serializes_supported_startup_views() {
@@ -510,4 +532,44 @@ mod startup_view_tests {
StartupView::All
);
}
#[test]
fn existing_configs_default_system_theme_pair() {
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
let object = value.as_object_mut().unwrap();
object.remove("system_light_theme");
object.remove("system_dark_theme");
let config: AppConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.system_light_theme, "light");
assert_eq!(config.system_dark_theme, "dark");
}
#[test]
fn note_switcher_is_opt_in_for_new_and_existing_configs() {
let config = AppConfig::default();
assert!(!config.show_note_switcher);
let mut value = serde_json::to_value(config).unwrap();
value.as_object_mut().unwrap().remove("show_note_switcher");
let config: AppConfig = serde_json::from_value(value).unwrap();
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
.iter()
.map(|t| format!("{}", t))
.map(|t| t.to_string())
.collect::<Vec<_>>()
.join(", ")
)
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
chars.next(); // skip '['
let mut depth = 1;
// Skip alt text
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '[' {
depth += 1;
}
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') {
chars.next();
let mut depth = 1;
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '(' {
depth += 1;
}
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if ch == '[' {
let mut link_text = String::new();
let mut depth = 1;
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '[' {
depth += 1;
}
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') {
chars.next();
let mut depth = 1;
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '(' {
depth += 1;
}
+51 -37
View File
@@ -112,13 +112,15 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
);
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;
format!("![{}]({})", alt, link_target)
} else {
let display = if alt_param.is_empty() {
file_part.rsplit('/').next().unwrap_or(file_part)
} else if is_dimension_spec(alt_param) {
let display = if alt_param.is_empty() || is_dimension_spec(alt_param) {
file_part.rsplit('/').next().unwrap_or(file_part)
} else {
alt_param
@@ -137,7 +139,11 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
let (file_part, anchor) = split_anchor(note_ref);
if file_part.is_empty() {
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;
return format!("[{}](#{})", display, a);
}
@@ -165,8 +171,22 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
.to_string();
content = after_links;
content = fix_md_image_refs(&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);
content = fix_md_image_refs(
&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 {
changed = true;
@@ -231,12 +251,10 @@ fn rename_deprecated_properties(raw: &str) -> String {
mapping.insert(new_key, val);
renamed = true;
}
} else {
if mapping.remove(&old_key).is_some() {
} else if mapping.remove(&old_key).is_some() {
renamed = true;
}
}
}
if !renamed {
return raw.to_string();
@@ -267,7 +285,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
let tags = normalize_tags(&mapping);
let title = mapping
.get(&serde_yaml::Value::String("title".into()))
.get(serde_yaml::Value::String("title".into()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
@@ -283,7 +301,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
});
let id = mapping
.get(&serde_yaml::Value::String("id".into()))
.get(serde_yaml::Value::String("id".into()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
@@ -293,9 +311,9 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
.iter()
.find_map(|key| {
mapping
.get(&serde_yaml::Value::String((*key).into()))
.get(serde_yaml::Value::String((*key).into()))
.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))
.unwrap_or_else(Utc::now);
@@ -304,15 +322,15 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
.iter()
.find_map(|key| {
mapping
.get(&serde_yaml::Value::String((*key).into()))
.get(serde_yaml::Value::String((*key).into()))
.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))
.unwrap_or_else(Utc::now);
let pinned = mapping
.get(&serde_yaml::Value::String("pinned".into()))
.get(serde_yaml::Value::String("pinned".into()))
.and_then(|v| v.as_bool())
.unwrap_or(false);
@@ -332,7 +350,7 @@ fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec<String> {
let mut seen = HashSet::new();
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) {
let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
@@ -389,9 +407,7 @@ fn file_created(path: &Path) -> Option<chrono::DateTime<Utc>> {
.ok()
.and_then(|m| m.created().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| {
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
})
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
}
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
@@ -399,9 +415,7 @@ fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| {
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
})
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
}
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 href = &caps[2];
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('#')
{
return format!("[{}]({})", display, href);
@@ -549,9 +565,7 @@ fn fix_md_link_refs(
.to_string()
}
fn move_attachments(
vault: &Path,
) -> Result<HashMap<String, String>, String> {
fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
let attachments_dir = vault.join(".helixnotes").join("attachments");
let _ = std::fs::create_dir_all(&attachments_dir);
@@ -601,10 +615,7 @@ fn move_attachments(
Ok(moved)
}
fn rewrite_attachment_refs(
vault: &Path,
moved: &HashMap<String, String>,
) -> Result<(), String> {
fn rewrite_attachment_refs(vault: &Path, moved: &HashMap<String, String>) -> Result<(), String> {
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
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())
.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 {
let dir_str = dir.to_string_lossy();
@@ -719,7 +730,8 @@ fn is_dimension_spec(s: &str) -> bool {
if s.is_empty() {
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 {
@@ -907,7 +919,10 @@ mod tests {
extract_heading_title("# My Title\n\nBody"),
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("## Subheading"), None);
assert_eq!(extract_heading_title(""), None);
@@ -947,8 +962,7 @@ mod tests {
#[test]
fn test_normalize_tags_strips_hash() {
let mapping: serde_yaml::Mapping =
serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
let tags = normalize_tags(&mapping);
assert_eq!(tags, vec!["hashed"]);
}
+567 -107
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 chrono::{DateTime, Local, Locale, Utc};
use rayon::prelude::*;
use std::cmp::Ordering;
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use uuid::Uuid;
use walkdir::WalkDir;
@@ -12,6 +17,107 @@ pub fn helixnotes_dir(vault_path: &str) -> PathBuf {
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> {
let hn_dir = helixnotes_dir(vault_path);
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(())
}
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> {
let root = Path::new(vault_path);
if !root.exists() {
@@ -69,10 +228,10 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.collect();
dirs.sort_by(|a, b| {
a.file_name()
.to_string_lossy()
.to_lowercase()
.cmp(&b.file_name().to_string_lossy().to_lowercase())
compare_natural_names(
&a.file_name().to_string_lossy(),
&b.file_name().to_string_lossy(),
)
});
// 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
.par_iter()
.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
.strip_prefix(root)
.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| {
a.file_name()
.to_string_lossy()
.to_lowercase()
.cmp(&b.file_name().to_string_lossy().to_lowercase())
compare_natural_names(
&a.file_name().to_string_lossy(),
&b.file_name().to_string_lossy(),
)
});
let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect();
let entries: Vec<NotebookEntry> = paths
.par_iter()
.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
.strip_prefix(root)
.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> {
let scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path);
ensure_vault_content_dir(vault_path, root)?;
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() {
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();
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);
}
@@ -243,7 +416,9 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
} else {
WalkDir::new(root)
.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())
.map(|e| e.path().to_path_buf())
.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())
.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)
}
}
@@ -365,8 +540,18 @@ fn read_note_entry_from_str(
})
}
pub fn read_note(path: &str) -> Result<NoteContent, String> {
let p = Path::new(path);
pub fn read_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
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 filename = p
.file_name()
@@ -396,14 +581,15 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
}
Ok(NoteContent {
path: path.to_string(),
path: reported_path.to_string(),
meta,
content,
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();
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
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() {
frontmatter::update_note_raw(&updated_meta, body)
} else {
@@ -429,14 +615,11 @@ pub fn create_note(
notebook_relative: Option<&str>,
title: &str,
) -> Result<NoteEntry, String> {
let dir = match notebook_relative {
Some(rel) => Path::new(vault_path).join(rel),
let requested_dir = match notebook_relative {
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
None => PathBuf::from(vault_path),
};
if !dir.exists() {
return Err("Notebook directory does not exist".to_string());
}
let dir = ensure_vault_content_dir(vault_path, &requested_dir)?;
let filename = sanitize_filename(title);
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> {
let src = Path::new(path);
if !src.is_file() {
return Err("Note does not exist".to_string());
}
let validated = ensure_note_path(vault_path, Path::new(path))?;
let src = validated.as_path();
let parent = src
.parent()
@@ -614,7 +795,9 @@ pub fn create_daily_note(
"eu" => target_date.format("%d/%m/%Y").to_string(),
_ => {
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>,
name: &str,
) -> Result<NotebookEntry, String> {
let parent = match parent_relative {
Some(rel) => Path::new(vault_path).join(rel),
let requested_parent = match parent_relative {
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
None => PathBuf::from(vault_path),
};
let dir_path = parent.join(name);
let parent = ensure_vault_content_dir(vault_path, &requested_parent)?;
let dir_path = parent.join(safe_child_name(name)?);
if dir_path.exists() {
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> {
let src = Path::new(note_path);
if !src.exists() {
return Err("Note does not exist".to_string());
}
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
let src = validated.as_path();
let trash_dir = helixnotes_dir(vault_path).join("trash");
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> {
let src = Path::new(notebook_path);
if !src.exists() {
return Err("Notebook does not exist".to_string());
}
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
let src = validated.as_path();
let trash_dir = helixnotes_dir(vault_path).join("trash");
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> {
let src = Path::new(path);
if !src.exists() {
return Err("Note does not exist".to_string());
}
let validated = ensure_note_path(vault_path, Path::new(path))?;
let src = validated.as_path();
// Read old title before renaming
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();
// 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)
}
@@ -840,11 +1023,17 @@ fn update_wikilinks_after_rename(
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
if !path.is_file() {
continue;
}
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
if *path_str == *new_path { continue; }
if *path_str == *new_path {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
@@ -860,22 +1049,13 @@ fn update_wikilinks_after_rename(
// If another note shares the same title, these would be ambiguous.
if old_title != new_title && title_is_unique {
// 1. Short title ref: [[Old Title]] → [[New Title]]
result = result.replace(
&format!("[[{}]]", old_title),
&format!("[[{}]]", new_title),
);
result = result.replace(&format!("[[{}]]", old_title), &format!("[[{}]]", new_title));
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
result = result.replace(
&format!("[[{}|", old_title),
&format!("[[{}|", new_title),
);
result = result.replace(&format!("[[{}|", old_title), &format!("[[{}|", new_title));
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
result = result.replace(
&format!("|{}]]", old_title),
&format!("|{}]]", new_title),
);
result = result.replace(&format!("|{}]]", old_title), &format!("|{}]]", new_title));
}
// 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> {
let src = Path::new(path);
if !src.exists() {
return Err("Notebook does not exist".to_string());
}
pub fn rename_notebook(vault_path: &str, path: &str, new_name: &str) -> Result<String, String> {
let validated = ensure_notebook_path(vault_path, Path::new(path))?;
let src = validated.as_path();
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() {
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())
}
pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String> {
let src = Path::new(note_path);
if !src.exists() {
return Err("Note does not exist".to_string());
}
pub fn move_note(vault_path: &str, note_path: &str, dest_notebook: &str) -> Result<String, String> {
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
let src = validated.as_path();
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() {
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())
}
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> {
let src = Path::new(notebook_path);
if !src.exists() || !src.is_dir() {
return Err("Notebook does not exist".to_string());
}
pub fn move_notebook(
vault_path: &str,
notebook_path: &str,
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() {
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> {
let trash_dir = helixnotes_dir(vault_path).join("trash");
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);
@@ -1009,7 +1192,10 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
.min_depth(1)
.into_iter()
.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();
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
// 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)
.and_then(|m| m.modified())
.map(|t| DateTime::<Utc>::from(t))
.map(DateTime::<Utc>::from)
.unwrap_or_else(|_| Utc::now());
notebooks.push(TrashNotebookEntry {
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));
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
notebooks.sort_by_key(|notebook| std::cmp::Reverse(notebook.modified));
Ok(TrashContents { notes, notebooks })
}
@@ -1043,15 +1229,17 @@ pub fn restore_note(
trash_path: &str,
dest_notebook: Option<&str>,
) -> Result<String, String> {
let src = Path::new(trash_path);
if !src.exists() {
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
let src = validated.as_path();
if !src.is_file() {
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),
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)
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> {
let src = Path::new(trash_path);
if !src.exists() || !src.is_dir() {
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
let src = validated.as_path();
if !src.is_dir() {
return Err("Trashed notebook does not exist".to_string());
}
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
// 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) {
original
} else {
@@ -1098,7 +1289,7 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
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
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> {
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());
if p.is_dir() {
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)
}
pub(crate) fn normalize_notebook_icon_key(path: &str) -> String {
path.replace('\\', "/")
}
pub fn load_notebook_icons(
vault_path: &str,
) -> Result<std::collections::HashMap<String, String>, String> {
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
if icons_path.exists() {
let data = fs::read_to_string(&icons_path).map_err(|e| e.to_string())?;
serde_json::from_str(&data).map_err(|e| e.to_string())
} else {
Ok(std::collections::HashMap::new())
if !icons_path.exists() {
return 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(
@@ -1232,12 +1433,13 @@ pub fn set_notebook_icon(
icon_relative: Option<&str>,
) -> Result<(), String> {
let mut icons = load_notebook_icons(vault_path)?;
let notebook_key = normalize_notebook_icon_key(notebook_relative);
match icon_relative {
Some(icon) => {
icons.insert(notebook_relative.to_string(), icon.to_string());
icons.insert(notebook_key, icon.to_string());
}
None => {
icons.remove(notebook_relative);
icons.remove(&notebook_key);
}
}
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(())
}
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> {
let list = load_quick_access(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 {
let full_path = vault_root.join(relative);
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);
}
}
@@ -1309,10 +1564,57 @@ pub fn sanitize_filename(name: &str) -> String {
#[cfg(test)]
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 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]
fn persists_and_removes_builtin_notebook_icons() {
let vault =
@@ -1320,19 +1622,177 @@ mod tests {
let vault_path = vault.to_string_lossy();
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();
assert_eq!(
icons.get("Projects").map(String::as_str),
icons.get("Projects/Client").map(String::as_str),
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());
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]
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
let vault =
+7 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.3.4",
"version": "1.3.5",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
@@ -24,10 +24,14 @@
}
],
"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": {
"enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"]
"scope": {
"allow": [],
"deny": [],
"requireLiteralLeadingDot": true
}
}
}
},
+21 -1
View File
@@ -43,6 +43,10 @@ export async function setTheme(theme: string): Promise<void> {
return invoke("set_theme", { theme });
}
export async function setSystemThemes(light: string, dark: string): Promise<void> {
return invoke("set_system_themes", { light, dark });
}
export async function setAccentColor(color: string): Promise<void> {
return invoke("set_accent_color", { color });
}
@@ -67,6 +71,10 @@ export async function setFontSize(size: number): Promise<void> {
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> {
return invoke("set_font_family", { family });
}
@@ -175,7 +183,13 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
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");
}
@@ -274,6 +288,7 @@ export async function setGeneralSettings(
closeToTray: boolean,
enableWikiLinks: boolean,
showNoteDates: boolean,
showNoteSwitcher: boolean,
startupView: StartupView,
restoreLastSession: boolean,
showAllNotes: boolean,
@@ -301,6 +316,7 @@ export async function setGeneralSettings(
closeToTray,
enableWikiLinks,
showNoteDates,
showNoteSwitcher,
startupView,
restoreLastSession,
showAllNotes,
@@ -352,6 +368,10 @@ export async function openFile(path: string): Promise<void> {
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> {
return invoke("open_url", { url });
}
+27 -29
View File
@@ -21,6 +21,8 @@
showSearch,
showCommandPalette,
theme,
resolvedTheme,
customThemes,
focusMode,
readOnly,
activeNote,
@@ -79,9 +81,9 @@
return null;
}
let sidebar: Sidebar;
let noteList: NoteList;
let editor: Editor;
let sidebar = $state<Sidebar>();
let noteList = $state<NoteList>();
let editor = $state<Editor>();
let unlistenFileChange: (() => void) | null = null;
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
if (target.mode === 'notebook') {
@@ -294,6 +296,24 @@
if (isMobile) $mobileView = 'editor';
}
async function selectNoteFromSwitcher(path: string): Promise<boolean> {
const currentPath = $activeNotePath;
if (currentPath && currentPath.replace(/\\/g, '/') === path.replace(/\\/g, '/')) return true;
editor?.flushSave();
try {
const content = await readNote(path);
$viewerNote = null;
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
handleNoteSelected(path, content.content);
return true;
} catch (e) {
console.error('Failed to switch note:', e);
return false;
}
}
function handleViewChanged() {
taskNoteOpened = false;
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
@@ -507,7 +527,8 @@
createAndFocusNote();
return;
case 'toggle-theme': {
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const customTheme = $customThemes.find(theme => theme.id === $resolvedTheme);
const isDark = darkThemes.includes($resolvedTheme) || (customTheme?.is_dark ?? false);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
@@ -565,23 +586,6 @@
}
}
function applyTheme(t: string) {
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
}
$effect(() => {
applyTheme($theme);
});
$effect(() => {
$collapsedNotebooks;
persistState();
@@ -663,12 +667,6 @@
prefetchPromise = readNote(lastNotePath).catch(() => null);
}
applyTheme($theme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if ($theme === 'system') applyTheme('system');
});
// Run sidebar and note list refresh in parallel
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
@@ -831,7 +829,7 @@
<!-- Mobile Header -->
<div class="mobile-header">
{#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">
<path d="M15 18l-6-6 6-6"/>
</svg>
@@ -1017,7 +1015,7 @@
</div>
</div>
{:else}
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} onSelectNote={selectNoteFromSwitcher} />
{/if}
<div class="app-layout">
{#if !$focusMode}
+1 -2
View File
@@ -89,7 +89,6 @@
action: () => {
$theme = 'system';
setTheme('system');
applyTheme('system');
$showCommandPalette = false;
}
},
@@ -187,7 +186,7 @@
if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
} else if (t === 'dark') {
root.classList.add('dark');
}
}
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 LabelRect { x: number; y: number; w: number; h: number; }
let nodes: GraphNode[] = [];
let edges: GraphEdge[] = [];
let nodes = $state.raw<GraphNode[]>([]);
let edges = $state.raw<GraphEdge[]>([]);
let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set();
let nodeDegree: number[] = [];
let searchMatchSet: Set<number> | null = null;
let searchMatchSet = $state.raw<Set<number> | null>(null);
let folderColorMap = new Map<string, string>();
let pan = { x: 0, y: 0 };
@@ -40,7 +40,7 @@
let hoveredNeighborSet: Set<number> = new Set();
let hoveredEdgeSet: Set<number> = new Set();
let glowPhase = 0;
let activeNodeIdx = -1;
let activeNodeIdx = $state(-1);
let navigatedFromGraph = false;
// Combined animation loop
+6 -182
View File
@@ -1,10 +1,7 @@
<script lang="ts">
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 { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types';
import {
ACTIONS,
keybindings,
@@ -84,13 +81,8 @@
if (capturingId === id) stopCapture();
}
let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
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');
@@ -99,49 +91,21 @@
activeTab = isMobile ? 'about' : 'shortcuts';
}
function closeFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) close();
}
function openLink(url: string) {
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>
{#if $showInfo}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="info-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
<div class="info-overlay" onclick={closeFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<div class="info-panel">
<div class="info-header">
<h2>Info</h2>
<button class="close-btn" onclick={close} aria-label="Close info">
@@ -176,49 +140,7 @@
<p class="app-version">v{appVersion}</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">
<p>Created by <strong>Yuri Karamian</strong></p>
@@ -417,39 +339,6 @@
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 {
margin-top: 20px;
@@ -481,71 +370,6 @@
.info-link:hover {
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 {
width: 100%;
+26 -39
View File
@@ -37,12 +37,12 @@
reorderQuickAccess,
moveNote,
getAllTags,
createDailyNote
createDailyNote,
revealFile
} from '$lib/api';
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.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))));
}
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 (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) {
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -1076,12 +1086,11 @@
{#if batchMovePicker}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchMovePicker = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-overlay" onclick={closeBatchMoveOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchMovePicker = false; }}>
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-move-title" tabindex="-1">
<div class="batch-move-header">
<span>Move {selectedPaths.size} notes to...</span>
<button class="selection-close" onclick={() => batchMovePicker = false}>
<span id="batch-move-title">Move {selectedPaths.size} notes to...</span>
<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>
</button>
</div>
@@ -1105,12 +1114,11 @@
{#if batchTagEdit && !contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-overlay" onclick={closeBatchTagOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchTagEdit = false; }}>
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-tags-title" tabindex="-1">
<div class="batch-move-header">
<span>Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false}>
<span id="batch-tags-title">Tags for {selectedPaths.size} notes</span>
<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>
</button>
</div>
@@ -1408,7 +1416,7 @@
</div>
{#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)}
<!-- Batch context menu -->
{#if $viewMode === 'trash'}
@@ -1559,7 +1567,7 @@
Open in New Window
</button>
{#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">
<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>
@@ -1611,8 +1619,7 @@
{/if}
{#if sortMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="sort-menu" style="left: {sortMenu.x}px; top: {sortMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="sort-menu" class:mobile={isMobile} style="left: {sortMenu.x}px; top: {sortMenu.y}px">
<div class="sort-menu-title">Sort by</div>
<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">
@@ -2335,13 +2342,13 @@
border-radius: 8px;
}
.note-list.mobile .sort-menu {
.sort-menu.mobile {
min-width: 220px;
border-radius: 12px;
padding: 6px;
}
.note-list.mobile .sort-menu button {
.sort-menu.mobile button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
@@ -2365,26 +2372,6 @@
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-btn {
background: var(--accent);
+466
View File
@@ -0,0 +1,466 @@
<script lang="ts">
import { tick } from 'svelte';
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
import { getNoteSwitcherTitles, getQuickAccess } from '$lib/api';
import { openNoteWindow } from '$lib/utils/window';
import {
buildNoteSwitcherRequestPaths,
buildNoteSwitcherSections,
type NoteSwitcherNote,
type NoteSwitcherRow,
type NoteSwitcherSections
} from '$lib/utils/note-switcher';
import type { NoteEntry, NoteTitleEntry } from '$lib/types';
let { onSelectNote = async () => false }: {
onSelectNote?: (path: string) => Promise<boolean>;
} = $props();
let wrapper: HTMLDivElement;
let trigger: HTMLButtonElement;
let popover = $state<HTMLDivElement | null>(null);
let open = $state(false);
let loading = $state(false);
let selectingPath = $state<string | null>(null);
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
let loadGeneration = 0;
let loadedVaultPath = $state<string | null | undefined>(undefined);
function normalizedPath(path: string): string {
return path.replace(/\\/g, '/').replace(/\/$/, '');
}
function isInsideVault(path: string, vaultPath: string): boolean {
return normalizedPath(path).startsWith(`${normalizedPath(vaultPath)}/`);
}
function vaultRelativePath(path: string, vaultPath: string): string {
return normalizedPath(path).slice(normalizedPath(vaultPath).length + 1);
}
function joinVaultPath(vaultPath: string, relativePath: string): string {
const separator = vaultPath.includes('\\') && !vaultPath.includes('/') ? '\\' : '/';
const root = vaultPath.replace(/[\\/]+$/, '');
return `${root}${separator}${relativePath.replace(/[\\/]/g, separator)}`;
}
function titleEntryToNote(entry: NoteTitleEntry, vaultPath: string): NoteSwitcherNote | null {
const relativePath = entry.path.replace(/\\/g, '/').replace(/^\/+/, '');
if (!relativePath || relativePath.split('/').includes('..')) return null;
const path = joinVaultPath(vaultPath, relativePath);
if (!isInsideVault(path, vaultPath)) return null;
return { path, title: entry.title, relativePath };
}
function quickAccessEntryToNote(entry: NoteEntry, vaultPath: string): NoteSwitcherNote | null {
if (!isInsideVault(entry.path, vaultPath)) return null;
return {
path: entry.path,
title: entry.meta.title,
relativePath: entry.relative_path.replace(/\\/g, '/')
};
}
async function focusInitialRow() {
await tick();
if (!open) return;
const current = wrapper.querySelector<HTMLButtonElement>('.note-primary[aria-current="true"]');
const first = wrapper.querySelector<HTMLButtonElement>('.note-primary');
(current ?? first ?? popover)?.focus();
}
async function refreshSections(generation: number) {
const vaultPath = $appConfig?.active_vault;
if (!vaultPath) {
sections = { recent: [], quickAccess: [] };
loadedVaultPath = null;
loading = false;
await focusInitialRow();
return;
}
const requestPaths = buildNoteSwitcherRequestPaths($activeNotePath, $navHistory.stack);
const [titlesResult, quickAccessResult] = await Promise.allSettled([
getNoteSwitcherTitles(requestPaths),
getQuickAccess()
]);
if (!open || generation !== loadGeneration) return;
if (titlesResult.status === 'rejected') {
console.error('Failed to load notes for note switcher:', titlesResult.reason);
}
if (quickAccessResult.status === 'rejected') {
console.error('Failed to load Quick Access for note switcher:', quickAccessResult.reason);
}
const knownNotes = titlesResult.status === 'fulfilled'
? titlesResult.value
.map((entry) => titleEntryToNote(entry, vaultPath))
.filter((entry): entry is NoteSwitcherNote => entry !== null)
: [];
const currentPath = $activeNotePath;
if (currentPath && isInsideVault(currentPath, vaultPath)) {
const currentKey = normalizedPath(currentPath);
if (!knownNotes.some((note) => normalizedPath(note.path) === currentKey)) {
knownNotes.push({
path: currentPath,
title: $activeNote?.meta.title || currentPath.split(/[\\/]/).pop()?.replace(/\.md$/i, '') || 'Untitled',
relativePath: vaultRelativePath(currentPath, vaultPath)
});
}
}
const quickAccessNotes = quickAccessResult.status === 'fulfilled'
? quickAccessResult.value
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
.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({
currentPath,
historyPaths: $navHistory.stack,
knownNotes,
quickAccessNotes
});
loadedVaultPath = vaultPath;
loading = false;
await focusInitialRow();
}
function refreshAfterPopoverPaint(generation: number) {
requestAnimationFrame(() => {
window.setTimeout(() => {
if (!open || generation !== loadGeneration) return;
void refreshSections(generation);
}, 0);
});
}
function toggleSwitcher() {
if (open) {
open = false;
loadGeneration += 1;
return;
}
const vaultPath = $appConfig?.active_vault ?? null;
const hasCachedSections = loadedVaultPath === vaultPath;
open = true;
loading = !hasCachedSections;
selectingPath = null;
if (!hasCachedSections) sections = { recent: [], quickAccess: [] };
loadGeneration += 1;
const generation = loadGeneration;
if (hasCachedSections) void focusInitialRow();
refreshAfterPopoverPaint(generation);
}
async function closeSwitcher(restoreTriggerFocus: boolean) {
open = false;
loadGeneration += 1;
if (restoreTriggerFocus) {
await tick();
trigger?.focus();
}
}
async function selectNote(row: NoteSwitcherRow) {
if (selectingPath !== null) return;
selectingPath = row.path;
try {
if (await onSelectNote(row.path)) await closeSwitcher(false);
} finally {
selectingPath = null;
}
}
function handlePopoverKeydown(event: KeyboardEvent) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const buttons = Array.from(wrapper.querySelectorAll<HTMLButtonElement>('.note-primary:not(:disabled)'));
if (buttons.length === 0) return;
event.preventDefault();
const activeRow = (document.activeElement as HTMLElement | null)?.closest('.note-row');
const activeButton = activeRow?.querySelector<HTMLButtonElement>('.note-primary') ?? null;
const currentIndex = activeButton ? buttons.indexOf(activeButton) : -1;
let nextIndex: number;
if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = buttons.length - 1;
else if (event.key === 'ArrowDown') nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
else nextIndex = currentIndex < 0 ? buttons.length - 1 : (currentIndex - 1 + buttons.length) % buttons.length;
buttons[nextIndex].focus();
}
$effect(() => {
if (!open) return;
const handleOutsideMouseDown = (event: MouseEvent) => {
if (!wrapper.contains(event.target as Node)) void closeSwitcher(false);
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
void closeSwitcher(true);
};
document.addEventListener('mousedown', handleOutsideMouseDown, true);
document.addEventListener('keydown', handleEscape, true);
return () => {
document.removeEventListener('mousedown', handleOutsideMouseDown, true);
document.removeEventListener('keydown', handleEscape, true);
};
});
</script>
<div class="note-switcher" bind:this={wrapper}>
<button
bind:this={trigger}
class="switcher-trigger"
type="button"
aria-label="Switch note"
aria-haspopup="dialog"
aria-expanded={open}
onclick={toggleSwitcher}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="7" y="4" width="12" height="14" rx="2" />
<path d="M5 7H4a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-1" />
</svg>
</button>
{#if open}
<div
bind:this={popover}
class="switcher-popover"
role="dialog"
aria-label="Note switcher"
tabindex="-1"
onkeydown={handlePopoverKeydown}
>
{#if loading}
<div class="switcher-status">Loading notes…</div>
{:else if sections.recent.length === 0 && sections.quickAccess.length === 0}
<div class="switcher-status">No notes available</div>
{:else}
{#each [
{ label: 'Recent', rows: sections.recent },
{ label: 'Quick Access', rows: sections.quickAccess }
] as section (section.label)}
{#if section.rows.length > 0}
<section class="switcher-section" aria-labelledby={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>
<h2 id={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>{section.label}</h2>
{#each section.rows as row (row.path)}
<div class="note-row" class:current-row={row.current}>
{#if row.current}<span class="current-marker" aria-hidden="true"></span>{/if}
<button
class="note-primary"
type="button"
aria-current={row.current ? 'true' : undefined}
disabled={selectingPath !== null}
onclick={() => selectNote(row)}
>
<span class="note-title">{row.title}</span>
<span class="note-folder">{row.folder}</span>
</button>
<button
class="open-window"
type="button"
aria-label={`Open ${row.title} in new window`}
title={`Open ${row.title} in new window`}
onclick={() => openNoteWindow(row.path, row.title)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="4" width="13" height="13" rx="2" />
<path d="M10 14L20 4m-6 0h6v6" />
</svg>
</button>
</div>
{/each}
</section>
{/if}
{/each}
{/if}
</div>
{/if}
</div>
<style>
.note-switcher {
position: relative;
-webkit-app-region: no-drag;
}
.switcher-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.switcher-trigger:hover,
.switcher-trigger[aria-expanded='true'] {
background: var(--bg-tertiary, var(--bg-hover));
color: var(--text-primary);
}
.switcher-trigger:focus-visible,
.note-primary:focus-visible,
.open-window:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.switcher-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 1000;
width: min(320px, calc(100vw - 24px));
max-height: min(420px, calc(100vh - 58px));
overflow-y: auto;
padding: 6px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
color: var(--text-primary);
user-select: none;
-webkit-app-region: no-drag;
}
.switcher-section + .switcher-section {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid var(--border-color);
}
h2 {
margin: 0;
padding: 5px 8px 4px;
color: var(--text-tertiary);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
line-height: 1.2;
text-transform: uppercase;
}
.note-row {
position: relative;
display: flex;
align-items: center;
min-height: 42px;
border-radius: 7px;
}
.note-row:hover,
.note-row:focus-within {
background: var(--bg-hover);
}
.note-row.current-row {
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.current-marker {
position: absolute;
left: 7px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
}
.note-primary {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 6px 38px 6px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.current-row .note-primary {
padding-left: 18px;
}
.note-primary:disabled {
cursor: wait;
}
.note-title,
.note-folder {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-title {
font-size: 12px;
font-weight: 500;
line-height: 1.25;
}
.note-folder {
color: var(--text-tertiary);
font-size: 10.5px;
line-height: 1.2;
}
.open-window {
position: absolute;
right: 5px;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s, background 0.12s, color 0.12s;
}
.note-row:hover .open-window,
.note-row:focus-within .open-window {
opacity: 1;
pointer-events: auto;
}
.open-window:hover {
background: var(--bg-tertiary, var(--bg-hover));
color: var(--text-primary);
}
.switcher-status {
padding: 22px 12px;
color: var(--text-tertiary);
font-size: 11px;
text-align: center;
}
</style>
+19 -18
View File
@@ -1,15 +1,16 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { getCurrentWindow } from '@tauri-apps/api/window';
import Editor from './Editor.svelte';
import {
appConfig,
activeNote,
activeNotePath,
editorDirty,
readOnly,
sourceMode,
theme
sourceMode
} from '$lib/stores/app';
import { readNote } from '$lib/api';
import { keybindings, matchAction } from '$lib/keybindings';
@@ -17,12 +18,12 @@
let { notePath }: { notePath: string } = $props();
import { darkThemes } from '$lib/platform';
const appWindow = getCurrentWindow();
const appWebview = getCurrentWebview();
const isMac = navigator.platform.startsWith('Mac');
let editor = $state<Editor>(null!);
let unlistenFileChange: (() => void) | null = null;
let unlistenUiScale: (() => void) | null = null;
let maximized = $state(false);
let loadError = $state<string | null>(null);
@@ -36,20 +37,6 @@
return () => { unlisten.then(fn => fn()); };
});
$effect(() => {
const t = $theme || 'system';
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
});
let lastMouseDown = 0;
const RESIZE_EDGE = 6;
@@ -84,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 () => {
unlistenUiScale = await listen<number>('ui-scale-changed', (event) => {
void applyUiScale(event.payload);
});
await applyUiScale($appConfig?.ui_scale ?? 1);
try {
const content = await readNote(notePath);
$activeNote = content;
@@ -116,6 +116,7 @@
onDestroy(() => {
unlistenFileChange?.();
unlistenUiScale?.();
});
</script>
+8 -8
View File
@@ -30,14 +30,14 @@
{#if appWindow && !maximized}
<div class="resize-layer" aria-hidden="true">
<div class="rz rz-n" onmousedown={(e) => start(e, 'North')}></div>
<div class="rz rz-s" onmousedown={(e) => start(e, 'South')}></div>
<div class="rz rz-w" onmousedown={(e) => start(e, 'West')}></div>
<div class="rz rz-e" onmousedown={(e) => start(e, 'East')}></div>
<div class="rz rz-nw" onmousedown={(e) => start(e, 'NorthWest')}></div>
<div class="rz rz-ne" onmousedown={(e) => start(e, 'NorthEast')}></div>
<div class="rz rz-sw" onmousedown={(e) => start(e, 'SouthWest')}></div>
<div class="rz rz-se" onmousedown={(e) => start(e, 'SouthEast')}></div>
<div class="rz rz-n" role="presentation" onmousedown={(e) => start(e, 'North')}></div>
<div class="rz rz-s" role="presentation" onmousedown={(e) => start(e, 'South')}></div>
<div class="rz rz-w" role="presentation" onmousedown={(e) => start(e, 'West')}></div>
<div class="rz rz-e" role="presentation" onmousedown={(e) => start(e, 'East')}></div>
<div class="rz rz-nw" role="presentation" onmousedown={(e) => start(e, 'NorthWest')}></div>
<div class="rz rz-ne" role="presentation" onmousedown={(e) => start(e, 'NorthEast')}></div>
<div class="rz rz-sw" role="presentation" onmousedown={(e) => start(e, 'SouthWest')}></div>
<div class="rz rz-se" role="presentation" onmousedown={(e) => start(e, 'SouthEast')}></div>
</div>
{/if}
+440 -65
View File
@@ -1,18 +1,19 @@
<script lang="ts">
import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { setTheme, 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 { 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, 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 { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app';
import { getCurrentWebview } from '@tauri-apps/api/webview';
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';
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');
// 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
let backups = $state<BackupEntry[]>([]);
let backupLoading = $state(false);
@@ -418,11 +480,12 @@
}
let isThemeDark = $derived(
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
($theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false))
darkThemes.includes($resolvedTheme) ||
($resolvedTheme.startsWith('custom-') && ($customThemes.find(c => c.id === $resolvedTheme)?.is_dark ?? false))
);
const themePresets = [
{ id: 'system', label: 'System', bg: '#ffffff', sidebar: '#1a1b26', accent: '#5b6abf' },
{ id: 'light', label: 'Light', bg: '#ffffff', sidebar: '#f8f9fa', accent: '#5b6abf' },
{ id: 'dark', label: 'Dark', bg: '#1a1b26', sidebar: '#1f2029', accent: '#89b4fa' },
{ id: 'solarized-light', label: 'Solarized Light', bg: '#fdf6e3', sidebar: '#eee8d5', accent: '#268bd2' },
@@ -520,6 +583,7 @@
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
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 activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
@@ -527,7 +591,45 @@
let themeDropdownOpen = $state(false);
let accentDropdownOpen = $state(false);
let activeCustomTheme = $derived($customThemes.find(c => c.id === $theme) ?? null);
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
let activeThemePreset = $derived(
themePresets.find(p => p.id === $theme) ?? themePresets.find(p => p.id === 'light')!
);
// Which theme each OS appearance maps to while `theme` is "system". Custom themes are offered
// alongside the built-ins so a light/dark pair can be built out of them.
let systemLightTheme = $state($appConfig?.system_light_theme ?? 'light');
let systemDarkTheme = $state($appConfig?.system_dark_theme ?? 'dark');
let systemPairOpen = $state<'light' | 'dark' | null>(null);
let pairPresets = $derived([
...themePresets.filter(p => p.id !== 'system'),
...$customThemes.map(ct => ({
id: ct.id,
label: ct.name,
bg: ct.colors.bg_primary,
sidebar: ct.colors.bg_secondary,
accent: ct.colors.text_primary,
})),
]);
let systemPairRows = $derived([
{ key: 'light' as const, title: 'Light appearance', selected: systemLightTheme },
{ key: 'dark' as const, title: 'Dark appearance', selected: systemDarkTheme },
]);
function selectSystemPairTheme(which: 'light' | 'dark', id: string) {
if (which === 'light') systemLightTheme = id;
else systemDarkTheme = id;
systemPairOpen = null;
if ($appConfig) {
$appConfig.system_light_theme = systemLightTheme;
$appConfig.system_dark_theme = systemDarkTheme;
$appConfig = { ...$appConfig };
}
setSystemThemes(systemLightTheme, systemDarkTheme).catch((e) =>
console.error('Failed to save system themes:', e)
);
}
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
let isCustomAccent = $derived(activeAccent.startsWith('#'));
let activeAccentColor = $derived(
@@ -553,7 +655,7 @@
let customThemeImporting = $state(false);
function openNewCustomThemeEditor() {
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) || $theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false);
const isDark = isThemeDark;
customThemeEditing = {
id: `custom-${Date.now()}`,
name: '',
@@ -585,18 +687,20 @@
root.style.setProperty('--text-primary', customThemeEditing.colors.text_primary);
root.style.setProperty('--text-secondary', customThemeEditing.colors.text_secondary);
root.style.setProperty('--border-color', customThemeEditing.colors.border_color);
root.style.setProperty('--border-light', customThemeEditing.colors.border_color);
root.style.setProperty('--text-tertiary', customThemeEditing.colors.text_secondary);
root.classList.toggle('dark', customThemeEditing.is_dark);
}
function restoreCurrentTheme() {
const root = document.documentElement;
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color','--border-light','--text-tertiary'];
root.classList.remove('dark');
root.removeAttribute('data-theme');
for (const v of varsToClear) root.style.removeProperty(v);
const namedThemes = ['solarized-light','solarized-dark','catppuccin','nord','tokyo-night','github-light','github-dark','dracula','blueberry','forest-green','gruvbox','midnight-tide','cherry-blossom','synthwave','ember','moonlit','light-coffee','dark-coffee','cotton-candy','crimson','cloud','peach','material-dark','material-light','monokai','rose-pine','everforest','horizon','cyberpunk','black','one-dark'];
if ($theme.startsWith('custom-')) {
const ct = $customThemes.find(c => c.id === $theme);
if ($resolvedTheme.startsWith('custom-')) {
const ct = $customThemes.find(c => c.id === $resolvedTheme);
if (ct) {
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
@@ -607,12 +711,14 @@
root.style.setProperty('--text-primary', ct.colors.text_primary);
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
root.style.setProperty('--border-color', ct.colors.border_color);
root.style.setProperty('--border-light', ct.colors.border_color);
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
if (ct.is_dark) root.classList.add('dark');
}
} else if (namedThemes.includes($theme)) {
root.setAttribute('data-theme', $theme);
if (darkThemes.includes($theme)) root.classList.add('dark');
} else if ($theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
} else if (namedThemes.includes($resolvedTheme)) {
root.setAttribute('data-theme', $resolvedTheme);
if (darkThemes.includes($resolvedTheme)) root.classList.add('dark');
} else if ($resolvedTheme === 'dark') {
root.classList.add('dark');
}
}
@@ -639,6 +745,10 @@
closeCustomThemeEditor();
}
function cancelCustomThemeFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) cancelCustomThemeEditor();
}
async function selectCustomTheme(ct: CustomTheme) {
$theme = ct.id;
setTheme(ct.id);
@@ -647,9 +757,15 @@
async function removeCustomTheme(ct: CustomTheme) {
try {
await deleteCustomTheme(ct.id);
if (systemLightTheme === ct.id) systemLightTheme = 'light';
if (systemDarkTheme === ct.id) systemDarkTheme = 'dark';
if ($appConfig) {
$appConfig.custom_themes = $appConfig.custom_themes.filter(t => t.id !== ct.id);
$appConfig = { ...$appConfig };
$appConfig = {
...$appConfig,
custom_themes: $appConfig.custom_themes.filter(t => t.id !== ct.id),
system_light_theme: systemLightTheme,
system_dark_theme: systemDarkTheme,
};
}
if ($theme === ct.id) {
$theme = 'system';
@@ -701,6 +817,7 @@
// General settings
let compactNotes = $state($appConfig?.compact_notes ?? false);
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
let showNoteSwitcher = $state($appConfig?.show_note_switcher ?? false);
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
let timeFormat = $state($appConfig?.time_format ?? 'relative');
@@ -776,6 +893,7 @@
if ($appConfig) {
$appConfig.compact_notes = compactNotes;
$appConfig.show_note_dates = showNoteDates;
$appConfig.show_note_switcher = showNoteSwitcher;
$appConfig.startup_view = startupView;
$appConfig.restore_last_session = restoreLastSession;
$appConfig.time_format = timeFormat;
@@ -800,7 +918,7 @@
$appConfig.show_daily_notes = showDailyNotes;
$appConfig.show_trash = showTrash;
}
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, showNoteSwitcher, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
.catch((e) => console.error('Failed to save general settings:', e));
}
@@ -812,7 +930,9 @@
$theme = preset.id;
setTheme(preset.id);
themeDropdownOpen = false;
selectCustomAccent(preset.accent);
// "System" has no palette of its own, it defers to whichever paired theme is active, so it
// must not overwrite the accent the way a concrete theme does.
if (preset.id !== 'system') selectCustomAccent(preset.accent);
}
function selectAccent(preset: typeof accentPresets[0]) {
@@ -823,7 +943,7 @@
function applyAccent(preset: typeof accentPresets[0]) {
const root = document.documentElement;
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const isDark = isThemeDark;
const color = isDark ? preset.dark : preset.light;
root.style.setProperty('--accent', color);
@@ -846,7 +966,7 @@
function applyCustomAccent(hex: string) {
const root = document.documentElement;
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const isDark = isThemeDark;
root.style.setProperty('--accent', hex);
root.style.setProperty('--text-accent', hex);
root.style.setProperty('--accent-hover', hex);
@@ -868,6 +988,13 @@
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) {
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
}
@@ -937,6 +1064,14 @@
$showSettings = false;
}
function closeSettingsFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) close();
}
function dismissRestoreConfirm(event: MouseEvent) {
if (event.target === event.currentTarget) restoreConfirm = null;
}
// Apply saved settings on mount
$effect(() => {
const savedAccent = $appConfig?.accent_color;
@@ -953,6 +1088,7 @@
}
}
}
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
const savedSize = $appConfig?.font_size;
if (savedSize) {
activeFontSize = savedSize;
@@ -980,6 +1116,7 @@
if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false;
showNoteDates = $appConfig.show_note_dates ?? true;
showNoteSwitcher = $appConfig.show_note_switcher ?? false;
startupView = normalizeStartupView($appConfig.startup_view);
restoreLastSession = $appConfig.restore_last_session ?? false;
timeFormat = $appConfig.time_format ?? 'relative';
@@ -999,9 +1136,9 @@
}
});
// Re-apply when theme changes
// Re-apply when the concrete theme changes, including an OS appearance switch.
$effect(() => {
const _ = $theme;
const _ = $resolvedTheme;
const preset = accentPresets.find(p => p.name === activeAccent);
if (preset) {
applyAccent(preset);
@@ -1011,12 +1148,11 @@
{#if $showSettings}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-overlay" class:mobile={isMobile} onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-panel" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
<div class="settings-header">
<h2>Settings</h2>
<button class="close-btn" onclick={close}>
<h2 id="settings-title">Settings</h2>
<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">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
@@ -1060,6 +1196,12 @@
Backup
</button>
{/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'}>
<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"/>
@@ -1092,7 +1234,7 @@
<span class="setting-name">Compact mode</span>
<span class="setting-desc">Show notes in a denser layout without preview</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>
</button>
</label>
@@ -1101,7 +1243,7 @@
<span class="setting-name">Show dates</span>
<span class="setting-desc">Show the date next to each note in the list</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>
</button>
</label>
@@ -1109,12 +1251,12 @@
<div class="settings-section">
<h3>Notebooks</h3>
<label class="setting-toggle">
<div class="setting-toggle">
<span class="setting-label">
<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>
</label>
</div>
<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 === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
@@ -1127,31 +1269,46 @@
<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">
<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>
</button>
</label>
<label class="setting-toggle">
<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>
</button>
</label>
<label class="setting-toggle">
<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>
</button>
</label>
<label class="setting-toggle">
<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>
</button>
</label>
<label class="setting-toggle">
<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>
</button>
</label>
</div>
{/if}
{#if !$platformIsMobile}
<div class="settings-section">
<h3>Interface</h3>
<label class="setting-toggle">
<span class="setting-label">
<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>
<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>
</button>
</label>
@@ -1175,7 +1332,7 @@
<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>
<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>
</button>
</label>
@@ -1222,6 +1379,7 @@
</div>
{/if}
{#if !isMobile}
<div class="settings-section">
<h3>Performance</h3>
@@ -1230,7 +1388,7 @@
<span class="setting-name">GPU Acceleration</span>
<span class="setting-desc">Use hardware acceleration for rendering (requires restart)</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>
</button>
</label>
@@ -1243,7 +1401,7 @@
<span class="setting-name">Start at system startup</span>
<span class="setting-desc">Launch HelixNotes when your computer starts</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>
</button>
</label>
@@ -1252,7 +1410,7 @@
<span class="setting-name">Show in system tray</span>
<span class="setting-desc">Show an icon in the notification area (requires restart)</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>
</button>
</label>
@@ -1262,7 +1420,7 @@
<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>
<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>
</button>
</label>
@@ -1270,6 +1428,78 @@
</div>
{/if}
</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'}
<div class="tab-content">
<div class="settings-section">
@@ -1279,7 +1509,7 @@
<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>
<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>
</button>
</label>
@@ -1288,7 +1518,7 @@
<span class="setting-name">Show link arrows</span>
<span class="setting-desc">Display the arrow icon after external links</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>
</button>
</label>
@@ -1298,7 +1528,7 @@
<span class="setting-name">Show line numbers</span>
<span class="setting-desc">Display line numbers in the markdown source editor</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>
</button>
</label>
@@ -1308,7 +1538,7 @@
<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>
<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>
</button>
</label>
@@ -1317,7 +1547,7 @@
<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>
<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>
</button>
</label>
@@ -1331,7 +1561,7 @@
<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>
<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>
</button>
</label>
@@ -1346,7 +1576,7 @@
<span class="setting-name">Inline PDF preview</span>
<span class="setting-desc">Render embedded PDF files as inline previews inside notes</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>
</button>
</label>
@@ -1415,6 +1645,9 @@
</div>
{/if}
</div>
{#if $theme === 'system'}
<span class="setting-hint">Follows the operating system appearance.</span>
{/if}
</div>
<div class="settings-section">
@@ -1463,6 +1696,54 @@
{/if}
</div>
</div>
{#if $theme === 'system'}
{#each systemPairRows as row}
<div class="settings-section">
<h3>{row.title}</h3>
<div class="dropdown-wrap">
<button
class="dropdown-trigger"
class:open={systemPairOpen === row.key}
onclick={() => { systemPairOpen = systemPairOpen === row.key ? null : row.key; themeDropdownOpen = false; accentDropdownOpen = false; }}
>
{#each pairPresets.filter(p => p.id === row.selected) as preset}
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
<span class="preview-sidebar"></span>
<span class="preview-main">
<span class="preview-accent-bar"></span>
</span>
</span>
<span class="dropdown-trigger-label">{preset.label}</span>
{/each}
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
{#if systemPairOpen === row.key}
<div class="dropdown-list" use:clickOutside={() => systemPairOpen = null}>
{#each pairPresets as preset}
<button
class="dropdown-item"
class:active={row.selected === preset.id}
onclick={() => selectSystemPairTheme(row.key, preset.id)}
>
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
<span class="preview-sidebar"></span>
<span class="preview-main">
<span class="preview-accent-bar"></span>
</span>
</span>
<span class="dropdown-item-label">{preset.label}</span>
{#if row.selected === preset.id}
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<!-- Custom Themes -->
@@ -1532,24 +1813,23 @@
<!-- Custom Theme Editor Modal -->
{#if customThemeEditorOpen && customThemeEditing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeEditor} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="custom-theme-modal" onclick={(e) => e.stopPropagation()}>
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
<div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
<div class="custom-theme-modal-header">
<h3>{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
<button class="close-btn" onclick={cancelCustomThemeEditor}>
<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} 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>
</button>
</div>
<div class="custom-theme-modal-body">
<div class="ct-field">
<label class="ct-label">Theme Name</label>
<input class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
<label class="ct-label" for="custom-theme-name">Theme Name</label>
<input id="custom-theme-name" class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
</div>
<div class="ct-field">
<label class="ct-label">Mode</label>
<span class="ct-label">Mode</span>
<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 = true; previewCustomTheme(); } }}>Dark</button>
@@ -1639,6 +1919,17 @@
</button>
{/each}
</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>
{#if !isMobile}
@@ -1716,7 +2007,7 @@
<label class="setting-toggle">
<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>
</button>
</label>
@@ -1744,7 +2035,7 @@
<span class="setting-name">Include attachments</span>
<span class="setting-desc">Include images and files in backups (increases size significantly)</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>
</button>
</label>
@@ -1830,10 +2121,9 @@
{#if restoreConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="restore-confirm-overlay" onclick={() => restoreConfirm = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="restore-confirm" onclick={(e) => e.stopPropagation()}>
<h4>Restore Backup?</h4>
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={(e) => { if (e.key === 'Escape') restoreConfirm = null; }}>
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
<h4 id="restore-confirm-title">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>
<div class="restore-confirm-actions">
<button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button>
@@ -2182,14 +2472,14 @@
<span class="setting-name">Sync when a note changes</span>
<span class="setting-desc">Sync shortly after you edit a note.</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 class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Sync when the vault opens</span>
<span class="setting-desc">Sync once when the app starts.</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>
</div>
{/if}
@@ -2842,6 +3132,91 @@
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 {
animation: spin 0.8s linear infinite;
}
+26 -18
View File
@@ -33,6 +33,7 @@
NOTEBOOK_ICON_OPTIONS,
decodeBuiltinNotebookIcon,
encodeBuiltinNotebookIcon,
normalizeNotebookIconKey,
type NotebookIconId
} 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) {
deleteConfirm = null;
try {
@@ -587,9 +592,10 @@
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
try {
const key = normalizeNotebookIconKey(nb.relative_path);
const value = encodeBuiltinNotebookIcon(icon);
await setNotebookIcon(nb.relative_path, value);
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: value };
await setNotebookIcon(key, value);
$notebookIcons = { ...$notebookIcons, [key]: value };
iconPickerNotebook = null;
} catch (e) {
console.error('Failed to set notebook icon:', e);
@@ -608,8 +614,9 @@
const data = await readFile(filePath);
const fileName = baseOf(filePath) || 'icon.png';
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
await setNotebookIcon(nb.relative_path, iconRelative);
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: iconRelative };
const key = normalizeNotebookIconKey(nb.relative_path);
await setNotebookIcon(key, iconRelative);
$notebookIcons = { ...$notebookIcons, [key]: iconRelative };
} catch (e) {
console.error('Failed to set notebook icon:', e);
}
@@ -619,9 +626,10 @@
contextMenu = null;
iconPickerNotebook = null;
try {
await setNotebookIcon(nb.relative_path, null);
const key = normalizeNotebookIconKey(nb.relative_path);
await setNotebookIcon(key, null);
const icons = { ...$notebookIcons };
delete icons[nb.relative_path];
delete icons[key];
$notebookIcons = icons;
} catch (e) {
console.error('Failed to remove notebook icon:', e);
@@ -629,7 +637,7 @@
}
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;
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot) return null;
@@ -955,7 +963,7 @@
</button>
<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>
{$notebookIcons[contextMenu.notebook.relative_path] ? 'Change Icon...' : 'Set Icon...'}
{$notebookIcons[normalizeNotebookIconKey(contextMenu.notebook.relative_path)] ? 'Change Icon...' : 'Set Icon...'}
</button>
<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>
@@ -981,7 +989,7 @@
{#each NOTEBOOK_ICON_OPTIONS as option}
<button
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}
title={option.label}
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>
Custom image...
</button>
{#if $notebookIcons[iconPickerNotebook.relative_path]}
{#if $notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)]}
<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>
Use default
@@ -1025,10 +1033,9 @@
{#if deleteConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<h4>Delete "{deleteConfirm.name}"?</h4>
<div class="delete-confirm-overlay" onclick={dismissDeleteConfirm} onkeydown={(e) => { if (e.key === 'Escape') deleteConfirm = null; }}>
<div class="delete-confirm" class:mobile={isMobile} role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" tabindex="-1">
<h4 id="delete-confirm-title">Delete "{deleteConfirm.name}"?</h4>
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
<div class="delete-confirm-actions">
<button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
@@ -1042,7 +1049,7 @@
{@const hasChildren = nb.children.length > 0}
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
{@const iconSrc = getNotebookIconSrc(nb)}
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[nb.relative_path])}
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[normalizeNotebookIconKey(nb.relative_path)])}
{#if editingNotebook === nb.path}
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
<input
@@ -1067,7 +1074,9 @@
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
style="padding-left: {4 + depth * 16}px"
data-nb-path={nb.path}
onclick={() => selectNotebook(nb)}
onclick={(e) => {
if (!(e.target as Element).closest('.nb-drag-handle')) selectNotebook(nb);
}}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
@@ -1149,12 +1158,11 @@
{/if}
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
{#if $notebookSortMode === 'manual'}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="nb-drag-handle"
title="Drag to reorder"
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>
</span>
+21 -3
View File
@@ -2,10 +2,16 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
import { syncNow } from '$lib/api';
import NoteSwitcher from './NoteSwitcher.svelte';
let { onNewNote = () => {}, onDailyNote = () => {} }: {
let {
onNewNote = () => {},
onDailyNote = () => {},
onSelectNote
}: {
onNewNote?: () => void;
onDailyNote?: () => void;
onSelectNote?: (path: string) => Promise<boolean>;
} = $props();
const appWindow = getCurrentWindow();
@@ -30,7 +36,7 @@
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions') || target.closest('.titlebar-note-switcher')) return;
// Don't start dragging near window edges - let Tauri handle resize
if (!maximized) {
@@ -81,6 +87,11 @@
</button>
{/if}
</div>
{#if $appConfig?.show_note_switcher}
<div class="titlebar-note-switcher">
<NoteSwitcher {onSelectNote} />
</div>
{/if}
<div class="titlebar-actions">
<button class="switch-vault-btn" onclick={() => ($vaultReady = false)} title="Switch Vault">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -173,7 +184,13 @@
gap: 8px;
padding-left: 14px;
pointer-events: none;
flex: 1;
}
.titlebar-note-switcher {
display: flex;
align-items: center;
margin-left: 16px;
-webkit-app-region: no-drag;
}
.titlebar-title {
@@ -205,6 +222,7 @@
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
margin-right: 8px;
-webkit-app-region: no-drag;
}
+4 -2
View File
@@ -214,19 +214,21 @@
{/if}
{#if isAndroid}
<div class="location-selector">
<label class="location-label">Location</label>
<div class="location-selector" role="group" aria-labelledby="storage-location-label">
<span id="storage-location-label" class="location-label">Location</span>
<div class="location-options">
{#each storageLocations as loc}
<button
class="location-option"
class:active={selectedLocation === loc.label}
aria-pressed={selectedLocation === loc.label}
onclick={() => selectedLocation = loc.label}
>{loc.label}</button>
{/each}
<button
class="location-option"
class:active={selectedLocation === 'Custom'}
aria-pressed={selectedLocation === 'Custom'}
onclick={() => selectedLocation = 'Custom'}
>Custom</button>
</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()];
},
});
+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;
}
+25 -1
View File
@@ -1,5 +1,6 @@
import { writable, derived, get } from "svelte/store";
import { isMobile } from "$lib/platform";
import { compareNaturalNames } from "$lib/utils/natural-sort";
import type {
AppConfig,
CustomTheme,
@@ -89,6 +90,29 @@ export const readOnly = writable(false);
export const theme = writable<string>("system");
export const customThemes = derived(appConfig, ($c): CustomTheme[] => $c?.custom_themes ?? []);
// Whether the OS is currently in dark mode. Held in a store (rather than read at each call site)
// so the theme re-resolves when the user flips appearance while the app is open.
const darkQuery =
typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null;
export const systemPrefersDark = writable<boolean>(darkQuery?.matches ?? false);
darkQuery?.addEventListener("change", (e) => systemPrefersDark.set(e.matches));
// The theme to actually render: "system" resolves to the configured light/dark pair, everything
// else passes through. Falls back to the plain schemes when no pair is configured yet.
export const resolvedTheme = derived(
[theme, appConfig, systemPrefersDark],
([$theme, $config, $prefersDark]): string => {
if ($theme !== "system") return $theme;
const fallback = $prefersDark ? "dark" : "light";
const paired = $prefersDark ? $config?.system_dark_theme : $config?.system_light_theme;
if (!paired) return fallback;
if (paired.startsWith("custom-") && !$config?.custom_themes.some((item) => item.id === paired)) {
return fallback;
}
return paired;
},
);
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
// whoever triggered it (manual button, settings, interval, on-change).
export const syncState = writable<{ running: boolean; error: string | null }>({
@@ -216,7 +240,7 @@ export const sortedNotes = derived(
return a.meta.title.localeCompare(b.meta.title);
}
case "title":
return a.meta.title.localeCompare(b.meta.title);
return compareNaturalNames(a.meta.title, b.meta.title);
case "created":
return (
new Date(b.meta.created).getTime() -
+4
View File
@@ -88,14 +88,18 @@ export interface AppConfig {
active_vault: string | null;
active_bookmark_id?: string | null;
theme: string;
system_light_theme: string;
system_dark_theme: string;
accent_color: string | null;
font_size: number | null;
scroll_to_change_font_size: boolean;
font_family: string | null;
line_height: number | null;
ui_scale: number | null;
content_width: number | null;
compact_notes: boolean;
show_note_dates: boolean;
show_note_switcher: boolean;
time_format: string;
week_start: string;
daily_title_format: string;
+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);
}
+104
View File
@@ -0,0 +1,104 @@
export interface NoteSwitcherNote {
path: string;
title: string;
relativePath: string;
}
export interface NoteSwitcherRow {
path: string;
title: string;
folder: string;
current: boolean;
}
export interface NoteSwitcherSections {
recent: NoteSwitcherRow[];
quickAccess: NoteSwitcherRow[];
}
interface BuildNoteSwitcherSectionsOptions {
currentPath: string | null;
historyPaths: readonly string[];
knownNotes: readonly NoteSwitcherNote[];
quickAccessNotes: readonly NoteSwitcherNote[];
recentLimit?: number;
}
function pathKey(path: string): string {
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 {
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
parts.pop();
return parts.join('/') || 'Unfiled';
}
function toRow(note: NoteSwitcherNote, currentPathKey: string | null): NoteSwitcherRow {
return {
path: note.path,
title: note.title,
folder: folderLabel(note.relativePath),
current: currentPathKey !== null && pathKey(note.path) === currentPathKey
};
}
export function buildNoteSwitcherSections({
currentPath,
historyPaths,
knownNotes,
quickAccessNotes,
recentLimit = 6
}: BuildNoteSwitcherSectionsOptions): NoteSwitcherSections {
const knownByPath = new Map(knownNotes.map((note) => [pathKey(note.path), note]));
const currentPathKey = currentPath ? pathKey(currentPath) : null;
const recent: NoteSwitcherRow[] = [];
const shownPaths = new Set<string>();
const limit = Math.max(0, Math.floor(recentLimit));
const addRecent = (path: string | null) => {
if (!path || recent.length >= limit) return;
const key = pathKey(path);
if (shownPaths.has(key)) return;
const note = knownByPath.get(key);
if (!note) return;
shownPaths.add(key);
recent.push(toRow(note, currentPathKey));
};
for (const path of buildNoteSwitcherRequestPaths(currentPath, historyPaths)) {
if (recent.length >= limit) break;
addRecent(path);
}
const quickAccess: NoteSwitcherRow[] = [];
for (const note of quickAccessNotes) {
const key = pathKey(note.path);
if (shownPaths.has(key) || !knownByPath.has(key)) continue;
shownPaths.add(key);
quickAccess.push(toRow(note, currentPathKey));
}
return { recent, quickAccess };
}
+4
View File
@@ -39,6 +39,10 @@ const BUILTIN_IDS: Record<NotebookIconId, true> = {
palette: true
};
export function normalizeNotebookIconKey(path: string): string {
return path.replace(/\\/g, '/');
}
export function encodeBuiltinNotebookIcon(icon: NotebookIconId): string {
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)];
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('/');
}
+18 -30
View File
@@ -1,18 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
import { resolvedTheme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
import { get } from 'svelte/store';
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import type { CustomTheme } from '$lib/types';
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
import { resolveVaultFilePath } from '$lib/utils/paths';
let { children } = $props();
// Reactively apply theme class to <html> whenever $theme or custom themes change
// Reactively apply theme class to <html> whenever the resolved theme or custom themes change.
// $resolvedTheme already maps "system" onto the configured light/dark pair, so flipping the OS
// appearance re-runs this effect.
$effect(() => {
applyTheme($theme, $customThemes);
applyTheme($resolvedTheme, $customThemes);
});
// Apply link arrow visibility from config
@@ -26,8 +29,13 @@
'--bg-primary', '--bg-secondary', '--bg-tertiary',
'--bg-hover', '--bg-active', '--bg-editor',
'--text-primary', '--text-secondary', '--border-color',
'--border-light', '--text-tertiary',
];
// A custom theme carries nine colors, but the stylesheet also reads --border-light and
// --text-tertiary. Named themes set those in their own :root[data-theme] block; a custom theme
// has no such block, so without these they fall back to the light defaults in app.css and a dark
// custom theme draws near-white dividers. Derive them from the closest color the theme does have.
function applyCustomThemeVars(root: HTMLElement, ct: CustomTheme) {
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
@@ -38,6 +46,8 @@
root.style.setProperty('--text-primary', ct.colors.text_primary);
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
root.style.setProperty('--border-color', ct.colors.border_color);
root.style.setProperty('--border-light', ct.colors.border_color);
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
}
function clearCustomThemeVars(root: HTMLElement) {
@@ -64,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) {
if (isAndroid) {
const bridge = (window as any).Android;
@@ -89,24 +89,12 @@
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));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig);
const vaultRoot = config?.active_vault;
let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) {
// .helixnotes/ paths are always relative to vault root, not the note's directory
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}`);
}
}
}
const absPath = resolveVaultFilePath(
decodeURIComponent(href),
get(activeNotePath),
config?.active_vault ?? null,
);
// Internal .md note link - navigate within the app
if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => {
+23 -7
View File
@@ -3,6 +3,7 @@
import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
import { darkThemes, isIOS, isMobile } from '$lib/platform';
import { getWheelFontSizeAction } from '$lib/utils/editor-zoom';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -16,6 +17,7 @@
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null;
let removeFontSizeListener: (() => void) | null = null;
let removeScrollFontSizeListener: (() => void) | null = null;
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
let startupWindowRevealed = false;
@@ -121,10 +123,15 @@
};
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.stopPropagation();
zoomEditor(event.deltaY < 0 ? 1 : -1);
if (action === 'increase') zoomEditor(1);
else if (action === 'decrease') zoomEditor(-1);
};
window.addEventListener('keydown', handleKeydown);
@@ -153,10 +160,18 @@
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
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';
// Apply theme immediately to prevent flash
const themeValue = config.theme || 'system';
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
// "system" against the configured pair here the same way resolvedTheme does.
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const rawTheme = config.theme || 'system';
const themeValue = rawTheme === 'system'
? (prefersDark ? config.system_dark_theme || 'dark' : config.system_light_theme || 'light')
: rawTheme;
const root = document.documentElement;
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
root.classList.remove('dark');
@@ -164,7 +179,7 @@
if (namedThemes.includes(themeValue)) {
root.setAttribute('data-theme', themeValue);
if (darkThemes.includes(themeValue)) root.classList.add('dark');
} else if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
} else if (themeValue === 'dark') {
root.classList.add('dark');
}
@@ -192,8 +207,8 @@
}
// Apply saved accent
if (config.accent_color) {
const themeVal = config.theme || 'light';
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const customTheme = config.custom_themes.find(theme => theme.id === themeValue);
const isDark = darkThemes.includes(themeValue) || (customTheme?.is_dark ?? false);
let color: string | null = null;
if (config.accent_color.startsWith('#')) {
color = config.accent_color;
@@ -282,6 +297,7 @@
onDestroy(() => {
removeEditorZoomShortcuts?.();
removeFontSizeListener?.();
removeScrollFontSizeListener?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
if (startupRevealTimer) clearTimeout(startupRevealTimer);
});
+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'
);
});