mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
28
Commits
43f15962f7
..
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 |
@@ -166,7 +166,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Rust](https://rustup.rs/) (1.77+)
|
- [Rust](https://rustup.rs/) (1.88+)
|
||||||
- [Node.js](https://nodejs.org/) (18+)
|
- [Node.js](https://nodejs.org/) (18+)
|
||||||
- [pnpm](https://pnpm.io/)
|
- [pnpm](https://pnpm.io/)
|
||||||
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
|
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||||
@@ -178,6 +178,14 @@ pnpm install
|
|||||||
pnpm tauri dev
|
pnpm tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
Run the frontend checks and tests, Rust tests and lints, and production frontend build with one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm verify
|
||||||
|
```
|
||||||
|
|
||||||
### Production Build
|
### Production Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+14
-10
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.3.4",
|
"version": "1.3.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
|
||||||
|
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
|
||||||
|
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
|
||||||
|
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build"
|
"tauri:build": "tauri build"
|
||||||
@@ -18,22 +22,22 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.70.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tauri-apps/cli": "^2.10.0",
|
"@tauri-apps/cli": "^2.10.0",
|
||||||
"svelte": "^5.49.2",
|
"@types/node": "26.2.0",
|
||||||
|
"svelte": "^5.56.9",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte-check": "^4.3.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
|
||||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||||
"@tiptap/core": "^3.19.0",
|
"@tiptap/core": "^3.19.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
||||||
@@ -62,11 +66,11 @@
|
|||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.3.0",
|
||||||
"markdown-it-mark": "^4.0.0",
|
"markdown-it-mark": "^4.0.0",
|
||||||
"markdown-it-sub": "^2.0.0",
|
"markdown-it-sub": "^2.0.0",
|
||||||
"markdown-it-sup": "^2.0.0",
|
"markdown-it-sup": "^2.0.0",
|
||||||
"markdown-it-task-lists": "^2.1.1",
|
"markdown-it-task-lists": "^2.1.1",
|
||||||
"mermaid": "^11.14.0"
|
"mermaid": "^11.16.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+598
-692
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,4 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ahash"
|
|
||||||
version = "0.7.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"once_cell",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -138,12 +127,6 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arrayvec"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -346,18 +329,6 @@ dependencies = [
|
|||||||
"crunchy",
|
"crunchy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitvec"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
|
|
||||||
dependencies = [
|
|
||||||
"funty",
|
|
||||||
"radium",
|
|
||||||
"tap",
|
|
||||||
"wyz",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -389,29 +360,6 @@ dependencies = [
|
|||||||
"piper",
|
"piper",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "borsh"
|
|
||||||
version = "1.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
|
|
||||||
dependencies = [
|
|
||||||
"borsh-derive",
|
|
||||||
"cfg_aliases",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "borsh-derive"
|
|
||||||
version = "1.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
|
|
||||||
dependencies = [
|
|
||||||
"once_cell",
|
|
||||||
"proc-macro-crate 3.4.0",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.2"
|
version = "8.0.2"
|
||||||
@@ -439,40 +387,6 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byte-unit"
|
|
||||||
version = "5.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
|
|
||||||
dependencies = [
|
|
||||||
"rust_decimal",
|
|
||||||
"schemars 1.2.1",
|
|
||||||
"serde",
|
|
||||||
"utf8-width",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bytecheck"
|
|
||||||
version = "0.6.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
|
|
||||||
dependencies = [
|
|
||||||
"bytecheck_derive",
|
|
||||||
"ptr_meta",
|
|
||||||
"simdutf8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bytecheck_derive"
|
|
||||||
version = "0.6.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 1.0.109",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.0"
|
version = "1.25.0"
|
||||||
@@ -624,6 +538,17 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -738,6 +663,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -768,9 +702,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
@@ -1314,12 +1248,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "funty"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1590,11 +1518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1604,10 +1530,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
|
"rand_core 0.10.1",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1796,9 +1725,6 @@ name = "hashbrown"
|
|||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
dependencies = [
|
|
||||||
"ahash 0.7.8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
@@ -1806,7 +1732,7 @@ version = "0.14.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash 0.8.12",
|
"ahash",
|
||||||
"allocator-api2",
|
"allocator-api2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1850,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.4"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1862,7 +1788,7 @@ dependencies = [
|
|||||||
"notify",
|
"notify",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"png 0.17.16",
|
"png 0.17.16",
|
||||||
"quick-xml 0.36.2",
|
"quick-xml",
|
||||||
"rayon",
|
"rayon",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -2554,9 +2480,6 @@ name = "log"
|
|||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
dependencies = [
|
|
||||||
"value-bag",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
@@ -2575,9 +2498,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lz4_flex"
|
name = "lz4_flex"
|
||||||
version = "0.11.5"
|
version = "0.11.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
|
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
@@ -3417,13 +3340,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plist"
|
name = "plist"
|
||||||
version = "1.8.0"
|
version = "1.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"quick-xml 0.38.4",
|
"quick-xml",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -3576,26 +3499,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ptr_meta"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
|
|
||||||
dependencies = [
|
|
||||||
"ptr_meta_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ptr_meta_derive"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 1.0.109",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pure-rust-locales"
|
name = "pure-rust-locales"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -3619,18 +3522,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.36.2"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quick-xml"
|
|
||||||
version = "0.38.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -3657,14 +3551,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.13"
|
version = "0.11.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.2",
|
"rand 0.10.2",
|
||||||
|
"rand_pcg 0.10.2",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash 2.1.1",
|
"rustc-hash 2.1.1",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -3705,12 +3600,6 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "radium"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -3722,7 +3611,7 @@ dependencies = [
|
|||||||
"rand_chacha 0.2.2",
|
"rand_chacha 0.2.2",
|
||||||
"rand_core 0.5.1",
|
"rand_core 0.5.1",
|
||||||
"rand_hc",
|
"rand_hc",
|
||||||
"rand_pcg",
|
"rand_pcg 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3738,12 +3627,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.2"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha 0.9.0",
|
"chacha20",
|
||||||
"rand_core 0.9.5",
|
"getrandom 0.4.1",
|
||||||
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3766,16 +3656,6 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_chacha"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
|
||||||
dependencies = [
|
|
||||||
"ppv-lite86",
|
|
||||||
"rand_core 0.9.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3796,12 +3676,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.9.5"
|
version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_distr"
|
name = "rand_distr"
|
||||||
@@ -3831,6 +3708,15 @@ dependencies = [
|
|||||||
"rand_core 0.5.1",
|
"rand_core 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3935,15 +3821,6 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rend"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
|
||||||
dependencies = [
|
|
||||||
"bytecheck",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
@@ -4063,35 +3940,6 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rkyv"
|
|
||||||
version = "0.7.46"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
|
|
||||||
dependencies = [
|
|
||||||
"bitvec",
|
|
||||||
"bytecheck",
|
|
||||||
"bytes",
|
|
||||||
"hashbrown 0.12.3",
|
|
||||||
"ptr_meta",
|
|
||||||
"rend",
|
|
||||||
"rkyv_derive",
|
|
||||||
"seahash",
|
|
||||||
"tinyvec",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rkyv_derive"
|
|
||||||
version = "0.7.46"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 1.0.109",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-stemmers"
|
name = "rust-stemmers"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -4102,22 +3950,6 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rust_decimal"
|
|
||||||
version = "1.40.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
|
|
||||||
dependencies = [
|
|
||||||
"arrayvec",
|
|
||||||
"borsh",
|
|
||||||
"bytes",
|
|
||||||
"num-traits",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"rkyv",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -4231,9 +4063,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.9"
|
version = "0.103.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -4327,12 +4159,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "seahash"
|
|
||||||
version = "4.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -4574,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4600,12 +4426,6 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "simdutf8"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@@ -5007,17 +4827,11 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tap"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.44"
|
version = "0.4.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5214,12 +5028,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-log"
|
name = "tauri-plugin-log"
|
||||||
version = "2.8.0"
|
version = "2.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
|
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"byte-unit",
|
|
||||||
"fern",
|
"fern",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -5964,12 +5777,6 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
|
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8-width"
|
|
||||||
version = "0.1.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -5988,12 +5795,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "value-bag"
|
|
||||||
version = "1.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -6246,12 +6047,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-scanner"
|
name = "wayland-scanner"
|
||||||
version = "0.31.8"
|
version = "0.31.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml 0.38.4",
|
"quick-xml",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7007,15 +6808,6 @@ dependencies = [
|
|||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wyz"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
|
||||||
dependencies = [
|
|
||||||
"tap",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11"
|
name = "x11"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|||||||
+10
-3
@@ -1,11 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.4"
|
version = "1.3.5"
|
||||||
description = "Local markdown note-taking app"
|
description = "Local markdown note-taking app"
|
||||||
authors = ["HelixNotes"]
|
authors = ["HelixNotes"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.88"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
# This style lint is version-dependent and produces over 100 legacy-only diagnostics on Rust 1.88.
|
||||||
|
uninlined_format_args = "allow"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "app_lib"
|
name = "app_lib"
|
||||||
@@ -14,6 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tauri = { version = "2", features = ["test"] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
@@ -38,7 +45,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
quick-xml = "0.36"
|
quick-xml = "0.41"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,32 +24,6 @@
|
|||||||
"dialog:allow-message",
|
"dialog:allow-message",
|
||||||
"dialog:allow-ask",
|
"dialog:allow-ask",
|
||||||
"dialog:allow-confirm",
|
"dialog:allow-confirm",
|
||||||
"fs:default",
|
"fs:allow-read-file"
|
||||||
"fs:allow-read",
|
|
||||||
"fs:allow-write",
|
|
||||||
"fs:allow-exists",
|
|
||||||
"fs:allow-mkdir",
|
|
||||||
"fs:allow-remove",
|
|
||||||
"fs:allow-rename",
|
|
||||||
"fs:allow-copy-file",
|
|
||||||
"fs:allow-stat",
|
|
||||||
"fs:allow-read-dir",
|
|
||||||
"fs:allow-read-file",
|
|
||||||
"fs:allow-write-file",
|
|
||||||
"fs:allow-watch",
|
|
||||||
"opener:default",
|
|
||||||
{
|
|
||||||
"identifier": "opener:allow-open-path",
|
|
||||||
"allow": [{ "path": "/**" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "opener:allow-open-url",
|
|
||||||
"allow": [
|
|
||||||
{ "url": "https://**" },
|
|
||||||
{ "url": "http://**" },
|
|
||||||
{ "url": "mailto:*" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"opener:allow-reveal-item-in-dir"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-16
@@ -8,6 +8,7 @@ const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
|||||||
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
||||||
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn ai_request(
|
pub fn ai_request(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
provider: String,
|
provider: String,
|
||||||
@@ -22,7 +23,11 @@ pub fn ai_request(
|
|||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
||||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
|
let key_opt = if api_key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(api_key.as_str())
|
||||||
|
};
|
||||||
let result = match provider.as_str() {
|
let result = match provider.as_str() {
|
||||||
"openai" => {
|
"openai" => {
|
||||||
stream_openai(
|
stream_openai(
|
||||||
@@ -98,7 +103,10 @@ pub fn ai_request(
|
|||||||
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
||||||
fn normalize_openai_base(base: &str) -> String {
|
fn normalize_openai_base(base: &str) -> String {
|
||||||
let b = base.trim().trim_end_matches('/');
|
let b = base.trim().trim_end_matches('/');
|
||||||
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
|
b.strip_suffix("/v1")
|
||||||
|
.unwrap_or(b)
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_anthropic(
|
async fn stream_anthropic(
|
||||||
@@ -268,9 +276,7 @@ async fn stream_openai(
|
|||||||
body["temperature"] = json!(0.7);
|
body["temperature"] = json!(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut req = client
|
let mut req = client.post(url).header("content-type", "application/json");
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/json");
|
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
req = req.header("Authorization", format!("Bearer {}", key));
|
req = req.header("Authorization", format!("Bearer {}", key));
|
||||||
@@ -380,7 +386,11 @@ pub async fn test_connection(
|
|||||||
model: &str,
|
model: &str,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
|
let key_opt = if api_key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(api_key)
|
||||||
|
};
|
||||||
match provider {
|
match provider {
|
||||||
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
||||||
"ollama" => {
|
"ollama" => {
|
||||||
@@ -434,14 +444,18 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let is_gpt5 = model.starts_with("gpt-5");
|
let is_gpt5 = model.starts_with("gpt-5");
|
||||||
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
|
let token_key = if is_gpt5 {
|
||||||
|
"max_completion_tokens"
|
||||||
|
} else {
|
||||||
|
"max_tokens"
|
||||||
|
};
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
token_key: 20,
|
token_key: 20,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": "Hi"
|
"content": "Hi"
|
||||||
@@ -449,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut req = client
|
let mut req = client.post(url).header("content-type", "application/json");
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/json");
|
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
req = req.header("Authorization", format!("Bearer {}", key));
|
req = req.header("Authorization", format!("Bearer {}", key));
|
||||||
|
|||||||
@@ -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 entry = entry.map_err(|e| e.to_string())?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
if path.extension().map_or(false, |ext| ext == "zip") {
|
if path.extension().is_some_and(|ext| ext == "zip") {
|
||||||
let filename = path
|
let filename = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore a backup by extracting the zip over the vault directory
|
fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
|
||||||
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> {
|
let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
|
||||||
let vault = Path::new(vault_path);
|
let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
|
||||||
let backup = Path::new(backup_path);
|
if backup.parent() != Some(backup_dir.as_path())
|
||||||
|
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
|
||||||
if !backup.exists() {
|
|| !backup.is_file()
|
||||||
return Err("Backup file does not exist".to_string());
|
{
|
||||||
|
return Err(
|
||||||
|
"Backup path must point to a ZIP file in the configured backup directory".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Ok(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a backup by extracting the zip over the vault directory
|
||||||
|
pub fn restore_backup(
|
||||||
|
vault_path: &str,
|
||||||
|
backup_dir: &Path,
|
||||||
|
backup_path: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let vault = Path::new(vault_path);
|
||||||
|
let backup = validated_backup_file(backup_dir, backup_path)?;
|
||||||
|
|
||||||
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
||||||
let mut archive =
|
let mut archive =
|
||||||
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a single backup file
|
/// Delete a single backup file
|
||||||
pub fn delete_backup(backup_path: &str) -> Result<(), String> {
|
pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
|
||||||
let path = Path::new(backup_path);
|
let path = validated_backup_file(backup_dir, backup_path)?;
|
||||||
if path.exists() {
|
fs::remove_file(path).map_err(|error| error.to_string())
|
||||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove old backups keeping only the newest `max_count`
|
/// Remove old backups keeping only the newest `max_count`
|
||||||
@@ -261,9 +272,31 @@ pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), Stri
|
|||||||
if backups.len() as u32 > max_count {
|
if backups.len() as u32 > max_count {
|
||||||
let to_remove = backups.split_off(max_count as usize);
|
let to_remove = backups.split_off(max_count as usize);
|
||||||
for entry in to_remove {
|
for entry in to_remove {
|
||||||
delete_backup(&entry.path)?;
|
delete_backup(backup_dir, &entry.path)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::delete_backup;
|
||||||
|
use std::fs;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_backup_rejects_files_outside_backup_directory() {
|
||||||
|
let root = std::env::temp_dir().join(format!("helixnotes-backup-test-{}", Uuid::new_v4()));
|
||||||
|
let backup_dir = root.join("backups");
|
||||||
|
let outside = root.join("outside.zip");
|
||||||
|
fs::create_dir_all(&backup_dir).unwrap();
|
||||||
|
fs::write(&outside, "must survive").unwrap();
|
||||||
|
|
||||||
|
let result = delete_backup(&backup_dir, &outside.to_string_lossy());
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(outside.exists());
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+549
-226
File diff suppressed because it is too large
Load Diff
+60
-22
@@ -1,22 +1,36 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
use crate::types::VersionEntry;
|
use crate::types::VersionEntry;
|
||||||
|
|
||||||
|
fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
|
||||||
|
let mut components = Path::new(value).components();
|
||||||
|
if value.is_empty()
|
||||||
|
|| !matches!(components.next(), Some(Component::Normal(_)))
|
||||||
|
|| components.next().is_some()
|
||||||
|
{
|
||||||
|
return Err(format!("Invalid {label}"));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
/// Directory: .helixnotes/history/<note-id>/
|
/// Directory: .helixnotes/history/<note-id>/
|
||||||
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
|
fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
|
||||||
Path::new(vault_path)
|
Ok(Path::new(vault_path)
|
||||||
.join(".helixnotes")
|
.join(".helixnotes")
|
||||||
.join("history")
|
.join("history")
|
||||||
.join(note_id)
|
.join(safe_path_component(note_id, "note ID")?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a version snapshot if enough time has passed since the last one.
|
/// Save a version snapshot if enough time has passed since the last one.
|
||||||
/// Minimum interval: 5 minutes between snapshots.
|
/// Minimum interval: 5 minutes between snapshots.
|
||||||
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
||||||
let dir = history_dir(vault_path, note_id);
|
let Ok(dir) = history_dir(vault_path, note_id) else {
|
||||||
|
log::warn!("Skipping history snapshot with an invalid note ID");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
// Check if we should create a snapshot (5 min cooldown)
|
// Check if we should create a snapshot (5 min cooldown)
|
||||||
if let Ok(entries) = fs::read_dir(&dir) {
|
if let Ok(entries) = fs::read_dir(&dir) {
|
||||||
@@ -68,31 +82,30 @@ pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_ve
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Force-create a version snapshot, bypassing the cooldown.
|
/// Force-create a version snapshot, bypassing the cooldown.
|
||||||
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
pub fn force_snapshot(
|
||||||
let dir = history_dir(vault_path, note_id);
|
vault_path: &str,
|
||||||
|
note_id: &str,
|
||||||
if let Err(e) = fs::create_dir_all(&dir) {
|
raw_content: &str,
|
||||||
eprintln!("Failed to create history dir: {}", e);
|
max_versions: u32,
|
||||||
return;
|
) -> Result<(), String> {
|
||||||
}
|
let dir = history_dir(vault_path, note_id)?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
||||||
let filename = format!("{}.md", timestamp);
|
let filename = format!("{}.md", timestamp);
|
||||||
let path = dir.join(&filename);
|
let path = dir.join(&filename);
|
||||||
|
|
||||||
if let Err(e) = fs::write(&path, raw_content) {
|
fs::write(&path, raw_content).map_err(|error| error.to_string())?;
|
||||||
eprintln!("Failed to write version snapshot: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if max_versions > 0 {
|
if max_versions > 0 {
|
||||||
let _ = prune_versions(&dir, max_versions);
|
prune_versions(&dir, max_versions)?;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all version snapshots for a note, newest first.
|
/// List all version snapshots for a note, newest first.
|
||||||
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
|
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
|
||||||
let dir = history_dir(vault_path, note_id);
|
let dir = history_dir(vault_path, note_id)?;
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -102,7 +115,7 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
|||||||
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||||
let entry = entry.map_err(|e| e.to_string())?;
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().map_or(false, |ext| ext == "md") {
|
if path.extension().is_some_and(|ext| ext == "md") {
|
||||||
let filename = path
|
let filename = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -134,17 +147,18 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
|||||||
|
|
||||||
/// Get the raw content of a specific version.
|
/// Get the raw content of a specific version.
|
||||||
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
|
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
|
||||||
|
safe_path_component(timestamp, "version timestamp")?;
|
||||||
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
|
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
|
||||||
let filename = if let Some(t_pos) = timestamp.find('T') {
|
let filename = if let Some(t_pos) = timestamp.find('T') {
|
||||||
let date_part = ×tamp[..t_pos];
|
let date_part = ×tamp[..t_pos];
|
||||||
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
|
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
|
||||||
let time_dashes = time_part.replace(':', "-");
|
let time_dashes = time_part.replace(':', "-");
|
||||||
format!("{}.md", format!("{}T{}", date_part, time_dashes))
|
format!("{date_part}T{time_dashes}.md")
|
||||||
} else {
|
} else {
|
||||||
format!("{}.md", timestamp)
|
format!("{}.md", timestamp)
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = history_dir(vault_path, note_id).join(&filename);
|
let path = history_dir(vault_path, note_id)?.join(&filename);
|
||||||
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
|
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +168,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
|||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.map(|e| e.path())
|
.map(|e| e.path())
|
||||||
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
|
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by name (timestamps sort lexicographically) - newest last
|
// Sort by name (timestamps sort lexicographically) - newest last
|
||||||
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{get_version, list_versions};
|
||||||
|
use std::fs;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_history_path_traversal() {
|
||||||
|
let vault =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-history-test-{}", Uuid::new_v4()));
|
||||||
|
let metadata = vault.join(".helixnotes");
|
||||||
|
let escaped_history = metadata.join("escaped");
|
||||||
|
fs::create_dir_all(&escaped_history).unwrap();
|
||||||
|
fs::write(escaped_history.join("2026-01-01T00-00-00.md"), "escaped").unwrap();
|
||||||
|
fs::create_dir_all(metadata.join("history").join("safe")).unwrap();
|
||||||
|
fs::write(metadata.join("secret.md"), "secret").unwrap();
|
||||||
|
|
||||||
|
assert!(list_versions(&vault.to_string_lossy(), "../escaped").is_err());
|
||||||
|
assert!(get_version(&vault.to_string_lossy(), "safe", "../../secret").is_err());
|
||||||
|
|
||||||
|
fs::remove_dir_all(vault).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ai;
|
||||||
|
mod asset_scope;
|
||||||
mod backup;
|
mod backup;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod image_proxy;
|
||||||
mod search;
|
mod search;
|
||||||
mod state;
|
mod state;
|
||||||
mod sync;
|
mod sync;
|
||||||
@@ -11,6 +13,7 @@ mod vault;
|
|||||||
use state::AppState;
|
use state::AppState;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
use tauri_plugin_fs::FsExt;
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
use tauri::{
|
use tauri::{
|
||||||
@@ -56,6 +59,16 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.on_webview_event(|webview, event| {
|
||||||
|
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
|
||||||
|
let scope = webview.fs_scope();
|
||||||
|
for path in paths {
|
||||||
|
if path.is_file() {
|
||||||
|
let _ = scope.allow_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.on_page_load(|webview, payload| {
|
.on_page_load(|webview, payload| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if webview.label() == "main"
|
if webview.label() == "main"
|
||||||
@@ -93,6 +106,20 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let active_vault = app
|
||||||
|
.state::<AppState>()
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|config| config.active_vault.clone());
|
||||||
|
if let Some(vault_path) = active_vault {
|
||||||
|
if let Err(error) =
|
||||||
|
asset_scope::allow_vault_assets(app.handle(), std::path::Path::new(&vault_path))
|
||||||
|
{
|
||||||
|
log::warn!("Failed to restore the vault asset scope: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
if show_tray {
|
if show_tray {
|
||||||
setup_tray(app)?;
|
setup_tray(app)?;
|
||||||
@@ -112,6 +139,14 @@ pub fn run() {
|
|||||||
std::env::current_dir().unwrap_or_default().join(&path)
|
std::env::current_dir().unwrap_or_default().join(&path)
|
||||||
};
|
};
|
||||||
if let Some(resolved_str) = resolved.to_str() {
|
if let Some(resolved_str) = resolved.to_str() {
|
||||||
|
if resolved.is_file() {
|
||||||
|
let _ = app.fs_scope().allow_file(&resolved);
|
||||||
|
if let Some(parent) = resolved.parent() {
|
||||||
|
let _ = app.fs_scope().allow_directory(parent, true);
|
||||||
|
}
|
||||||
|
let _ =
|
||||||
|
asset_scope::allow_external_note_assets(app.handle(), &resolved);
|
||||||
|
}
|
||||||
let app_state = app.state::<AppState>();
|
let app_state = app.state::<AppState>();
|
||||||
let _ = app_state.pending_open_file.lock().map(|mut p| {
|
let _ = app_state.pending_open_file.lock().map(|mut p| {
|
||||||
*p = Some(resolved_str.to_string());
|
*p = Some(resolved_str.to_string());
|
||||||
@@ -137,6 +172,7 @@ pub fn run() {
|
|||||||
commands::export_custom_theme,
|
commands::export_custom_theme,
|
||||||
commands::import_custom_themes,
|
commands::import_custom_themes,
|
||||||
commands::set_font_size,
|
commands::set_font_size,
|
||||||
|
commands::set_scroll_to_change_font_size,
|
||||||
commands::set_font_family,
|
commands::set_font_family,
|
||||||
commands::set_line_height,
|
commands::set_line_height,
|
||||||
commands::set_ui_scale,
|
commands::set_ui_scale,
|
||||||
@@ -190,6 +226,7 @@ pub fn run() {
|
|||||||
commands::trash_orphaned_attachments,
|
commands::trash_orphaned_attachments,
|
||||||
commands::import_obsidian,
|
commands::import_obsidian,
|
||||||
commands::open_file,
|
commands::open_file,
|
||||||
|
commands::reveal_file,
|
||||||
commands::open_url,
|
commands::open_url,
|
||||||
commands::copy_file_to,
|
commands::copy_file_to,
|
||||||
commands::write_bytes_to,
|
commands::write_bytes_to,
|
||||||
@@ -217,72 +254,21 @@ pub fn run() {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let encoded = path.strip_prefix('/').unwrap_or(&path);
|
let encoded = path.strip_prefix('/').unwrap_or(&path);
|
||||||
let external_url = percent_decode(encoded);
|
let external_url = percent_decode(encoded);
|
||||||
|
let response = match image_proxy::fetch(&external_url) {
|
||||||
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
Ok(image) => tauri::http::Response::builder()
|
||||||
let _ = responder.respond(
|
.status(200)
|
||||||
|
.header("Content-Type", image.content_type)
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
.body(image.body),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Blocked external image request: {}", error.message());
|
||||||
tauri::http::Response::builder()
|
tauri::http::Response::builder()
|
||||||
.status(400)
|
.status(error.status())
|
||||||
.body(Vec::new())
|
.body(Vec::new())
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = match reqwest::blocking::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => {
|
|
||||||
let _ = responder.respond(
|
|
||||||
tauri::http::Response::builder()
|
|
||||||
.status(502)
|
|
||||||
.body(Vec::new())
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
responder.respond(response.expect("valid image proxy response"));
|
||||||
match client.get(&external_url).send() {
|
|
||||||
Ok(resp) => {
|
|
||||||
let content_type = resp
|
|
||||||
.headers()
|
|
||||||
.get("content-type")
|
|
||||||
.and_then(|h| h.to_str().ok())
|
|
||||||
.unwrap_or("application/octet-stream")
|
|
||||||
.to_string();
|
|
||||||
let status = resp.status().as_u16();
|
|
||||||
match resp.bytes() {
|
|
||||||
Ok(bytes) => {
|
|
||||||
let _ = responder.respond(
|
|
||||||
tauri::http::Response::builder()
|
|
||||||
.status(status)
|
|
||||||
.header("Content-Type", &content_type)
|
|
||||||
.header("Access-Control-Allow-Origin", "*")
|
|
||||||
.body(bytes.to_vec())
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = responder.respond(
|
|
||||||
tauri::http::Response::builder()
|
|
||||||
.status(502)
|
|
||||||
.body(Vec::new())
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = responder.respond(
|
|
||||||
tauri::http::Response::builder()
|
|
||||||
.status(502)
|
|
||||||
.body(Vec::new())
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,6 +295,13 @@ pub fn run() {
|
|||||||
std::path::Path::new(&cwd).join(path)
|
std::path::Path::new(&cwd).join(path)
|
||||||
};
|
};
|
||||||
if let Some(resolved_str) = resolved.to_str() {
|
if let Some(resolved_str) = resolved.to_str() {
|
||||||
|
if resolved.is_file() {
|
||||||
|
let _ = app.fs_scope().allow_file(&resolved);
|
||||||
|
if let Some(parent) = resolved.parent() {
|
||||||
|
let _ = app.fs_scope().allow_directory(parent, true);
|
||||||
|
}
|
||||||
|
let _ = asset_scope::allow_external_note_assets(app, &resolved);
|
||||||
|
}
|
||||||
let _ = app.emit("open-file", resolved_str.to_string());
|
let _ = app.emit("open-file", resolved_str.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,9 +343,9 @@ pub fn run() {
|
|||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tauri::WindowEvent::Destroyed => {
|
tauri::WindowEvent::Destroyed
|
||||||
// When main window is destroyed, close all note windows
|
// When main window is destroyed, close all note windows
|
||||||
if window.label() == "main" {
|
if window.label() == "main" => {
|
||||||
let app = window.app_handle();
|
let app = window.app_handle();
|
||||||
for (label, win) in app.webview_windows() {
|
for (label, win) in app.webview_windows() {
|
||||||
if label.starts_with("note-") {
|
if label.starts_with("note-") {
|
||||||
@@ -360,7 +353,6 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
app_lib::run();
|
app_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(vault_path.as_bytes());
|
hasher.update(vault_path.as_bytes());
|
||||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
let key: String = hasher.finalize()[..8]
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect();
|
||||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +238,9 @@ impl SearchIndex {
|
|||||||
// before the writer is created, so both indexing and querying use it).
|
// before the writer is created, so both indexing and querying use it).
|
||||||
index.tokenizers().register(
|
index.tokenizers().register(
|
||||||
"cjk",
|
"cjk",
|
||||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
TextAnalyzer::builder(CjkTokenizer)
|
||||||
|
.filter(LowerCaser)
|
||||||
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(mobile)]
|
#[cfg(mobile)]
|
||||||
@@ -290,7 +295,7 @@ impl SearchIndex {
|
|||||||
doc.add_text(self.path_field, &path_str);
|
doc.add_text(self.path_field, &path_str);
|
||||||
doc.add_text(self.title_field, &meta.title);
|
doc.add_text(self.title_field, &meta.title);
|
||||||
doc.add_text(self.body_field, &content);
|
doc.add_text(self.body_field, &content);
|
||||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||||
let _ = writer.add_document(doc);
|
let _ = writer.add_document(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +326,7 @@ impl SearchIndex {
|
|||||||
doc.add_text(self.path_field, path);
|
doc.add_text(self.path_field, path);
|
||||||
doc.add_text(self.title_field, &meta.title);
|
doc.add_text(self.title_field, &meta.title);
|
||||||
doc.add_text(self.body_field, &content);
|
doc.add_text(self.body_field, &content);
|
||||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||||
let _ = writer.add_document(doc);
|
let _ = writer.add_document(doc);
|
||||||
|
|
||||||
writer.commit().map_err(|e| e.to_string())?;
|
writer.commit().map_err(|e| e.to_string())?;
|
||||||
@@ -341,7 +346,7 @@ impl SearchIndex {
|
|||||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||||
let searcher = reader.searcher();
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
let fields = [self.title_field, self.body_field, self.tags_field];
|
||||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||||
@@ -376,9 +381,10 @@ impl SearchIndex {
|
|||||||
));
|
));
|
||||||
vec![(Occur::Should, exact)]
|
vec![(Occur::Should, exact)]
|
||||||
} else {
|
} else {
|
||||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
let prefix: Box<dyn Query> =
|
||||||
vec![Term::from_field_text(field, term)],
|
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
|
||||||
));
|
field, term,
|
||||||
|
)]));
|
||||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||||
Term::from_field_text(field, term),
|
Term::from_field_text(field, term),
|
||||||
1,
|
1,
|
||||||
|
|||||||
+31
-8
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_etag(s: &str) -> String {
|
fn normalize_etag(s: &str) -> String {
|
||||||
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
s.trim()
|
||||||
|
.trim_start_matches("W/")
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percent-encode each path segment, keeping the `/` separators.
|
/// Percent-encode each path segment, keeping the `/` separators.
|
||||||
@@ -265,7 +268,11 @@ impl WebdavClient {
|
|||||||
.send()
|
.send()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
return Err(format!(
|
||||||
|
"GET {} failed: HTTP {}",
|
||||||
|
relpath,
|
||||||
|
resp.status().as_u16()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||||
}
|
}
|
||||||
@@ -279,7 +286,11 @@ impl WebdavClient {
|
|||||||
.send()
|
.send()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
return Err(format!(
|
||||||
|
"PUT {} failed: HTTP {}",
|
||||||
|
relpath,
|
||||||
|
resp.status().as_u16()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(resp
|
Ok(resp
|
||||||
.headers()
|
.headers()
|
||||||
@@ -453,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
|||||||
}
|
}
|
||||||
Ok(Event::Text(e)) => {
|
Ok(Event::Text(e)) => {
|
||||||
if cap != Cap::None {
|
if cap != Cap::None {
|
||||||
if let Ok(t) = e.unescape() {
|
if let Ok(decoded) = e.decode() {
|
||||||
buf.push_str(&t);
|
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
|
||||||
|
buf.push_str(&text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -515,8 +528,14 @@ fn apply_changes(
|
|||||||
|
|
||||||
match (l, r) {
|
match (l, r) {
|
||||||
(Some(lf), Some(re)) => {
|
(Some(lf), Some(re)) => {
|
||||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
let local_changed = match m {
|
||||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
Some(entry) => entry.local_hash != lf.hash,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
let remote_changed = match m {
|
||||||
|
Some(entry) => &entry.remote_etag != re,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
if !local_changed && !remote_changed {
|
if !local_changed && !remote_changed {
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
@@ -669,7 +688,11 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
|||||||
|
|
||||||
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||||
/// rebuilds the search index. Returns a summary of what changed.
|
/// rebuilds the search index. Returns a summary of what changed.
|
||||||
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
|
pub fn run_sync(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
vault: String,
|
||||||
|
cfg: WebdavConfig,
|
||||||
|
) -> Result<SyncSummary, String> {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
|
|||||||
+19
-4
@@ -124,6 +124,8 @@ pub struct AppConfig {
|
|||||||
pub accent_color: Option<String>,
|
pub accent_color: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub font_size: Option<u32>,
|
pub font_size: Option<u32>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub scroll_to_change_font_size: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub font_family: Option<String>,
|
pub font_family: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -296,6 +298,7 @@ impl Default for AppConfig {
|
|||||||
system_dark_theme: default_system_dark_theme(),
|
system_dark_theme: default_system_dark_theme(),
|
||||||
accent_color: None,
|
accent_color: None,
|
||||||
font_size: None,
|
font_size: None,
|
||||||
|
scroll_to_change_font_size: true,
|
||||||
font_family: None,
|
font_family: None,
|
||||||
line_height: None,
|
line_height: None,
|
||||||
ui_scale: None,
|
ui_scale: None,
|
||||||
@@ -549,12 +552,24 @@ mod startup_view_tests {
|
|||||||
assert!(!config.show_note_switcher);
|
assert!(!config.show_note_switcher);
|
||||||
|
|
||||||
let mut value = serde_json::to_value(config).unwrap();
|
let mut value = serde_json::to_value(config).unwrap();
|
||||||
value
|
value.as_object_mut().unwrap().remove("show_note_switcher");
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.remove("show_note_switcher");
|
|
||||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||||
|
|
||||||
assert!(!config.show_note_switcher);
|
assert!(!config.show_note_switcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
assert!(config.scroll_to_change_font_size);
|
||||||
|
|
||||||
|
let mut value = serde_json::to_value(config).unwrap();
|
||||||
|
value
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("scroll_to_change_font_size");
|
||||||
|
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||||
|
|
||||||
|
assert!(config.scroll_to_change_font_size);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
|
|||||||
"[{}]",
|
"[{}]",
|
||||||
meta.tags
|
meta.tags
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| format!("{}", t))
|
.map(|t| t.to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
)
|
)
|
||||||
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
chars.next(); // skip '['
|
chars.next(); // skip '['
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
// Skip alt text
|
// Skip alt text
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '[' {
|
if c == '[' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
if chars.peek() == Some(&'(') {
|
if chars.peek() == Some(&'(') {
|
||||||
chars.next();
|
chars.next();
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '(' {
|
if c == '(' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
if ch == '[' {
|
if ch == '[' {
|
||||||
let mut link_text = String::new();
|
let mut link_text = String::new();
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '[' {
|
if c == '[' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
if chars.peek() == Some(&'(') {
|
if chars.peek() == Some(&'(') {
|
||||||
chars.next();
|
chars.next();
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '(' {
|
if c == '(' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,13 +112,15 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
|||||||
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
|
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
|
||||||
);
|
);
|
||||||
if is_embeddable {
|
if is_embeddable {
|
||||||
let alt = if is_dimension_spec(alt_param) { "" } else { alt_param };
|
let alt = if is_dimension_spec(alt_param) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
alt_param
|
||||||
|
};
|
||||||
result.links_converted += 1;
|
result.links_converted += 1;
|
||||||
format!("", alt, link_target)
|
format!("", alt, link_target)
|
||||||
} else {
|
} else {
|
||||||
let display = if alt_param.is_empty() {
|
let display = if alt_param.is_empty() || is_dimension_spec(alt_param) {
|
||||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
|
||||||
} else if is_dimension_spec(alt_param) {
|
|
||||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
file_part.rsplit('/').next().unwrap_or(file_part)
|
||||||
} else {
|
} else {
|
||||||
alt_param
|
alt_param
|
||||||
@@ -137,7 +139,11 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
|||||||
let (file_part, anchor) = split_anchor(note_ref);
|
let (file_part, anchor) = split_anchor(note_ref);
|
||||||
if file_part.is_empty() {
|
if file_part.is_empty() {
|
||||||
if let Some(a) = anchor {
|
if let Some(a) = anchor {
|
||||||
let display = if display_param.is_empty() { a } else { display_param };
|
let display = if display_param.is_empty() {
|
||||||
|
a
|
||||||
|
} else {
|
||||||
|
display_param
|
||||||
|
};
|
||||||
result.links_converted += 1;
|
result.links_converted += 1;
|
||||||
return format!("[{}](#{})", display, a);
|
return format!("[{}](#{})", display, a);
|
||||||
}
|
}
|
||||||
@@ -165,8 +171,22 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
|||||||
.to_string();
|
.to_string();
|
||||||
content = after_links;
|
content = after_links;
|
||||||
|
|
||||||
content = fix_md_image_refs(&content, &md_img_re, vault, note_dir, &file_index, &mut result.links_converted);
|
content = fix_md_image_refs(
|
||||||
content = fix_md_link_refs(&content, &md_link_re, vault, note_dir, &file_index, &mut result.links_converted);
|
&content,
|
||||||
|
&md_img_re,
|
||||||
|
vault,
|
||||||
|
note_dir,
|
||||||
|
&file_index,
|
||||||
|
&mut result.links_converted,
|
||||||
|
);
|
||||||
|
content = fix_md_link_refs(
|
||||||
|
&content,
|
||||||
|
&md_link_re,
|
||||||
|
vault,
|
||||||
|
note_dir,
|
||||||
|
&file_index,
|
||||||
|
&mut result.links_converted,
|
||||||
|
);
|
||||||
|
|
||||||
if result.links_converted > links_before {
|
if result.links_converted > links_before {
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -231,10 +251,8 @@ fn rename_deprecated_properties(raw: &str) -> String {
|
|||||||
mapping.insert(new_key, val);
|
mapping.insert(new_key, val);
|
||||||
renamed = true;
|
renamed = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else if mapping.remove(&old_key).is_some() {
|
||||||
if mapping.remove(&old_key).is_some() {
|
renamed = true;
|
||||||
renamed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +285,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
|||||||
let tags = normalize_tags(&mapping);
|
let tags = normalize_tags(&mapping);
|
||||||
|
|
||||||
let title = mapping
|
let title = mapping
|
||||||
.get(&serde_yaml::Value::String("title".into()))
|
.get(serde_yaml::Value::String("title".into()))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
@@ -283,7 +301,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let id = mapping
|
let id = mapping
|
||||||
.get(&serde_yaml::Value::String("id".into()))
|
.get(serde_yaml::Value::String("id".into()))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -293,9 +311,9 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|key| {
|
.find_map(|key| {
|
||||||
mapping
|
mapping
|
||||||
.get(&serde_yaml::Value::String((*key).into()))
|
.get(serde_yaml::Value::String((*key).into()))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
.and_then(frontmatter::parse_date_flexible)
|
||||||
})
|
})
|
||||||
.or_else(|| file_created(path))
|
.or_else(|| file_created(path))
|
||||||
.unwrap_or_else(Utc::now);
|
.unwrap_or_else(Utc::now);
|
||||||
@@ -304,15 +322,15 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|key| {
|
.find_map(|key| {
|
||||||
mapping
|
mapping
|
||||||
.get(&serde_yaml::Value::String((*key).into()))
|
.get(serde_yaml::Value::String((*key).into()))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
.and_then(frontmatter::parse_date_flexible)
|
||||||
})
|
})
|
||||||
.or_else(|| file_modified(path))
|
.or_else(|| file_modified(path))
|
||||||
.unwrap_or_else(Utc::now);
|
.unwrap_or_else(Utc::now);
|
||||||
|
|
||||||
let pinned = mapping
|
let pinned = mapping
|
||||||
.get(&serde_yaml::Value::String("pinned".into()))
|
.get(serde_yaml::Value::String("pinned".into()))
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
@@ -332,7 +350,7 @@ fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec<String> {
|
|||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
for key in &["tags", "tag"] {
|
for key in &["tags", "tag"] {
|
||||||
if let Some(val) = mapping.get(&serde_yaml::Value::String((*key).into())) {
|
if let Some(val) = mapping.get(serde_yaml::Value::String((*key).into())) {
|
||||||
for raw in yaml_value_to_strings(val) {
|
for raw in yaml_value_to_strings(val) {
|
||||||
let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
|
let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
|
||||||
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
|
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
|
||||||
@@ -389,9 +407,7 @@ fn file_created(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.created().ok())
|
.and_then(|m| m.created().ok())
|
||||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
.and_then(|d| {
|
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||||
@@ -399,9 +415,7 @@ fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.modified().ok())
|
.and_then(|m| m.modified().ok())
|
||||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
.and_then(|d| {
|
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String {
|
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String {
|
||||||
@@ -513,7 +527,9 @@ fn fix_md_link_refs(
|
|||||||
let display = &caps[1];
|
let display = &caps[1];
|
||||||
let href = &caps[2];
|
let href = &caps[2];
|
||||||
let decoded = percent_decode(href);
|
let decoded = percent_decode(href);
|
||||||
if decoded.starts_with("http") || decoded.starts_with('/') || decoded.starts_with("data:")
|
if decoded.starts_with("http")
|
||||||
|
|| decoded.starts_with('/')
|
||||||
|
|| decoded.starts_with("data:")
|
||||||
|| decoded.starts_with('#')
|
|| decoded.starts_with('#')
|
||||||
{
|
{
|
||||||
return format!("[{}]({})", display, href);
|
return format!("[{}]({})", display, href);
|
||||||
@@ -549,9 +565,7 @@ fn fix_md_link_refs(
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_attachments(
|
fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
|
||||||
vault: &Path,
|
|
||||||
) -> Result<HashMap<String, String>, String> {
|
|
||||||
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
||||||
let _ = std::fs::create_dir_all(&attachments_dir);
|
let _ = std::fs::create_dir_all(&attachments_dir);
|
||||||
|
|
||||||
@@ -601,10 +615,7 @@ fn move_attachments(
|
|||||||
Ok(moved)
|
Ok(moved)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rewrite_attachment_refs(
|
fn rewrite_attachment_refs(vault: &Path, moved: &HashMap<String, String>) -> Result<(), String> {
|
||||||
vault: &Path,
|
|
||||||
moved: &HashMap<String, String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
|
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
|
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
|
||||||
@@ -686,7 +697,7 @@ fn cleanup_empty_dirs(root: &Path) {
|
|||||||
.map(|e| e.path().to_path_buf())
|
.map(|e| e.path().to_path_buf())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count()));
|
dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
|
||||||
|
|
||||||
for dir in dirs {
|
for dir in dirs {
|
||||||
let dir_str = dir.to_string_lossy();
|
let dir_str = dir.to_string_lossy();
|
||||||
@@ -719,7 +730,8 @@ fn is_dimension_spec(s: &str) -> bool {
|
|||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
s.chars().all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
|
s.chars()
|
||||||
|
.all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_wiki_ref(file_index: &HashMap<String, String>, reference: &str) -> String {
|
fn resolve_wiki_ref(file_index: &HashMap<String, String>, reference: &str) -> String {
|
||||||
@@ -907,7 +919,10 @@ mod tests {
|
|||||||
extract_heading_title("# My Title\n\nBody"),
|
extract_heading_title("# My Title\n\nBody"),
|
||||||
Some("My Title".to_string())
|
Some("My Title".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(extract_heading_title("\n\n# Spaced Title"), Some("Spaced Title".to_string()));
|
assert_eq!(
|
||||||
|
extract_heading_title("\n\n# Spaced Title"),
|
||||||
|
Some("Spaced Title".to_string())
|
||||||
|
);
|
||||||
assert_eq!(extract_heading_title("Body without heading"), None);
|
assert_eq!(extract_heading_title("Body without heading"), None);
|
||||||
assert_eq!(extract_heading_title("## Subheading"), None);
|
assert_eq!(extract_heading_title("## Subheading"), None);
|
||||||
assert_eq!(extract_heading_title(""), None);
|
assert_eq!(extract_heading_title(""), None);
|
||||||
@@ -947,8 +962,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_tags_strips_hash() {
|
fn test_normalize_tags_strips_hash() {
|
||||||
let mapping: serde_yaml::Mapping =
|
let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
||||||
serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
|
||||||
let tags = normalize_tags(&mapping);
|
let tags = normalize_tags(&mapping);
|
||||||
assert_eq!(tags, vec!["hashed"]);
|
assert_eq!(tags, vec!["hashed"]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,107 @@ pub fn helixnotes_dir(vault_path: &str) -> PathBuf {
|
|||||||
Path::new(vault_path).join(".helixnotes")
|
Path::new(vault_path).join(".helixnotes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn canonicalize_path(path: &Path, label: &str) -> Result<PathBuf, String> {
|
||||||
|
fs::canonicalize(path).map_err(|error| format!("Invalid {label}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_vault_content_path(
|
||||||
|
vault_path: &str,
|
||||||
|
requested_path: &Path,
|
||||||
|
allow_root: bool,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let vault = canonicalize_path(Path::new(vault_path), "vault path")?;
|
||||||
|
let requested = canonicalize_path(requested_path, "vault item path")?;
|
||||||
|
let metadata = vault.join(".helixnotes");
|
||||||
|
|
||||||
|
if !requested.starts_with(&vault)
|
||||||
|
|| requested.starts_with(&metadata)
|
||||||
|
|| (!allow_root && requested == vault)
|
||||||
|
{
|
||||||
|
return Err("Path must stay inside the active vault".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(requested_path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_vault_content_dir(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let requested = ensure_vault_content_path(vault_path, requested_path, true)?;
|
||||||
|
if !requested.is_dir() {
|
||||||
|
return Err("Vault destination is not a directory".to_string());
|
||||||
|
}
|
||||||
|
Ok(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
|
||||||
|
if !requested.is_file()
|
||||||
|
|| requested
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
!= Some("md")
|
||||||
|
{
|
||||||
|
return Err("Note path must point to a Markdown file".to_string());
|
||||||
|
}
|
||||||
|
Ok(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_readable_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
if let Ok(note) = ensure_note_path(vault_path, requested_path) {
|
||||||
|
return Ok(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trashed_note = ensure_trash_entry(vault_path, requested_path)?;
|
||||||
|
if !trashed_note.is_file()
|
||||||
|
|| trashed_note
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
!= Some("md")
|
||||||
|
{
|
||||||
|
return Err("Note path must point to a Markdown file".to_string());
|
||||||
|
}
|
||||||
|
Ok(trashed_note)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_notebook_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
|
||||||
|
if !requested.is_dir() {
|
||||||
|
return Err("Notebook path must point to a directory".to_string());
|
||||||
|
}
|
||||||
|
Ok(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_trash_entry(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let trash = canonicalize_path(&helixnotes_dir(vault_path).join("trash"), "trash path")?;
|
||||||
|
let requested = canonicalize_path(requested_path, "trash item path")?;
|
||||||
|
if requested == trash || !requested.starts_with(&trash) {
|
||||||
|
return Err("Path must be an item inside the active vault trash".to_string());
|
||||||
|
}
|
||||||
|
Ok(requested_path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_relative_path(path: &str) -> Result<&Path, String> {
|
||||||
|
let relative = Path::new(path);
|
||||||
|
if relative.as_os_str().is_empty()
|
||||||
|
|| !relative
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
return Err("Path must be a safe vault-relative path".to_string());
|
||||||
|
}
|
||||||
|
Ok(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_child_name(name: &str) -> Result<&str, String> {
|
||||||
|
let mut components = Path::new(name).components();
|
||||||
|
if name.trim().is_empty()
|
||||||
|
|| !matches!(components.next(), Some(Component::Normal(_)))
|
||||||
|
|| components.next().is_some()
|
||||||
|
{
|
||||||
|
return Err("Name must not contain path separators".to_string());
|
||||||
|
}
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
|
pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
|
||||||
let hn_dir = helixnotes_dir(vault_path);
|
let hn_dir = helixnotes_dir(vault_path);
|
||||||
fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?;
|
fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?;
|
||||||
@@ -140,7 +241,11 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
|||||||
paths
|
paths
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.map(|path| {
|
.map(|path| {
|
||||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
let relative = path
|
let relative = path
|
||||||
.strip_prefix(root)
|
.strip_prefix(root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
@@ -191,7 +296,11 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
|
|||||||
let entries: Vec<NotebookEntry> = paths
|
let entries: Vec<NotebookEntry> = paths
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.map(|path| {
|
.map(|path| {
|
||||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
let relative = path
|
let relative = path
|
||||||
.strip_prefix(root)
|
.strip_prefix(root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
@@ -243,9 +352,15 @@ pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
|
|||||||
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
|
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
|
||||||
let scan_path = notebook_path.unwrap_or(vault_path);
|
let scan_path = notebook_path.unwrap_or(vault_path);
|
||||||
let root = Path::new(scan_path);
|
let root = Path::new(scan_path);
|
||||||
|
ensure_vault_content_dir(vault_path, root)?;
|
||||||
let vault_root = Path::new(vault_path);
|
let vault_root = Path::new(vault_path);
|
||||||
|
|
||||||
log::info!("scan_notes: vault={}, scan={}, exists={}", vault_path, scan_path, root.exists());
|
log::info!(
|
||||||
|
"scan_notes: vault={}, scan={}, exists={}",
|
||||||
|
vault_path,
|
||||||
|
scan_path,
|
||||||
|
root.exists()
|
||||||
|
);
|
||||||
|
|
||||||
if !root.exists() {
|
if !root.exists() {
|
||||||
return Err("Path does not exist".to_string());
|
return Err("Path does not exist".to_string());
|
||||||
@@ -285,7 +400,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
log::info!("scan_notes: mobile scan found {} notes", notes.len());
|
log::info!("scan_notes: mobile scan found {} notes", notes.len());
|
||||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
|
||||||
return Ok(notes);
|
return Ok(notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +416,9 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
|||||||
} else {
|
} else {
|
||||||
WalkDir::new(root)
|
WalkDir::new(root)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
|
.filter_entry(|e| {
|
||||||
|
!is_hidden(e.path()) && !e.path().starts_with(helixnotes_dir(vault_path))
|
||||||
|
})
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.map(|e| e.path().to_path_buf())
|
.map(|e| e.path().to_path_buf())
|
||||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||||
@@ -313,7 +430,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
|||||||
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
|
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
|
||||||
Ok(notes)
|
Ok(notes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,8 +540,18 @@ fn read_note_entry_from_str(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
pub fn read_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
|
||||||
let p = Path::new(path);
|
let validated = ensure_readable_note_path(vault_path, Path::new(path))?;
|
||||||
|
read_note_content(&validated, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_vault_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
|
||||||
|
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||||
|
read_note_content(&validated, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_note_content(validated: &Path, reported_path: &str) -> Result<NoteContent, String> {
|
||||||
|
let p = validated;
|
||||||
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
|
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
|
||||||
let filename = p
|
let filename = p
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -454,14 +581,15 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(NoteContent {
|
Ok(NoteContent {
|
||||||
path: path.to_string(),
|
path: reported_path.to_string(),
|
||||||
meta,
|
meta,
|
||||||
content,
|
content,
|
||||||
raw,
|
raw,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
|
pub fn save_note(vault_path: &str, path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
|
||||||
|
let path = ensure_note_path(vault_path, Path::new(path))?;
|
||||||
let mut updated_meta = meta.clone();
|
let mut updated_meta = meta.clone();
|
||||||
updated_meta.modified = Utc::now();
|
updated_meta.modified = Utc::now();
|
||||||
|
|
||||||
@@ -471,7 +599,7 @@ pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read existing file to preserve unknown frontmatter fields
|
// Read existing file to preserve unknown frontmatter fields
|
||||||
let existing = fs::read_to_string(path).unwrap_or_default();
|
let existing = fs::read_to_string(&path).unwrap_or_default();
|
||||||
let raw = if existing.is_empty() {
|
let raw = if existing.is_empty() {
|
||||||
frontmatter::update_note_raw(&updated_meta, body)
|
frontmatter::update_note_raw(&updated_meta, body)
|
||||||
} else {
|
} else {
|
||||||
@@ -487,14 +615,11 @@ pub fn create_note(
|
|||||||
notebook_relative: Option<&str>,
|
notebook_relative: Option<&str>,
|
||||||
title: &str,
|
title: &str,
|
||||||
) -> Result<NoteEntry, String> {
|
) -> Result<NoteEntry, String> {
|
||||||
let dir = match notebook_relative {
|
let requested_dir = match notebook_relative {
|
||||||
Some(rel) => Path::new(vault_path).join(rel),
|
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
|
||||||
None => PathBuf::from(vault_path),
|
None => PathBuf::from(vault_path),
|
||||||
};
|
};
|
||||||
|
let dir = ensure_vault_content_dir(vault_path, &requested_dir)?;
|
||||||
if !dir.exists() {
|
|
||||||
return Err("Notebook directory does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let filename = sanitize_filename(title);
|
let filename = sanitize_filename(title);
|
||||||
let mut file_path = dir.join(format!("{}.md", filename));
|
let mut file_path = dir.join(format!("{}.md", filename));
|
||||||
@@ -535,10 +660,8 @@ pub fn create_note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
||||||
let src = Path::new(path);
|
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||||
if !src.is_file() {
|
let src = validated.as_path();
|
||||||
return Err("Note does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let parent = src
|
let parent = src
|
||||||
.parent()
|
.parent()
|
||||||
@@ -672,7 +795,9 @@ pub fn create_daily_note(
|
|||||||
"eu" => target_date.format("%d/%m/%Y").to_string(),
|
"eu" => target_date.format("%d/%m/%Y").to_string(),
|
||||||
_ => {
|
_ => {
|
||||||
let locale = get_system_locale();
|
let locale = get_system_locale();
|
||||||
target_date.format_localized("%B %d, %Y", locale).to_string()
|
target_date
|
||||||
|
.format_localized("%B %d, %Y", locale)
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -719,12 +844,12 @@ pub fn create_notebook(
|
|||||||
parent_relative: Option<&str>,
|
parent_relative: Option<&str>,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<NotebookEntry, String> {
|
) -> Result<NotebookEntry, String> {
|
||||||
let parent = match parent_relative {
|
let requested_parent = match parent_relative {
|
||||||
Some(rel) => Path::new(vault_path).join(rel),
|
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
|
||||||
None => PathBuf::from(vault_path),
|
None => PathBuf::from(vault_path),
|
||||||
};
|
};
|
||||||
|
let parent = ensure_vault_content_dir(vault_path, &requested_parent)?;
|
||||||
let dir_path = parent.join(name);
|
let dir_path = parent.join(safe_child_name(name)?);
|
||||||
if dir_path.exists() {
|
if dir_path.exists() {
|
||||||
return Err("Notebook already exists".to_string());
|
return Err("Notebook already exists".to_string());
|
||||||
}
|
}
|
||||||
@@ -748,10 +873,8 @@ pub fn create_notebook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
||||||
let src = Path::new(note_path);
|
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Note does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||||
@@ -771,10 +894,8 @@ pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> {
|
pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> {
|
||||||
let src = Path::new(notebook_path);
|
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Notebook does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||||
@@ -803,10 +924,8 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
|
pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
|
||||||
let src = Path::new(path);
|
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Note does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read old title before renaming
|
// Read old title before renaming
|
||||||
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||||
@@ -839,7 +958,13 @@ pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<Stri
|
|||||||
let new_path_str = new_path.to_string_lossy().to_string();
|
let new_path_str = new_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
// Update wikilinks in other notes that reference this note
|
// Update wikilinks in other notes that reference this note
|
||||||
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
|
update_wikilinks_after_rename(
|
||||||
|
vault_path,
|
||||||
|
&old_path_str,
|
||||||
|
&new_path_str,
|
||||||
|
&old_title,
|
||||||
|
new_title,
|
||||||
|
);
|
||||||
|
|
||||||
Ok(new_path_str)
|
Ok(new_path_str)
|
||||||
}
|
}
|
||||||
@@ -898,11 +1023,17 @@ fn update_wikilinks_after_rename(
|
|||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
{
|
{
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() { continue; }
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let path_str = path.to_string_lossy();
|
let path_str = path.to_string_lossy();
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Skip the renamed note itself
|
// Skip the renamed note itself
|
||||||
if *path_str == *new_path { continue; }
|
if *path_str == *new_path {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let content = match fs::read_to_string(path) {
|
let content = match fs::read_to_string(path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -918,22 +1049,13 @@ fn update_wikilinks_after_rename(
|
|||||||
// If another note shares the same title, these would be ambiguous.
|
// If another note shares the same title, these would be ambiguous.
|
||||||
if old_title != new_title && title_is_unique {
|
if old_title != new_title && title_is_unique {
|
||||||
// 1. Short title ref: [[Old Title]] → [[New Title]]
|
// 1. Short title ref: [[Old Title]] → [[New Title]]
|
||||||
result = result.replace(
|
result = result.replace(&format!("[[{}]]", old_title), &format!("[[{}]]", new_title));
|
||||||
&format!("[[{}]]", old_title),
|
|
||||||
&format!("[[{}]]", new_title),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
|
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
|
||||||
result = result.replace(
|
result = result.replace(&format!("[[{}|", old_title), &format!("[[{}|", new_title));
|
||||||
&format!("[[{}|", old_title),
|
|
||||||
&format!("[[{}|", new_title),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
|
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
|
||||||
result = result.replace(
|
result = result.replace(&format!("|{}]]", old_title), &format!("|{}]]", new_title));
|
||||||
&format!("|{}]]", old_title),
|
|
||||||
&format!("|{}]]", new_title),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path-based rules are always safe (paths are unique).
|
// Path-based rules are always safe (paths are unique).
|
||||||
@@ -966,13 +1088,11 @@ fn update_wikilinks_after_rename(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
|
pub fn rename_notebook(vault_path: &str, path: &str, new_name: &str) -> Result<String, String> {
|
||||||
let src = Path::new(path);
|
let validated = ensure_notebook_path(vault_path, Path::new(path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Notebook does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_path = src.parent().unwrap().join(new_name);
|
let new_path = src.parent().unwrap().join(safe_child_name(new_name)?);
|
||||||
if new_path.exists() {
|
if new_path.exists() {
|
||||||
return Err("A notebook with that name already exists".to_string());
|
return Err("A notebook with that name already exists".to_string());
|
||||||
}
|
}
|
||||||
@@ -981,13 +1101,12 @@ pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
|
|||||||
Ok(new_path.to_string_lossy().to_string())
|
Ok(new_path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String> {
|
pub fn move_note(vault_path: &str, note_path: &str, dest_notebook: &str) -> Result<String, String> {
|
||||||
let src = Path::new(note_path);
|
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Note does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let dest_dir = Path::new(dest_notebook);
|
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_notebook))?;
|
||||||
|
let dest_dir = validated_dest.as_path();
|
||||||
if !dest_dir.is_dir() {
|
if !dest_dir.is_dir() {
|
||||||
return Err("Destination notebook does not exist".to_string());
|
return Err("Destination notebook does not exist".to_string());
|
||||||
}
|
}
|
||||||
@@ -999,13 +1118,16 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String>
|
|||||||
Ok(dest.to_string_lossy().to_string())
|
Ok(dest.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> {
|
pub fn move_notebook(
|
||||||
let src = Path::new(notebook_path);
|
vault_path: &str,
|
||||||
if !src.exists() || !src.is_dir() {
|
notebook_path: &str,
|
||||||
return Err("Notebook does not exist".to_string());
|
dest_parent: &str,
|
||||||
}
|
) -> Result<String, String> {
|
||||||
|
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
|
||||||
|
let src = validated.as_path();
|
||||||
|
|
||||||
let dest_parent_path = Path::new(dest_parent);
|
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_parent))?;
|
||||||
|
let dest_parent_path = validated_dest.as_path();
|
||||||
if !dest_parent_path.is_dir() {
|
if !dest_parent_path.is_dir() {
|
||||||
return Err("Destination does not exist".to_string());
|
return Err("Destination does not exist".to_string());
|
||||||
}
|
}
|
||||||
@@ -1048,7 +1170,10 @@ fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
|
|||||||
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||||
if !trash_dir.exists() {
|
if !trash_dir.exists() {
|
||||||
return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
|
return Ok(TrashContents {
|
||||||
|
notes: Vec::new(),
|
||||||
|
notebooks: Vec::new(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let vault_root = Path::new(vault_path);
|
let vault_root = Path::new(vault_path);
|
||||||
@@ -1067,7 +1192,10 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
|||||||
.min_depth(1)
|
.min_depth(1)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md"))
|
.filter(|e| {
|
||||||
|
e.path().is_file()
|
||||||
|
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
||||||
|
})
|
||||||
.count();
|
.count();
|
||||||
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
|
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
// Strip timestamp prefix to get original notebook name
|
// Strip timestamp prefix to get original notebook name
|
||||||
@@ -1080,7 +1208,7 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
|||||||
};
|
};
|
||||||
let modified = fs::metadata(&path)
|
let modified = fs::metadata(&path)
|
||||||
.and_then(|m| m.modified())
|
.and_then(|m| m.modified())
|
||||||
.map(|t| DateTime::<Utc>::from(t))
|
.map(DateTime::<Utc>::from)
|
||||||
.unwrap_or_else(|_| Utc::now());
|
.unwrap_or_else(|_| Utc::now());
|
||||||
notebooks.push(TrashNotebookEntry {
|
notebooks.push(TrashNotebookEntry {
|
||||||
name,
|
name,
|
||||||
@@ -1091,8 +1219,8 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
|
||||||
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
|
notebooks.sort_by_key(|notebook| std::cmp::Reverse(notebook.modified));
|
||||||
Ok(TrashContents { notes, notebooks })
|
Ok(TrashContents { notes, notebooks })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,15 +1229,17 @@ pub fn restore_note(
|
|||||||
trash_path: &str,
|
trash_path: &str,
|
||||||
dest_notebook: Option<&str>,
|
dest_notebook: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let src = Path::new(trash_path);
|
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
|
if !src.is_file() {
|
||||||
return Err("Trashed note does not exist".to_string());
|
return Err("Trashed note does not exist".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let dest_dir = match dest_notebook {
|
let requested_dest = match dest_notebook {
|
||||||
Some(nb) => PathBuf::from(nb),
|
Some(nb) => PathBuf::from(nb),
|
||||||
None => PathBuf::from(vault_path),
|
None => PathBuf::from(vault_path),
|
||||||
};
|
};
|
||||||
|
let dest_dir = ensure_vault_content_dir(vault_path, &requested_dest)?;
|
||||||
|
|
||||||
// Strip timestamp prefix from trash filename (17-char with millis or 14-char legacy)
|
// Strip timestamp prefix from trash filename (17-char with millis or 14-char legacy)
|
||||||
let filename = src.file_name().unwrap_or_default().to_string_lossy();
|
let filename = src.file_name().unwrap_or_default().to_string_lossy();
|
||||||
@@ -1132,15 +1262,18 @@ pub fn restore_note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
|
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
|
||||||
let src = Path::new(trash_path);
|
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
|
||||||
if !src.exists() || !src.is_dir() {
|
let src = validated.as_path();
|
||||||
|
if !src.is_dir() {
|
||||||
return Err("Trashed notebook does not exist".to_string());
|
return Err("Trashed notebook does not exist".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
|
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
|
||||||
|
|
||||||
// Try to read original path from sidecar .meta file
|
// Try to read original path from sidecar .meta file
|
||||||
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname));
|
let meta_path = src
|
||||||
|
.with_extension("")
|
||||||
|
.with_file_name(format!("{}.meta", dirname));
|
||||||
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
|
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
|
||||||
original
|
original
|
||||||
} else {
|
} else {
|
||||||
@@ -1156,7 +1289,7 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
|
|||||||
name.to_string()
|
name.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let dest = Path::new(vault_path).join(&relative);
|
let dest = Path::new(vault_path).join(safe_relative_path(&relative)?);
|
||||||
|
|
||||||
// Recreate parent directories if needed
|
// Recreate parent directories if needed
|
||||||
if let Some(parent) = dest.parent() {
|
if let Some(parent) = dest.parent() {
|
||||||
@@ -1170,7 +1303,8 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
|
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
|
||||||
let p = Path::new(path);
|
let validated = ensure_trash_entry(vault_path, Path::new(path))?;
|
||||||
|
let p = validated.as_path();
|
||||||
let parent = p.parent().map(|pp| pp.to_path_buf());
|
let parent = p.parent().map(|pp| pp.to_path_buf());
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
|
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
|
||||||
@@ -1431,8 +1565,9 @@ pub fn sanitize_filename(name: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
compare_natural_names, duplicate_note, get_note_switcher_titles, helixnotes_dir,
|
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
|
||||||
load_notebook_icons, scan_notebooks, set_notebook_icon,
|
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
|
||||||
|
scan_notebooks, set_notebook_icon,
|
||||||
};
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -1441,10 +1576,7 @@ mod tests {
|
|||||||
fn compares_numeric_segments_anywhere_in_names() {
|
fn compares_numeric_segments_anywhere_in_names() {
|
||||||
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
|
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
|
||||||
names.sort_by(|left, right| compare_natural_names(left, right));
|
names.sort_by(|left, right| compare_natural_names(left, right));
|
||||||
assert_eq!(
|
assert_eq!(names, ["Class 2a", "Class 2b", "Class 10a", "Class 10b"]);
|
||||||
names,
|
|
||||||
["Class 2a", "Class 2b", "Class 10a", "Class 10b"]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compare_natural_names("Class 02", "Class 2b"),
|
compare_natural_names("Class 02", "Class 2b"),
|
||||||
std::cmp::Ordering::Less
|
std::cmp::Ordering::Less
|
||||||
@@ -1592,6 +1724,75 @@ mod tests {
|
|||||||
fs::remove_file(outside).unwrap();
|
fs::remove_file(outside).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_markdown_notes_from_trash_without_allowing_external_files() {
|
||||||
|
let test_root =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||||
|
let vault = test_root.join("vault");
|
||||||
|
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
|
||||||
|
let trashed_note = trash.join("20240101000000000_Note.md");
|
||||||
|
let outside = test_root.join("outside.md");
|
||||||
|
fs::create_dir_all(&trash).unwrap();
|
||||||
|
fs::write(&trashed_note, "---\ntitle: Note\n---\n\ntrashed").unwrap();
|
||||||
|
fs::write(&outside, "outside").unwrap();
|
||||||
|
|
||||||
|
assert!(read_note(&vault.to_string_lossy(), &trashed_note.to_string_lossy()).is_ok());
|
||||||
|
assert!(read_note(&vault.to_string_lossy(), &outside.to_string_lossy()).is_err());
|
||||||
|
|
||||||
|
fs::remove_dir_all(test_root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_permanent_deletion_outside_trash() {
|
||||||
|
let test_root =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||||
|
let vault = test_root.join("vault");
|
||||||
|
let outside = test_root.join("outside.md");
|
||||||
|
fs::create_dir_all(helixnotes_dir(&vault.to_string_lossy()).join("trash")).unwrap();
|
||||||
|
fs::write(&outside, "must survive").unwrap();
|
||||||
|
|
||||||
|
let result = permanent_delete(&vault.to_string_lossy(), &outside.to_string_lossy());
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(fs::read_to_string(&outside).unwrap(), "must survive");
|
||||||
|
fs::remove_dir_all(test_root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_notebook_creation_outside_vault() {
|
||||||
|
let test_root =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||||
|
let vault = test_root.join("vault");
|
||||||
|
fs::create_dir_all(&vault).unwrap();
|
||||||
|
|
||||||
|
let result = create_notebook(&vault.to_string_lossy(), Some(".."), "escaped");
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(!test_root.join("escaped").exists());
|
||||||
|
fs::remove_dir_all(test_root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_traversal_in_restored_notebook_metadata() {
|
||||||
|
let test_root =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||||
|
let vault = test_root.join("vault");
|
||||||
|
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
|
||||||
|
let trashed_notebook = trash.join("20240101000000000_Notebook");
|
||||||
|
fs::create_dir_all(&trashed_notebook).unwrap();
|
||||||
|
fs::write(trash.join("20240101000000000_Notebook.meta"), "../escaped").unwrap();
|
||||||
|
|
||||||
|
let result = restore_notebook(
|
||||||
|
&vault.to_string_lossy(),
|
||||||
|
&trashed_notebook.to_string_lossy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(trashed_notebook.exists());
|
||||||
|
assert!(!test_root.join("escaped").exists());
|
||||||
|
fs::remove_dir_all(test_root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
||||||
let vault =
|
let vault =
|
||||||
@@ -1623,4 +1824,4 @@ mod tests {
|
|||||||
|
|
||||||
fs::remove_dir_all(vault).unwrap();
|
fs::remove_dir_all(vault).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "HelixNotes",
|
"productName": "HelixNotes",
|
||||||
"version": "1.3.4",
|
"version": "1.3.5",
|
||||||
"identifier": "com.helixnotes.app",
|
"identifier": "com.helixnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../build",
|
"frontendDist": "../build",
|
||||||
@@ -24,10 +24,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
|
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": ["**/*", "/**", "**/.helixnotes/**"]
|
"scope": {
|
||||||
|
"allow": [],
|
||||||
|
"deny": [],
|
||||||
|
"requireLiteralLeadingDot": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+9
-1
@@ -71,6 +71,10 @@ export async function setFontSize(size: number): Promise<void> {
|
|||||||
return invoke("set_font_size", { size });
|
return invoke("set_font_size", { size });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
|
||||||
|
return invoke("set_scroll_to_change_font_size", { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
export async function setFontFamily(family: string): Promise<void> {
|
export async function setFontFamily(family: string): Promise<void> {
|
||||||
return invoke("set_font_family", { family });
|
return invoke("set_font_family", { family });
|
||||||
}
|
}
|
||||||
@@ -185,7 +189,7 @@ export async function getNoteSwitcherTitles(
|
|||||||
return invoke("get_note_switcher_titles", { recentPaths });
|
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");
|
return invoke("get_graph_data");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +368,10 @@ export async function openFile(path: string): Promise<void> {
|
|||||||
return invoke("open_file", { path });
|
return invoke("open_file", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revealFile(path: string): Promise<void> {
|
||||||
|
return invoke("reveal_file", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export async function openUrl(url: string): Promise<void> {
|
export async function openUrl(url: string): Promise<void> {
|
||||||
return invoke("open_url", { url });
|
return invoke("open_url", { url });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,9 +81,9 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sidebar: Sidebar;
|
let sidebar = $state<Sidebar>();
|
||||||
let noteList: NoteList;
|
let noteList = $state<NoteList>();
|
||||||
let editor: Editor;
|
let editor = $state<Editor>();
|
||||||
let unlistenFileChange: (() => void) | null = null;
|
let unlistenFileChange: (() => void) | null = null;
|
||||||
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
|
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
|
||||||
if (target.mode === 'notebook') {
|
if (target.mode === 'notebook') {
|
||||||
@@ -829,7 +829,7 @@
|
|||||||
<!-- Mobile Header -->
|
<!-- Mobile Header -->
|
||||||
<div class="mobile-header">
|
<div class="mobile-header">
|
||||||
{#if $mobileView !== 'sidebar'}
|
{#if $mobileView !== 'sidebar'}
|
||||||
<button class="mobile-header-btn" onclick={mobileBack}>
|
<button class="mobile-header-btn" onclick={mobileBack} aria-label="Go back">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M15 18l-6-6 6-6"/>
|
<path d="M15 18l-6-6 6-6"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
+166
-162
@@ -54,6 +54,7 @@
|
|||||||
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||||
import { clearFormatting } from '$lib/editor/clearFormatting';
|
import { clearFormatting } from '$lib/editor/clearFormatting';
|
||||||
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
||||||
|
import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
|
||||||
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
|
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
|
||||||
import GraphView from './GraphView.svelte';
|
import GraphView from './GraphView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
@@ -132,6 +133,8 @@
|
|||||||
let highlightDropdown = $state(false);
|
let highlightDropdown = $state(false);
|
||||||
let alignDropdown = $state(false);
|
let alignDropdown = $state(false);
|
||||||
let insertDropdown = $state(false);
|
let insertDropdown = $state(false);
|
||||||
|
let tablePickerOpen = $state(false);
|
||||||
|
let tablePickerHover = $state({ rows: 0, cols: 0 });
|
||||||
|
|
||||||
function scrollEditorBodyToBottom(source: HTMLElement | null | undefined = editorElement) {
|
function scrollEditorBodyToBottom(source: HTMLElement | null | undefined = editorElement) {
|
||||||
const editorBody = source?.closest('.editor-body') as HTMLElement | null;
|
const editorBody = source?.closest('.editor-body') as HTMLElement | null;
|
||||||
@@ -306,6 +309,22 @@
|
|||||||
let editorState = $state(0);
|
let editorState = $state(0);
|
||||||
let editorStateRaf = 0; // RAF handle for batching toolbar updates
|
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
|
// AI
|
||||||
let aiMenu = $state<{ x: number; y: number } | null>(null);
|
let aiMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let aiLoading = $state(false);
|
let aiLoading = $state(false);
|
||||||
@@ -470,8 +489,6 @@
|
|||||||
});
|
});
|
||||||
let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null);
|
let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null);
|
||||||
let tableContextMenu = $state<{ x: number; y: number; hasStyling: 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 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 imageViewer = $state<{ src: string; alt: string } | null>(null);
|
||||||
let copyToast = $state<'copying' | 'done' | null>(null);
|
let copyToast = $state<'copying' | 'done' | null>(null);
|
||||||
@@ -2531,10 +2548,7 @@
|
|||||||
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
|
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
|
||||||
if (!editor || !wikiLinkMenu) return;
|
if (!editor || !wikiLinkMenu) return;
|
||||||
const { from } = wikiLinkMenu;
|
const { from } = wikiLinkMenu;
|
||||||
// Delete the [[ trigger and query text
|
|
||||||
const to = editor.state.selection.from;
|
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
|
// For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
|
||||||
const displayText = entry.title;
|
const displayText = entry.title;
|
||||||
let titleAttr = originalRef || entry.title;
|
let titleAttr = originalRef || entry.title;
|
||||||
@@ -2546,17 +2560,16 @@
|
|||||||
titleAttr = relPath + anchor;
|
titleAttr = relPath + anchor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tick().then(() => {
|
const transaction = replaceWithWikiLink(
|
||||||
if (!editor) return;
|
editor.state,
|
||||||
editor.chain().focus()
|
from,
|
||||||
.insertContent({
|
to,
|
||||||
type: 'text',
|
displayText,
|
||||||
text: displayText,
|
{ title: titleAttr, path: entry.path || '', aliased: displayText !== titleAttr },
|
||||||
marks: [{ type: 'wikiLink', attrs: { title: titleAttr, path: entry.path, aliased: displayText !== titleAttr } }],
|
);
|
||||||
})
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
closeWikiLinkMenu();
|
closeWikiLinkMenu();
|
||||||
|
editor.view.dispatch(transaction);
|
||||||
|
editor.view.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeWikiLinkCommand(index: number) {
|
function executeWikiLinkCommand(index: number) {
|
||||||
@@ -2743,21 +2756,13 @@
|
|||||||
closeWikiLinkMenu();
|
closeWikiLinkMenu();
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
// Use a single ProseMirror transaction to replace [[query] with the
|
editor.view.dispatch(replaceWithWikiLink(
|
||||||
// wiki-link and clear stored marks atomically, preventing the inclusive
|
editor.state,
|
||||||
// mark from bleeding into subsequent text. Do NOT call deleteRange first
|
menuFrom,
|
||||||
// — that would shift positions and make menuFrom/curTo invalid here.
|
curTo,
|
||||||
const { tr, schema } = editor.view.state;
|
display,
|
||||||
const wikiLinkMark = schema.marks.wikiLink.create({
|
{ title: noteRef, path: '', aliased: display !== noteRef },
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -3681,6 +3686,27 @@
|
|||||||
requestAnimationFrame(() => el.focus());
|
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 ──
|
// ── In-note search functions ──
|
||||||
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -6222,7 +6248,7 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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}
|
{:else}
|
||||||
<!-- Desktop: conditional rendering with line numbers -->
|
<!-- Desktop: conditional rendering with line numbers -->
|
||||||
{#if $sourceMode}
|
{#if $sourceMode}
|
||||||
@@ -6326,7 +6352,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -6342,7 +6368,7 @@
|
|||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<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="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
@@ -6382,7 +6408,7 @@
|
|||||||
<div class="outline-panel" style="width: {$outlineWidth}px">
|
<div class="outline-panel" style="width: {$outlineWidth}px">
|
||||||
<div class="outline-header">
|
<div class="outline-header">
|
||||||
<h3>Outline</h3>
|
<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">
|
<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="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
@@ -6409,7 +6435,7 @@
|
|||||||
<div class="info-panel">
|
<div class="info-panel">
|
||||||
<div class="info-panel-header">
|
<div class="info-panel-header">
|
||||||
<span class="info-panel-title">Note Info</span>
|
<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">
|
<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"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -6505,7 +6531,7 @@
|
|||||||
|
|
||||||
{#if editorReady && !$sourceMode && !$viewerNote}
|
{#if editorReady && !$sourceMode && !$viewerNote}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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}
|
{#if isMobile}
|
||||||
<!-- ═══ MOBILE formatting bar: compact, relevant buttons only ═══ -->
|
<!-- ═══ MOBILE formatting bar: compact, relevant buttons only ═══ -->
|
||||||
|
|
||||||
@@ -6515,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>
|
<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>
|
</button>
|
||||||
{#if insertDropdown}
|
{#if insertDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="fmt-dropdown insert-dropdown">
|
||||||
<div class="fmt-dropdown insert-dropdown" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-image-input')?.click(); }}>
|
<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>
|
<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
|
Image
|
||||||
@@ -6567,16 +6592,15 @@
|
|||||||
|
|
||||||
<!-- Heading dropdown -->
|
<!-- Heading dropdown -->
|
||||||
<div class="fmt-dropdown-wrap">
|
<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>
|
<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>
|
</button>
|
||||||
{#if headingDropdown}
|
{#if headingDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="fmt-dropdown">
|
||||||
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||||
<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={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</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={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</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={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||||
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -6584,36 +6608,36 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Bold / Italic / Underline / Strike -->
|
<!-- 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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
</button>
|
||||||
|
|
||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Link -->
|
<!-- 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>
|
<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>
|
</button>
|
||||||
|
|
||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Lists -->
|
<!-- 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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
</button>
|
||||||
|
|
||||||
@@ -6667,7 +6691,7 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Highlight -->
|
<!-- 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>
|
<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>
|
</button>
|
||||||
|
|
||||||
@@ -6706,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>
|
<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>
|
</button>
|
||||||
{#if insertDropdown}
|
{#if insertDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="fmt-dropdown insert-dropdown">
|
||||||
<div class="fmt-dropdown insert-dropdown" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-image-input')?.click(); }}>
|
<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>
|
<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
|
Image
|
||||||
@@ -6736,17 +6759,16 @@
|
|||||||
|
|
||||||
<!-- Heading dropdown -->
|
<!-- Heading dropdown -->
|
||||||
<div class="fmt-dropdown-wrap">
|
<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>
|
<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>
|
</button>
|
||||||
{#if headingDropdown}
|
{#if headingDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="fmt-dropdown">
|
||||||
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||||
<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={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</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={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</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={isEditorActive('heading', { level: 4 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</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={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||||
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -6754,16 +6776,16 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Text formatting -->
|
<!-- 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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
</button>
|
||||||
|
|
||||||
@@ -6771,14 +6793,13 @@
|
|||||||
<div class="fmt-dropdown-wrap">
|
<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">
|
<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>
|
<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>
|
</button>
|
||||||
{#if colorDropdown}
|
{#if colorDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="fmt-dropdown color-grid-dropdown">
|
||||||
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
|
||||||
{#each textColors as color}
|
{#each textColors as color}
|
||||||
<button class="color-swatch" title={color.name} onclick={() => setTextColor(color.value)} style="background: {color.value || 'var(--text-primary)'}">
|
<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>
|
<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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -6790,20 +6811,20 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Link -->
|
<!-- 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>
|
<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>
|
</button>
|
||||||
|
|
||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Lists -->
|
<!-- 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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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>
|
</button>
|
||||||
|
|
||||||
@@ -6820,25 +6841,25 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Code & Code Block -->
|
<!-- 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>
|
<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>
|
||||||
<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>
|
<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>
|
</button>
|
||||||
|
|
||||||
<!-- Blockquote -->
|
<!-- 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>
|
<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>
|
</button>
|
||||||
|
|
||||||
<!-- Collapsible Section -->
|
<!-- 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>
|
<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>
|
</button>
|
||||||
|
|
||||||
<!-- Callout -->
|
<!-- 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>
|
<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>
|
</button>
|
||||||
|
|
||||||
@@ -6848,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>
|
<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>
|
</button>
|
||||||
{#if tablePickerOpen}
|
{#if tablePickerOpen}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="fmt-dropdown table-picker-dropdown">
|
||||||
<div class="fmt-dropdown table-picker-dropdown" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="table-picker-grid">
|
<div class="table-picker-grid">
|
||||||
{#each Array(8) as _, r}
|
{#each Array(8) as _, r}
|
||||||
{#each Array(10) as _, c}
|
{#each Array(10) as _, c}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<button
|
||||||
<div
|
type="button"
|
||||||
class="table-picker-cell"
|
class="table-picker-cell"
|
||||||
class:active={r < tablePickerHover.rows && c < tablePickerHover.cols}
|
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 }}
|
onmouseenter={() => tablePickerHover = { rows: r + 1, cols: c + 1 }}
|
||||||
onclick={() => insertTable(r + 1, c + 1)}
|
onclick={() => insertTable(r + 1, c + 1)}
|
||||||
></div>
|
></button>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -6879,22 +6900,21 @@
|
|||||||
|
|
||||||
<!-- Highlight -->
|
<!-- Highlight -->
|
||||||
<div class="fmt-dropdown-wrap">
|
<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>
|
<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>
|
</button>
|
||||||
{#if highlightDropdown}
|
{#if highlightDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="fmt-dropdown color-grid-dropdown">
|
||||||
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
|
||||||
{#each highlightColors as color}
|
{#each highlightColors as color}
|
||||||
<button class="color-swatch" title={color.name} onclick={() => setHighlightColor(color.value)} style="background: {color.swatch}">
|
<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>
|
<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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="color-swatch" title="Remove highlight" onclick={() => setHighlightColor('')} style="background: var(--bg-tertiary);">
|
<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>
|
<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}
|
{: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>
|
<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>
|
||||||
@@ -6905,10 +6925,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subscript & Superscript -->
|
<!-- 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>
|
<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>
|
||||||
<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>
|
<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>
|
</button>
|
||||||
|
|
||||||
@@ -6917,32 +6937,31 @@
|
|||||||
<!-- Text Alignment -->
|
<!-- Text Alignment -->
|
||||||
<div class="fmt-dropdown-wrap">
|
<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">
|
<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>
|
<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>
|
<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>
|
<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}
|
{: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>
|
<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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if alignDropdown}
|
{#if alignDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="fmt-dropdown align-dropdown">
|
||||||
<div class="fmt-dropdown align-dropdown" onclick={(e) => e.stopPropagation()}>
|
<button class:active={isEditorActive({ textAlign: 'left' })} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
|
||||||
<button class:active={(editorState, editor.isActive({ 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>
|
<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
|
Left
|
||||||
</button>
|
</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>
|
<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
|
Center
|
||||||
</button>
|
</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>
|
<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
|
Right
|
||||||
</button>
|
</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>
|
<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
|
Justify
|
||||||
</button>
|
</button>
|
||||||
@@ -7022,9 +7041,8 @@
|
|||||||
|
|
||||||
{#if linkContextMenu}
|
{#if linkContextMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="link-context-overlay" onclick={closeLinkContextMenu}>
|
<div class="link-context-overlay" onclick={(e) => closeFromOverlay(e, closeLinkContextMenu)} onkeydown={(e) => closeOnEscape(e, closeLinkContextMenu)}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="link-context-menu" style="left: {linkContextMenu.x}px; top: {linkContextMenu.y}px">
|
||||||
<div class="link-context-menu" style="left: {linkContextMenu.x}px; top: {linkContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="link-context-url">{linkContextMenu.href}</div>
|
<div class="link-context-url">{linkContextMenu.href}</div>
|
||||||
<button onclick={linkMenuOpen}>
|
<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">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -7069,9 +7087,8 @@
|
|||||||
|
|
||||||
{#if textContextMenu}
|
{#if textContextMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="text-ctx-overlay" onclick={closeTextContextMenu}>
|
<div class="text-ctx-overlay" onclick={(e) => closeFromOverlay(e, closeTextContextMenu)} onkeydown={(e) => closeOnEscape(e, closeTextContextMenu)}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="text-ctx-menu" style="left: {textContextMenu.x}px; top: {textContextMenu.y}px">
|
||||||
<div class="text-ctx-menu" style="left: {textContextMenu.x}px; top: {textContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<button onclick={ctxCut}>
|
<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>
|
<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
|
Cut
|
||||||
@@ -7103,12 +7120,12 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if ctxHeadingSubmenu}
|
{#if ctxHeadingSubmenu}
|
||||||
<div class="text-ctx-submenu" class:flip-left={textContextMenu?.submenuLeft}>
|
<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={isEditorActive('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={isEditorActive('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={isEditorActive('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: 4 })} onclick={() => ctxSetHeading(4)}>Heading 4</button>
|
||||||
<div class="text-ctx-sep"></div>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -7199,9 +7216,8 @@
|
|||||||
|
|
||||||
{#if tableContextMenu}
|
{#if tableContextMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="table-ctx-overlay" onclick={closeTableContextMenu}>
|
<div class="table-ctx-overlay" onclick={(e) => closeFromOverlay(e, closeTableContextMenu)} onkeydown={(e) => closeOnEscape(e, 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;">
|
||||||
<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()}>
|
|
||||||
<button onclick={tblAddRowBefore}>
|
<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>
|
<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
|
Add Row Above
|
||||||
@@ -7275,9 +7291,8 @@
|
|||||||
|
|
||||||
{#if imageToolbar}
|
{#if imageToolbar}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="img-toolbar-overlay" onclick={() => (imageToolbar = null)}>
|
<div class="img-toolbar-overlay" onclick={(e) => closeFromOverlay(e, () => (imageToolbar = null))} onkeydown={(e) => closeOnEscape(e, () => (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">
|
||||||
<div class="img-toolbar" class:mobile-viewer-toolbar={isAndroid} style="left: {imageToolbar.x}px; top: {imageToolbar.y}px" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
|
<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 === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
|
||||||
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
|
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
|
||||||
@@ -7323,8 +7338,8 @@
|
|||||||
|
|
||||||
{#if mathModal}
|
{#if mathModal}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="math-modal-overlay" onclick={cancelMathModal}>
|
<div class="math-modal-overlay" onclick={(e) => closeFromOverlay(e, cancelMathModal)} onkeydown={(e) => closeOnEscape(e, cancelMathModal)}>
|
||||||
<div class="math-modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Math editor">
|
<div class="math-modal" role="dialog" aria-modal="true" aria-label="Math editor" tabindex="-1">
|
||||||
<div class="math-modal-header">
|
<div class="math-modal-header">
|
||||||
<span>{mathModal.editPos !== null ? 'Edit' : 'Insert'} {mathModal.kind === 'block' ? 'Math Block' : 'Inline Math'}</span>
|
<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">
|
<button type="button" class="math-modal-close" onclick={cancelMathModal} aria-label="Close">
|
||||||
@@ -7339,7 +7354,7 @@
|
|||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commitMathModal(); }
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commitMathModal(); }
|
||||||
if (e.key === 'Escape') { e.preventDefault(); cancelMathModal(); }
|
if (e.key === 'Escape') { e.preventDefault(); cancelMathModal(); }
|
||||||
}}
|
}}
|
||||||
autofocus
|
use:autofocus
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="math-modal-preview">
|
<div class="math-modal-preview">
|
||||||
{#if mathModal.tex.trim()}
|
{#if mathModal.tex.trim()}
|
||||||
@@ -7363,8 +7378,8 @@
|
|||||||
|
|
||||||
{#if secretModal}
|
{#if secretModal}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="math-modal-overlay" onclick={cancelSecretModal}>
|
<div class="math-modal-overlay" onclick={(e) => closeFromOverlay(e, cancelSecretModal)} onkeydown={(e) => closeOnEscape(e, cancelSecretModal)}>
|
||||||
<div class="math-modal secret-modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Encrypted secret editor">
|
<div class="math-modal secret-modal" role="dialog" aria-modal="true" aria-label="Encrypted secret editor" tabindex="-1">
|
||||||
<div class="math-modal-header">
|
<div class="math-modal-header">
|
||||||
<span>Insert Secret</span>
|
<span>Insert Secret</span>
|
||||||
<button type="button" class="math-modal-close" onclick={cancelSecretModal} aria-label="Close">
|
<button type="button" class="math-modal-close" onclick={cancelSecretModal} aria-label="Close">
|
||||||
@@ -7431,8 +7446,8 @@
|
|||||||
|
|
||||||
{#if viewerImportPickerOpen}
|
{#if viewerImportPickerOpen}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="viewer-import-overlay" onclick={() => (viewerImportPickerOpen = false)}>
|
<div class="viewer-import-overlay" onclick={(e) => closeFromOverlay(e, () => (viewerImportPickerOpen = false))} onkeydown={(e) => closeOnEscape(e, () => (viewerImportPickerOpen = false))}>
|
||||||
<div class="viewer-import-picker" onclick={(e) => e.stopPropagation()} role="dialog">
|
<div class="viewer-import-picker" role="dialog" aria-modal="true" aria-label="Import image to folder" tabindex="-1">
|
||||||
<div class="viewer-import-header">
|
<div class="viewer-import-header">
|
||||||
<span>Import to folder</span>
|
<span>Import to folder</span>
|
||||||
<button type="button" class="viewer-import-close" onclick={() => (viewerImportPickerOpen = false)} aria-label="Close">
|
<button type="button" class="viewer-import-close" onclick={() => (viewerImportPickerOpen = false)} aria-label="Close">
|
||||||
@@ -7457,11 +7472,8 @@
|
|||||||
|
|
||||||
{#if tagMenu && $activeNote}
|
{#if tagMenu && $activeNote}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<div class="tag-menu-overlay" onclick={(e) => closeFromOverlay(e, () => (tagMenu = null))} onkeydown={(e) => closeOnEscape(e, () => (tagMenu = null))}>
|
||||||
<div class="tag-menu-overlay" onclick={() => (tagMenu = null)}>
|
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px">
|
||||||
<!-- 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()}>
|
|
||||||
{#if $activeNote.meta.tags.length > 0}
|
{#if $activeNote.meta.tags.length > 0}
|
||||||
<div class="tag-menu-list">
|
<div class="tag-menu-list">
|
||||||
{#each $activeNote.meta.tags as tag}
|
{#each $activeNote.meta.tags as tag}
|
||||||
@@ -7486,9 +7498,8 @@
|
|||||||
|
|
||||||
{#if codeLangDropdown}
|
{#if codeLangDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}>
|
<div class="code-lang-overlay" onclick={(e) => closeFromOverlay(e, closeCodeLangDropdown)} onkeydown={(e) => closeOnEscape(e, closeCodeLangDropdown)}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px">
|
||||||
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<input class="code-lang-search" type="text" placeholder="Search..." bind:this={codeLangInput} bind:value={codeLangSearch} onkeydown={(e) => {
|
<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 === 'Enter' && codeLangFiltered.length > 0) { e.preventDefault(); e.stopPropagation(); selectCodeLang(codeLangFiltered[0]); }
|
||||||
if (e.key === 'Escape') closeCodeLangDropdown();
|
if (e.key === 'Escape') closeCodeLangDropdown();
|
||||||
@@ -7516,9 +7527,8 @@
|
|||||||
|
|
||||||
{#if slashMenu}
|
{#if slashMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="slash-menu-overlay" onclick={closeSlashMenu}>
|
<div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, closeSlashMenu)} onkeydown={(e) => closeOnEscape(e, closeSlashMenu)}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="slash-menu" style="left: {slashMenu.x}px; top: {slashMenu.y}px">
|
||||||
<div class="slash-menu" style="left: {slashMenu.x}px; top: {slashMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
|
||||||
{#if slashTablePicker}
|
{#if slashTablePicker}
|
||||||
<div class="slash-table-picker">
|
<div class="slash-table-picker">
|
||||||
<div class="slash-table-picker-grid">
|
<div class="slash-table-picker-grid">
|
||||||
@@ -7580,9 +7590,8 @@
|
|||||||
|
|
||||||
{#if taskMetaMenu}
|
{#if taskMetaMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="slash-menu-overlay" onclick={closeTaskMetaMenu}>
|
<div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, closeTaskMetaMenu)} onkeydown={(e) => closeOnEscape(e, closeTaskMetaMenu)}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="slash-menu" style="left: {taskMetaMenu.x}px; top: {taskMetaMenu.y}px">
|
||||||
<div class="slash-menu" style="left: {taskMetaMenu.x}px; top: {taskMetaMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
|
||||||
{#if taskMetaFiltered.length === 0}
|
{#if taskMetaFiltered.length === 0}
|
||||||
<div class="slash-menu-empty">No match</div>
|
<div class="slash-menu-empty">No match</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -7604,13 +7613,12 @@
|
|||||||
|
|
||||||
{#if taskDuePicker}
|
{#if taskDuePicker}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
class="task-due-input"
|
class="task-due-input"
|
||||||
bind:this={taskDueInputEl}
|
bind:this={taskDueInputEl}
|
||||||
style="left: {taskDuePicker.x}px; top: {taskDuePicker.y}px"
|
style="left: {taskDuePicker.x}px; top: {taskDuePicker.y}px"
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
onchange={(e) => applyTaskDue((e.currentTarget as HTMLInputElement).value)}
|
onchange={(e) => applyTaskDue((e.currentTarget as HTMLInputElement).value)}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') { taskDuePicker = null; editor?.commands.focus(); } }}
|
onkeydown={(e) => { if (e.key === 'Escape') { taskDuePicker = null; editor?.commands.focus(); } }}
|
||||||
/>
|
/>
|
||||||
@@ -7619,9 +7627,8 @@
|
|||||||
|
|
||||||
{#if wikiLinkMenu}
|
{#if wikiLinkMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="wiki-link-overlay" onclick={closeWikiLinkMenu}>
|
<div class="wiki-link-overlay" onclick={(e) => closeFromOverlay(e, closeWikiLinkMenu)} onkeydown={(e) => closeOnEscape(e, closeWikiLinkMenu)}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="wiki-link-menu" style="left: {wikiLinkMenu.x}px; top: {wikiLinkMenu.y}px">
|
||||||
<div class="wiki-link-menu" style="left: {wikiLinkMenu.x}px; top: {wikiLinkMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
|
||||||
{#if wikiLinkFiltered.length === 0}
|
{#if wikiLinkFiltered.length === 0}
|
||||||
<div class="wiki-link-empty">
|
<div class="wiki-link-empty">
|
||||||
{wikiLinkMenu.query ? 'No matching notes' : 'Type to search notes...'}
|
{wikiLinkMenu.query ? 'No matching notes' : 'Type to search notes...'}
|
||||||
@@ -7650,9 +7657,8 @@
|
|||||||
|
|
||||||
{#if wikiLinkNavDisambig}
|
{#if wikiLinkNavDisambig}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="wiki-link-overlay" onclick={() => wikiLinkNavDisambig = null}>
|
<div class="wiki-link-overlay" onclick={(e) => closeFromOverlay(e, () => wikiLinkNavDisambig = null)} onkeydown={(e) => closeOnEscape(e, () => wikiLinkNavDisambig = null)}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px">
|
||||||
<div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="wiki-link-disambig-header">Multiple notes found - choose one:</div>
|
<div class="wiki-link-disambig-header">Multiple notes found - choose one:</div>
|
||||||
{#each wikiLinkNavDisambig.entries as entry, i}
|
{#each wikiLinkNavDisambig.entries as entry, i}
|
||||||
<button
|
<button
|
||||||
@@ -7674,9 +7680,8 @@
|
|||||||
|
|
||||||
{#if aiMenu}
|
{#if aiMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="ai-menu-overlay" class:mobile={isMobile} onclick={closeAiMenu}>
|
<div class="ai-menu-overlay" class:mobile={isMobile} onclick={(e) => closeFromOverlay(e, closeAiMenu)} onkeydown={(e) => closeOnEscape(e, 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`}>
|
||||||
<div class="ai-menu" class:mobile={isMobile} style={isMobile ? '' : `left: ${aiMenu.x}px; top: ${aiMenu.y}px`} onclick={(e) => e.stopPropagation()}>
|
|
||||||
{#if aiResult !== null || aiLoading}
|
{#if aiResult !== null || aiLoading}
|
||||||
<!-- Result view -->
|
<!-- Result view -->
|
||||||
<div class="ai-result-header">
|
<div class="ai-result-header">
|
||||||
@@ -7688,7 +7693,7 @@
|
|||||||
AI Result
|
AI Result
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -7712,7 +7717,7 @@
|
|||||||
{:else if aiShowCustom}
|
{:else if aiShowCustom}
|
||||||
<!-- Custom prompt input -->
|
<!-- Custom prompt input -->
|
||||||
<div class="ai-custom-header">
|
<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>
|
<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>
|
</button>
|
||||||
<span>Custom Prompt</span>
|
<span>Custom Prompt</span>
|
||||||
@@ -7733,7 +7738,7 @@
|
|||||||
{:else if aiTranslateMenu}
|
{:else if aiTranslateMenu}
|
||||||
<!-- Translate submenu -->
|
<!-- Translate submenu -->
|
||||||
<div class="ai-custom-header">
|
<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>
|
<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>
|
</button>
|
||||||
<span>Translate to</span>
|
<span>Translate to</span>
|
||||||
@@ -7815,9 +7820,8 @@
|
|||||||
|
|
||||||
{#if linkModal}
|
{#if linkModal}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="link-modal-overlay" onclick={linkModalCancel}>
|
<div class="link-modal-overlay" onclick={(e) => closeFromOverlay(e, linkModalCancel)} onkeydown={(e) => closeOnEscape(e, linkModalCancel)}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="link-modal" role="dialog" aria-modal="true" aria-label="Insert link" tabindex="-1">
|
||||||
<div class="link-modal" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="link-modal-header">
|
<div class="link-modal-header">
|
||||||
<svg width="28" height="28" viewBox="0 0 48 48" fill="none">
|
<svg width="28" height="28" viewBox="0 0 48 48" fill="none">
|
||||||
<rect width="48" height="48" rx="12" fill="var(--accent)" />
|
<rect width="48" height="48" rx="12" fill="var(--accent)" />
|
||||||
@@ -11240,11 +11244,11 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-actions.mobile {
|
.editor-container.mobile .toolbar-actions {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-actions.mobile .icon-btn {
|
.editor-container.mobile .toolbar-actions .icon-btn {
|
||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -11252,8 +11256,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-actions.mobile .save-indicator,
|
.editor-container.mobile .toolbar-actions .save-indicator,
|
||||||
.toolbar-actions.mobile .readonly-indicator {
|
.editor-container.mobile .toolbar-actions .readonly-indicator {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,12 @@
|
|||||||
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
|
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
|
||||||
interface LabelRect { x: number; y: number; w: number; h: number; }
|
interface LabelRect { x: number; y: number; w: number; h: number; }
|
||||||
|
|
||||||
let nodes: GraphNode[] = [];
|
let nodes = $state.raw<GraphNode[]>([]);
|
||||||
let edges: GraphEdge[] = [];
|
let edges = $state.raw<GraphEdge[]>([]);
|
||||||
let nodeIndexMap: Map<string, number> = new Map();
|
let nodeIndexMap: Map<string, number> = new Map();
|
||||||
let connectedSet: Set<number> = new Set();
|
let connectedSet: Set<number> = new Set();
|
||||||
let nodeDegree: number[] = [];
|
let nodeDegree: number[] = [];
|
||||||
let searchMatchSet: Set<number> | null = null;
|
let searchMatchSet = $state.raw<Set<number> | null>(null);
|
||||||
let folderColorMap = new Map<string, string>();
|
let folderColorMap = new Map<string, string>();
|
||||||
|
|
||||||
let pan = { x: 0, y: 0 };
|
let pan = { x: 0, y: 0 };
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
let hoveredNeighborSet: Set<number> = new Set();
|
let hoveredNeighborSet: Set<number> = new Set();
|
||||||
let hoveredEdgeSet: Set<number> = new Set();
|
let hoveredEdgeSet: Set<number> = new Set();
|
||||||
let glowPhase = 0;
|
let glowPhase = 0;
|
||||||
let activeNodeIdx = -1;
|
let activeNodeIdx = $state(-1);
|
||||||
let navigatedFromGraph = false;
|
let navigatedFromGraph = false;
|
||||||
|
|
||||||
// Combined animation loop
|
// Combined animation loop
|
||||||
|
|||||||
@@ -91,6 +91,10 @@
|
|||||||
activeTab = isMobile ? 'about' : 'shortcuts';
|
activeTab = isMobile ? 'about' : 'shortcuts';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeFromOverlay(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) close();
|
||||||
|
}
|
||||||
|
|
||||||
function openLink(url: string) {
|
function openLink(url: string) {
|
||||||
openUrl(url).catch(console.error);
|
openUrl(url).catch(console.error);
|
||||||
}
|
}
|
||||||
@@ -100,9 +104,8 @@
|
|||||||
|
|
||||||
{#if $showInfo}
|
{#if $showInfo}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="info-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
<div class="info-overlay" onclick={closeFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="info-panel">
|
||||||
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="info-header">
|
<div class="info-header">
|
||||||
<h2>Info</h2>
|
<h2>Info</h2>
|
||||||
<button class="close-btn" onclick={close} aria-label="Close info">
|
<button class="close-btn" onclick={close} aria-label="Close info">
|
||||||
|
|||||||
@@ -37,12 +37,12 @@
|
|||||||
reorderQuickAccess,
|
reorderQuickAccess,
|
||||||
moveNote,
|
moveNote,
|
||||||
getAllTags,
|
getAllTags,
|
||||||
createDailyNote
|
createDailyNote,
|
||||||
|
revealFile
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
|
||||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||||
import TasksView from './TasksView.svelte';
|
import TasksView from './TasksView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
@@ -966,11 +966,21 @@
|
|||||||
await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e))));
|
await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWindowClick() {
|
function handleWindowClick(event: MouseEvent) {
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
if (target?.closest('.context-menu, .sort-menu')) return;
|
||||||
if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; }
|
if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; }
|
||||||
if (sortMenu) sortMenu = null;
|
if (sortMenu) sortMenu = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeBatchMoveOverlay(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) batchMovePicker = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBatchTagOverlay(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) batchTagEdit = false;
|
||||||
|
}
|
||||||
|
|
||||||
function openSortMenu(e: MouseEvent) {
|
function openSortMenu(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
@@ -1076,12 +1086,11 @@
|
|||||||
|
|
||||||
{#if batchMovePicker}
|
{#if batchMovePicker}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="batch-move-overlay" onclick={() => batchMovePicker = false}>
|
<div class="batch-move-overlay" onclick={closeBatchMoveOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchMovePicker = false; }}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-move-title" tabindex="-1">
|
||||||
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="batch-move-header">
|
<div class="batch-move-header">
|
||||||
<span>Move {selectedPaths.size} notes to...</span>
|
<span id="batch-move-title">Move {selectedPaths.size} notes to...</span>
|
||||||
<button class="selection-close" onclick={() => batchMovePicker = false}>
|
<button class="selection-close" onclick={() => batchMovePicker = false} aria-label="Close move picker">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1105,12 +1114,11 @@
|
|||||||
|
|
||||||
{#if batchTagEdit && !contextMenu}
|
{#if batchTagEdit && !contextMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
|
<div class="batch-move-overlay" onclick={closeBatchTagOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchTagEdit = false; }}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-tags-title" tabindex="-1">
|
||||||
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="batch-move-header">
|
<div class="batch-move-header">
|
||||||
<span>Tags for {selectedPaths.size} notes</span>
|
<span id="batch-tags-title">Tags for {selectedPaths.size} notes</span>
|
||||||
<button class="selection-close" onclick={() => batchTagEdit = false}>
|
<button class="selection-close" onclick={() => batchTagEdit = false} aria-label="Close tag editor">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1408,7 +1416,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#if contextMenu}
|
||||||
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" role="group" aria-label="Note actions">
|
||||||
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
||||||
<!-- Batch context menu -->
|
<!-- Batch context menu -->
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
@@ -1559,7 +1567,7 @@
|
|||||||
Open in New Window
|
Open in New Window
|
||||||
</button>
|
</button>
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealItemInDir(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
|
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealFile(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -1611,8 +1619,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if sortMenu}
|
{#if sortMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="sort-menu" class:mobile={isMobile} style="left: {sortMenu.x}px; top: {sortMenu.y}px">
|
||||||
<div class="sort-menu" style="left: {sortMenu.x}px; top: {sortMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
|
||||||
<div class="sort-menu-title">Sort by</div>
|
<div class="sort-menu-title">Sort by</div>
|
||||||
<button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}>
|
<button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -2335,13 +2342,13 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .sort-menu {
|
.sort-menu.mobile {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .sort-menu button {
|
.sort-menu.mobile button {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@@ -2365,26 +2372,6 @@
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile selection checkboxes */
|
|
||||||
.mobile-select-check {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid var(--text-tertiary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-select-check.checked {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile create note button (round, accent-colored) */
|
/* Mobile create note button (round, accent-colored) */
|
||||||
.mobile-create-btn {
|
.mobile-create-btn {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
@@ -30,14 +30,14 @@
|
|||||||
|
|
||||||
{#if appWindow && !maximized}
|
{#if appWindow && !maximized}
|
||||||
<div class="resize-layer" aria-hidden="true">
|
<div class="resize-layer" aria-hidden="true">
|
||||||
<div class="rz rz-n" onmousedown={(e) => start(e, 'North')}></div>
|
<div class="rz rz-n" role="presentation" onmousedown={(e) => start(e, 'North')}></div>
|
||||||
<div class="rz rz-s" onmousedown={(e) => start(e, 'South')}></div>
|
<div class="rz rz-s" role="presentation" onmousedown={(e) => start(e, 'South')}></div>
|
||||||
<div class="rz rz-w" onmousedown={(e) => start(e, 'West')}></div>
|
<div class="rz rz-w" role="presentation" onmousedown={(e) => start(e, 'West')}></div>
|
||||||
<div class="rz rz-e" onmousedown={(e) => start(e, 'East')}></div>
|
<div class="rz rz-e" role="presentation" onmousedown={(e) => start(e, 'East')}></div>
|
||||||
<div class="rz rz-nw" onmousedown={(e) => start(e, 'NorthWest')}></div>
|
<div class="rz rz-nw" role="presentation" onmousedown={(e) => start(e, 'NorthWest')}></div>
|
||||||
<div class="rz rz-ne" onmousedown={(e) => start(e, 'NorthEast')}></div>
|
<div class="rz rz-ne" role="presentation" onmousedown={(e) => start(e, 'NorthEast')}></div>
|
||||||
<div class="rz rz-sw" onmousedown={(e) => start(e, 'SouthWest')}></div>
|
<div class="rz rz-sw" role="presentation" onmousedown={(e) => start(e, 'SouthWest')}></div>
|
||||||
<div class="rz rz-se" onmousedown={(e) => start(e, 'SouthEast')}></div>
|
<div class="rz rz-se" role="presentation" onmousedown={(e) => start(e, 'SouthEast')}></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, 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 { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
@@ -583,6 +583,7 @@
|
|||||||
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
|
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
|
||||||
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
|
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
|
||||||
let activeFontSize = $state($appConfig?.font_size ?? 14);
|
let activeFontSize = $state($appConfig?.font_size ?? 14);
|
||||||
|
let scrollToChangeFontSize = $state($appConfig?.scroll_to_change_font_size ?? true);
|
||||||
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
|
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
|
||||||
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
|
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
|
||||||
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
|
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
|
||||||
@@ -744,6 +745,10 @@
|
|||||||
closeCustomThemeEditor();
|
closeCustomThemeEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelCustomThemeFromOverlay(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) cancelCustomThemeEditor();
|
||||||
|
}
|
||||||
|
|
||||||
async function selectCustomTheme(ct: CustomTheme) {
|
async function selectCustomTheme(ct: CustomTheme) {
|
||||||
$theme = ct.id;
|
$theme = ct.id;
|
||||||
setTheme(ct.id);
|
setTheme(ct.id);
|
||||||
@@ -983,6 +988,13 @@
|
|||||||
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
|
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleScrollToChangeFontSize() {
|
||||||
|
scrollToChangeFontSize = !scrollToChangeFontSize;
|
||||||
|
if ($appConfig) $appConfig.scroll_to_change_font_size = scrollToChangeFontSize;
|
||||||
|
setScrollToChangeFontSize(scrollToChangeFontSize)
|
||||||
|
.catch((e) => console.error('Failed to save scroll font size setting:', e));
|
||||||
|
}
|
||||||
|
|
||||||
function applyFontSize(size: number) {
|
function applyFontSize(size: number) {
|
||||||
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
|
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
|
||||||
}
|
}
|
||||||
@@ -1052,6 +1064,14 @@
|
|||||||
$showSettings = false;
|
$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
|
// Apply saved settings on mount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const savedAccent = $appConfig?.accent_color;
|
const savedAccent = $appConfig?.accent_color;
|
||||||
@@ -1068,6 +1088,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
|
||||||
const savedSize = $appConfig?.font_size;
|
const savedSize = $appConfig?.font_size;
|
||||||
if (savedSize) {
|
if (savedSize) {
|
||||||
activeFontSize = savedSize;
|
activeFontSize = savedSize;
|
||||||
@@ -1127,12 +1148,11 @@
|
|||||||
|
|
||||||
{#if $showSettings}
|
{#if $showSettings}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="settings-overlay" class:mobile={isMobile} onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
|
||||||
<div class="settings-panel" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h2>Settings</h2>
|
<h2 id="settings-title">Settings</h2>
|
||||||
<button class="close-btn" onclick={close}>
|
<button class="close-btn" onclick={close} aria-label="Close settings">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
@@ -1214,7 +1234,7 @@
|
|||||||
<span class="setting-name">Compact mode</span>
|
<span class="setting-name">Compact mode</span>
|
||||||
<span class="setting-desc">Show notes in a denser layout without preview</span>
|
<span class="setting-desc">Show notes in a denser layout without preview</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={compactNotes} onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={compactNotes} role="switch" aria-checked={compactNotes} aria-label="Compact notes" onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1223,7 +1243,7 @@
|
|||||||
<span class="setting-name">Show dates</span>
|
<span class="setting-name">Show dates</span>
|
||||||
<span class="setting-desc">Show the date next to each note in the list</span>
|
<span class="setting-desc">Show the date next to each note in the list</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={showNoteDates} onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={showNoteDates} role="switch" aria-checked={showNoteDates} aria-label="Show note dates" onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1231,12 +1251,12 @@
|
|||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Notebooks</h3>
|
<h3>Notebooks</h3>
|
||||||
<label class="setting-toggle">
|
<div class="setting-toggle">
|
||||||
<span class="setting-label">
|
<span class="setting-label">
|
||||||
<span class="setting-name">Sort order</span>
|
<span class="setting-name">Sort order</span>
|
||||||
<span class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span>
|
<span class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
<div class="setting-options" style="margin-top: 8px;">
|
<div class="setting-options" style="margin-top: 8px;">
|
||||||
<button class="option-btn" class:active={$notebookSortMode === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button>
|
<button class="option-btn" class:active={$notebookSortMode === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button>
|
||||||
<button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
|
<button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
|
||||||
@@ -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>
|
<p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p>
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<span class="setting-label"><span class="setting-name">All Notes</span></span>
|
<span class="setting-label"><span class="setting-name">All Notes</span></span>
|
||||||
<button class="toggle-switch" class:on={showAllNotes} onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={showAllNotes} role="switch" aria-checked={showAllNotes} aria-label="Show All Notes" onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<span class="setting-label"><span class="setting-name">Quick Access</span></span>
|
<span class="setting-label"><span class="setting-name">Quick Access</span></span>
|
||||||
<button class="toggle-switch" class:on={showQuickAccess} onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={showQuickAccess} role="switch" aria-checked={showQuickAccess} aria-label="Show Quick Access" onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<span class="setting-label"><span class="setting-name">Tasks</span></span>
|
<span class="setting-label"><span class="setting-name">Tasks</span></span>
|
||||||
<button class="toggle-switch" class:on={showTasks} onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={showTasks} role="switch" aria-checked={showTasks} aria-label="Show Tasks" onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<span class="setting-label"><span class="setting-name">Daily Notes</span></span>
|
<span class="setting-label"><span class="setting-name">Daily Notes</span></span>
|
||||||
<button class="toggle-switch" class:on={showDailyNotes} onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={showDailyNotes} role="switch" aria-checked={showDailyNotes} aria-label="Show Daily Notes" onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<span class="setting-label"><span class="setting-name">Trash</span></span>
|
<span class="setting-label"><span class="setting-name">Trash</span></span>
|
||||||
<button class="toggle-switch" class:on={showTrash} onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={showTrash} role="switch" aria-checked={showTrash} aria-label="Show Trash" onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1288,7 +1308,7 @@
|
|||||||
<span class="setting-name">Show note switcher in title bar</span>
|
<span class="setting-name">Show note switcher in title bar</span>
|
||||||
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
|
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={showNoteSwitcher} onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={showNoteSwitcher} role="switch" aria-checked={showNoteSwitcher} aria-label="Show note switcher in title bar" onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1312,7 +1332,7 @@
|
|||||||
<span class="setting-name">Restore last session on launch</span>
|
<span class="setting-name">Restore last session on launch</span>
|
||||||
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
|
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={restoreLastSession} role="switch" aria-checked={restoreLastSession} aria-label="Restore last session on launch" onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1368,7 +1388,7 @@
|
|||||||
<span class="setting-name">GPU Acceleration</span>
|
<span class="setting-name">GPU Acceleration</span>
|
||||||
<span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span>
|
<span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={gpuAcceleration} onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={gpuAcceleration} role="switch" aria-checked={gpuAcceleration} aria-label="GPU acceleration" onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1381,7 +1401,7 @@
|
|||||||
<span class="setting-name">Start at system startup</span>
|
<span class="setting-name">Start at system startup</span>
|
||||||
<span class="setting-desc">Launch HelixNotes when your computer starts</span>
|
<span class="setting-desc">Launch HelixNotes when your computer starts</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={autostart} onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={autostart} role="switch" aria-checked={autostart} aria-label="Start at system startup" onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1390,7 +1410,7 @@
|
|||||||
<span class="setting-name">Show in system tray</span>
|
<span class="setting-name">Show in system tray</span>
|
||||||
<span class="setting-desc">Show an icon in the notification area (requires restart)</span>
|
<span class="setting-desc">Show an icon in the notification area (requires restart)</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={showTrayIcon} role="switch" aria-checked={showTrayIcon} aria-label="Show in system tray" onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1400,7 +1420,7 @@
|
|||||||
<span class="setting-name">Close to tray</span>
|
<span class="setting-name">Close to tray</span>
|
||||||
<span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
|
<span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={closeToTray} onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={closeToTray} role="switch" aria-checked={closeToTray} aria-label="Close to tray" onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1489,7 +1509,7 @@
|
|||||||
<span class="setting-name">Hide title in note body</span>
|
<span class="setting-name">Hide title in note body</span>
|
||||||
<span class="setting-desc">Hide the first heading when it matches the note title</span>
|
<span class="setting-desc">Hide the first heading when it matches the note title</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={hideTitleInBody} onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={hideTitleInBody} role="switch" aria-checked={hideTitleInBody} aria-label="Hide title in note body" onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1498,7 +1518,7 @@
|
|||||||
<span class="setting-name">Show link arrows</span>
|
<span class="setting-name">Show link arrows</span>
|
||||||
<span class="setting-desc">Display the arrow icon after external links</span>
|
<span class="setting-desc">Display the arrow icon after external links</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={showLinkArrows} onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
|
<button class="toggle-switch" class:on={showLinkArrows} role="switch" aria-checked={showLinkArrows} aria-label="Show link arrows" onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1508,7 +1528,7 @@
|
|||||||
<span class="setting-name">Show line numbers</span>
|
<span class="setting-name">Show line numbers</span>
|
||||||
<span class="setting-desc">Display line numbers in the markdown source editor</span>
|
<span class="setting-desc">Display line numbers in the markdown source editor</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={showLineNumbers} onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={showLineNumbers} role="switch" aria-checked={showLineNumbers} aria-label="Show line numbers" onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1518,7 +1538,7 @@
|
|||||||
<span class="setting-name">Open notes in View Mode</span>
|
<span class="setting-name">Open notes in View Mode</span>
|
||||||
<span class="setting-desc">Notes open as read-only by default. Click the eye icon to switch to editing.</span>
|
<span class="setting-desc">Notes open as read-only by default. Click the eye icon to switch to editing.</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={defaultViewMode} onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={defaultViewMode} role="switch" aria-checked={defaultViewMode} aria-label="Open notes in View Mode" onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1527,7 +1547,7 @@
|
|||||||
<span class="setting-name">Open new notes in source mode</span>
|
<span class="setting-name">Open new notes in source mode</span>
|
||||||
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
|
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={newNotesInSourceMode} onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={newNotesInSourceMode} role="switch" aria-checked={newNotesInSourceMode} aria-label="Open new notes in source mode" onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1541,7 +1561,7 @@
|
|||||||
<span class="setting-name">Enable wiki links</span>
|
<span class="setting-name">Enable wiki links</span>
|
||||||
<span class="setting-desc">Link notes with [[Note Title]] syntax and visualize connections in a graph view</span>
|
<span class="setting-desc">Link notes with [[Note Title]] syntax and visualize connections in a graph view</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={enableWikiLinks} onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={enableWikiLinks} role="switch" aria-checked={enableWikiLinks} aria-label="Enable wiki links" onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1556,7 +1576,7 @@
|
|||||||
<span class="setting-name">Inline PDF preview</span>
|
<span class="setting-name">Inline PDF preview</span>
|
||||||
<span class="setting-desc">Render embedded PDF files as inline previews inside notes</span>
|
<span class="setting-desc">Render embedded PDF files as inline previews inside notes</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={pdfPreview} onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
|
<button class="toggle-switch" class:on={pdfPreview} role="switch" aria-checked={pdfPreview} aria-label="Inline PDF preview" onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -1793,24 +1813,23 @@
|
|||||||
<!-- Custom Theme Editor Modal -->
|
<!-- Custom Theme Editor Modal -->
|
||||||
{#if customThemeEditorOpen && customThemeEditing}
|
{#if customThemeEditorOpen && customThemeEditing}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeEditor} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
|
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
|
||||||
<div class="custom-theme-modal" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="custom-theme-modal-header">
|
<div class="custom-theme-modal-header">
|
||||||
<h3>{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
|
<h3 id="custom-theme-title">{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
|
||||||
<button class="close-btn" onclick={cancelCustomThemeEditor}>
|
<button class="close-btn" onclick={cancelCustomThemeEditor} aria-label="Close theme editor">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="custom-theme-modal-body">
|
<div class="custom-theme-modal-body">
|
||||||
<div class="ct-field">
|
<div class="ct-field">
|
||||||
<label class="ct-label">Theme Name</label>
|
<label class="ct-label" for="custom-theme-name">Theme Name</label>
|
||||||
<input class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
|
<input id="custom-theme-name" class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ct-field">
|
<div class="ct-field">
|
||||||
<label class="ct-label">Mode</label>
|
<span class="ct-label">Mode</span>
|
||||||
<div class="setting-options">
|
<div class="setting-options">
|
||||||
<button class="option-btn" class:active={!customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = false; previewCustomTheme(); } }}>Light</button>
|
<button class="option-btn" class:active={!customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = false; previewCustomTheme(); } }}>Light</button>
|
||||||
<button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button>
|
<button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button>
|
||||||
@@ -1900,6 +1919,17 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if !isMobile}
|
||||||
|
<label class="setting-toggle">
|
||||||
|
<span class="setting-label">
|
||||||
|
<span class="setting-name">Scroll to change font size</span>
|
||||||
|
<span class="setting-desc">Allow Cmd/Ctrl + scroll to resize editor text</span>
|
||||||
|
</span>
|
||||||
|
<button class="toggle-switch" class:on={scrollToChangeFontSize} role="switch" aria-checked={scrollToChangeFontSize} aria-label="Scroll to change font size" onclick={toggleScrollToChangeFontSize}>
|
||||||
|
<span class="toggle-knob"></span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
@@ -1977,7 +2007,7 @@
|
|||||||
|
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<span>Enable automatic backup</span>
|
<span>Enable automatic backup</span>
|
||||||
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
|
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} role="switch" aria-checked={$appConfig?.backup_enabled ?? false} aria-label="Enable automatic backup" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -2005,7 +2035,7 @@
|
|||||||
<span class="setting-name">Include attachments</span>
|
<span class="setting-name">Include attachments</span>
|
||||||
<span class="setting-desc">Include images and files in backups (increases size significantly)</span>
|
<span class="setting-desc">Include images and files in backups (increases size significantly)</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
|
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} role="switch" aria-checked={$appConfig?.backup_include_attachments ?? false} aria-label="Include attachments in backups" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
|
||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -2091,10 +2121,9 @@
|
|||||||
|
|
||||||
{#if restoreConfirm}
|
{#if restoreConfirm}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="restore-confirm-overlay" onclick={() => restoreConfirm = null}>
|
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={(e) => { if (e.key === 'Escape') restoreConfirm = null; }}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
|
||||||
<div class="restore-confirm" onclick={(e) => e.stopPropagation()}>
|
<h4 id="restore-confirm-title">Restore Backup?</h4>
|
||||||
<h4>Restore Backup?</h4>
|
|
||||||
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
|
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
|
||||||
<div class="restore-confirm-actions">
|
<div class="restore-confirm-actions">
|
||||||
<button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button>
|
<button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button>
|
||||||
@@ -2443,14 +2472,14 @@
|
|||||||
<span class="setting-name">Sync when a note changes</span>
|
<span class="setting-name">Sync when a note changes</span>
|
||||||
<span class="setting-desc">Sync shortly after you edit a note.</span>
|
<span class="setting-desc">Sync shortly after you edit a note.</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={syncOnChange} onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
<button class="toggle-switch" class:on={syncOnChange} role="switch" aria-checked={syncOnChange} aria-label="Sync when a note changes" onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||||
</label>
|
</label>
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<span class="setting-label">
|
<span class="setting-label">
|
||||||
<span class="setting-name">Sync when the vault opens</span>
|
<span class="setting-name">Sync when the vault opens</span>
|
||||||
<span class="setting-desc">Sync once when the app starts.</span>
|
<span class="setting-desc">Sync once when the app starts.</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-switch" class:on={syncOnOpen} onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
<button class="toggle-switch" class:on={syncOnOpen} role="switch" aria-checked={syncOnOpen} aria-label="Sync when the vault opens" onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -335,6 +335,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dismissDeleteConfirm(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) deleteConfirm = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmDelete(nb: NotebookEntry) {
|
async function confirmDelete(nb: NotebookEntry) {
|
||||||
deleteConfirm = null;
|
deleteConfirm = null;
|
||||||
try {
|
try {
|
||||||
@@ -1029,10 +1033,9 @@
|
|||||||
|
|
||||||
{#if deleteConfirm}
|
{#if deleteConfirm}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
|
<div class="delete-confirm-overlay" onclick={dismissDeleteConfirm} onkeydown={(e) => { if (e.key === 'Escape') deleteConfirm = null; }}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="delete-confirm" class:mobile={isMobile} role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" tabindex="-1">
|
||||||
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
|
<h4 id="delete-confirm-title">Delete "{deleteConfirm.name}"?</h4>
|
||||||
<h4>Delete "{deleteConfirm.name}"?</h4>
|
|
||||||
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
||||||
<div class="delete-confirm-actions">
|
<div class="delete-confirm-actions">
|
||||||
<button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
|
<button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
|
||||||
@@ -1071,7 +1074,9 @@
|
|||||||
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
||||||
style="padding-left: {4 + depth * 16}px"
|
style="padding-left: {4 + depth * 16}px"
|
||||||
data-nb-path={nb.path}
|
data-nb-path={nb.path}
|
||||||
onclick={() => selectNotebook(nb)}
|
onclick={(e) => {
|
||||||
|
if (!(e.target as Element).closest('.nb-drag-handle')) selectNotebook(nb);
|
||||||
|
}}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'F2') {
|
if (e.key === 'F2') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1153,12 +1158,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
||||||
{#if $notebookSortMode === 'manual'}
|
{#if $notebookSortMode === 'manual'}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<span
|
<span
|
||||||
class="nb-drag-handle"
|
class="nb-drag-handle"
|
||||||
title="Drag to reorder"
|
title="Drag to reorder"
|
||||||
onpointerdown={(e) => nbHandleDown(e, nb)}
|
onpointerdown={(e) => nbHandleDown(e, nb)}
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -214,19 +214,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isAndroid}
|
{#if isAndroid}
|
||||||
<div class="location-selector">
|
<div class="location-selector" role="group" aria-labelledby="storage-location-label">
|
||||||
<label class="location-label">Location</label>
|
<span id="storage-location-label" class="location-label">Location</span>
|
||||||
<div class="location-options">
|
<div class="location-options">
|
||||||
{#each storageLocations as loc}
|
{#each storageLocations as loc}
|
||||||
<button
|
<button
|
||||||
class="location-option"
|
class="location-option"
|
||||||
class:active={selectedLocation === loc.label}
|
class:active={selectedLocation === loc.label}
|
||||||
|
aria-pressed={selectedLocation === loc.label}
|
||||||
onclick={() => selectedLocation = loc.label}
|
onclick={() => selectedLocation = loc.label}
|
||||||
>{loc.label}</button>
|
>{loc.label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
<button
|
<button
|
||||||
class="location-option"
|
class="location-option"
|
||||||
class:active={selectedLocation === 'Custom'}
|
class:active={selectedLocation === 'Custom'}
|
||||||
|
aria-pressed={selectedLocation === 'Custom'}
|
||||||
onclick={() => selectedLocation = 'Custom'}
|
onclick={() => selectedLocation = 'Custom'}
|
||||||
>Custom</button>
|
>Custom</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ export interface AppConfig {
|
|||||||
system_dark_theme: string;
|
system_dark_theme: string;
|
||||||
accent_color: string | null;
|
accent_color: string | null;
|
||||||
font_size: number | null;
|
font_size: number | null;
|
||||||
|
scroll_to_change_font_size: boolean;
|
||||||
font_family: string | null;
|
font_family: string | null;
|
||||||
line_height: number | null;
|
line_height: number | null;
|
||||||
ui_scale: number | null;
|
ui_scale: number | null;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
+13
-2
@@ -3,6 +3,7 @@
|
|||||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
||||||
import { darkThemes, isIOS, isMobile } from '$lib/platform';
|
import { darkThemes, isIOS, isMobile } from '$lib/platform';
|
||||||
|
import { getWheelFontSizeAction } from '$lib/utils/editor-zoom';
|
||||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||||
let removeFontSizeListener: (() => void) | null = null;
|
let removeFontSizeListener: (() => void) | null = null;
|
||||||
|
let removeScrollFontSizeListener: (() => void) | null = null;
|
||||||
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let startupWindowRevealed = false;
|
let startupWindowRevealed = false;
|
||||||
|
|
||||||
@@ -121,10 +123,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
if (!event.ctrlKey && !event.metaKey) return;
|
const action = getWheelFontSizeAction(
|
||||||
|
event,
|
||||||
|
$appConfig?.scroll_to_change_font_size ?? true
|
||||||
|
);
|
||||||
|
if (action === 'ignore') return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
zoomEditor(event.deltaY < 0 ? 1 : -1);
|
if (action === 'increase') zoomEditor(1);
|
||||||
|
else if (action === 'decrease') zoomEditor(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
@@ -153,6 +160,9 @@
|
|||||||
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
|
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
|
||||||
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
|
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
|
||||||
});
|
});
|
||||||
|
removeScrollFontSizeListener = await listen<boolean>('scroll-to-change-font-size-changed', (event) => {
|
||||||
|
if ($appConfig) $appConfig.scroll_to_change_font_size = event.payload;
|
||||||
|
});
|
||||||
$theme = config.theme || 'system';
|
$theme = config.theme || 'system';
|
||||||
|
|
||||||
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
|
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
|
||||||
@@ -287,6 +297,7 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
removeEditorZoomShortcuts?.();
|
removeEditorZoomShortcuts?.();
|
||||||
removeFontSizeListener?.();
|
removeFontSizeListener?.();
|
||||||
|
removeScrollFontSizeListener?.();
|
||||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||||
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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