mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-20 01:47:29 +02:00
Compare commits
35
Commits
32ef51161d
..
v1.3.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15b267614 | ||
|
|
ce48d77cd8 | ||
|
|
221625aabd | ||
|
|
1bd4cd8138 | ||
|
|
dd748776b2 | ||
|
|
0391a60c1b | ||
|
|
33d77cf2fd | ||
|
|
81e4e26290 | ||
|
|
111d819f92 | ||
|
|
c37818de1a | ||
|
|
95dab8cf74 | ||
|
|
62365f52b1 | ||
|
|
6eb1ec6eb2 | ||
|
|
527c6a7a91 | ||
|
|
45e3c389e4 | ||
|
|
9ccbf3aff5 | ||
|
|
695664913a | ||
|
|
d0fb87cf3a | ||
|
|
ac7c79af04 | ||
|
|
1ebf2a9843 | ||
|
|
44dcfbe24a | ||
|
|
00196ecedb | ||
|
|
c4d2d1b2d1 | ||
|
|
3a9a5129c4 | ||
|
|
a964ee1930 | ||
|
|
2d57577338 | ||
|
|
206faed113 | ||
|
|
b3c292d6e2 | ||
|
|
43f15962f7 | ||
|
|
ade146f9f3 | ||
|
|
b253ec4121 | ||
|
|
c4a91b8f1c | ||
|
|
87c879bcac | ||
|
|
27609fb4f3 | ||
|
|
b0555fe648 |
@@ -166,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/)
|
||||
@@ -178,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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+598
-692
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
overrides:
|
||||
cookie: 0.7.2
|
||||
|
||||
Generated
+67
-275
@@ -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
@@ -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
-1
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
+28
-16
@@ -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" => {
|
||||
@@ -434,14 +444,18 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
||||
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 client = Client::new();
|
||||
let is_gpt5 = model.starts_with("gpt-5");
|
||||
let token_key = if is_gpt5 {
|
||||
"max_completion_tokens"
|
||||
} else {
|
||||
"max_tokens"
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
token_key: 20,
|
||||
"messages": [
|
||||
let body = json!({
|
||||
"model": model,
|
||||
token_key: 20,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hi"
|
||||
@@ -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));
|
||||
|
||||
@@ -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(¬e_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(¬e_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(¬e, 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(), ¬e).unwrap();
|
||||
let scope = app.asset_protocol_scope();
|
||||
|
||||
assert!(scope.is_allowed(¬e));
|
||||
assert!(scope.is_allowed(&image));
|
||||
assert!(!scope.is_allowed(&outside));
|
||||
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
+48
-15
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+555
-230
File diff suppressed because it is too large
Load Diff
+60
-22
@@ -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 = ×tamp[..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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+57
-65
@@ -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());
|
||||
@@ -137,6 +172,7 @@ pub fn run() {
|
||||
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,
|
||||
@@ -190,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,
|
||||
@@ -217,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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,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());
|
||||
}
|
||||
}
|
||||
@@ -350,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-") {
|
||||
@@ -360,7 +353,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
app_lib::run();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>();
|
||||
|
||||
+19
-4
@@ -124,6 +124,8 @@ pub struct AppConfig {
|
||||
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)]
|
||||
@@ -296,6 +298,7 @@ impl Default for AppConfig {
|
||||
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,
|
||||
@@ -549,12 +552,24 @@ mod startup_view_tests {
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("show_note_switcher");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,10 +251,8 @@ fn rename_deprecated_properties(raw: &str) -> String {
|
||||
mapping.insert(new_key, val);
|
||||
renamed = true;
|
||||
}
|
||||
} else {
|
||||
if mapping.remove(&old_key).is_some() {
|
||||
renamed = true;
|
||||
}
|
||||
} else if mapping.remove(&old_key).is_some() {
|
||||
renamed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
+442
-106
@@ -5,6 +5,7 @@ use crate::types::{
|
||||
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;
|
||||
@@ -16,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())?;
|
||||
@@ -47,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() {
|
||||
@@ -73,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
|
||||
@@ -86,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)
|
||||
@@ -127,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)
|
||||
@@ -189,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());
|
||||
@@ -231,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);
|
||||
}
|
||||
|
||||
@@ -247,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"))
|
||||
@@ -259,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)
|
||||
}
|
||||
}
|
||||
@@ -369,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()
|
||||
@@ -400,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();
|
||||
|
||||
@@ -417,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 {
|
||||
@@ -433,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));
|
||||
@@ -481,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()
|
||||
@@ -618,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()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -665,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());
|
||||
}
|
||||
@@ -694,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())?;
|
||||
@@ -717,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())?;
|
||||
@@ -749,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())?;
|
||||
@@ -785,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)
|
||||
}
|
||||
@@ -844,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,
|
||||
@@ -864,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).
|
||||
@@ -912,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());
|
||||
}
|
||||
@@ -927,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());
|
||||
}
|
||||
@@ -945,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());
|
||||
}
|
||||
@@ -994,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);
|
||||
@@ -1013,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
|
||||
@@ -1026,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,
|
||||
@@ -1037,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 })
|
||||
}
|
||||
|
||||
@@ -1047,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();
|
||||
@@ -1078,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 {
|
||||
@@ -1102,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() {
|
||||
@@ -1116,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())?;
|
||||
@@ -1218,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(
|
||||
@@ -1236,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(¬ebook_key);
|
||||
}
|
||||
}
|
||||
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
|
||||
@@ -1367,12 +1565,56 @@ pub fn sanitize_filename(name: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
duplicate_note, get_note_switcher_titles, helixnotes_dir, load_notebook_icons,
|
||||
set_notebook_icon,
|
||||
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 =
|
||||
@@ -1380,19 +1622,44 @@ 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 =
|
||||
@@ -1457,6 +1724,75 @@ mod tests {
|
||||
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 =
|
||||
@@ -1488,4 +1824,4 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+9
-1
@@ -71,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 });
|
||||
}
|
||||
@@ -185,7 +189,7 @@ export async function getNoteSwitcherTitles(
|
||||
return invoke("get_note_switcher_titles", { recentPaths });
|
||||
}
|
||||
|
||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
|
||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number; bidirectional: boolean }[] }> {
|
||||
return invoke("get_graph_data");
|
||||
}
|
||||
|
||||
@@ -364,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 });
|
||||
}
|
||||
|
||||
@@ -81,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') {
|
||||
@@ -829,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>
|
||||
|
||||
+204
-261
@@ -52,8 +52,10 @@
|
||||
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
||||
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
||||
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||
import { clearFormatting } from '$lib/editor/clearFormatting';
|
||||
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
||||
import { relativePath } from '$lib/utils/paths';
|
||||
import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
|
||||
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
|
||||
import GraphView from './GraphView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
import ImageViewer from './ImageViewer.svelte';
|
||||
@@ -131,6 +133,8 @@
|
||||
let highlightDropdown = $state(false);
|
||||
let alignDropdown = $state(false);
|
||||
let insertDropdown = $state(false);
|
||||
let tablePickerOpen = $state(false);
|
||||
let tablePickerHover = $state({ rows: 0, cols: 0 });
|
||||
|
||||
function scrollEditorBodyToBottom(source: HTMLElement | null | undefined = editorElement) {
|
||||
const editorBody = source?.closest('.editor-body') as HTMLElement | null;
|
||||
@@ -305,6 +309,22 @@
|
||||
let editorState = $state(0);
|
||||
let editorStateRaf = 0; // RAF handle for batching toolbar updates
|
||||
|
||||
function isEditorActive(
|
||||
nameOrAttributes: string | Record<string, unknown>,
|
||||
attributes?: Record<string, unknown>,
|
||||
): boolean {
|
||||
void editorState;
|
||||
if (!editor) return false;
|
||||
return typeof nameOrAttributes === 'string'
|
||||
? editor.isActive(nameOrAttributes, attributes)
|
||||
: editor.isActive(nameOrAttributes);
|
||||
}
|
||||
|
||||
function getEditorAttributes(name: string): Record<string, unknown> {
|
||||
void editorState;
|
||||
return editor?.getAttributes(name) ?? {};
|
||||
}
|
||||
|
||||
// AI
|
||||
let aiMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let aiLoading = $state(false);
|
||||
@@ -469,8 +489,6 @@
|
||||
});
|
||||
let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null);
|
||||
let tableContextMenu = $state<{ x: number; y: number; hasStyling: boolean } | null>(null);
|
||||
let tablePickerOpen = $state(false);
|
||||
let tablePickerHover = $state({ rows: 0, cols: 0 });
|
||||
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string; alt: string } | null>(null);
|
||||
let imageViewer = $state<{ src: string; alt: string } | null>(null);
|
||||
let copyToast = $state<'copying' | 'done' | null>(null);
|
||||
@@ -2530,10 +2548,7 @@
|
||||
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
|
||||
if (!editor || !wikiLinkMenu) return;
|
||||
const { from } = wikiLinkMenu;
|
||||
// Delete the [[ trigger and query text
|
||||
const to = editor.state.selection.from;
|
||||
editor.chain().focus().deleteRange({ from, to }).run();
|
||||
// Insert the wiki-link mark
|
||||
// For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
|
||||
const displayText = entry.title;
|
||||
let titleAttr = originalRef || entry.title;
|
||||
@@ -2545,17 +2560,16 @@
|
||||
titleAttr = relPath + anchor;
|
||||
}
|
||||
}
|
||||
tick().then(() => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus()
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: displayText,
|
||||
marks: [{ type: 'wikiLink', attrs: { title: titleAttr, path: entry.path, aliased: displayText !== titleAttr } }],
|
||||
})
|
||||
.run();
|
||||
});
|
||||
const transaction = replaceWithWikiLink(
|
||||
editor.state,
|
||||
from,
|
||||
to,
|
||||
displayText,
|
||||
{ title: titleAttr, path: entry.path || '', aliased: displayText !== titleAttr },
|
||||
);
|
||||
closeWikiLinkMenu();
|
||||
editor.view.dispatch(transaction);
|
||||
editor.view.focus();
|
||||
}
|
||||
|
||||
function executeWikiLinkCommand(index: number) {
|
||||
@@ -2742,21 +2756,13 @@
|
||||
closeWikiLinkMenu();
|
||||
tick().then(() => {
|
||||
if (!editor) return;
|
||||
// Use a single ProseMirror transaction to replace [[query] with the
|
||||
// wiki-link and clear stored marks atomically, preventing the inclusive
|
||||
// mark from bleeding into subsequent text. Do NOT call deleteRange first
|
||||
// — that would shift positions and make menuFrom/curTo invalid here.
|
||||
const { tr, schema } = editor.view.state;
|
||||
const wikiLinkMark = schema.marks.wikiLink.create({
|
||||
title: noteRef,
|
||||
path: '',
|
||||
aliased: display !== noteRef,
|
||||
});
|
||||
const textNode = schema.text(display, [wikiLinkMark]);
|
||||
tr.replaceWith(menuFrom, curTo, textNode);
|
||||
tr.setSelection(TextSelection.create(tr.doc, menuFrom + display.length));
|
||||
tr.setStoredMarks([]);
|
||||
editor.view.dispatch(tr);
|
||||
editor.view.dispatch(replaceWithWikiLink(
|
||||
editor.state,
|
||||
menuFrom,
|
||||
curTo,
|
||||
display,
|
||||
{ title: noteRef, path: '', aliased: display !== noteRef },
|
||||
));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -2890,6 +2896,12 @@
|
||||
$activeNote = { ...content, content: content.content };
|
||||
$activeNotePath = absPath;
|
||||
} catch (e) {
|
||||
// Explicit Markdown links do not have a wiki-link title to recreate from.
|
||||
// Do not turn a failed read into an untitled note.
|
||||
if (!noteTitle) {
|
||||
console.error('Failed to navigate to note link:', e);
|
||||
return;
|
||||
}
|
||||
// Note at path no longer exists (deleted/moved). Refresh cache and
|
||||
// retry as unresolved so the user can recreate it from the link.
|
||||
await refreshWikiLinkTitles();
|
||||
@@ -2967,13 +2979,11 @@
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
return convertFileSrc(src, 'imgproxy');
|
||||
}
|
||||
// Decode percent-encoding (%20 → space, etc.) for filesystem resolution
|
||||
let decoded = decodeURIComponent(src);
|
||||
// Fix multiple leading slashes (from broken saves)
|
||||
if (decoded.match(/^\/{2,}/)) {
|
||||
decoded = decoded.replace(/^\/{2,}/, '/');
|
||||
}
|
||||
if (decoded.startsWith('/')) {
|
||||
// Decode URL encoding and undo the extra leading slash added to Windows drive paths.
|
||||
let decoded = normalizeLocalAssetPath(decodeURIComponent(src));
|
||||
// Keep repairing legacy POSIX paths that were saved with duplicate leading slashes.
|
||||
if (/^\/{2,}/.test(decoded)) decoded = decoded.replace(/^\/{2,}/, '/');
|
||||
if (decoded.startsWith('/') || /^[A-Za-z]:\//.test(decoded)) {
|
||||
return convertFileSrc(normalizePath(decoded));
|
||||
}
|
||||
// Paths containing .helixnotes/ are vault-root relative (our own attachments)
|
||||
@@ -3676,6 +3686,27 @@
|
||||
requestAnimationFrame(() => el.focus());
|
||||
}
|
||||
|
||||
function closeFromOverlay(event: MouseEvent, close: () => void) {
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function closeOnEscape(event: KeyboardEvent, close: () => void) {
|
||||
if (event.key === 'Escape') close();
|
||||
}
|
||||
|
||||
function closeFormattingDropdowns() {
|
||||
headingDropdown = false;
|
||||
colorDropdown = false;
|
||||
highlightDropdown = false;
|
||||
tablePickerOpen = false;
|
||||
alignDropdown = false;
|
||||
insertDropdown = false;
|
||||
}
|
||||
|
||||
function handleFormattingBarClick(event: MouseEvent) {
|
||||
if (!(event.target as Element).closest('.fmt-dropdown')) closeFormattingDropdowns();
|
||||
}
|
||||
|
||||
// ── In-note search functions ──
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -3918,42 +3949,7 @@
|
||||
}
|
||||
|
||||
function stripAssetSrc(src: string): string {
|
||||
// blob: URLs are not persistable - they were temporary browser references
|
||||
if (src.startsWith('blob:')) return '';
|
||||
// Convert imgproxy:// URLs back to original external URLs for saving
|
||||
if (src.startsWith('imgproxy:') || src.startsWith('http://imgproxy.localhost') || src.startsWith('https://imgproxy.localhost')) {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
return decodeURIComponent(url.pathname.substring(1));
|
||||
} catch {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
// Convert asset:// URLs back to relative paths for saving
|
||||
if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost') && !src.startsWith('https://asset.localhost')) return src;
|
||||
let absPath = '';
|
||||
try {
|
||||
const url = new URL(src);
|
||||
absPath = decodeURIComponent(url.pathname);
|
||||
} catch {
|
||||
return src;
|
||||
}
|
||||
// Clean up any leading double/triple slashes (URL parsing artifact)
|
||||
absPath = absPath.replace(/^\/{2,}/, '/');
|
||||
absPath = absPath.replace(/^\/([A-Za-z]:\/)/, '$1').replace(/\\/g, '/');
|
||||
const notePath = $activeNotePath;
|
||||
const vaultRoot = $appConfig?.active_vault?.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
if (vaultRoot && absPath.startsWith(vaultRoot + '/')) {
|
||||
const vaultRelative = absPath.substring(vaultRoot.length + 1);
|
||||
if (vaultRelative.startsWith('.helixnotes/')) return vaultRelative;
|
||||
if (notePath) {
|
||||
const normalizedNotePath = notePath.replace(/\\/g, '/');
|
||||
const noteDir = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
|
||||
return relativePath(noteDir, absPath);
|
||||
}
|
||||
return vaultRelative;
|
||||
}
|
||||
return absPath;
|
||||
return assetSourceToMarkdown(src, $activeNotePath, $appConfig?.active_vault ?? null);
|
||||
}
|
||||
|
||||
function htmlToMarkdown(html: string): string {
|
||||
@@ -4848,34 +4844,13 @@
|
||||
}
|
||||
|
||||
function getImageAbsPath(src: string): string {
|
||||
// asset:// or http://asset.localhost → extract absolute path
|
||||
if (src.startsWith('asset:') || src.startsWith('http://asset.localhost')) {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
let absPath = decodeURIComponent(url.pathname);
|
||||
absPath = absPath.replace(/^\/{2,}/, '/');
|
||||
return absPath;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
// Relative path → resolve against note directory
|
||||
let decoded = decodeURIComponent(src);
|
||||
if (decoded.match(/^\/{2,}/)) decoded = decoded.replace(/^\/{2,}/, '/');
|
||||
if (decoded.startsWith('/')) return decoded;
|
||||
if (decoded.includes('.helixnotes/')) {
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (vaultRoot) {
|
||||
const idx = decoded.indexOf('.helixnotes/');
|
||||
return `${vaultRoot}/${decoded.substring(idx)}`;
|
||||
}
|
||||
}
|
||||
const notePath = $activeNotePath;
|
||||
if (notePath) {
|
||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
||||
return normalizePath(`${noteDir}/${decoded}`);
|
||||
}
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
|
||||
return src;
|
||||
const assetPath = assetUrlToLocalPath(src);
|
||||
if (assetPath !== null) return assetPath;
|
||||
return resolveVaultFilePath(
|
||||
decodeURIComponent(src),
|
||||
$activeNotePath,
|
||||
$appConfig?.active_vault ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
async function copyImageToClipboard() {
|
||||
@@ -5079,6 +5054,11 @@
|
||||
closeTextContextMenu();
|
||||
}
|
||||
|
||||
function ctxClearFormatting() {
|
||||
if (editor) clearFormatting(editor);
|
||||
closeTextContextMenu();
|
||||
}
|
||||
|
||||
function ctxCode() {
|
||||
editor?.chain().focus().toggleCode().run();
|
||||
closeTextContextMenu();
|
||||
@@ -5523,18 +5503,8 @@
|
||||
function resolveNoteHref(href: string): string | null {
|
||||
const decoded = decodeURIComponent(href);
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) return null;
|
||||
let absPath = decoded;
|
||||
if (!decoded.startsWith('/')) {
|
||||
const notePath = $activeNotePath;
|
||||
if (notePath) {
|
||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
||||
absPath = normalizePath(`${noteDir}/${decoded}`);
|
||||
} else {
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (vaultRoot) absPath = normalizePath(`${vaultRoot}/${decoded}`);
|
||||
}
|
||||
}
|
||||
return absPath.endsWith('.md') ? absPath : null;
|
||||
const path = resolveVaultFilePath(decoded, $activeNotePath, $appConfig?.active_vault ?? null);
|
||||
return path.endsWith('.md') ? path : null;
|
||||
}
|
||||
|
||||
function linkMenuOpen() {
|
||||
@@ -5598,20 +5568,11 @@
|
||||
}
|
||||
|
||||
function resolveHrefToAbsPath(href: string): string {
|
||||
const decoded = decodeURIComponent(href);
|
||||
if (decoded.startsWith('/')) return decoded;
|
||||
// .helixnotes/ paths are always relative to vault root, not the note's directory
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (decoded.startsWith('.helixnotes/') && vaultRoot) {
|
||||
return normalizePath(`${vaultRoot}/${decoded}`);
|
||||
}
|
||||
const notePath = $activeNotePath;
|
||||
if (notePath) {
|
||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
||||
return normalizePath(`${noteDir}/${decoded}`);
|
||||
}
|
||||
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
|
||||
return decoded;
|
||||
return resolveVaultFilePath(
|
||||
decodeURIComponent(href),
|
||||
$activeNotePath,
|
||||
$appConfig?.active_vault ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function isFileLink(href: string): boolean {
|
||||
@@ -6287,7 +6248,7 @@
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }}></div>
|
||||
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} onkeydown={(e) => { if (e.key === 'Escape') closeLinkContextMenu(); }}></div>
|
||||
{:else}
|
||||
<!-- Desktop: conditional rendering with line numbers -->
|
||||
{#if $sourceMode}
|
||||
@@ -6391,7 +6352,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu}></div>
|
||||
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} onkeydown={(e) => { if (e.key === 'Escape') closeLinkContextMenu(); }} oncontextmenu={handleEditorContextMenu}></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6407,7 +6368,7 @@
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="history-close" onclick={() => { showHistory = false; historyPreview = null; historySelected = null; }}>
|
||||
<button class="history-close" onclick={() => { showHistory = false; historyPreview = null; historySelected = null; }} aria-label="Close version history">
|
||||
<svg width="14" height="14" 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" />
|
||||
@@ -6447,7 +6408,7 @@
|
||||
<div class="outline-panel" style="width: {$outlineWidth}px">
|
||||
<div class="outline-header">
|
||||
<h3>Outline</h3>
|
||||
<button class="outline-close" onclick={() => { showOutline = false; }}>
|
||||
<button class="outline-close" onclick={() => { showOutline = false; }} aria-label="Close outline">
|
||||
<svg width="14" height="14" 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" />
|
||||
@@ -6474,7 +6435,7 @@
|
||||
<div class="info-panel">
|
||||
<div class="info-panel-header">
|
||||
<span class="info-panel-title">Note Info</span>
|
||||
<button class="info-close-btn" onclick={() => showInfo = false}>
|
||||
<button class="info-close-btn" onclick={() => showInfo = false} aria-label="Close note info">
|
||||
<svg width="14" height="14" 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>
|
||||
@@ -6570,7 +6531,7 @@
|
||||
|
||||
{#if editorReady && !$sourceMode && !$viewerNote}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="editor-formatting-bar" style={isMobile ? `${keyboardHeight > 0 ? `bottom: ${keyboardHeight}px;` : ''}${anyDropdownOpen ? 'overflow: visible;' : ''}` : ''} onclick={() => { headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }}>
|
||||
<div class="editor-formatting-bar" style={isMobile ? `${keyboardHeight > 0 ? `bottom: ${keyboardHeight}px;` : ''}${anyDropdownOpen ? 'overflow: visible;' : ''}` : ''} onclick={handleFormattingBarClick} onkeydown={(e) => { if (e.key === 'Escape') closeFormattingDropdowns(); }}>
|
||||
{#if isMobile}
|
||||
<!-- ═══ MOBILE formatting bar: compact, relevant buttons only ═══ -->
|
||||
|
||||
@@ -6580,8 +6541,7 @@
|
||||
<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="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||
</button>
|
||||
{#if insertDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown insert-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="fmt-dropdown insert-dropdown">
|
||||
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-image-input')?.click(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 00-2.828 0L6 21"/></svg>
|
||||
Image
|
||||
@@ -6632,16 +6592,15 @@
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="fmt-dropdown-wrap">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('heading'))} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; insertDropdown = false; }} title="Heading">
|
||||
<button class="fmt-btn" class:active={isEditorActive('heading')} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; insertDropdown = false; }} title="Heading">
|
||||
<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="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></svg>
|
||||
</button>
|
||||
{#if headingDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<button class:active={(editorState, editor.isActive('heading', { level: 1 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||
<button class:active={(editorState, editor.isActive('heading', { level: 2 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
||||
<button class:active={(editorState, editor.isActive('heading', { level: 3 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
||||
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||
<div class="fmt-dropdown">
|
||||
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
||||
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
||||
<button class:active={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6649,36 +6608,36 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Bold / Italic / Underline / Strike -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bold'))} onclick={() => editor?.chain().focus().toggleBold().run()} title="Bold">
|
||||
<button class="fmt-btn" class:active={isEditorActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()} title="Bold">
|
||||
<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="M6 12h9a4 4 0 010 8H7a1 1 0 01-1-1V5a1 1 0 011-1h7a4 4 0 010 8"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('italic'))} onclick={() => editor?.chain().focus().toggleItalic().run()} title="Italic">
|
||||
<button class="fmt-btn" class:active={isEditorActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()} title="Italic">
|
||||
<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="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline">
|
||||
<button class="fmt-btn" class:active={isEditorActive('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline">
|
||||
<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="M6 4v6a6 6 0 0012 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough">
|
||||
<button class="fmt-btn" class:active={isEditorActive('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough">
|
||||
<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="M16 4H9a3 3 0 00-2.83 4"/><path d="M14 12a4 4 0 010 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Link -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title="Link">
|
||||
<button class="fmt-btn" class:active={isEditorActive('link')} onclick={addLinkFromToolbar} title="Link">
|
||||
<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="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Lists -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title="Bullet List">
|
||||
<button class="fmt-btn" class:active={isEditorActive('bulletList')} onclick={toggleBulletList} title="Bullet List">
|
||||
<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="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
|
||||
<button class="fmt-btn" class:active={isEditorActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
|
||||
<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="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title="Task List">
|
||||
<button class="fmt-btn" class:active={isEditorActive('taskList')} onclick={toggleTaskList} title="Task List">
|
||||
<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="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -6732,7 +6691,7 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Highlight -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={() => editor?.chain().focus().toggleHighlight({ color: highlightColors[0].value }).run()} title="Highlight">
|
||||
<button class="fmt-btn" class:active={isEditorActive('highlight')} onclick={() => editor?.chain().focus().toggleHighlight({ color: highlightColors[0].value }).run()} title="Highlight">
|
||||
<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="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 01-2.8 0l-5.2-5.2a2 2 0 010-2.8L14 4"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -6771,8 +6730,7 @@
|
||||
<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="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||
</button>
|
||||
{#if insertDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown insert-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="fmt-dropdown insert-dropdown">
|
||||
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-image-input')?.click(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 00-2.828 0L6 21"/></svg>
|
||||
Image
|
||||
@@ -6801,17 +6759,16 @@
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="fmt-dropdown-wrap">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('heading'))} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Heading">
|
||||
<button class="fmt-btn" class:active={isEditorActive('heading')} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Heading">
|
||||
<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="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></svg>
|
||||
</button>
|
||||
{#if headingDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<button class:active={(editorState, editor.isActive('heading', { level: 1 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||
<button class:active={(editorState, editor.isActive('heading', { level: 2 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
||||
<button class:active={(editorState, editor.isActive('heading', { level: 3 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
||||
<button class:active={(editorState, editor.isActive('heading', { level: 4 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</button>
|
||||
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||
<div class="fmt-dropdown">
|
||||
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
||||
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
||||
<button class:active={isEditorActive('heading', { level: 4 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</button>
|
||||
<button class:active={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6819,16 +6776,16 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Text formatting -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bold'))} onclick={() => editor?.chain().focus().toggleBold().run()} title={`Bold (${modKey}+B)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()} title={`Bold (${modKey}+B)`}>
|
||||
<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="M6 12h9a4 4 0 010 8H7a1 1 0 01-1-1V5a1 1 0 011-1h7a4 4 0 010 8"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('italic'))} onclick={() => editor?.chain().focus().toggleItalic().run()} title={`Italic (${modKey}+I)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()} title={`Italic (${modKey}+I)`}>
|
||||
<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="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title={`Underline (${modKey}+U)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} title={`Underline (${modKey}+U)`}>
|
||||
<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="M6 4v6a6 6 0 0012 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title={`Strikethrough (${modKey}+Shift+X)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} title={`Strikethrough (${modKey}+Shift+X)`}>
|
||||
<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="M16 4H9a3 3 0 00-2.83 4"/><path d="M14 12a4 4 0 010 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -6836,14 +6793,13 @@
|
||||
<div class="fmt-dropdown-wrap">
|
||||
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); colorDropdown = !colorDropdown; headingDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Text Color">
|
||||
<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="M4 20h16"/><path d="m6 16 6-12 6 12"/><path d="M8 12h8"/></svg>
|
||||
<span class="color-indicator" style="background: {editor.getAttributes('textStyle').color || 'var(--accent)'}"></span>
|
||||
<span class="color-indicator" style="background: {getEditorAttributes('textStyle').color || 'var(--accent)'}"></span>
|
||||
</button>
|
||||
{#if colorDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="fmt-dropdown color-grid-dropdown">
|
||||
{#each textColors as color}
|
||||
<button class="color-swatch" title={color.name} onclick={() => setTextColor(color.value)} style="background: {color.value || 'var(--text-primary)'}">
|
||||
{#if (color.value === '' && !editor.getAttributes('textStyle').color) || editor.getAttributes('textStyle').color === color.value}
|
||||
{#if (color.value === '' && !getEditorAttributes('textStyle').color) || getEditorAttributes('textStyle').color === color.value}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -6855,20 +6811,20 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Link -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title={`Link (${modKey}+K)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('link')} onclick={addLinkFromToolbar} title={`Link (${modKey}+K)`}>
|
||||
<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="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Lists -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title={`Bullet List (${modKey}+Shift+8)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('bulletList')} onclick={toggleBulletList} title={`Bullet List (${modKey}+Shift+8)`}>
|
||||
<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="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
|
||||
<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="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title={`Task List (${modKey}+Shift+9)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('taskList')} onclick={toggleTaskList} title={`Task List (${modKey}+Shift+9)`}>
|
||||
<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="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -6885,25 +6841,25 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Code & Code Block -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('code'))} onclick={() => editor?.chain().focus().toggleCode().run()} title={`Inline Code (${modKey}+E)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('code')} onclick={() => editor?.chain().focus().toggleCode().run()} title={`Inline Code (${modKey}+E)`}>
|
||||
<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="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('codeBlock'))} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title={`Code Block (${modKey}+Alt+C)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('codeBlock')} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title={`Code Block (${modKey}+Alt+C)`}>
|
||||
<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="m10 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Blockquote -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('blockquote'))} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title={`Quote (${modKey}+Shift+B)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('blockquote')} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title={`Quote (${modKey}+Shift+B)`}>
|
||||
<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="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Collapsible Section -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title={`Collapsible Section (${modKey}+.)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('details')} onclick={() => insertDetails()} title={`Collapsible Section (${modKey}+.)`}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Callout -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('callout'))} onclick={() => insertCallout('note')} title="Callout">
|
||||
<button class="fmt-btn" class:active={isEditorActive('callout')} onclick={() => insertCallout('note')} title="Callout">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="7" y1="5" x2="7" y2="19"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -6913,18 +6869,18 @@
|
||||
<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 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>
|
||||
</button>
|
||||
{#if tablePickerOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown table-picker-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="fmt-dropdown table-picker-dropdown">
|
||||
<div class="table-picker-grid">
|
||||
{#each Array(8) as _, r}
|
||||
{#each Array(10) as _, c}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
class="table-picker-cell"
|
||||
class:active={r < tablePickerHover.rows && c < tablePickerHover.cols}
|
||||
aria-label={`Insert ${r + 1} by ${c + 1} table`}
|
||||
onmouseenter={() => tablePickerHover = { rows: r + 1, cols: c + 1 }}
|
||||
onclick={() => insertTable(r + 1, c + 1)}
|
||||
></div>
|
||||
></button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -6944,22 +6900,21 @@
|
||||
|
||||
<!-- Highlight -->
|
||||
<div class="fmt-dropdown-wrap">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title={`Highlight (${modKey}+Shift+H)`}>
|
||||
<button class="fmt-btn" class:active={isEditorActive('highlight')} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title={`Highlight (${modKey}+Shift+H)`}>
|
||||
<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="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 01-2.8 0l-5.2-5.2a2 2 0 010-2.8L14 4"/></svg>
|
||||
<span class="color-indicator" style="background: {editor.getAttributes('highlight').color || 'var(--accent)'}"></span>
|
||||
<span class="color-indicator" style="background: {getEditorAttributes('highlight').color || 'var(--accent)'}"></span>
|
||||
</button>
|
||||
{#if highlightDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="fmt-dropdown color-grid-dropdown">
|
||||
{#each highlightColors as color}
|
||||
<button class="color-swatch" title={color.name} onclick={() => setHighlightColor(color.value)} style="background: {color.swatch}">
|
||||
{#if editor.isActive('highlight', { color: color.value })}
|
||||
{#if isEditorActive('highlight', { color: color.value })}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="color-swatch" title="Remove highlight" onclick={() => setHighlightColor('')} style="background: var(--bg-tertiary);">
|
||||
{#if !editor.isActive('highlight')}
|
||||
{#if !isEditorActive('highlight')}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-primary)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
@@ -6970,10 +6925,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Subscript & Superscript -->
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('subscript'))} onclick={() => editor?.chain().focus().toggleSubscript().run()} title="Subscript">
|
||||
<button class="fmt-btn" class:active={isEditorActive('subscript')} onclick={() => editor?.chain().focus().toggleSubscript().run()} title="Subscript">
|
||||
<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="m4 5 8 8"/><path d="m12 5-8 8"/><path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 00-2.62-.44c-.42.24-.74.62-.9 1.07"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('superscript'))} onclick={() => editor?.chain().focus().toggleSuperscript().run()} title="Superscript">
|
||||
<button class="fmt-btn" class:active={isEditorActive('superscript')} onclick={() => editor?.chain().focus().toggleSuperscript().run()} title="Superscript">
|
||||
<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="m4 19 8-8"/><path d="m12 19-8-8"/><path d="M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 00-2.617-.436c-.42.239-.738.614-.899 1.06"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -6982,32 +6937,31 @@
|
||||
<!-- Text Alignment -->
|
||||
<div class="fmt-dropdown-wrap">
|
||||
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); alignDropdown = !alignDropdown; headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; insertDropdown = false; }} title="Text Alignment">
|
||||
{#if (editorState, editor.isActive({ textAlign: 'center' }))}
|
||||
{#if isEditorActive({ textAlign: 'center' })}
|
||||
<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="M21 5H3"/><path d="M17 12H7"/><path d="M19 19H5"/></svg>
|
||||
{:else if (editorState, editor.isActive({ textAlign: 'right' }))}
|
||||
{:else if isEditorActive({ textAlign: 'right' })}
|
||||
<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="M21 5H3"/><path d="M21 12H9"/><path d="M21 19H7"/></svg>
|
||||
{:else if (editorState, editor.isActive({ textAlign: 'justify' }))}
|
||||
{:else if isEditorActive({ textAlign: 'justify' })}
|
||||
<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="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg>
|
||||
{:else}
|
||||
<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="M21 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{#if alignDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown align-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<button class:active={(editorState, editor.isActive({ textAlign: 'left' }))} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
|
||||
<div class="fmt-dropdown align-dropdown">
|
||||
<button class:active={isEditorActive({ textAlign: 'left' })} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></svg>
|
||||
Left
|
||||
</button>
|
||||
<button class:active={(editorState, editor.isActive({ textAlign: 'center' }))} onclick={() => { editor?.chain().focus().setTextAlign('center').run(); alignDropdown = false; }}>
|
||||
<button class:active={isEditorActive({ textAlign: 'center' })} onclick={() => { editor?.chain().focus().setTextAlign('center').run(); alignDropdown = false; }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M17 12H7"/><path d="M19 19H5"/></svg>
|
||||
Center
|
||||
</button>
|
||||
<button class:active={(editorState, editor.isActive({ textAlign: 'right' }))} onclick={() => { editor?.chain().focus().setTextAlign('right').run(); alignDropdown = false; }}>
|
||||
<button class:active={isEditorActive({ textAlign: 'right' })} onclick={() => { editor?.chain().focus().setTextAlign('right').run(); alignDropdown = false; }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M21 12H9"/><path d="M21 19H7"/></svg>
|
||||
Right
|
||||
</button>
|
||||
<button class:active={(editorState, editor.isActive({ textAlign: 'justify' }))} onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); alignDropdown = false; }}>
|
||||
<button class:active={isEditorActive({ textAlign: 'justify' })} onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); alignDropdown = false; }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg>
|
||||
Justify
|
||||
</button>
|
||||
@@ -7087,9 +7041,8 @@
|
||||
|
||||
{#if linkContextMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="link-context-overlay" onclick={closeLinkContextMenu}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="link-context-menu" style="left: {linkContextMenu.x}px; top: {linkContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="link-context-overlay" onclick={(e) => closeFromOverlay(e, closeLinkContextMenu)} onkeydown={(e) => closeOnEscape(e, closeLinkContextMenu)}>
|
||||
<div class="link-context-menu" style="left: {linkContextMenu.x}px; top: {linkContextMenu.y}px">
|
||||
<div class="link-context-url">{linkContextMenu.href}</div>
|
||||
<button onclick={linkMenuOpen}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -7134,9 +7087,8 @@
|
||||
|
||||
{#if textContextMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="text-ctx-overlay" onclick={closeTextContextMenu}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="text-ctx-menu" style="left: {textContextMenu.x}px; top: {textContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="text-ctx-overlay" onclick={(e) => closeFromOverlay(e, closeTextContextMenu)} onkeydown={(e) => closeOnEscape(e, closeTextContextMenu)}>
|
||||
<div class="text-ctx-menu" style="left: {textContextMenu.x}px; top: {textContextMenu.y}px">
|
||||
<button onclick={ctxCut}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>
|
||||
Cut
|
||||
@@ -7168,12 +7120,12 @@
|
||||
</button>
|
||||
{#if ctxHeadingSubmenu}
|
||||
<div class="text-ctx-submenu" class:flip-left={textContextMenu?.submenuLeft}>
|
||||
<button class:active={(editorState, editor?.isActive('heading', { level: 1 }))} onclick={() => ctxSetHeading(1)}>Heading 1</button>
|
||||
<button class:active={(editorState, editor?.isActive('heading', { level: 2 }))} onclick={() => ctxSetHeading(2)}>Heading 2</button>
|
||||
<button class:active={(editorState, editor?.isActive('heading', { level: 3 }))} onclick={() => ctxSetHeading(3)}>Heading 3</button>
|
||||
<button class:active={(editorState, editor?.isActive('heading', { level: 4 }))} onclick={() => ctxSetHeading(4)}>Heading 4</button>
|
||||
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => ctxSetHeading(1)}>Heading 1</button>
|
||||
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => ctxSetHeading(2)}>Heading 2</button>
|
||||
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => ctxSetHeading(3)}>Heading 3</button>
|
||||
<button class:active={isEditorActive('heading', { level: 4 })} onclick={() => ctxSetHeading(4)}>Heading 4</button>
|
||||
<div class="text-ctx-sep"></div>
|
||||
<button class:active={(editorState, editor?.isActive('paragraph'))} onclick={ctxSetParagraph}>Paragraph</button>
|
||||
<button class:active={isEditorActive('paragraph')} onclick={ctxSetParagraph}>Paragraph</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -7201,6 +7153,10 @@
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Highlight
|
||||
</button>
|
||||
<button onclick={ctxClearFormatting}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>
|
||||
Clear Formatting
|
||||
</button>
|
||||
<div class="text-ctx-sep"></div>
|
||||
<button onclick={ctxLink}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
@@ -7260,9 +7216,8 @@
|
||||
|
||||
{#if tableContextMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="table-ctx-overlay" onclick={closeTableContextMenu}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px; max-height: calc(100vh - {tableContextMenu.y}px - 8px); overflow-y: auto;" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="table-ctx-overlay" onclick={(e) => closeFromOverlay(e, closeTableContextMenu)} onkeydown={(e) => closeOnEscape(e, closeTableContextMenu)}>
|
||||
<div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px; max-height: calc(100vh - {tableContextMenu.y}px - 8px); overflow-y: auto;">
|
||||
<button onclick={tblAddRowBefore}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/><path d="M12 3v3"/><polyline points="9 4.5 12 2 15 4.5"/></svg>
|
||||
Add Row Above
|
||||
@@ -7336,9 +7291,8 @@
|
||||
|
||||
{#if imageToolbar}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="img-toolbar-overlay" onclick={() => (imageToolbar = null)}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="img-toolbar" class:mobile-viewer-toolbar={isAndroid} style="left: {imageToolbar.x}px; top: {imageToolbar.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="img-toolbar-overlay" onclick={(e) => closeFromOverlay(e, () => (imageToolbar = null))} onkeydown={(e) => closeOnEscape(e, () => (imageToolbar = null))}>
|
||||
<div class="img-toolbar" class:mobile-viewer-toolbar={isAndroid} style="left: {imageToolbar.x}px; top: {imageToolbar.y}px">
|
||||
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
|
||||
<button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
|
||||
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
|
||||
@@ -7348,7 +7302,7 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3M16 3h3a2 2 0 012 2v3M8 21H5a2 2 0 01-2-2v-3M16 21h3a2 2 0 002-2v-3"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost')}
|
||||
{#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost') && !imageToolbar.src.startsWith('https://imgproxy.localhost')}
|
||||
<span class="img-toolbar-sep"></span>
|
||||
<button onclick={copyImageToClipboard} title="Copy image">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
@@ -7384,8 +7338,8 @@
|
||||
|
||||
{#if mathModal}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="math-modal-overlay" onclick={cancelMathModal}>
|
||||
<div class="math-modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Math editor">
|
||||
<div class="math-modal-overlay" onclick={(e) => closeFromOverlay(e, cancelMathModal)} onkeydown={(e) => closeOnEscape(e, cancelMathModal)}>
|
||||
<div class="math-modal" role="dialog" aria-modal="true" aria-label="Math editor" tabindex="-1">
|
||||
<div class="math-modal-header">
|
||||
<span>{mathModal.editPos !== null ? 'Edit' : 'Insert'} {mathModal.kind === 'block' ? 'Math Block' : 'Inline Math'}</span>
|
||||
<button type="button" class="math-modal-close" onclick={cancelMathModal} aria-label="Close">
|
||||
@@ -7400,7 +7354,7 @@
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commitMathModal(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancelMathModal(); }
|
||||
}}
|
||||
autofocus
|
||||
use:autofocus
|
||||
></textarea>
|
||||
<div class="math-modal-preview">
|
||||
{#if mathModal.tex.trim()}
|
||||
@@ -7424,8 +7378,8 @@
|
||||
|
||||
{#if secretModal}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="math-modal-overlay" onclick={cancelSecretModal}>
|
||||
<div class="math-modal secret-modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Encrypted secret editor">
|
||||
<div class="math-modal-overlay" onclick={(e) => closeFromOverlay(e, cancelSecretModal)} onkeydown={(e) => closeOnEscape(e, cancelSecretModal)}>
|
||||
<div class="math-modal secret-modal" role="dialog" aria-modal="true" aria-label="Encrypted secret editor" tabindex="-1">
|
||||
<div class="math-modal-header">
|
||||
<span>Insert Secret</span>
|
||||
<button type="button" class="math-modal-close" onclick={cancelSecretModal} aria-label="Close">
|
||||
@@ -7492,8 +7446,8 @@
|
||||
|
||||
{#if viewerImportPickerOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="viewer-import-overlay" onclick={() => (viewerImportPickerOpen = false)}>
|
||||
<div class="viewer-import-picker" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<div class="viewer-import-overlay" onclick={(e) => closeFromOverlay(e, () => (viewerImportPickerOpen = false))} onkeydown={(e) => closeOnEscape(e, () => (viewerImportPickerOpen = false))}>
|
||||
<div class="viewer-import-picker" role="dialog" aria-modal="true" aria-label="Import image to folder" tabindex="-1">
|
||||
<div class="viewer-import-header">
|
||||
<span>Import to folder</span>
|
||||
<button type="button" class="viewer-import-close" onclick={() => (viewerImportPickerOpen = false)} aria-label="Close">
|
||||
@@ -7518,11 +7472,8 @@
|
||||
|
||||
{#if tagMenu && $activeNote}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="tag-menu-overlay" onclick={() => (tagMenu = null)}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="tag-menu-overlay" onclick={(e) => closeFromOverlay(e, () => (tagMenu = null))} onkeydown={(e) => closeOnEscape(e, () => (tagMenu = null))}>
|
||||
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px">
|
||||
{#if $activeNote.meta.tags.length > 0}
|
||||
<div class="tag-menu-list">
|
||||
{#each $activeNote.meta.tags as tag}
|
||||
@@ -7547,9 +7498,8 @@
|
||||
|
||||
{#if codeLangDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="code-lang-overlay" onclick={(e) => closeFromOverlay(e, closeCodeLangDropdown)} onkeydown={(e) => closeOnEscape(e, closeCodeLangDropdown)}>
|
||||
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px">
|
||||
<input class="code-lang-search" type="text" placeholder="Search..." bind:this={codeLangInput} bind:value={codeLangSearch} onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && codeLangFiltered.length > 0) { e.preventDefault(); e.stopPropagation(); selectCodeLang(codeLangFiltered[0]); }
|
||||
if (e.key === 'Escape') closeCodeLangDropdown();
|
||||
@@ -7577,9 +7527,8 @@
|
||||
|
||||
{#if slashMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="slash-menu-overlay" onclick={closeSlashMenu}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="slash-menu" style="left: {slashMenu.x}px; top: {slashMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, closeSlashMenu)} onkeydown={(e) => closeOnEscape(e, closeSlashMenu)}>
|
||||
<div class="slash-menu" style="left: {slashMenu.x}px; top: {slashMenu.y}px">
|
||||
{#if slashTablePicker}
|
||||
<div class="slash-table-picker">
|
||||
<div class="slash-table-picker-grid">
|
||||
@@ -7641,9 +7590,8 @@
|
||||
|
||||
{#if taskMetaMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="slash-menu-overlay" onclick={closeTaskMetaMenu}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="slash-menu" style="left: {taskMetaMenu.x}px; top: {taskMetaMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, closeTaskMetaMenu)} onkeydown={(e) => closeOnEscape(e, closeTaskMetaMenu)}>
|
||||
<div class="slash-menu" style="left: {taskMetaMenu.x}px; top: {taskMetaMenu.y}px">
|
||||
{#if taskMetaFiltered.length === 0}
|
||||
<div class="slash-menu-empty">No match</div>
|
||||
{:else}
|
||||
@@ -7665,13 +7613,12 @@
|
||||
|
||||
{#if taskDuePicker}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="slash-menu-overlay" onclick={() => { taskDuePicker = null; editor?.commands.focus(); }}>
|
||||
<div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, () => { taskDuePicker = null; editor?.commands.focus(); })} onkeydown={(e) => closeOnEscape(e, () => { taskDuePicker = null; editor?.commands.focus(); })}>
|
||||
<input
|
||||
type="date"
|
||||
class="task-due-input"
|
||||
bind:this={taskDueInputEl}
|
||||
style="left: {taskDuePicker.x}px; top: {taskDuePicker.y}px"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onchange={(e) => applyTaskDue((e.currentTarget as HTMLInputElement).value)}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') { taskDuePicker = null; editor?.commands.focus(); } }}
|
||||
/>
|
||||
@@ -7680,9 +7627,8 @@
|
||||
|
||||
{#if wikiLinkMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="wiki-link-overlay" onclick={closeWikiLinkMenu}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="wiki-link-menu" style="left: {wikiLinkMenu.x}px; top: {wikiLinkMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="wiki-link-overlay" onclick={(e) => closeFromOverlay(e, closeWikiLinkMenu)} onkeydown={(e) => closeOnEscape(e, closeWikiLinkMenu)}>
|
||||
<div class="wiki-link-menu" style="left: {wikiLinkMenu.x}px; top: {wikiLinkMenu.y}px">
|
||||
{#if wikiLinkFiltered.length === 0}
|
||||
<div class="wiki-link-empty">
|
||||
{wikiLinkMenu.query ? 'No matching notes' : 'Type to search notes...'}
|
||||
@@ -7711,9 +7657,8 @@
|
||||
|
||||
{#if wikiLinkNavDisambig}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="wiki-link-overlay" onclick={() => wikiLinkNavDisambig = null}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="wiki-link-overlay" onclick={(e) => closeFromOverlay(e, () => wikiLinkNavDisambig = null)} onkeydown={(e) => closeOnEscape(e, () => wikiLinkNavDisambig = null)}>
|
||||
<div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px">
|
||||
<div class="wiki-link-disambig-header">Multiple notes found - choose one:</div>
|
||||
{#each wikiLinkNavDisambig.entries as entry, i}
|
||||
<button
|
||||
@@ -7735,9 +7680,8 @@
|
||||
|
||||
{#if aiMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="ai-menu-overlay" class:mobile={isMobile} onclick={closeAiMenu}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="ai-menu" class:mobile={isMobile} style={isMobile ? '' : `left: ${aiMenu.x}px; top: ${aiMenu.y}px`} onclick={(e) => e.stopPropagation()}>
|
||||
<div class="ai-menu-overlay" class:mobile={isMobile} onclick={(e) => closeFromOverlay(e, closeAiMenu)} onkeydown={(e) => closeOnEscape(e, closeAiMenu)}>
|
||||
<div class="ai-menu" class:mobile={isMobile} style={isMobile ? '' : `left: ${aiMenu.x}px; top: ${aiMenu.y}px`}>
|
||||
{#if aiResult !== null || aiLoading}
|
||||
<!-- Result view -->
|
||||
<div class="ai-result-header">
|
||||
@@ -7749,7 +7693,7 @@
|
||||
AI Result
|
||||
{/if}
|
||||
</span>
|
||||
<button class="ai-result-close" onclick={closeAiMenu}>
|
||||
<button class="ai-result-close" onclick={closeAiMenu} aria-label="Close AI menu">
|
||||
<svg width="14" height="14" 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>
|
||||
@@ -7773,7 +7717,7 @@
|
||||
{:else if aiShowCustom}
|
||||
<!-- Custom prompt input -->
|
||||
<div class="ai-custom-header">
|
||||
<button class="ai-back-btn" onclick={() => aiShowCustom = false}>
|
||||
<button class="ai-back-btn" onclick={() => aiShowCustom = false} aria-label="Back to AI actions">
|
||||
<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="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
<span>Custom Prompt</span>
|
||||
@@ -7794,7 +7738,7 @@
|
||||
{:else if aiTranslateMenu}
|
||||
<!-- Translate submenu -->
|
||||
<div class="ai-custom-header">
|
||||
<button class="ai-back-btn" onclick={() => aiTranslateMenu = false}>
|
||||
<button class="ai-back-btn" onclick={() => aiTranslateMenu = false} aria-label="Back to AI actions">
|
||||
<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="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
<span>Translate to</span>
|
||||
@@ -7876,9 +7820,8 @@
|
||||
|
||||
{#if linkModal}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="link-modal-overlay" onclick={linkModalCancel}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="link-modal" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="link-modal-overlay" onclick={(e) => closeFromOverlay(e, linkModalCancel)} onkeydown={(e) => closeOnEscape(e, linkModalCancel)}>
|
||||
<div class="link-modal" role="dialog" aria-modal="true" aria-label="Insert link" tabindex="-1">
|
||||
<div class="link-modal-header">
|
||||
<svg width="28" height="28" viewBox="0 0 48 48" fill="none">
|
||||
<rect width="48" height="48" rx="12" fill="var(--accent)" />
|
||||
@@ -11301,11 +11244,11 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-actions.mobile {
|
||||
.editor-container.mobile .toolbar-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-actions.mobile .icon-btn {
|
||||
.editor-container.mobile .toolbar-actions .icon-btn {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
@@ -11313,8 +11256,8 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar-actions.mobile .save-indicator,
|
||||
.toolbar-actions.mobile .readonly-indicator {
|
||||
.editor-container.mobile .toolbar-actions .save-indicator,
|
||||
.editor-container.mobile .toolbar-actions .readonly-indicator {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
activeTab = isMobile ? 'about' : 'shortcuts';
|
||||
}
|
||||
|
||||
function closeFromOverlay(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
openUrl(url).catch(console.error);
|
||||
}
|
||||
@@ -100,9 +104,8 @@
|
||||
|
||||
{#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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setScrollToChangeFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
@@ -583,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);
|
||||
@@ -744,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);
|
||||
@@ -983,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`);
|
||||
}
|
||||
@@ -1052,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;
|
||||
@@ -1068,6 +1088,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
|
||||
const savedSize = $appConfig?.font_size;
|
||||
if (savedSize) {
|
||||
activeFontSize = savedSize;
|
||||
@@ -1127,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" />
|
||||
@@ -1214,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>
|
||||
@@ -1223,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>
|
||||
@@ -1231,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>
|
||||
@@ -1249,31 +1269,31 @@
|
||||
<p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p>
|
||||
<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>
|
||||
@@ -1288,7 +1308,7 @@
|
||||
<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} onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showNoteSwitcher} role="switch" aria-checked={showNoteSwitcher} aria-label="Show note switcher in title bar" onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1312,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>
|
||||
@@ -1368,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>
|
||||
@@ -1381,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>
|
||||
@@ -1390,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>
|
||||
@@ -1400,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>
|
||||
@@ -1489,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>
|
||||
@@ -1498,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>
|
||||
@@ -1508,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>
|
||||
@@ -1518,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>
|
||||
@@ -1527,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>
|
||||
@@ -1541,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>
|
||||
@@ -1556,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>
|
||||
@@ -1793,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>
|
||||
@@ -1900,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}
|
||||
@@ -1977,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>
|
||||
@@ -2005,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>
|
||||
@@ -2091,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>
|
||||
@@ -2443,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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -239,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() -
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface AppConfig {
|
||||
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;
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
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();
|
||||
|
||||
@@ -73,16 +74,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
const parts = p.split('/');
|
||||
const resolved: string[] = [];
|
||||
for (const seg of parts) {
|
||||
if (seg === '..') resolved.pop();
|
||||
else if (seg !== '.') resolved.push(seg);
|
||||
}
|
||||
return resolved.join('/');
|
||||
}
|
||||
|
||||
function openLocalFile(path: string) {
|
||||
if (isAndroid) {
|
||||
const bridge = (window as any).Android;
|
||||
@@ -98,24 +89,12 @@
|
||||
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) {
|
||||
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) => {
|
||||
|
||||
+13
-2
@@ -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,6 +160,9 @@
|
||||
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. Runs before the stores settle, so resolve
|
||||
@@ -287,6 +297,7 @@
|
||||
onDestroy(() => {
|
||||
removeEditorZoomShortcuts?.();
|
||||
removeFontSizeListener?.();
|
||||
removeScrollFontSizeListener?.();
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user