mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
76
Commits
b8308d89e8
...
v1.3.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15b267614 | ||
|
|
ce48d77cd8 | ||
|
|
221625aabd | ||
|
|
1bd4cd8138 | ||
|
|
dd748776b2 | ||
|
|
0391a60c1b | ||
|
|
33d77cf2fd | ||
|
|
81e4e26290 | ||
|
|
111d819f92 | ||
|
|
c37818de1a | ||
|
|
95dab8cf74 | ||
|
|
62365f52b1 | ||
|
|
6eb1ec6eb2 | ||
|
|
527c6a7a91 | ||
|
|
45e3c389e4 | ||
|
|
9ccbf3aff5 | ||
|
|
695664913a | ||
|
|
d0fb87cf3a | ||
|
|
ac7c79af04 | ||
|
|
1ebf2a9843 | ||
|
|
44dcfbe24a | ||
|
|
00196ecedb | ||
|
|
c4d2d1b2d1 | ||
|
|
3a9a5129c4 | ||
|
|
a964ee1930 | ||
|
|
2d57577338 | ||
|
|
206faed113 | ||
|
|
b3c292d6e2 | ||
|
|
43f15962f7 | ||
|
|
ade146f9f3 | ||
|
|
b253ec4121 | ||
|
|
c4a91b8f1c | ||
|
|
87c879bcac | ||
|
|
27609fb4f3 | ||
|
|
b0555fe648 | ||
|
|
32ef51161d | ||
|
|
23abd0a19a | ||
|
|
5f726eb01f | ||
|
|
73f7604187 | ||
|
|
7ca226d918 | ||
|
|
1cbdf704bd | ||
|
|
b5bd5ea151 | ||
|
|
bdf94e2494 | ||
|
|
1950c4cc3c | ||
|
|
aeddcc6e58 | ||
|
|
bb9d01f57c | ||
|
|
ac25dfd773 | ||
|
|
e61c66dc26 | ||
|
|
3e8aa7f10d | ||
|
|
2dd0985ea9 | ||
|
|
18bb6bb702 | ||
|
|
fcf48a5b7a | ||
|
|
c7906f4069 | ||
|
|
e6e1ed1ed6 | ||
|
|
1a880a4f96 | ||
|
|
f4e05ed9c4 | ||
|
|
4a1045b41e | ||
|
|
c305b5f8dd | ||
|
|
b5962af466 | ||
|
|
13e83b6689 | ||
|
|
432e4f472a | ||
|
|
8731ca4ec1 | ||
|
|
6b165059cc | ||
|
|
213797a38e | ||
|
|
fc3e388b80 | ||
|
|
1c0359e540 | ||
|
|
da859e581b | ||
|
|
763e673738 | ||
|
|
550d3ab3e9 | ||
|
|
e62404d4c0 | ||
|
|
54e051ba02 | ||
|
|
344728dbe8 | ||
|
|
818e9ad82a | ||
|
|
4931ee3fab | ||
|
|
65e09e8fdd | ||
|
|
64e521d549 |
@@ -13,6 +13,9 @@ bun.lockb
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Local tests
|
||||
/tests/
|
||||
|
||||
# Nix
|
||||
/result
|
||||
|
||||
|
||||
@@ -14,25 +14,41 @@ No cloud, no lock-in.
|
||||
|
||||
### Linux
|
||||
|
||||
#### Arch / Manjaro (AUR)
|
||||
#### AppImage
|
||||
|
||||
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
|
||||
|
||||
#### Distro-specific packages
|
||||
|
||||
##### Fedora 43+ (DNF)
|
||||
|
||||
```bash
|
||||
yay -S helixnotes-appimage-bin
|
||||
sudo dnf config-manager addrepo \
|
||||
--from-repofile=https://repo.arkhost.com/helixnotes.repo
|
||||
sudo dnf install helix-notes
|
||||
```
|
||||
|
||||
#### Debian / Ubuntu / Mint (APT)
|
||||
##### Debian / Ubuntu / Mint (APT)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
|
||||
```
|
||||
|
||||
#### Solus (EOPKG)
|
||||
##### Arch / Manjaro (AUR)
|
||||
|
||||
```bash
|
||||
yay -S helixnotes-appimage-bin
|
||||
```
|
||||
|
||||
##### Solus (EOPKG)
|
||||
|
||||
```bash
|
||||
sudo eopkg it helixnotes
|
||||
```
|
||||
|
||||
#### NixOS
|
||||
##### NixOS
|
||||
|
||||
<details>
|
||||
<summary>flake.nix</summary>
|
||||
@@ -90,17 +106,10 @@ sudo eopkg it helixnotes
|
||||
```
|
||||
</details>
|
||||
|
||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||
#### Manual package downloads
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
|
||||
|
||||
#### .deb (manual)
|
||||
|
||||
[Download .deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
||||
|
||||
#### .rpm (manual)
|
||||
|
||||
[Download .rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
||||
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -157,7 +166,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Rust](https://rustup.rs/) (1.77+)
|
||||
- [Rust](https://rustup.rs/) (1.88+)
|
||||
- [Node.js](https://nodejs.org/) (18+)
|
||||
- [pnpm](https://pnpm.io/)
|
||||
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
@@ -169,6 +178,14 @@ pnpm install
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
Run the frontend checks and tests, Rust tests and lints, and production frontend build with one command:
|
||||
|
||||
```bash
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+3
-3
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1782959384,
|
||||
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
||||
"lastModified": 1785571196,
|
||||
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
||||
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
|
||||
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
|
||||
+14
-10
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -11,6 +11,10 @@
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
|
||||
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
|
||||
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
|
||||
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
@@ -18,22 +22,22 @@
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"svelte": "^5.49.2",
|
||||
"@types/node": "26.2.0",
|
||||
"svelte": "^5.56.9",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
||||
@@ -62,11 +66,11 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"lowlight": "^3.3.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it": "^14.3.0",
|
||||
"markdown-it-mark": "^4.0.0",
|
||||
"markdown-it-sub": "^2.0.0",
|
||||
"markdown-it-sup": "^2.0.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"mermaid": "^11.14.0"
|
||||
"mermaid": "^11.16.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+598
-692
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
overrides:
|
||||
cookie: 0.7.2
|
||||
Generated
+67
-275
@@ -8,17 +8,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -138,12 +127,6 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -346,18 +329,6 @@ dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitvec"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
|
||||
dependencies = [
|
||||
"funty",
|
||||
"radium",
|
||||
"tap",
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -389,29 +360,6 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
@@ -439,40 +387,6 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byte-unit"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
|
||||
dependencies = [
|
||||
"rust_decimal",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
|
||||
dependencies = [
|
||||
"bytecheck_derive",
|
||||
"ptr_meta",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck_derive"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -624,6 +538,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -738,6 +663,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -768,9 +702,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -1314,12 +1248,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -1590,11 +1518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1604,10 +1530,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1796,9 +1725,6 @@ name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash 0.7.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -1806,7 +1732,7 @@ version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
@@ -1850,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.3.4"
|
||||
version = "1.3.5"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1862,7 +1788,7 @@ dependencies = [
|
||||
"notify",
|
||||
"objc2-app-kit",
|
||||
"png 0.17.16",
|
||||
"quick-xml 0.36.2",
|
||||
"quick-xml",
|
||||
"rayon",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2554,9 +2480,6 @@ name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
@@ -2575,9 +2498,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.5"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
|
||||
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
@@ -3417,13 +3340,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -3576,26 +3499,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
|
||||
dependencies = [
|
||||
"ptr_meta_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta_derive"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pure-rust-locales"
|
||||
version = "0.8.2"
|
||||
@@ -3619,18 +3522,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.36.2"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -3657,14 +3551,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.1",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg 0.10.2",
|
||||
"ring",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
@@ -3705,12 +3600,6 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "radium"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
@@ -3722,7 +3611,7 @@ dependencies = [
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
"rand_pcg",
|
||||
"rand_pcg 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3738,12 +3627,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
"chacha20",
|
||||
"getrandom 0.4.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3766,16 +3656,6 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3796,12 +3676,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
@@ -3831,6 +3708,15 @@ dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3935,15 +3821,6 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
||||
dependencies = [
|
||||
"bytecheck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
@@ -4063,35 +3940,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
|
||||
dependencies = [
|
||||
"bitvec",
|
||||
"bytecheck",
|
||||
"bytes",
|
||||
"hashbrown 0.12.3",
|
||||
"ptr_meta",
|
||||
"rend",
|
||||
"rkyv_derive",
|
||||
"seahash",
|
||||
"tinyvec",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv_derive"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-stemmers"
|
||||
version = "1.2.0"
|
||||
@@ -4102,22 +3950,6 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -4231,9 +4063,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4327,12 +4159,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "seahash"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4574,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4600,12 +4426,6 @@ version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -5007,17 +4827,11 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -5214,12 +5028,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.8.0"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
|
||||
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"byte-unit",
|
||||
"fern",
|
||||
"log",
|
||||
"objc2",
|
||||
@@ -5964,12 +5777,6 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -5988,12 +5795,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -6246,12 +6047,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.8"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -7007,15 +6808,6 @@ dependencies = [
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||
dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11"
|
||||
version = "2.21.0"
|
||||
|
||||
+10
-3
@@ -1,11 +1,15 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.3.4"
|
||||
version = "1.3.5"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
rust-version = "1.88"
|
||||
|
||||
[lints.clippy]
|
||||
# This style lint is version-dependent and produces over 100 legacy-only diagnostics on Rust 1.88.
|
||||
uninlined_format_args = "allow"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
@@ -14,6 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
@@ -38,7 +45,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
||||
futures = "0.3"
|
||||
rayon = "1"
|
||||
quick-xml = "0.36"
|
||||
quick-xml = "0.41"
|
||||
urlencoding = "2"
|
||||
sha2 = "0.10"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-set-decorations",
|
||||
"core:window:allow-is-fullscreen",
|
||||
@@ -23,32 +24,6 @@
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-ask",
|
||||
"dialog:allow-confirm",
|
||||
"fs:default",
|
||||
"fs:allow-read",
|
||||
"fs:allow-write",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-remove",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-copy-file",
|
||||
"fs:allow-stat",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-watch",
|
||||
"opener:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [{ "path": "/**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [
|
||||
{ "url": "https://**" },
|
||||
{ "url": "http://**" },
|
||||
{ "url": "mailto:*" }
|
||||
]
|
||||
},
|
||||
"opener:allow-reveal-item-in-dir"
|
||||
"fs:allow-read-file"
|
||||
]
|
||||
}
|
||||
|
||||
+28
-16
@@ -8,6 +8,7 @@ const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
||||
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn ai_request(
|
||||
app: AppHandle,
|
||||
provider: String,
|
||||
@@ -22,7 +23,11 @@ pub fn ai_request(
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
|
||||
let key_opt = if api_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(api_key.as_str())
|
||||
};
|
||||
let result = match provider.as_str() {
|
||||
"openai" => {
|
||||
stream_openai(
|
||||
@@ -98,7 +103,10 @@ pub fn ai_request(
|
||||
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
||||
fn normalize_openai_base(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
|
||||
b.strip_suffix("/v1")
|
||||
.unwrap_or(b)
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn stream_anthropic(
|
||||
@@ -268,9 +276,7 @@ async fn stream_openai(
|
||||
body["temperature"] = json!(0.7);
|
||||
}
|
||||
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
let mut req = client.post(url).header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
@@ -380,7 +386,11 @@ pub async fn test_connection(
|
||||
model: &str,
|
||||
base_url: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
|
||||
let key_opt = if api_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(api_key)
|
||||
};
|
||||
match provider {
|
||||
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
||||
"ollama" => {
|
||||
@@ -434,14 +444,18 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let is_gpt5 = model.starts_with("gpt-5");
|
||||
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
|
||||
let client = Client::new();
|
||||
let is_gpt5 = model.starts_with("gpt-5");
|
||||
let token_key = if is_gpt5 {
|
||||
"max_completion_tokens"
|
||||
} else {
|
||||
"max_tokens"
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
token_key: 20,
|
||||
"messages": [
|
||||
let body = json!({
|
||||
"model": model,
|
||||
token_key: 20,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hi"
|
||||
@@ -449,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
|
||||
]
|
||||
});
|
||||
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
let mut req = client.post(url).header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
|
||||
/// Grants asset-protocol access only to a vault selected by the user.
|
||||
/// `.helixnotes` is added explicitly because Unix glob matching excludes hidden paths.
|
||||
pub fn allow_vault_assets<R: Runtime>(app: &AppHandle<R>, vault_path: &Path) -> Result<(), String> {
|
||||
// Keep the canonical native path: Tauri's scope normalizes Unix paths plus
|
||||
// Windows drive, verbatim, and UNC prefixes when matching asset requests.
|
||||
let vault = std::fs::canonicalize(vault_path).map_err(|error| {
|
||||
format!(
|
||||
"Failed to resolve vault asset path '{}': {error}",
|
||||
vault_path.display()
|
||||
)
|
||||
})?;
|
||||
let scope = app.asset_protocol_scope();
|
||||
scope
|
||||
.allow_directory(&vault, true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
scope
|
||||
.allow_directory(vault.join(".helixnotes"), true)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Grants access to relative assets next to a Markdown file explicitly opened by the user.
|
||||
pub fn allow_external_note_assets<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
note_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let note = std::fs::canonicalize(note_path).map_err(|error| {
|
||||
format!(
|
||||
"Failed to resolve external note path '{}': {error}",
|
||||
note_path.display()
|
||||
)
|
||||
})?;
|
||||
let parent = note
|
||||
.parent()
|
||||
.ok_or_else(|| "External note has no parent directory".to_string())?;
|
||||
app.asset_protocol_scope()
|
||||
.allow_directory(parent, true)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn test_directory(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("helixnotes-{name}-{}", uuid::Uuid::new_v4()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_asset_scope_does_not_expose_the_filesystem() {
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
let scope = &config["app"]["security"]["assetProtocol"]["scope"];
|
||||
|
||||
assert_eq!(scope["allow"], serde_json::json!([]));
|
||||
assert_eq!(scope["deny"], serde_json::json!([]));
|
||||
assert_eq!(scope["requireLiteralLeadingDot"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_scope_allows_only_the_selected_vault() {
|
||||
let root = test_directory("asset-scope");
|
||||
let vault = root.join("Vault [Cross Platform]");
|
||||
let attachments = vault.join(".helixnotes").join("attachments");
|
||||
fs::create_dir_all(&attachments).unwrap();
|
||||
let note_image = vault.join("images").join("note image.png");
|
||||
fs::create_dir_all(note_image.parent().unwrap()).unwrap();
|
||||
fs::write(¬e_image, b"image").unwrap();
|
||||
let attachment = attachments.join("attachment.png");
|
||||
fs::write(&attachment, b"attachment").unwrap();
|
||||
let outside = root.join("outside.png");
|
||||
fs::write(&outside, b"outside").unwrap();
|
||||
|
||||
let app = tauri::test::mock_app();
|
||||
allow_vault_assets(app.handle(), &vault).unwrap();
|
||||
let scope = app.asset_protocol_scope();
|
||||
|
||||
assert!(scope.is_allowed(¬e_image));
|
||||
assert!(scope.is_allowed(&attachment));
|
||||
assert!(!scope.is_allowed(&outside));
|
||||
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_note_scope_allows_relative_assets_only_below_its_directory() {
|
||||
let root = test_directory("external-note-assets");
|
||||
let note_dir = root.join("shared note");
|
||||
let assets = note_dir.join("images");
|
||||
fs::create_dir_all(&assets).unwrap();
|
||||
let note = note_dir.join("note.md");
|
||||
let image = assets.join("image.png");
|
||||
let outside = root.join("outside.png");
|
||||
fs::write(¬e, b"note").unwrap();
|
||||
fs::write(&image, b"image").unwrap();
|
||||
fs::write(&outside, b"outside").unwrap();
|
||||
|
||||
let app = tauri::test::mock_app();
|
||||
allow_external_note_assets(app.handle(), ¬e).unwrap();
|
||||
let scope = app.asset_protocol_scope();
|
||||
|
||||
assert!(scope.is_allowed(¬e));
|
||||
assert!(scope.is_allowed(&image));
|
||||
assert!(!scope.is_allowed(&outside));
|
||||
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
+48
-15
@@ -134,7 +134,7 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map_or(false, |ext| ext == "zip") {
|
||||
if path.extension().is_some_and(|ext| ext == "zip") {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Restore a backup by extracting the zip over the vault directory
|
||||
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let backup = Path::new(backup_path);
|
||||
|
||||
if !backup.exists() {
|
||||
return Err("Backup file does not exist".to_string());
|
||||
fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
|
||||
let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
|
||||
let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
|
||||
if backup.parent() != Some(backup_dir.as_path())
|
||||
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
|
||||
|| !backup.is_file()
|
||||
{
|
||||
return Err(
|
||||
"Backup path must point to a ZIP file in the configured backup directory".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
/// Restore a backup by extracting the zip over the vault directory
|
||||
pub fn restore_backup(
|
||||
vault_path: &str,
|
||||
backup_dir: &Path,
|
||||
backup_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let backup = validated_backup_file(backup_dir, backup_path)?;
|
||||
|
||||
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
||||
let mut archive =
|
||||
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
|
||||
}
|
||||
|
||||
/// Delete a single backup file
|
||||
pub fn delete_backup(backup_path: &str) -> Result<(), String> {
|
||||
let path = Path::new(backup_path);
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
|
||||
let path = validated_backup_file(backup_dir, backup_path)?;
|
||||
fs::remove_file(path).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Remove old backups keeping only the newest `max_count`
|
||||
@@ -261,9 +272,31 @@ pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), Stri
|
||||
if backups.len() as u32 > max_count {
|
||||
let to_remove = backups.split_off(max_count as usize);
|
||||
for entry in to_remove {
|
||||
delete_backup(&entry.path)?;
|
||||
delete_backup(backup_dir, &entry.path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::delete_backup;
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn delete_backup_rejects_files_outside_backup_directory() {
|
||||
let root = std::env::temp_dir().join(format!("helixnotes-backup-test-{}", Uuid::new_v4()));
|
||||
let backup_dir = root.join("backups");
|
||||
let outside = root.join("outside.zip");
|
||||
fs::create_dir_all(&backup_dir).unwrap();
|
||||
fs::write(&outside, "must survive").unwrap();
|
||||
|
||||
let result = delete_backup(&backup_dir, &outside.to_string_lossy());
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(outside.exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+670
-230
File diff suppressed because it is too large
Load Diff
+60
-22
@@ -1,22 +1,36 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::types::VersionEntry;
|
||||
|
||||
fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
|
||||
let mut components = Path::new(value).components();
|
||||
if value.is_empty()
|
||||
|| !matches!(components.next(), Some(Component::Normal(_)))
|
||||
|| components.next().is_some()
|
||||
{
|
||||
return Err(format!("Invalid {label}"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Directory: .helixnotes/history/<note-id>/
|
||||
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
|
||||
Path::new(vault_path)
|
||||
fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(Path::new(vault_path)
|
||||
.join(".helixnotes")
|
||||
.join("history")
|
||||
.join(note_id)
|
||||
.join(safe_path_component(note_id, "note ID")?))
|
||||
}
|
||||
|
||||
/// Save a version snapshot if enough time has passed since the last one.
|
||||
/// Minimum interval: 5 minutes between snapshots.
|
||||
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
||||
let dir = history_dir(vault_path, note_id);
|
||||
let Ok(dir) = history_dir(vault_path, note_id) else {
|
||||
log::warn!("Skipping history snapshot with an invalid note ID");
|
||||
return;
|
||||
};
|
||||
|
||||
// Check if we should create a snapshot (5 min cooldown)
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
@@ -68,31 +82,30 @@ pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_ve
|
||||
}
|
||||
|
||||
/// Force-create a version snapshot, bypassing the cooldown.
|
||||
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
||||
let dir = history_dir(vault_path, note_id);
|
||||
|
||||
if let Err(e) = fs::create_dir_all(&dir) {
|
||||
eprintln!("Failed to create history dir: {}", e);
|
||||
return;
|
||||
}
|
||||
pub fn force_snapshot(
|
||||
vault_path: &str,
|
||||
note_id: &str,
|
||||
raw_content: &str,
|
||||
max_versions: u32,
|
||||
) -> Result<(), String> {
|
||||
let dir = history_dir(vault_path, note_id)?;
|
||||
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||
|
||||
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
||||
let filename = format!("{}.md", timestamp);
|
||||
let path = dir.join(&filename);
|
||||
|
||||
if let Err(e) = fs::write(&path, raw_content) {
|
||||
eprintln!("Failed to write version snapshot: {}", e);
|
||||
return;
|
||||
}
|
||||
fs::write(&path, raw_content).map_err(|error| error.to_string())?;
|
||||
|
||||
if max_versions > 0 {
|
||||
let _ = prune_versions(&dir, max_versions);
|
||||
prune_versions(&dir, max_versions)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all version snapshots for a note, newest first.
|
||||
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
|
||||
let dir = history_dir(vault_path, note_id);
|
||||
let dir = history_dir(vault_path, note_id)?;
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -102,7 +115,7 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
||||
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |ext| ext == "md") {
|
||||
if path.extension().is_some_and(|ext| ext == "md") {
|
||||
let filename = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
@@ -134,17 +147,18 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
||||
|
||||
/// Get the raw content of a specific version.
|
||||
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
|
||||
safe_path_component(timestamp, "version timestamp")?;
|
||||
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
|
||||
let filename = if let Some(t_pos) = timestamp.find('T') {
|
||||
let date_part = ×tamp[..t_pos];
|
||||
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
|
||||
let time_dashes = time_part.replace(':', "-");
|
||||
format!("{}.md", format!("{}T{}", date_part, time_dashes))
|
||||
format!("{date_part}T{time_dashes}.md")
|
||||
} else {
|
||||
format!("{}.md", timestamp)
|
||||
};
|
||||
|
||||
let path = history_dir(vault_path, note_id).join(&filename);
|
||||
let path = history_dir(vault_path, note_id)?.join(&filename);
|
||||
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
|
||||
}
|
||||
|
||||
@@ -154,7 +168,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
|
||||
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
|
||||
.collect();
|
||||
|
||||
// Sort by name (timestamps sort lexicographically) - newest last
|
||||
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{get_version, list_versions};
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn rejects_history_path_traversal() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-history-test-{}", Uuid::new_v4()));
|
||||
let metadata = vault.join(".helixnotes");
|
||||
let escaped_history = metadata.join("escaped");
|
||||
fs::create_dir_all(&escaped_history).unwrap();
|
||||
fs::write(escaped_history.join("2026-01-01T00-00-00.md"), "escaped").unwrap();
|
||||
fs::create_dir_all(metadata.join("history").join("safe")).unwrap();
|
||||
fs::write(metadata.join("secret.md"), "secret").unwrap();
|
||||
|
||||
assert!(list_versions(&vault.to_string_lossy(), "../escaped").is_err());
|
||||
assert!(get_version(&vault.to_string_lossy(), "safe", "../../secret").is_err());
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
use reqwest::{blocking::Client, redirect::Policy, Url};
|
||||
use std::io::Read;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
pub struct ImageResponse {
|
||||
pub content_type: String,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
pub enum ProxyError {
|
||||
Invalid(String),
|
||||
Blocked(String),
|
||||
Fetch(String),
|
||||
}
|
||||
|
||||
impl ProxyError {
|
||||
pub fn status(&self) -> u16 {
|
||||
match self {
|
||||
Self::Invalid(_) => 400,
|
||||
Self::Blocked(_) => 403,
|
||||
Self::Fetch(_) => 502,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::Invalid(message) | Self::Blocked(message) | Self::Fetch(message) => message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
|
||||
let [a, b, c, _] = ip.octets();
|
||||
!(a == 0
|
||||
|| a == 10
|
||||
|| a == 127
|
||||
|| a >= 224
|
||||
|| (a == 100 && (64..=127).contains(&b))
|
||||
|| (a == 169 && b == 254)
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
|| (a == 192 && b == 0 && c == 0)
|
||||
|| (a == 192 && b == 0 && c == 2)
|
||||
|| (a == 198 && (b == 18 || b == 19))
|
||||
|| (a == 198 && b == 51 && c == 100)
|
||||
|| (a == 203 && b == 0 && c == 113))
|
||||
}
|
||||
|
||||
fn is_public_ipv6(ip: Ipv6Addr) -> bool {
|
||||
if ip.is_unspecified() || ip.is_loopback() || ip.to_ipv4().is_some() {
|
||||
return false;
|
||||
}
|
||||
let first = ip.segments()[0];
|
||||
(0x2000..=0x3fff).contains(&first) && ip.segments()[..2] != [0x2001, 0x0db8]
|
||||
}
|
||||
|
||||
fn is_public_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => is_public_ipv4(ip),
|
||||
IpAddr::V6(ip) => is_public_ipv6(ip),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_host(host: &str) -> String {
|
||||
host.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn validate_url(raw_url: &str) -> Result<Url, ProxyError> {
|
||||
let url = Url::parse(raw_url).map_err(|_| ProxyError::Invalid("Invalid image URL".into()))?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(ProxyError::Invalid(
|
||||
"Only HTTP and HTTPS images are supported".into(),
|
||||
));
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(ProxyError::Invalid(
|
||||
"Image URLs must not contain credentials".into(),
|
||||
));
|
||||
}
|
||||
let expected_port = if url.scheme() == "https" { 443 } else { 80 };
|
||||
if url.port_or_known_default() != Some(expected_port) {
|
||||
return Err(ProxyError::Blocked("Non-standard image URL port".into()));
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
|
||||
if host.ends_with('.') {
|
||||
return Err(ProxyError::Invalid(
|
||||
"Image URL host must not have a trailing dot".into(),
|
||||
));
|
||||
}
|
||||
let normalized = normalized_host(host);
|
||||
if normalized == "localhost" || normalized.ends_with(".localhost") {
|
||||
return Err(ProxyError::Blocked(
|
||||
"Local image hosts are not allowed".into(),
|
||||
));
|
||||
}
|
||||
if let Ok(ip) = normalized.parse::<IpAddr>() {
|
||||
if !is_public_ip(ip) {
|
||||
return Err(ProxyError::Blocked(
|
||||
"Private image addresses are not allowed".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn resolve_public_host(url: &Url) -> Result<(String, Vec<SocketAddr>), ProxyError> {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
|
||||
let host = normalized_host(host);
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| ProxyError::Invalid("Image URL has no port".into()))?;
|
||||
let addresses: Vec<_> = (host.as_str(), port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|error| ProxyError::Fetch(format!("Could not resolve image host: {error}")))?
|
||||
.collect();
|
||||
if addresses.is_empty() {
|
||||
return Err(ProxyError::Fetch(
|
||||
"Image host resolved to no addresses".into(),
|
||||
));
|
||||
}
|
||||
if addresses.iter().any(|address| !is_public_ip(address.ip())) {
|
||||
return Err(ProxyError::Blocked(
|
||||
"Image host resolves to a private address".into(),
|
||||
));
|
||||
}
|
||||
Ok((host, addresses))
|
||||
}
|
||||
|
||||
fn image_client(host: &str, addresses: &[SocketAddr]) -> Result<Client, ProxyError> {
|
||||
let redirect_host = host.to_string();
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(Policy::custom(move |attempt| {
|
||||
let redirected_elsewhere = match attempt.url().host_str() {
|
||||
Some(host) => normalized_host(host) != redirect_host,
|
||||
None => true,
|
||||
};
|
||||
if attempt.previous().len() >= 5
|
||||
|| redirected_elsewhere
|
||||
|| validate_url(attempt.url().as_str()).is_err()
|
||||
{
|
||||
attempt.stop()
|
||||
} else {
|
||||
attempt.follow()
|
||||
}
|
||||
}))
|
||||
.resolve_to_addrs(host, addresses)
|
||||
.build()
|
||||
.map_err(|error| ProxyError::Fetch(format!("Could not create image client: {error}")))
|
||||
}
|
||||
|
||||
pub fn fetch(raw_url: &str) -> Result<ImageResponse, ProxyError> {
|
||||
let url = validate_url(raw_url)?;
|
||||
let (host, addresses) = resolve_public_host(&url)?;
|
||||
let client = image_client(&host, &addresses)?;
|
||||
let mut response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|error| ProxyError::Fetch(format!("Image request failed: {error}")))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ProxyError::Fetch(format!(
|
||||
"Image server returned {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_IMAGE_BYTES)
|
||||
{
|
||||
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
|
||||
}
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !content_type.to_ascii_lowercase().starts_with("image/") {
|
||||
return Err(ProxyError::Blocked(
|
||||
"Remote resource is not an image".into(),
|
||||
));
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
.by_ref()
|
||||
.take(MAX_IMAGE_BYTES + 1)
|
||||
.read_to_end(&mut body)
|
||||
.map_err(|error| ProxyError::Fetch(format!("Could not read image: {error}")))?;
|
||||
if body.len() as u64 > MAX_IMAGE_BYTES {
|
||||
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
|
||||
}
|
||||
Ok(ImageResponse { content_type, body })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_public_ip, validate_url};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn blocks_local_and_private_networks() {
|
||||
for ip in [
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
|
||||
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
|
||||
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
"fc00::1".parse().unwrap(),
|
||||
"fe80::1".parse().unwrap(),
|
||||
] {
|
||||
assert!(!is_public_ip(ip), "{ip}");
|
||||
}
|
||||
assert!(is_public_ip("93.184.216.34".parse().unwrap()));
|
||||
assert!(is_public_ip(
|
||||
"2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_only_public_web_image_urls() {
|
||||
for url in [
|
||||
"file:///etc/passwd",
|
||||
"http://localhost/image.png",
|
||||
"http://127.0.0.1/image.png",
|
||||
"http://[::1]/image.png",
|
||||
"http://example.com:8080/image.png",
|
||||
"https://example.com./image.png",
|
||||
"https://user:pass@example.com/image.png",
|
||||
] {
|
||||
assert!(validate_url(url).is_err(), "{url}");
|
||||
}
|
||||
assert!(validate_url("https://example.com/image.png").is_ok());
|
||||
assert!(validate_url("http://93.184.216.34/image.png").is_ok());
|
||||
}
|
||||
}
|
||||
+96
-75
@@ -1,7 +1,9 @@
|
||||
mod ai;
|
||||
mod asset_scope;
|
||||
mod backup;
|
||||
mod commands;
|
||||
mod history;
|
||||
mod image_proxy;
|
||||
mod search;
|
||||
mod state;
|
||||
mod sync;
|
||||
@@ -11,6 +13,7 @@ mod vault;
|
||||
use state::AppState;
|
||||
#[allow(unused_imports)]
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_fs::FsExt;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use tauri::{
|
||||
@@ -56,19 +59,22 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.on_webview_event(|webview, event| {
|
||||
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
|
||||
let scope = webview.fs_scope();
|
||||
for path in paths {
|
||||
if path.is_file() {
|
||||
let _ = scope.allow_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if webview.label() == "main"
|
||||
&& matches!(
|
||||
payload.event(),
|
||||
tauri::webview::PageLoadEvent::Finished
|
||||
)
|
||||
&& matches!(payload.event(), tauri::webview::PageLoadEvent::Finished)
|
||||
{
|
||||
let window = webview.window();
|
||||
let window_on_main = window.clone();
|
||||
let _ = window.run_on_main_thread(move || {
|
||||
fix_macos_traffic_lights(&window_on_main);
|
||||
});
|
||||
queue_macos_traffic_light_fix(&webview.window());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (webview, payload);
|
||||
@@ -100,6 +106,20 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
let active_vault = app
|
||||
.state::<AppState>()
|
||||
.config
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|config| config.active_vault.clone());
|
||||
if let Some(vault_path) = active_vault {
|
||||
if let Err(error) =
|
||||
asset_scope::allow_vault_assets(app.handle(), std::path::Path::new(&vault_path))
|
||||
{
|
||||
log::warn!("Failed to restore the vault asset scope: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
if show_tray {
|
||||
setup_tray(app)?;
|
||||
@@ -119,6 +139,14 @@ pub fn run() {
|
||||
std::env::current_dir().unwrap_or_default().join(&path)
|
||||
};
|
||||
if let Some(resolved_str) = resolved.to_str() {
|
||||
if resolved.is_file() {
|
||||
let _ = app.fs_scope().allow_file(&resolved);
|
||||
if let Some(parent) = resolved.parent() {
|
||||
let _ = app.fs_scope().allow_directory(parent, true);
|
||||
}
|
||||
let _ =
|
||||
asset_scope::allow_external_note_assets(app.handle(), &resolved);
|
||||
}
|
||||
let app_state = app.state::<AppState>();
|
||||
let _ = app_state.pending_open_file.lock().map(|mut p| {
|
||||
*p = Some(resolved_str.to_string());
|
||||
@@ -137,12 +165,14 @@ pub fn run() {
|
||||
commands::remove_vault,
|
||||
commands::get_app_config,
|
||||
commands::set_theme,
|
||||
commands::set_system_themes,
|
||||
commands::set_accent_color,
|
||||
commands::save_custom_theme,
|
||||
commands::delete_custom_theme,
|
||||
commands::export_custom_theme,
|
||||
commands::import_custom_themes,
|
||||
commands::set_font_size,
|
||||
commands::set_scroll_to_change_font_size,
|
||||
commands::set_font_family,
|
||||
commands::set_line_height,
|
||||
commands::set_ui_scale,
|
||||
@@ -157,12 +187,14 @@ pub fn run() {
|
||||
commands::read_note,
|
||||
commands::save_note,
|
||||
commands::create_note,
|
||||
commands::duplicate_note,
|
||||
commands::create_daily_note,
|
||||
commands::rename_note,
|
||||
commands::delete_note,
|
||||
commands::move_note,
|
||||
commands::get_all_tags,
|
||||
commands::get_all_note_titles,
|
||||
commands::get_note_switcher_titles,
|
||||
commands::get_graph_data,
|
||||
commands::get_tasks,
|
||||
commands::set_task_done,
|
||||
@@ -177,6 +209,7 @@ pub fn run() {
|
||||
commands::empty_trash,
|
||||
commands::load_vault_state,
|
||||
commands::save_vault_state,
|
||||
commands::copy_text_to_clipboard,
|
||||
commands::read_clipboard_image,
|
||||
commands::copy_image_to_clipboard,
|
||||
commands::save_image,
|
||||
@@ -193,6 +226,7 @@ pub fn run() {
|
||||
commands::trash_orphaned_attachments,
|
||||
commands::import_obsidian,
|
||||
commands::open_file,
|
||||
commands::reveal_file,
|
||||
commands::open_url,
|
||||
commands::copy_file_to,
|
||||
commands::write_bytes_to,
|
||||
@@ -220,72 +254,21 @@ pub fn run() {
|
||||
std::thread::spawn(move || {
|
||||
let encoded = path.strip_prefix('/').unwrap_or(&path);
|
||||
let external_url = percent_decode(encoded);
|
||||
|
||||
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
||||
let _ = responder.respond(
|
||||
let response = match image_proxy::fetch(&external_url) {
|
||||
Ok(image) => tauri::http::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", image.content_type)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.body(image.body),
|
||||
Err(error) => {
|
||||
log::warn!("Blocked external image request: {}", error.message());
|
||||
tauri::http::Response::builder()
|
||||
.status(400)
|
||||
.status(error.status())
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let client = match reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.get(&external_url).send() {
|
||||
Ok(resp) => {
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let status = resp.status().as_u16();
|
||||
match resp.bytes() {
|
||||
Ok(bytes) => {
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", &content_type)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(bytes.to_vec())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
responder.respond(response.expect("valid image proxy response"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -312,15 +295,46 @@ pub fn run() {
|
||||
std::path::Path::new(&cwd).join(path)
|
||||
};
|
||||
if let Some(resolved_str) = resolved.to_str() {
|
||||
if resolved.is_file() {
|
||||
let _ = app.fs_scope().allow_file(&resolved);
|
||||
if let Some(parent) = resolved.parent() {
|
||||
let _ = app.fs_scope().allow_directory(parent, true);
|
||||
}
|
||||
let _ = asset_scope::allow_external_note_assets(app, &resolved);
|
||||
}
|
||||
let _ = app.emit("open-file", resolved_str.to_string());
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||
let window_state_builder = tauri_plugin_window_state::Builder::default();
|
||||
#[cfg(target_os = "linux")]
|
||||
let window_state_builder = window_state_builder.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::SIZE
|
||||
| tauri_plugin_window_state::StateFlags::POSITION
|
||||
| tauri_plugin_window_state::StateFlags::MAXIMIZED
|
||||
| tauri_plugin_window_state::StateFlags::DECORATIONS
|
||||
| tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||
);
|
||||
builder = builder.plugin(window_state_builder.build());
|
||||
|
||||
builder = builder.on_window_event(move |window, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if window.label() == "main"
|
||||
&& matches!(
|
||||
event,
|
||||
tauri::WindowEvent::Resized(_)
|
||||
| tauri::WindowEvent::Focused(true)
|
||||
| tauri::WindowEvent::ScaleFactorChanged { .. }
|
||||
| tauri::WindowEvent::ThemeChanged(_)
|
||||
)
|
||||
{
|
||||
// AppKit can restore the default button Y position after window-state
|
||||
// changes. Queue this pass so it runs after AppKit finishes its layout.
|
||||
queue_macos_traffic_light_fix(window);
|
||||
}
|
||||
|
||||
match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
// Only hide to tray for the main window
|
||||
@@ -329,9 +343,9 @@ pub fn run() {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
tauri::WindowEvent::Destroyed
|
||||
// When main window is destroyed, close all note windows
|
||||
if window.label() == "main" {
|
||||
if window.label() == "main" => {
|
||||
let app = window.app_handle();
|
||||
for (label, win) in app.webview_windows() {
|
||||
if label.starts_with("note-") {
|
||||
@@ -339,7 +353,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
@@ -410,6 +423,14 @@ fn percent_decode(input: &str) -> String {
|
||||
String::from_utf8_lossy(&output).to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn queue_macos_traffic_light_fix(window: &tauri::Window) {
|
||||
let window_on_main = window.clone();
|
||||
let _ = window.run_on_main_thread(move || {
|
||||
fix_macos_traffic_lights(&window_on_main);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn fix_macos_traffic_lights(window: &tauri::Window) {
|
||||
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
app_lib::run();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(vault_path.as_bytes());
|
||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let key: String = hasher.finalize()[..8]
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect();
|
||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||
}
|
||||
|
||||
@@ -235,7 +238,9 @@ impl SearchIndex {
|
||||
// before the writer is created, so both indexing and querying use it).
|
||||
index.tokenizers().register(
|
||||
"cjk",
|
||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
||||
TextAnalyzer::builder(CjkTokenizer)
|
||||
.filter(LowerCaser)
|
||||
.build(),
|
||||
);
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -290,7 +295,7 @@ impl SearchIndex {
|
||||
doc.add_text(self.path_field, &path_str);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
doc.add_text(self.body_field, &content);
|
||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
||||
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
}
|
||||
}
|
||||
@@ -321,7 +326,7 @@ impl SearchIndex {
|
||||
doc.add_text(self.path_field, path);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
doc.add_text(self.body_field, &content);
|
||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
||||
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
|
||||
writer.commit().map_err(|e| e.to_string())?;
|
||||
@@ -341,7 +346,7 @@ impl SearchIndex {
|
||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
||||
let fields = [self.title_field, self.body_field, self.tags_field];
|
||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||
@@ -376,9 +381,10 @@ impl SearchIndex {
|
||||
));
|
||||
vec![(Occur::Should, exact)]
|
||||
} else {
|
||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
||||
vec![Term::from_field_text(field, term)],
|
||||
));
|
||||
let prefix: Box<dyn Query> =
|
||||
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
|
||||
field, term,
|
||||
)]));
|
||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
1,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::search::SearchIndex;
|
||||
use crate::types::AppConfig;
|
||||
use notify::RecommendedWatcher;
|
||||
use crate::vault::watcher::VaultWatcher;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub watcher: Mutex<Option<VaultWatcher>>,
|
||||
pub vault_transition: tokio::sync::Mutex<()>,
|
||||
pub importing: AtomicBool,
|
||||
pub syncing: AtomicBool,
|
||||
|
||||
+63
-15
@@ -6,9 +6,9 @@
|
||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||
// conflict copies so nothing is ever lost.
|
||||
//
|
||||
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
||||
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
||||
// manifest itself) is local-only and never synced.
|
||||
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
|
||||
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
|
||||
// and the manifest itself remain local-only.
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
||||
}
|
||||
|
||||
fn normalize_etag(s: &str) -> String {
|
||||
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
||||
s.trim()
|
||||
.trim_start_matches("W/")
|
||||
.trim_matches('"')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Percent-encode each path segment, keeping the `/` separators.
|
||||
@@ -119,12 +122,12 @@ struct LocalFile {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
||||
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
||||
/// folder with unrelated files never drags them into the vault.
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
|
||||
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
|
||||
/// and remote so pointing at a folder with unrelated files never imports them.
|
||||
fn is_synced_relpath(rel: &str) -> bool {
|
||||
if rel.starts_with(".helixnotes/") {
|
||||
rel.starts_with(".helixnotes/attachments/")
|
||||
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
|
||||
} else {
|
||||
rel.ends_with(".md")
|
||||
}
|
||||
@@ -265,7 +268,11 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
return Err(format!(
|
||||
"GET {} failed: HTTP {}",
|
||||
relpath,
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||
}
|
||||
@@ -279,7 +286,11 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
return Err(format!(
|
||||
"PUT {} failed: HTTP {}",
|
||||
relpath,
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(resp
|
||||
.headers()
|
||||
@@ -453,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if cap != Cap::None {
|
||||
if let Ok(t) = e.unescape() {
|
||||
buf.push_str(&t);
|
||||
if let Ok(decoded) = e.decode() {
|
||||
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
|
||||
buf.push_str(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,8 +528,14 @@ fn apply_changes(
|
||||
|
||||
match (l, r) {
|
||||
(Some(lf), Some(re)) => {
|
||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
||||
let local_changed = match m {
|
||||
Some(entry) => entry.local_hash != lf.hash,
|
||||
None => true,
|
||||
};
|
||||
let remote_changed = match m {
|
||||
Some(entry) => &entry.remote_etag != re,
|
||||
None => true,
|
||||
};
|
||||
if !local_changed && !remote_changed {
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
@@ -669,7 +688,11 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
||||
|
||||
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||
/// rebuilds the search index. Returns a summary of what changed.
|
||||
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
|
||||
pub fn run_sync(
|
||||
app: tauri::AppHandle,
|
||||
vault: String,
|
||||
cfg: WebdavConfig,
|
||||
) -> Result<SyncSummary, String> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
@@ -724,3 +747,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
||||
);
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_synced_relpath;
|
||||
|
||||
#[test]
|
||||
fn syncs_notebook_icon_mapping_and_assets_only() {
|
||||
for path in [
|
||||
"Notes/plan.md",
|
||||
".helixnotes/attachments/notebook-icon.png",
|
||||
".helixnotes/notebook_icons.json",
|
||||
] {
|
||||
assert!(is_synced_relpath(path), "expected {path} to be synced");
|
||||
}
|
||||
|
||||
for path in [
|
||||
"Notes/image.png",
|
||||
".helixnotes/sync_state.json",
|
||||
".helixnotes/notebook_icons.json.bak",
|
||||
".helixnotes/attachments-old/icon.png",
|
||||
] {
|
||||
assert!(!is_synced_relpath(path), "expected {path} to stay local");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,17 @@ pub struct CustomTheme {
|
||||
pub colors: CustomThemeColors,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StartupView {
|
||||
Daily,
|
||||
QuickAccess,
|
||||
Tasks,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub vaults: Vec<VaultConfig>,
|
||||
@@ -103,10 +114,18 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub active_bookmark_id: Option<String>,
|
||||
pub theme: String,
|
||||
/// Themes used when `theme` is "system": the frontend picks one by the OS color scheme.
|
||||
/// Default to the plain "light"/"dark" schemes so existing configs keep their behavior.
|
||||
#[serde(default = "default_system_light_theme")]
|
||||
pub system_light_theme: String,
|
||||
#[serde(default = "default_system_dark_theme")]
|
||||
pub system_dark_theme: String,
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub font_size: Option<u32>,
|
||||
#[serde(default = "default_true")]
|
||||
pub scroll_to_change_font_size: bool,
|
||||
#[serde(default)]
|
||||
pub font_family: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -120,6 +139,8 @@ pub struct AppConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub show_note_dates: bool,
|
||||
#[serde(default)]
|
||||
pub show_note_switcher: bool,
|
||||
#[serde(default)]
|
||||
pub time_format: String,
|
||||
#[serde(default = "default_week_start")]
|
||||
pub week_start: String,
|
||||
@@ -186,12 +207,16 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub default_view_mode: bool,
|
||||
#[serde(default)]
|
||||
pub new_notes_in_source_mode: bool,
|
||||
#[serde(default)]
|
||||
pub show_tray_icon: bool,
|
||||
#[serde(default)]
|
||||
pub close_to_tray: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub enable_wiki_links: bool,
|
||||
#[serde(default)]
|
||||
pub startup_view: StartupView,
|
||||
#[serde(default)]
|
||||
pub restore_last_session: bool,
|
||||
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
|
||||
#[serde(default)]
|
||||
@@ -254,6 +279,14 @@ fn default_ai_model() -> String {
|
||||
"claude-sonnet-4-6".to_string()
|
||||
}
|
||||
|
||||
fn default_system_light_theme() -> String {
|
||||
"light".to_string()
|
||||
}
|
||||
|
||||
fn default_system_dark_theme() -> String {
|
||||
"dark".to_string()
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -261,14 +294,18 @@ impl Default for AppConfig {
|
||||
active_vault: None,
|
||||
active_bookmark_id: None,
|
||||
theme: "system".to_string(),
|
||||
system_light_theme: default_system_light_theme(),
|
||||
system_dark_theme: default_system_dark_theme(),
|
||||
accent_color: None,
|
||||
font_size: None,
|
||||
scroll_to_change_font_size: true,
|
||||
font_family: None,
|
||||
line_height: None,
|
||||
ui_scale: None,
|
||||
content_width: None,
|
||||
compact_notes: false,
|
||||
show_note_dates: true,
|
||||
show_note_switcher: false,
|
||||
time_format: "relative".to_string(),
|
||||
week_start: "monday".to_string(),
|
||||
daily_title_format: "localized".to_string(),
|
||||
@@ -302,9 +339,11 @@ impl Default for AppConfig {
|
||||
ai_model: "claude-sonnet-4-6".to_string(),
|
||||
ai_writing_style: None,
|
||||
default_view_mode: false,
|
||||
new_notes_in_source_mode: false,
|
||||
show_tray_icon: false,
|
||||
close_to_tray: false,
|
||||
enable_wiki_links: true,
|
||||
startup_view: StartupView::All,
|
||||
restore_last_session: false,
|
||||
sync_provider: None,
|
||||
webdav_url: None,
|
||||
@@ -469,3 +508,68 @@ pub struct TaskItem {
|
||||
pub due: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_view_tests {
|
||||
use super::{AppConfig, StartupView};
|
||||
|
||||
#[test]
|
||||
fn serializes_supported_startup_views() {
|
||||
for (view, expected) in [
|
||||
(StartupView::All, "\"all\""),
|
||||
(StartupView::QuickAccess, "\"quickaccess\""),
|
||||
(StartupView::Tasks, "\"tasks\""),
|
||||
(StartupView::Daily, "\"daily\""),
|
||||
] {
|
||||
assert_eq!(serde_json::to_string(&view).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_startup_view_falls_back_to_all_notes() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<StartupView>("\"future-view\"").unwrap(),
|
||||
StartupView::All
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_configs_default_system_theme_pair() {
|
||||
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
|
||||
let object = value.as_object_mut().unwrap();
|
||||
object.remove("system_light_theme");
|
||||
object.remove("system_dark_theme");
|
||||
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(config.system_light_theme, "light");
|
||||
assert_eq!(config.system_dark_theme, "dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_switcher_is_opt_in_for_new_and_existing_configs() {
|
||||
let config = AppConfig::default();
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value.as_object_mut().unwrap().remove("show_note_switcher");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(!config.show_note_switcher);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
|
||||
let config = AppConfig::default();
|
||||
assert!(config.scroll_to_change_font_size);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("scroll_to_change_font_size");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(config.scroll_to_change_font_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
|
||||
"[{}]",
|
||||
meta.tags
|
||||
.iter()
|
||||
.map(|t| format!("{}", t))
|
||||
.map(|t| t.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
chars.next(); // skip '['
|
||||
let mut depth = 1;
|
||||
// Skip alt text
|
||||
while let Some(c) = chars.next() {
|
||||
for c in chars.by_ref() {
|
||||
if c == '[' {
|
||||
depth += 1;
|
||||
}
|
||||
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
let mut depth = 1;
|
||||
while let Some(c) = chars.next() {
|
||||
for c in chars.by_ref() {
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
}
|
||||
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
if ch == '[' {
|
||||
let mut link_text = String::new();
|
||||
let mut depth = 1;
|
||||
while let Some(c) = chars.next() {
|
||||
for c in chars.by_ref() {
|
||||
if c == '[' {
|
||||
depth += 1;
|
||||
}
|
||||
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
let mut depth = 1;
|
||||
while let Some(c) = chars.next() {
|
||||
for c in chars.by_ref() {
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
@@ -112,13 +112,15 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
||||
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
|
||||
);
|
||||
if is_embeddable {
|
||||
let alt = if is_dimension_spec(alt_param) { "" } else { alt_param };
|
||||
let alt = if is_dimension_spec(alt_param) {
|
||||
""
|
||||
} else {
|
||||
alt_param
|
||||
};
|
||||
result.links_converted += 1;
|
||||
format!("", alt, link_target)
|
||||
} else {
|
||||
let display = if alt_param.is_empty() {
|
||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
||||
} else if is_dimension_spec(alt_param) {
|
||||
let display = if alt_param.is_empty() || is_dimension_spec(alt_param) {
|
||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
||||
} else {
|
||||
alt_param
|
||||
@@ -137,7 +139,11 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
||||
let (file_part, anchor) = split_anchor(note_ref);
|
||||
if file_part.is_empty() {
|
||||
if let Some(a) = anchor {
|
||||
let display = if display_param.is_empty() { a } else { display_param };
|
||||
let display = if display_param.is_empty() {
|
||||
a
|
||||
} else {
|
||||
display_param
|
||||
};
|
||||
result.links_converted += 1;
|
||||
return format!("[{}](#{})", display, a);
|
||||
}
|
||||
@@ -165,8 +171,22 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
||||
.to_string();
|
||||
content = after_links;
|
||||
|
||||
content = fix_md_image_refs(&content, &md_img_re, vault, note_dir, &file_index, &mut result.links_converted);
|
||||
content = fix_md_link_refs(&content, &md_link_re, vault, note_dir, &file_index, &mut result.links_converted);
|
||||
content = fix_md_image_refs(
|
||||
&content,
|
||||
&md_img_re,
|
||||
vault,
|
||||
note_dir,
|
||||
&file_index,
|
||||
&mut result.links_converted,
|
||||
);
|
||||
content = fix_md_link_refs(
|
||||
&content,
|
||||
&md_link_re,
|
||||
vault,
|
||||
note_dir,
|
||||
&file_index,
|
||||
&mut result.links_converted,
|
||||
);
|
||||
|
||||
if result.links_converted > links_before {
|
||||
changed = true;
|
||||
@@ -231,10 +251,8 @@ fn rename_deprecated_properties(raw: &str) -> String {
|
||||
mapping.insert(new_key, val);
|
||||
renamed = true;
|
||||
}
|
||||
} else {
|
||||
if mapping.remove(&old_key).is_some() {
|
||||
renamed = true;
|
||||
}
|
||||
} else if mapping.remove(&old_key).is_some() {
|
||||
renamed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +285,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
let tags = normalize_tags(&mapping);
|
||||
|
||||
let title = mapping
|
||||
.get(&serde_yaml::Value::String("title".into()))
|
||||
.get(serde_yaml::Value::String("title".into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
@@ -283,7 +301,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
});
|
||||
|
||||
let id = mapping
|
||||
.get(&serde_yaml::Value::String("id".into()))
|
||||
.get(serde_yaml::Value::String("id".into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -293,9 +311,9 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
.iter()
|
||||
.find_map(|key| {
|
||||
mapping
|
||||
.get(&serde_yaml::Value::String((*key).into()))
|
||||
.get(serde_yaml::Value::String((*key).into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
||||
.and_then(frontmatter::parse_date_flexible)
|
||||
})
|
||||
.or_else(|| file_created(path))
|
||||
.unwrap_or_else(Utc::now);
|
||||
@@ -304,15 +322,15 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
.iter()
|
||||
.find_map(|key| {
|
||||
mapping
|
||||
.get(&serde_yaml::Value::String((*key).into()))
|
||||
.get(serde_yaml::Value::String((*key).into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
||||
.and_then(frontmatter::parse_date_flexible)
|
||||
})
|
||||
.or_else(|| file_modified(path))
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
let pinned = mapping
|
||||
.get(&serde_yaml::Value::String("pinned".into()))
|
||||
.get(serde_yaml::Value::String("pinned".into()))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -332,7 +350,7 @@ fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for key in &["tags", "tag"] {
|
||||
if let Some(val) = mapping.get(&serde_yaml::Value::String((*key).into())) {
|
||||
if let Some(val) = mapping.get(serde_yaml::Value::String((*key).into())) {
|
||||
for raw in yaml_value_to_strings(val) {
|
||||
let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
|
||||
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
|
||||
@@ -389,9 +407,7 @@ fn file_created(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||
.ok()
|
||||
.and_then(|m| m.created().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|d| {
|
||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
||||
})
|
||||
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||
}
|
||||
|
||||
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||
@@ -399,9 +415,7 @@ fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|d| {
|
||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
||||
})
|
||||
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||
}
|
||||
|
||||
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String {
|
||||
@@ -513,7 +527,9 @@ fn fix_md_link_refs(
|
||||
let display = &caps[1];
|
||||
let href = &caps[2];
|
||||
let decoded = percent_decode(href);
|
||||
if decoded.starts_with("http") || decoded.starts_with('/') || decoded.starts_with("data:")
|
||||
if decoded.starts_with("http")
|
||||
|| decoded.starts_with('/')
|
||||
|| decoded.starts_with("data:")
|
||||
|| decoded.starts_with('#')
|
||||
{
|
||||
return format!("[{}]({})", display, href);
|
||||
@@ -549,9 +565,7 @@ fn fix_md_link_refs(
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn move_attachments(
|
||||
vault: &Path,
|
||||
) -> Result<HashMap<String, String>, String> {
|
||||
fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
|
||||
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
||||
let _ = std::fs::create_dir_all(&attachments_dir);
|
||||
|
||||
@@ -601,10 +615,7 @@ fn move_attachments(
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn rewrite_attachment_refs(
|
||||
vault: &Path,
|
||||
moved: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
fn rewrite_attachment_refs(vault: &Path, moved: &HashMap<String, String>) -> Result<(), String> {
|
||||
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
|
||||
|
||||
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
|
||||
@@ -686,7 +697,7 @@ fn cleanup_empty_dirs(root: &Path) {
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.collect();
|
||||
|
||||
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count()));
|
||||
dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
|
||||
|
||||
for dir in dirs {
|
||||
let dir_str = dir.to_string_lossy();
|
||||
@@ -719,7 +730,8 @@ fn is_dimension_spec(s: &str) -> bool {
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
s.chars().all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
|
||||
s.chars()
|
||||
.all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
|
||||
}
|
||||
|
||||
fn resolve_wiki_ref(file_index: &HashMap<String, String>, reference: &str) -> String {
|
||||
@@ -907,7 +919,10 @@ mod tests {
|
||||
extract_heading_title("# My Title\n\nBody"),
|
||||
Some("My Title".to_string())
|
||||
);
|
||||
assert_eq!(extract_heading_title("\n\n# Spaced Title"), Some("Spaced Title".to_string()));
|
||||
assert_eq!(
|
||||
extract_heading_title("\n\n# Spaced Title"),
|
||||
Some("Spaced Title".to_string())
|
||||
);
|
||||
assert_eq!(extract_heading_title("Body without heading"), None);
|
||||
assert_eq!(extract_heading_title("## Subheading"), None);
|
||||
assert_eq!(extract_heading_title(""), None);
|
||||
@@ -947,8 +962,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_normalize_tags_strips_hash() {
|
||||
let mapping: serde_yaml::Mapping =
|
||||
serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
||||
let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
||||
let tags = normalize_tags(&mapping);
|
||||
assert_eq!(tags, vec!["hashed"]);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
@@ -8,14 +8,57 @@ use crate::state::AppState;
|
||||
use crate::types::FileEvent;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
|
||||
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WatcherBackend {
|
||||
Recommended,
|
||||
Poll,
|
||||
}
|
||||
|
||||
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
|
||||
if is_ios {
|
||||
WatcherBackend::Poll
|
||||
} else {
|
||||
WatcherBackend::Recommended
|
||||
}
|
||||
}
|
||||
|
||||
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
|
||||
match backend {
|
||||
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
|
||||
WatcherBackend::Poll => IOS_POLL_INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum VaultWatcher {
|
||||
Recommended(RecommendedWatcher),
|
||||
Poll(PollWatcher),
|
||||
}
|
||||
|
||||
impl VaultWatcher {
|
||||
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
|
||||
match self {
|
||||
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
|
||||
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
tx,
|
||||
Config::default().with_poll_interval(Duration::from_secs(1)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
|
||||
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
|
||||
let mut watcher = match backend {
|
||||
WatcherBackend::Recommended => VaultWatcher::Recommended(
|
||||
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
|
||||
),
|
||||
WatcherBackend::Poll => {
|
||||
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
|
||||
watcher
|
||||
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
||||
@@ -80,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ios_uses_polling_backend() {
|
||||
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_platforms_keep_recommended_backend() {
|
||||
assert_eq!(
|
||||
watcher_backend_for_target(false),
|
||||
WatcherBackend::Recommended
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_backends_use_the_expected_intervals() {
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Poll),
|
||||
Duration::from_secs(10)
|
||||
);
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Recommended),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.5",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
@@ -24,10 +24,14 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
|
||||
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**/*", "/**", "**/.helixnotes/**"]
|
||||
"scope": {
|
||||
"allow": [],
|
||||
"deny": [],
|
||||
"requireLiteralLeadingDot": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+34
-1
@@ -16,6 +16,7 @@ import type {
|
||||
VersionEntry,
|
||||
TaskItem,
|
||||
ExternalVaultResult,
|
||||
StartupView,
|
||||
} from "./types";
|
||||
|
||||
export async function openVault(path: string): Promise<void> {
|
||||
@@ -42,6 +43,10 @@ export async function setTheme(theme: string): Promise<void> {
|
||||
return invoke("set_theme", { theme });
|
||||
}
|
||||
|
||||
export async function setSystemThemes(light: string, dark: string): Promise<void> {
|
||||
return invoke("set_system_themes", { light, dark });
|
||||
}
|
||||
|
||||
export async function setAccentColor(color: string): Promise<void> {
|
||||
return invoke("set_accent_color", { color });
|
||||
}
|
||||
@@ -66,6 +71,10 @@ export async function setFontSize(size: number): Promise<void> {
|
||||
return invoke("set_font_size", { size });
|
||||
}
|
||||
|
||||
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
|
||||
return invoke("set_scroll_to_change_font_size", { enabled });
|
||||
}
|
||||
|
||||
export async function setFontFamily(family: string): Promise<void> {
|
||||
return invoke("set_font_family", { family });
|
||||
}
|
||||
@@ -140,6 +149,10 @@ export async function createNote(
|
||||
return invoke("create_note", { notebookRelative, title });
|
||||
}
|
||||
|
||||
export async function duplicateNote(path: string): Promise<NoteEntry> {
|
||||
return invoke("duplicate_note", { path });
|
||||
}
|
||||
|
||||
export async function createDailyNote(date?: string): Promise<NoteEntry> {
|
||||
return invoke("create_daily_note", { date: date ?? null });
|
||||
}
|
||||
@@ -170,7 +183,13 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
|
||||
return invoke("get_all_note_titles");
|
||||
}
|
||||
|
||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
|
||||
export async function getNoteSwitcherTitles(
|
||||
recentPaths: string[],
|
||||
): Promise<NoteTitleEntry[]> {
|
||||
return invoke("get_note_switcher_titles", { recentPaths });
|
||||
}
|
||||
|
||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number; bidirectional: boolean }[] }> {
|
||||
return invoke("get_graph_data");
|
||||
}
|
||||
|
||||
@@ -216,6 +235,10 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
|
||||
return invoke("save_vault_state", { vaultState });
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
return invoke("copy_text_to_clipboard", { text });
|
||||
}
|
||||
|
||||
export async function readClipboardImage(): Promise<number[]> {
|
||||
return invoke("read_clipboard_image");
|
||||
}
|
||||
@@ -260,10 +283,13 @@ export async function setGeneralSettings(
|
||||
showLineNumbers: boolean,
|
||||
showLinkArrows: boolean,
|
||||
defaultViewMode: boolean,
|
||||
newNotesInSourceMode: boolean,
|
||||
showTrayIcon: boolean,
|
||||
closeToTray: boolean,
|
||||
enableWikiLinks: boolean,
|
||||
showNoteDates: boolean,
|
||||
showNoteSwitcher: boolean,
|
||||
startupView: StartupView,
|
||||
restoreLastSession: boolean,
|
||||
showAllNotes: boolean,
|
||||
showQuickAccess: boolean,
|
||||
@@ -285,10 +311,13 @@ export async function setGeneralSettings(
|
||||
showLineNumbers,
|
||||
showLinkArrows,
|
||||
defaultViewMode,
|
||||
newNotesInSourceMode,
|
||||
showTrayIcon,
|
||||
closeToTray,
|
||||
enableWikiLinks,
|
||||
showNoteDates,
|
||||
showNoteSwitcher,
|
||||
startupView,
|
||||
restoreLastSession,
|
||||
showAllNotes,
|
||||
showQuickAccess,
|
||||
@@ -339,6 +368,10 @@ export async function openFile(path: string): Promise<void> {
|
||||
return invoke("open_file", { path });
|
||||
}
|
||||
|
||||
export async function revealFile(path: string): Promise<void> {
|
||||
return invoke("reveal_file", { path });
|
||||
}
|
||||
|
||||
export async function openUrl(url: string): Promise<void> {
|
||||
return invoke("open_url", { url });
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
showSearch,
|
||||
showCommandPalette,
|
||||
theme,
|
||||
resolvedTheme,
|
||||
customThemes,
|
||||
focusMode,
|
||||
readOnly,
|
||||
activeNote,
|
||||
@@ -61,12 +63,14 @@
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
const isMobile = $derived($platformIsMobile);
|
||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { loadVaultState, saveVaultState, readNote, deleteNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isAndroid } from '$lib/platform';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { normalizeStartupView, resolveStartupTarget } from '$lib/utils/startup-view';
|
||||
import { get } from 'svelte/store';
|
||||
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
|
||||
import type { StartupTarget } from '$lib/utils/startup-view';
|
||||
|
||||
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
|
||||
for (const nb of list) {
|
||||
@@ -77,10 +81,42 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
let sidebar: Sidebar;
|
||||
let noteList: NoteList;
|
||||
let editor: Editor;
|
||||
let sidebar = $state<Sidebar>();
|
||||
let noteList = $state<NoteList>();
|
||||
let editor = $state<Editor>();
|
||||
let unlistenFileChange: (() => void) | null = null;
|
||||
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
|
||||
if (target.mode === 'notebook') {
|
||||
const vault = $appConfig?.active_vault;
|
||||
const notebook = target.notebookPath === '' && vault
|
||||
? { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount }
|
||||
: findNotebookByPath($notebooks, target.notebookPath);
|
||||
if (!notebook) return false;
|
||||
const changed = $viewMode !== 'notebook' || $activeNotebook?.relative_path !== notebook.relative_path;
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = notebook;
|
||||
$activeTag = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (target.mode === 'tag') {
|
||||
const changed = $viewMode !== 'tag' || $activeTag !== target.tag;
|
||||
$viewMode = 'tag';
|
||||
$activeTag = target.tag;
|
||||
$activeNotebook = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
const changed = $viewMode !== target.mode || $activeNotebook !== null || $activeTag !== null;
|
||||
$viewMode = target.mode;
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
let unlistenOpenFile: (() => void) | null = null;
|
||||
|
||||
// Mobile editor header helpers
|
||||
@@ -260,6 +296,24 @@
|
||||
if (isMobile) $mobileView = 'editor';
|
||||
}
|
||||
|
||||
async function selectNoteFromSwitcher(path: string): Promise<boolean> {
|
||||
const currentPath = $activeNotePath;
|
||||
if (currentPath && currentPath.replace(/\\/g, '/') === path.replace(/\\/g, '/')) return true;
|
||||
editor?.flushSave();
|
||||
try {
|
||||
const content = await readNote(path);
|
||||
$viewerNote = null;
|
||||
$activeNote = content;
|
||||
$activeNotePath = path;
|
||||
$editorDirty = false;
|
||||
handleNoteSelected(path, content.content);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to switch note:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewChanged() {
|
||||
taskNoteOpened = false;
|
||||
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||
@@ -415,6 +469,28 @@
|
||||
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
|
||||
}
|
||||
|
||||
async function trashOpenNote(path: string): Promise<boolean> {
|
||||
if (path !== $activeNotePath || $viewerNote || $viewMode === 'trash') return false;
|
||||
try {
|
||||
await deleteNote(path);
|
||||
if (Object.hasOwn($noteOrder, path)) {
|
||||
const { [path]: _, ...rest } = $noteOrder;
|
||||
$noteOrder = rest;
|
||||
}
|
||||
$notes = $notes.filter((note) => note.path !== path);
|
||||
if ($activeNotePath === path) {
|
||||
$activeNote = null;
|
||||
$activeNotePath = null;
|
||||
}
|
||||
noteList?.refresh(true).catch((error) => console.error('Failed to refresh notes after trashing:', error));
|
||||
if (isMobile) $mobileView = 'notelist';
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to move open note to Trash:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
|
||||
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
|
||||
@@ -433,7 +509,7 @@
|
||||
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||
e.preventDefault();
|
||||
noteList?.trashActiveNote();
|
||||
editor?.moveOpenNoteToTrash();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,7 +527,8 @@
|
||||
createAndFocusNote();
|
||||
return;
|
||||
case 'toggle-theme': {
|
||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const customTheme = $customThemes.find(theme => theme.id === $resolvedTheme);
|
||||
const isDark = darkThemes.includes($resolvedTheme) || (customTheme?.is_dark ?? false);
|
||||
const next = isDark ? 'light' : 'dark';
|
||||
$theme = next;
|
||||
setTheme(next);
|
||||
@@ -509,23 +586,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
applyTheme($theme);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$collapsedNotebooks;
|
||||
persistState();
|
||||
@@ -599,18 +659,14 @@
|
||||
lastTag = state.last_tag ?? null;
|
||||
} catch (_) {}
|
||||
|
||||
const restoreLastSession = $appConfig?.restore_last_session === true;
|
||||
|
||||
// On mobile, prefetch last-opened note so first tap is instant
|
||||
let prefetchPromise: Promise<any> | null = null;
|
||||
if (isMobile && lastNotePath) {
|
||||
if (isMobile && restoreLastSession && lastNotePath) {
|
||||
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
||||
}
|
||||
|
||||
applyTheme($theme);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if ($theme === 'system') applyTheme('system');
|
||||
});
|
||||
|
||||
// Run sidebar and note list refresh in parallel
|
||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||
|
||||
@@ -637,32 +693,26 @@
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Restore the last session (view + open note) if enabled.
|
||||
if ($appConfig?.restore_last_session) {
|
||||
const vault = $appConfig?.active_vault;
|
||||
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
|
||||
$activeTag = null;
|
||||
await noteList?.refresh();
|
||||
} else if (lastViewMode === 'notebook' && lastNotebook) {
|
||||
const nb = findNotebookByPath($notebooks, lastNotebook);
|
||||
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
|
||||
} else if (lastViewMode === 'tag' && lastTag) {
|
||||
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
|
||||
} else if (lastViewMode === 'quickaccess') {
|
||||
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
|
||||
}
|
||||
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
|
||||
if (!isMobile && lastNotePath) {
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
$activeNotePath = lastNotePath;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
const startupTarget = resolveStartupTarget({
|
||||
startupView: $appConfig?.startup_view,
|
||||
restoreLastSession,
|
||||
lastViewMode,
|
||||
lastNotebook,
|
||||
lastTag
|
||||
});
|
||||
if (!(await applyStartupTarget(startupTarget))) {
|
||||
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
|
||||
}
|
||||
|
||||
// Reopen the last note only when session restoration is enabled.
|
||||
if (restoreLastSession && !isMobile && lastNotePath) {
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
$activeNotePath = lastNotePath;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
||||
@@ -779,7 +829,7 @@
|
||||
<!-- Mobile Header -->
|
||||
<div class="mobile-header">
|
||||
{#if $mobileView !== 'sidebar'}
|
||||
<button class="mobile-header-btn" onclick={mobileBack}>
|
||||
<button class="mobile-header-btn" onclick={mobileBack} aria-label="Go back">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
@@ -918,10 +968,10 @@
|
||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||
<Editor bind:this={editor} />
|
||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -965,7 +1015,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
|
||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} onSelectNote={selectNoteFromSwitcher} />
|
||||
{/if}
|
||||
<div class="app-layout">
|
||||
{#if !$focusMode}
|
||||
@@ -979,7 +1029,7 @@
|
||||
|
||||
{#if !$notelistCollapsed}
|
||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
@@ -998,7 +1048,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="editor-panel">
|
||||
<Editor bind:this={editor} />
|
||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||
{#if $viewMode === 'tasks' && !taskNoteOpened}
|
||||
<div class="tasks-editor-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
action: () => {
|
||||
$theme = 'system';
|
||||
setTheme('system');
|
||||
applyTheme('system');
|
||||
$showCommandPalette = false;
|
||||
}
|
||||
},
|
||||
@@ -187,7 +186,7 @@
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
} else if (t === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
+714
-515
File diff suppressed because it is too large
Load Diff
@@ -20,12 +20,12 @@
|
||||
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
|
||||
interface LabelRect { x: number; y: number; w: number; h: number; }
|
||||
|
||||
let nodes: GraphNode[] = [];
|
||||
let edges: GraphEdge[] = [];
|
||||
let nodes = $state.raw<GraphNode[]>([]);
|
||||
let edges = $state.raw<GraphEdge[]>([]);
|
||||
let nodeIndexMap: Map<string, number> = new Map();
|
||||
let connectedSet: Set<number> = new Set();
|
||||
let nodeDegree: number[] = [];
|
||||
let searchMatchSet: Set<number> | null = null;
|
||||
let searchMatchSet = $state.raw<Set<number> | null>(null);
|
||||
let folderColorMap = new Map<string, string>();
|
||||
|
||||
let pan = { x: 0, y: 0 };
|
||||
@@ -40,7 +40,7 @@
|
||||
let hoveredNeighborSet: Set<number> = new Set();
|
||||
let hoveredEdgeSet: Set<number> = new Set();
|
||||
let glowPhase = 0;
|
||||
let activeNodeIdx = -1;
|
||||
let activeNodeIdx = $state(-1);
|
||||
let navigatedFromGraph = false;
|
||||
|
||||
// Combined animation loop
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { src, alt = '', onclose }: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 5;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const TAP_MOVE_THRESHOLD = 6;
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type ViewerGesture =
|
||||
| { kind: 'pan'; start: Point; pan: Point }
|
||||
| { kind: 'pinch'; distance: number; imagePoint: Point };
|
||||
|
||||
let canvasElement = $state<HTMLDivElement | null>(null);
|
||||
let imageElement = $state<HTMLImageElement | null>(null);
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
let scale = $state(1);
|
||||
let panX = $state(0);
|
||||
let panY = $state(0);
|
||||
let interacting = $state(false);
|
||||
let gesture: ViewerGesture | null = null;
|
||||
let touchMoved = false;
|
||||
let lastTapAt = 0;
|
||||
let zoomPercent = $derived(Math.round(scale * 100));
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function midpoint(first: Touch, second: Touch): Point {
|
||||
return {
|
||||
x: (first.clientX + second.clientX) / 2,
|
||||
y: (first.clientY + second.clientY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function touchDistance(first: Touch, second: Touch): number {
|
||||
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
|
||||
}
|
||||
|
||||
function panLimits(nextScale: number): Point {
|
||||
const canvasWidth = canvasElement?.clientWidth ?? window.innerWidth;
|
||||
const canvasHeight = canvasElement?.clientHeight ?? window.innerHeight;
|
||||
const imageWidth = imageElement?.clientWidth ?? 0;
|
||||
const imageHeight = imageElement?.clientHeight ?? 0;
|
||||
return {
|
||||
x: Math.max(0, (imageWidth * nextScale - canvasWidth) / 2),
|
||||
y: Math.max(0, (imageHeight * nextScale - canvasHeight) / 2),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTransform(nextScale: number, nextPanX: number, nextPanY: number) {
|
||||
const clampedScale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
||||
const limits = panLimits(clampedScale);
|
||||
scale = clampedScale;
|
||||
panX = clamp(nextPanX, -limits.x, limits.x);
|
||||
panY = clamp(nextPanY, -limits.y, limits.y);
|
||||
}
|
||||
|
||||
function resetViewer() {
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
applyTransform(MIN_SCALE, 0, 0);
|
||||
}
|
||||
|
||||
function zoomAt(point: Point, nextScale: number) {
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
const imageX = (point.x - centerX - panX) / scale;
|
||||
const imageY = (point.y - centerY - panY) / scale;
|
||||
applyTransform(
|
||||
nextScale,
|
||||
point.x - centerX - imageX * nextScale,
|
||||
point.y - centerY - imageY * nextScale,
|
||||
);
|
||||
}
|
||||
|
||||
function beginPan(touch: Touch) {
|
||||
gesture = {
|
||||
kind: 'pan',
|
||||
start: { x: touch.clientX, y: touch.clientY },
|
||||
pan: { x: panX, y: panY },
|
||||
};
|
||||
}
|
||||
|
||||
function beginPinch(first: Touch, second: Touch) {
|
||||
const point = midpoint(first, second);
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
gesture = {
|
||||
kind: 'pinch',
|
||||
distance: Math.max(1, touchDistance(first, second)),
|
||||
imagePoint: {
|
||||
x: (point.x - centerX - panX) / scale,
|
||||
y: (point.y - centerY - panY) / scale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
interacting = true;
|
||||
if (event.touches.length >= 2) {
|
||||
touchMoved = true;
|
||||
beginPinch(event.touches[0], event.touches[1]);
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1) {
|
||||
touchMoved = false;
|
||||
beginPan(event.touches[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
if (event.touches.length >= 2) {
|
||||
if (gesture?.kind !== 'pinch') beginPinch(event.touches[0], event.touches[1]);
|
||||
if (gesture?.kind !== 'pinch') return;
|
||||
const point = midpoint(event.touches[0], event.touches[1]);
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
const nextScale = clamp(
|
||||
scale * (touchDistance(event.touches[0], event.touches[1]) / gesture.distance),
|
||||
MIN_SCALE,
|
||||
MAX_SCALE,
|
||||
);
|
||||
gesture.distance = Math.max(1, touchDistance(event.touches[0], event.touches[1]));
|
||||
applyTransform(
|
||||
nextScale,
|
||||
point.x - centerX - gesture.imagePoint.x * nextScale,
|
||||
point.y - centerY - gesture.imagePoint.y * nextScale,
|
||||
);
|
||||
touchMoved = true;
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1 && gesture?.kind === 'pan') {
|
||||
const dx = event.touches[0].clientX - gesture.start.x;
|
||||
const dy = event.touches[0].clientY - gesture.start.y;
|
||||
if (Math.hypot(dx, dy) > TAP_MOVE_THRESHOLD) touchMoved = true;
|
||||
applyTransform(scale, gesture.pan.x + dx, gesture.pan.y + dy);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
if (event.touches.length >= 2) {
|
||||
beginPinch(event.touches[0], event.touches[1]);
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1) {
|
||||
beginPan(event.touches[0]);
|
||||
touchMoved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
if (!touchMoved && event.changedTouches.length > 0) {
|
||||
const now = performance.now();
|
||||
const touch = event.changedTouches[0];
|
||||
if (now - lastTapAt <= DOUBLE_TAP_MS) {
|
||||
if (scale > MIN_SCALE + 0.01) resetViewer();
|
||||
else zoomAt({ x: touch.clientX, y: touch.clientY }, 2);
|
||||
lastTapAt = 0;
|
||||
} else {
|
||||
lastTapAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchCancel(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
touchMoved = false;
|
||||
applyTransform(scale, panX, panY);
|
||||
}
|
||||
|
||||
function attachTouchGestures(node: HTMLElement) {
|
||||
node.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
node.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
node.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||
node.addEventListener('touchcancel', handleTouchCancel, { passive: false });
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('touchstart', handleTouchStart);
|
||||
node.removeEventListener('touchmove', handleTouchMove);
|
||||
node.removeEventListener('touchend', handleTouchEnd);
|
||||
node.removeEventListener('touchcancel', handleTouchCancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
closeButton?.focus();
|
||||
const handleResize = () => applyTransform(scale, panX, panY);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="image-viewer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt ? `Image viewer: ${alt}` : 'Image viewer'}
|
||||
tabindex="-1"
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onclose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="image-viewer-canvas"
|
||||
bind:this={canvasElement}
|
||||
use:attachTouchGestures
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
onkeydown={(event) => {
|
||||
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) onclose();
|
||||
}}
|
||||
>
|
||||
<img
|
||||
bind:this={imageElement}
|
||||
class:interacting
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable="false"
|
||||
style:transform={`translate3d(${panX}px, ${panY}px, 0) scale(${scale})`}
|
||||
onload={resetViewer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="image-viewer-reset"
|
||||
onclick={resetViewer}
|
||||
disabled={scale === MIN_SCALE && panX === 0 && panY === 0}
|
||||
aria-label={`Reset zoom, currently ${zoomPercent}%`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||
<path d="M3 4v6h6" />
|
||||
</svg>
|
||||
<span>{zoomPercent}%</span>
|
||||
</button>
|
||||
<button bind:this={closeButton} type="button" class="image-viewer-close" onclick={onclose} aria-label="Close image viewer">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer-hint" aria-hidden="true">Pinch to zoom · drag to move · double-tap to zoom or reset</div>
|
||||
<span class="sr-only" aria-live="polite">Zoom {zoomPercent}%</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.image-viewer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: #06080c;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.image-viewer-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-canvas img {
|
||||
display: block;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100dvh - 24px);
|
||||
object-fit: contain;
|
||||
transform-origin: center center;
|
||||
transition: transform 160ms ease-out;
|
||||
will-change: transform;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-canvas img.interacting {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.image-viewer-controls {
|
||||
position: absolute;
|
||||
inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) auto max(12px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-viewer-controls button {
|
||||
min-height: 44px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(20, 23, 30, 0.86);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-viewer-reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.image-viewer-reset:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.image-viewer-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.image-viewer-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: max(18px, calc(env(safe-area-inset-bottom) + 10px));
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(20, 23, 30, 0.82);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.image-viewer-canvas img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { showInfo, appConfig } from '$lib/stores/app';
|
||||
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import type { OrphanAttachment } from '$lib/api';
|
||||
import { showInfo } from '$lib/stores/app';
|
||||
import { openUrl } from '$lib/api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { VaultStats } from '$lib/types';
|
||||
import {
|
||||
ACTIONS,
|
||||
keybindings,
|
||||
@@ -84,13 +81,8 @@
|
||||
if (capturingId === id) stopCapture();
|
||||
}
|
||||
|
||||
let stats = $state<VaultStats | null>(null);
|
||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||
let appVersion = $state('...');
|
||||
let orphans = $state<OrphanAttachment[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
let trashing = $state(false);
|
||||
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
|
||||
|
||||
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
||||
|
||||
@@ -99,52 +91,24 @@
|
||||
activeTab = isMobile ? 'about' : 'shortcuts';
|
||||
}
|
||||
|
||||
function closeFromOverlay(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
openUrl(url).catch(console.error);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($showInfo) {
|
||||
getVaultStats().then((s) => { stats = s; }).catch(console.error);
|
||||
} else {
|
||||
stats = null;
|
||||
orphans = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function scanOrphans() {
|
||||
scanning = true;
|
||||
try { orphans = await findOrphanedAttachments(); }
|
||||
catch (e) { console.error(e); }
|
||||
finally { scanning = false; }
|
||||
}
|
||||
async function trashOrphans() {
|
||||
if (!orphans || orphans.length === 0) return;
|
||||
trashing = true;
|
||||
try {
|
||||
await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||
orphans = null;
|
||||
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { trashing = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $showInfo}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="info-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="info-overlay" onclick={closeFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||
<div class="info-panel">
|
||||
<div class="info-header">
|
||||
<h2>Info</h2>
|
||||
<button class="close-btn" onclick={close}>
|
||||
<button class="close-btn" onclick={close} aria-label="Close info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
@@ -176,49 +140,7 @@
|
||||
<p class="app-version">v{appVersion}</p>
|
||||
<p class="app-description">A local markdown note-taking app.</p>
|
||||
|
||||
{#if stats}
|
||||
<div class="info-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Notes</span>
|
||||
<span class="stat-value">{stats.total_notes}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Attachments</span>
|
||||
<span class="stat-value">{stats.total_attachments}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Notes size</span>
|
||||
<span class="stat-value">{formatSize(stats.notes_size)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Attachments size</span>
|
||||
<span class="stat-value">{formatSize(stats.attachments_size)}</span>
|
||||
</div>
|
||||
<div class="stat-row stat-total">
|
||||
<span class="stat-label">Total vault size</span>
|
||||
<span class="stat-value">{formatSize(stats.total_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stats}
|
||||
<div class="info-cleanup">
|
||||
{#if orphans === null}
|
||||
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
|
||||
{:else if orphans.length === 0}
|
||||
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
|
||||
{:else}
|
||||
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
|
||||
<div class="cleanup-list">
|
||||
{#each orphans as o}
|
||||
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
|
||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info-credits">
|
||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||
@@ -273,6 +195,7 @@
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Collapsible section</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>.</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
|
||||
@@ -416,39 +339,6 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.info-stats {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 7px 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.stat-total .stat-value {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.info-credits {
|
||||
margin-top: 20px;
|
||||
@@ -480,71 +370,6 @@
|
||||
.info-link:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
.info-cleanup {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cleanup-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.cleanup-msg {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.cleanup-list {
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cleanup-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cleanup-name {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cleanup-size {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cleanup-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
.spinner-icon {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.shortcuts-section {
|
||||
width: 100%;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
getNotes,
|
||||
readNote,
|
||||
createNote,
|
||||
duplicateNote,
|
||||
deleteNote,
|
||||
renameNote,
|
||||
saveNote,
|
||||
@@ -36,20 +37,22 @@
|
||||
reorderQuickAccess,
|
||||
moveNote,
|
||||
getAllTags,
|
||||
createDailyNote
|
||||
createDailyNote,
|
||||
revealFile
|
||||
} from '$lib/api';
|
||||
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||
import TasksView from './TasksView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
import { isMobile, isAndroid } from '$lib/platform';
|
||||
|
||||
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
||||
onNoteMoved?: () => void;
|
||||
onBeforeNoteSwitch?: () => void;
|
||||
onBeforeNoteDuplicate?: () => Promise<boolean>;
|
||||
onNoteCreated?: () => void;
|
||||
onToggleTask?: (t: TaskItem) => Promise<void>;
|
||||
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
||||
@@ -265,6 +268,19 @@
|
||||
if (listContainer) listContainer.scrollTop = 0;
|
||||
});
|
||||
|
||||
// Notes can also be removed from this list by a drop handled in the sidebar.
|
||||
$effect(() => {
|
||||
if (selectedPaths.size === 0) return;
|
||||
const availablePaths = new Set($notes.map((note) => note.path));
|
||||
const remaining = new Set([...selectedPaths].filter((path) => availablePaths.has(path)));
|
||||
if (remaining.size === selectedPaths.size) return;
|
||||
if (remaining.size === 0) {
|
||||
clearSelection();
|
||||
} else {
|
||||
selectedPaths = remaining;
|
||||
}
|
||||
});
|
||||
|
||||
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
|
||||
$effect(() => {
|
||||
$quickAccessPaths;
|
||||
@@ -454,6 +470,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
try {
|
||||
if ($activeNotePath === note.path && $editorDirty) {
|
||||
const saved = await onBeforeNoteDuplicate();
|
||||
if (!saved) {
|
||||
console.error('Failed to save note before duplicating');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const entry = await duplicateNote(note.path);
|
||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||
noteCache.clear();
|
||||
if ($viewMode !== 'quickaccess') $notes = [entry, ...$notes];
|
||||
await selectNote(entry);
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleListDoubleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
||||
@@ -505,12 +542,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Move the currently open note to the trash (keyboard shortcut from the editor).
|
||||
export async function trashActiveNote() {
|
||||
if ($viewMode === 'trash' || !$activeNotePath) return;
|
||||
const note = $notes.find(n => n.path === $activeNotePath);
|
||||
if (note) await handleDelete(note);
|
||||
}
|
||||
|
||||
async function handleRestore(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
@@ -935,11 +966,21 @@
|
||||
await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e))));
|
||||
}
|
||||
|
||||
function handleWindowClick() {
|
||||
function handleWindowClick(event: MouseEvent) {
|
||||
const target = event.target as Element | null;
|
||||
if (target?.closest('.context-menu, .sort-menu')) return;
|
||||
if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; }
|
||||
if (sortMenu) sortMenu = null;
|
||||
}
|
||||
|
||||
function closeBatchMoveOverlay(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) batchMovePicker = false;
|
||||
}
|
||||
|
||||
function closeBatchTagOverlay(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) batchTagEdit = false;
|
||||
}
|
||||
|
||||
function openSortMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
@@ -1045,12 +1086,11 @@
|
||||
|
||||
{#if batchMovePicker}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="batch-move-overlay" onclick={() => batchMovePicker = false}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="batch-move-overlay" onclick={closeBatchMoveOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchMovePicker = false; }}>
|
||||
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-move-title" tabindex="-1">
|
||||
<div class="batch-move-header">
|
||||
<span>Move {selectedPaths.size} notes to...</span>
|
||||
<button class="selection-close" onclick={() => batchMovePicker = false}>
|
||||
<span id="batch-move-title">Move {selectedPaths.size} notes to...</span>
|
||||
<button class="selection-close" onclick={() => batchMovePicker = false} aria-label="Close move picker">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1074,12 +1114,11 @@
|
||||
|
||||
{#if batchTagEdit && !contextMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="batch-move-overlay" onclick={closeBatchTagOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchTagEdit = false; }}>
|
||||
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-tags-title" tabindex="-1">
|
||||
<div class="batch-move-header">
|
||||
<span>Tags for {selectedPaths.size} notes</span>
|
||||
<button class="selection-close" onclick={() => batchTagEdit = false}>
|
||||
<span id="batch-tags-title">Tags for {selectedPaths.size} notes</span>
|
||||
<button class="selection-close" onclick={() => batchTagEdit = false} aria-label="Close tag editor">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1259,11 +1298,10 @@
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
return;
|
||||
}
|
||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
||||
} else {
|
||||
e.dataTransfer!.setData('text/plain', note.path);
|
||||
}
|
||||
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
|
||||
? selectedPaths
|
||||
: [note.path];
|
||||
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
}}
|
||||
ondragover={(e) => {
|
||||
@@ -1378,7 +1416,7 @@
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" role="group" aria-label="Note actions">
|
||||
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
||||
<!-- Batch context menu -->
|
||||
{#if $viewMode === 'trash'}
|
||||
@@ -1529,7 +1567,7 @@
|
||||
Open in New Window
|
||||
</button>
|
||||
{#if !isMobile}
|
||||
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealItemInDir(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
|
||||
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealFile(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
@@ -1542,6 +1580,12 @@
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={() => handleDuplicate(contextMenu!.note)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v9a2 2 0 002 2h3"/>
|
||||
</svg>
|
||||
Duplicate Note
|
||||
</button>
|
||||
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
|
||||
<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 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||
@@ -1575,8 +1619,7 @@
|
||||
{/if}
|
||||
|
||||
{#if sortMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="sort-menu" style="left: {sortMenu.x}px; top: {sortMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<div class="sort-menu" class:mobile={isMobile} style="left: {sortMenu.x}px; top: {sortMenu.y}px">
|
||||
<div class="sort-menu-title">Sort by</div>
|
||||
<button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -2284,26 +2327,28 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu {
|
||||
.context-menu.mobile {
|
||||
min-width: 220px;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu button {
|
||||
.context-menu.mobile button {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.note-list.mobile .sort-menu {
|
||||
.sort-menu.mobile {
|
||||
min-width: 220px;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.note-list.mobile .sort-menu button {
|
||||
.sort-menu.mobile button {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
@@ -2327,26 +2372,6 @@
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Mobile selection checkboxes */
|
||||
.mobile-select-check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.mobile-select-check.checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Mobile create note button (round, accent-colored) */
|
||||
.mobile-create-btn {
|
||||
background: var(--accent);
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
|
||||
import { getNoteSwitcherTitles, getQuickAccess } from '$lib/api';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import {
|
||||
buildNoteSwitcherRequestPaths,
|
||||
buildNoteSwitcherSections,
|
||||
type NoteSwitcherNote,
|
||||
type NoteSwitcherRow,
|
||||
type NoteSwitcherSections
|
||||
} from '$lib/utils/note-switcher';
|
||||
import type { NoteEntry, NoteTitleEntry } from '$lib/types';
|
||||
|
||||
let { onSelectNote = async () => false }: {
|
||||
onSelectNote?: (path: string) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
let wrapper: HTMLDivElement;
|
||||
let trigger: HTMLButtonElement;
|
||||
let popover = $state<HTMLDivElement | null>(null);
|
||||
let open = $state(false);
|
||||
let loading = $state(false);
|
||||
let selectingPath = $state<string | null>(null);
|
||||
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
|
||||
let loadGeneration = 0;
|
||||
let loadedVaultPath = $state<string | null | undefined>(undefined);
|
||||
|
||||
function normalizedPath(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isInsideVault(path: string, vaultPath: string): boolean {
|
||||
return normalizedPath(path).startsWith(`${normalizedPath(vaultPath)}/`);
|
||||
}
|
||||
|
||||
function vaultRelativePath(path: string, vaultPath: string): string {
|
||||
return normalizedPath(path).slice(normalizedPath(vaultPath).length + 1);
|
||||
}
|
||||
|
||||
function joinVaultPath(vaultPath: string, relativePath: string): string {
|
||||
const separator = vaultPath.includes('\\') && !vaultPath.includes('/') ? '\\' : '/';
|
||||
const root = vaultPath.replace(/[\\/]+$/, '');
|
||||
return `${root}${separator}${relativePath.replace(/[\\/]/g, separator)}`;
|
||||
}
|
||||
|
||||
function titleEntryToNote(entry: NoteTitleEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||
const relativePath = entry.path.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!relativePath || relativePath.split('/').includes('..')) return null;
|
||||
const path = joinVaultPath(vaultPath, relativePath);
|
||||
if (!isInsideVault(path, vaultPath)) return null;
|
||||
return { path, title: entry.title, relativePath };
|
||||
}
|
||||
|
||||
function quickAccessEntryToNote(entry: NoteEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||
if (!isInsideVault(entry.path, vaultPath)) return null;
|
||||
return {
|
||||
path: entry.path,
|
||||
title: entry.meta.title,
|
||||
relativePath: entry.relative_path.replace(/\\/g, '/')
|
||||
};
|
||||
}
|
||||
|
||||
async function focusInitialRow() {
|
||||
await tick();
|
||||
if (!open) return;
|
||||
const current = wrapper.querySelector<HTMLButtonElement>('.note-primary[aria-current="true"]');
|
||||
const first = wrapper.querySelector<HTMLButtonElement>('.note-primary');
|
||||
(current ?? first ?? popover)?.focus();
|
||||
}
|
||||
|
||||
async function refreshSections(generation: number) {
|
||||
const vaultPath = $appConfig?.active_vault;
|
||||
if (!vaultPath) {
|
||||
sections = { recent: [], quickAccess: [] };
|
||||
loadedVaultPath = null;
|
||||
loading = false;
|
||||
await focusInitialRow();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestPaths = buildNoteSwitcherRequestPaths($activeNotePath, $navHistory.stack);
|
||||
const [titlesResult, quickAccessResult] = await Promise.allSettled([
|
||||
getNoteSwitcherTitles(requestPaths),
|
||||
getQuickAccess()
|
||||
]);
|
||||
if (!open || generation !== loadGeneration) return;
|
||||
|
||||
if (titlesResult.status === 'rejected') {
|
||||
console.error('Failed to load notes for note switcher:', titlesResult.reason);
|
||||
}
|
||||
if (quickAccessResult.status === 'rejected') {
|
||||
console.error('Failed to load Quick Access for note switcher:', quickAccessResult.reason);
|
||||
}
|
||||
|
||||
const knownNotes = titlesResult.status === 'fulfilled'
|
||||
? titlesResult.value
|
||||
.map((entry) => titleEntryToNote(entry, vaultPath))
|
||||
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||
: [];
|
||||
const currentPath = $activeNotePath;
|
||||
if (currentPath && isInsideVault(currentPath, vaultPath)) {
|
||||
const currentKey = normalizedPath(currentPath);
|
||||
if (!knownNotes.some((note) => normalizedPath(note.path) === currentKey)) {
|
||||
knownNotes.push({
|
||||
path: currentPath,
|
||||
title: $activeNote?.meta.title || currentPath.split(/[\\/]/).pop()?.replace(/\.md$/i, '') || 'Untitled',
|
||||
relativePath: vaultRelativePath(currentPath, vaultPath)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const quickAccessNotes = quickAccessResult.status === 'fulfilled'
|
||||
? quickAccessResult.value
|
||||
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
|
||||
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||
: [];
|
||||
for (const note of quickAccessNotes) {
|
||||
if (!knownNotes.some((knownNote) => normalizedPath(knownNote.path) === normalizedPath(note.path))) {
|
||||
knownNotes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
sections = buildNoteSwitcherSections({
|
||||
currentPath,
|
||||
historyPaths: $navHistory.stack,
|
||||
knownNotes,
|
||||
quickAccessNotes
|
||||
});
|
||||
loadedVaultPath = vaultPath;
|
||||
loading = false;
|
||||
await focusInitialRow();
|
||||
}
|
||||
|
||||
function refreshAfterPopoverPaint(generation: number) {
|
||||
requestAnimationFrame(() => {
|
||||
window.setTimeout(() => {
|
||||
if (!open || generation !== loadGeneration) return;
|
||||
void refreshSections(generation);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSwitcher() {
|
||||
if (open) {
|
||||
open = false;
|
||||
loadGeneration += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultPath = $appConfig?.active_vault ?? null;
|
||||
const hasCachedSections = loadedVaultPath === vaultPath;
|
||||
open = true;
|
||||
loading = !hasCachedSections;
|
||||
selectingPath = null;
|
||||
if (!hasCachedSections) sections = { recent: [], quickAccess: [] };
|
||||
loadGeneration += 1;
|
||||
const generation = loadGeneration;
|
||||
if (hasCachedSections) void focusInitialRow();
|
||||
refreshAfterPopoverPaint(generation);
|
||||
}
|
||||
|
||||
async function closeSwitcher(restoreTriggerFocus: boolean) {
|
||||
open = false;
|
||||
loadGeneration += 1;
|
||||
if (restoreTriggerFocus) {
|
||||
await tick();
|
||||
trigger?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectNote(row: NoteSwitcherRow) {
|
||||
if (selectingPath !== null) return;
|
||||
selectingPath = row.path;
|
||||
try {
|
||||
if (await onSelectNote(row.path)) await closeSwitcher(false);
|
||||
} finally {
|
||||
selectingPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePopoverKeydown(event: KeyboardEvent) {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
const buttons = Array.from(wrapper.querySelectorAll<HTMLButtonElement>('.note-primary:not(:disabled)'));
|
||||
if (buttons.length === 0) return;
|
||||
event.preventDefault();
|
||||
const activeRow = (document.activeElement as HTMLElement | null)?.closest('.note-row');
|
||||
const activeButton = activeRow?.querySelector<HTMLButtonElement>('.note-primary') ?? null;
|
||||
const currentIndex = activeButton ? buttons.indexOf(activeButton) : -1;
|
||||
let nextIndex: number;
|
||||
if (event.key === 'Home') nextIndex = 0;
|
||||
else if (event.key === 'End') nextIndex = buttons.length - 1;
|
||||
else if (event.key === 'ArrowDown') nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
|
||||
else nextIndex = currentIndex < 0 ? buttons.length - 1 : (currentIndex - 1 + buttons.length) % buttons.length;
|
||||
buttons[nextIndex].focus();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const handleOutsideMouseDown = (event: MouseEvent) => {
|
||||
if (!wrapper.contains(event.target as Node)) void closeSwitcher(false);
|
||||
};
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void closeSwitcher(true);
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideMouseDown, true);
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideMouseDown, true);
|
||||
document.removeEventListener('keydown', handleEscape, true);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="note-switcher" bind:this={wrapper}>
|
||||
<button
|
||||
bind:this={trigger}
|
||||
class="switcher-trigger"
|
||||
type="button"
|
||||
aria-label="Switch note"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onclick={toggleSwitcher}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="7" y="4" width="12" height="14" rx="2" />
|
||||
<path d="M5 7H4a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
bind:this={popover}
|
||||
class="switcher-popover"
|
||||
role="dialog"
|
||||
aria-label="Note switcher"
|
||||
tabindex="-1"
|
||||
onkeydown={handlePopoverKeydown}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="switcher-status">Loading notes…</div>
|
||||
{:else if sections.recent.length === 0 && sections.quickAccess.length === 0}
|
||||
<div class="switcher-status">No notes available</div>
|
||||
{:else}
|
||||
{#each [
|
||||
{ label: 'Recent', rows: sections.recent },
|
||||
{ label: 'Quick Access', rows: sections.quickAccess }
|
||||
] as section (section.label)}
|
||||
{#if section.rows.length > 0}
|
||||
<section class="switcher-section" aria-labelledby={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>
|
||||
<h2 id={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>{section.label}</h2>
|
||||
{#each section.rows as row (row.path)}
|
||||
<div class="note-row" class:current-row={row.current}>
|
||||
{#if row.current}<span class="current-marker" aria-hidden="true"></span>{/if}
|
||||
<button
|
||||
class="note-primary"
|
||||
type="button"
|
||||
aria-current={row.current ? 'true' : undefined}
|
||||
disabled={selectingPath !== null}
|
||||
onclick={() => selectNote(row)}
|
||||
>
|
||||
<span class="note-title">{row.title}</span>
|
||||
<span class="note-folder">{row.folder}</span>
|
||||
</button>
|
||||
<button
|
||||
class="open-window"
|
||||
type="button"
|
||||
aria-label={`Open ${row.title} in new window`}
|
||||
title={`Open ${row.title} in new window`}
|
||||
onclick={() => openNoteWindow(row.path, row.title)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="4" y="4" width="13" height="13" rx="2" />
|
||||
<path d="M10 14L20 4m-6 0h6v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.note-switcher {
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.switcher-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.switcher-trigger:hover,
|
||||
.switcher-trigger[aria-expanded='true'] {
|
||||
background: var(--bg-tertiary, var(--bg-hover));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.switcher-trigger:focus-visible,
|
||||
.note-primary:focus-visible,
|
||||
.open-window:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.switcher-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
max-height: min(420px, calc(100vh - 58px));
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
|
||||
color: var(--text-primary);
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.switcher-section + .switcher-section {
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
padding: 5px 8px 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.note-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.note-row:hover,
|
||||
.note-row:focus-within {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.note-row.current-row {
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.current-marker {
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.note-primary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 6px 38px 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.current-row .note-primary {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.note-primary:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.note-title,
|
||||
.note-folder {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.note-folder {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.open-window {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.note-row:hover .open-window,
|
||||
.note-row:focus-within .open-window {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.open-window:hover {
|
||||
background: var(--bg-tertiary, var(--bg-hover));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.switcher-status {
|
||||
padding: 22px 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import Editor from './Editor.svelte';
|
||||
import {
|
||||
appConfig,
|
||||
activeNote,
|
||||
activeNotePath,
|
||||
editorDirty,
|
||||
readOnly,
|
||||
sourceMode,
|
||||
theme
|
||||
sourceMode
|
||||
} from '$lib/stores/app';
|
||||
import { readNote } from '$lib/api';
|
||||
import { keybindings, matchAction } from '$lib/keybindings';
|
||||
@@ -17,12 +18,12 @@
|
||||
|
||||
let { notePath }: { notePath: string } = $props();
|
||||
|
||||
import { darkThemes } from '$lib/platform';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const appWebview = getCurrentWebview();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
let editor = $state<Editor>(null!);
|
||||
let unlistenFileChange: (() => void) | null = null;
|
||||
let unlistenUiScale: (() => void) | null = null;
|
||||
let maximized = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
@@ -36,20 +37,6 @@
|
||||
return () => { unlisten.then(fn => fn()); };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const t = $theme || 'system';
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
});
|
||||
|
||||
let lastMouseDown = 0;
|
||||
const RESIZE_EDGE = 6;
|
||||
|
||||
@@ -84,7 +71,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUiScale(scale: number) {
|
||||
try {
|
||||
await appWebview.setZoom(scale);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply interface scale to note window:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
unlistenUiScale = await listen<number>('ui-scale-changed', (event) => {
|
||||
void applyUiScale(event.payload);
|
||||
});
|
||||
await applyUiScale($appConfig?.ui_scale ?? 1);
|
||||
|
||||
try {
|
||||
const content = await readNote(notePath);
|
||||
$activeNote = content;
|
||||
@@ -116,6 +116,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
unlistenFileChange?.();
|
||||
unlistenUiScale?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import type { NotebookIconId } from '$lib/utils/notebook-icons';
|
||||
|
||||
let { icon, size = 18 }: { icon: NotebookIconId; size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if icon === 'book'}
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" />
|
||||
{:else if icon === 'folder'}
|
||||
<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2.5h7.5A2.5 2.5 0 0 1 21 9v8.5a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z" />
|
||||
{:else if icon === 'briefcase'}
|
||||
<rect x="3" y="7" width="18" height="13" rx="2" />
|
||||
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18M10 12v2h4v-2" />
|
||||
{:else if icon === 'home'}
|
||||
<path d="m3 11 9-8 9 8" />
|
||||
<path d="M5 10v11h14V10M9 21v-7h6v7" />
|
||||
{:else if icon === 'star'}
|
||||
<path d="m12 2 3.1 6.3 6.9 1-5 4.8 1.2 6.9-6.2-3.3L5.8 21 7 14.1l-5-4.8 6.9-1Z" />
|
||||
{:else if icon === 'heart'}
|
||||
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z" />
|
||||
{:else if icon === 'lightbulb'}
|
||||
<path d="M9 18h6M10 22h4" />
|
||||
<path d="M8.2 15.5A7 7 0 1 1 15.8 15.5c-.7.5-.8 1.1-.8 2.5H9c0-1.4-.1-2-.8-2.5Z" />
|
||||
{:else if icon === 'code'}
|
||||
<path d="m8 9-4 3 4 3M16 9l4 3-4 3M14 5l-4 14" />
|
||||
{:else if icon === 'flask'}
|
||||
<path d="M9 3h6M10 3v6l-5.5 9.5A1.7 1.7 0 0 0 6 21h12a1.7 1.7 0 0 0 1.5-2.5L14 9V3" />
|
||||
<path d="M7 16h10" />
|
||||
{:else if icon === 'graduation'}
|
||||
<path d="m2 9 10-5 10 5-10 5Z" />
|
||||
<path d="M6 11.5V16c3.5 2.7 8.5 2.7 12 0v-4.5M22 9v6" />
|
||||
{:else if icon === 'calendar'}
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M16 3v4M8 3v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01" />
|
||||
{:else if icon === 'tasks'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="m7 8 1.5 1.5L11 7M13 9h4M7 14l1.5 1.5L11 13M13 15h4" />
|
||||
{:else if icon === 'archive'}
|
||||
<path d="M4 8v12h16V8M3 4h18v4H3ZM9 12h6" />
|
||||
{:else if icon === 'globe'}
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||
{:else if icon === 'plane'}
|
||||
<path d="M22 2 9.5 14.5M22 2l-7 20-4-8-8-4Z" />
|
||||
{:else}
|
||||
<path d="M12 3a9 9 0 1 0 0 18h1.5a1.5 1.5 0 0 0 0-3H12a1.5 1.5 0 0 1 0-3h3a6 6 0 0 0 0-12Z" />
|
||||
<path d="M7.5 10h.01M9.5 6.5h.01M14.5 6.5h.01M17 10h.01" />
|
||||
{/if}
|
||||
</svg>
|
||||
@@ -30,14 +30,14 @@
|
||||
|
||||
{#if appWindow && !maximized}
|
||||
<div class="resize-layer" aria-hidden="true">
|
||||
<div class="rz rz-n" onmousedown={(e) => start(e, 'North')}></div>
|
||||
<div class="rz rz-s" onmousedown={(e) => start(e, 'South')}></div>
|
||||
<div class="rz rz-w" onmousedown={(e) => start(e, 'West')}></div>
|
||||
<div class="rz rz-e" onmousedown={(e) => start(e, 'East')}></div>
|
||||
<div class="rz rz-nw" onmousedown={(e) => start(e, 'NorthWest')}></div>
|
||||
<div class="rz rz-ne" onmousedown={(e) => start(e, 'NorthEast')}></div>
|
||||
<div class="rz rz-sw" onmousedown={(e) => start(e, 'SouthWest')}></div>
|
||||
<div class="rz rz-se" onmousedown={(e) => start(e, 'SouthEast')}></div>
|
||||
<div class="rz rz-n" role="presentation" onmousedown={(e) => start(e, 'North')}></div>
|
||||
<div class="rz rz-s" role="presentation" onmousedown={(e) => start(e, 'South')}></div>
|
||||
<div class="rz rz-w" role="presentation" onmousedown={(e) => start(e, 'West')}></div>
|
||||
<div class="rz rz-e" role="presentation" onmousedown={(e) => start(e, 'East')}></div>
|
||||
<div class="rz rz-nw" role="presentation" onmousedown={(e) => start(e, 'NorthWest')}></div>
|
||||
<div class="rz rz-ne" role="presentation" onmousedown={(e) => start(e, 'NorthEast')}></div>
|
||||
<div class="rz rz-sw" role="presentation" onmousedown={(e) => start(e, 'SouthWest')}></div>
|
||||
<div class="rz rz-se" role="presentation" onmousedown={(e) => start(e, 'SouthEast')}></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
|
||||
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setScrollToChangeFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { openUrl } from '$lib/api';
|
||||
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors } from '$lib/types';
|
||||
import type { OrphanAttachment } from '$lib/api';
|
||||
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView, VaultStats } from '$lib/types';
|
||||
import { normalizeStartupView } from '$lib/utils/startup-view';
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
|
||||
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
|
||||
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'maintenance' | 'ai' | 'sync' | 'updates';
|
||||
let activeTab = $state<Tab>('styling');
|
||||
|
||||
// Updates state
|
||||
@@ -115,6 +117,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Vault maintenance state
|
||||
let vaultStats = $state<VaultStats | null>(null);
|
||||
let vaultStatsError = $state(false);
|
||||
let orphanedAttachments = $state<OrphanAttachment[] | null>(null);
|
||||
let scanningOrphanedAttachments = $state(false);
|
||||
let trashingOrphanedAttachments = $state(false);
|
||||
const orphanedAttachmentTotal = $derived((orphanedAttachments ?? []).reduce((total, attachment) => total + attachment.size, 0));
|
||||
|
||||
$effect(() => {
|
||||
if (!$showSettings) {
|
||||
vaultStats = null;
|
||||
vaultStatsError = false;
|
||||
orphanedAttachments = null;
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'maintenance') void loadVaultStats();
|
||||
});
|
||||
|
||||
async function loadVaultStats() {
|
||||
vaultStatsError = false;
|
||||
try {
|
||||
vaultStats = await getVaultStats();
|
||||
} catch (error) {
|
||||
vaultStats = null;
|
||||
vaultStatsError = true;
|
||||
console.error('Failed to load vault statistics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatVaultSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function scanOrphanedAttachments() {
|
||||
scanningOrphanedAttachments = true;
|
||||
try {
|
||||
orphanedAttachments = await findOrphanedAttachments();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
scanningOrphanedAttachments = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function trashFoundOrphanedAttachments() {
|
||||
if (!orphanedAttachments || orphanedAttachments.length === 0) return;
|
||||
trashingOrphanedAttachments = true;
|
||||
try {
|
||||
await trashOrphanedAttachments(orphanedAttachments.map((attachment) => attachment.name));
|
||||
orphanedAttachments = null;
|
||||
void loadVaultStats();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
trashingOrphanedAttachments = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Backup state
|
||||
let backups = $state<BackupEntry[]>([]);
|
||||
let backupLoading = $state(false);
|
||||
@@ -417,11 +480,12 @@
|
||||
}
|
||||
|
||||
let isThemeDark = $derived(
|
||||
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||
($theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false))
|
||||
darkThemes.includes($resolvedTheme) ||
|
||||
($resolvedTheme.startsWith('custom-') && ($customThemes.find(c => c.id === $resolvedTheme)?.is_dark ?? false))
|
||||
);
|
||||
|
||||
const themePresets = [
|
||||
{ id: 'system', label: 'System', bg: '#ffffff', sidebar: '#1a1b26', accent: '#5b6abf' },
|
||||
{ id: 'light', label: 'Light', bg: '#ffffff', sidebar: '#f8f9fa', accent: '#5b6abf' },
|
||||
{ id: 'dark', label: 'Dark', bg: '#1a1b26', sidebar: '#1f2029', accent: '#89b4fa' },
|
||||
{ id: 'solarized-light', label: 'Solarized Light', bg: '#fdf6e3', sidebar: '#eee8d5', accent: '#268bd2' },
|
||||
@@ -519,6 +583,7 @@
|
||||
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
|
||||
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
|
||||
let activeFontSize = $state($appConfig?.font_size ?? 14);
|
||||
let scrollToChangeFontSize = $state($appConfig?.scroll_to_change_font_size ?? true);
|
||||
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
|
||||
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
|
||||
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
|
||||
@@ -526,7 +591,45 @@
|
||||
let themeDropdownOpen = $state(false);
|
||||
let accentDropdownOpen = $state(false);
|
||||
let activeCustomTheme = $derived($customThemes.find(c => c.id === $theme) ?? null);
|
||||
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
|
||||
let activeThemePreset = $derived(
|
||||
themePresets.find(p => p.id === $theme) ?? themePresets.find(p => p.id === 'light')!
|
||||
);
|
||||
|
||||
// Which theme each OS appearance maps to while `theme` is "system". Custom themes are offered
|
||||
// alongside the built-ins so a light/dark pair can be built out of them.
|
||||
let systemLightTheme = $state($appConfig?.system_light_theme ?? 'light');
|
||||
let systemDarkTheme = $state($appConfig?.system_dark_theme ?? 'dark');
|
||||
let systemPairOpen = $state<'light' | 'dark' | null>(null);
|
||||
|
||||
let pairPresets = $derived([
|
||||
...themePresets.filter(p => p.id !== 'system'),
|
||||
...$customThemes.map(ct => ({
|
||||
id: ct.id,
|
||||
label: ct.name,
|
||||
bg: ct.colors.bg_primary,
|
||||
sidebar: ct.colors.bg_secondary,
|
||||
accent: ct.colors.text_primary,
|
||||
})),
|
||||
]);
|
||||
|
||||
let systemPairRows = $derived([
|
||||
{ key: 'light' as const, title: 'Light appearance', selected: systemLightTheme },
|
||||
{ key: 'dark' as const, title: 'Dark appearance', selected: systemDarkTheme },
|
||||
]);
|
||||
|
||||
function selectSystemPairTheme(which: 'light' | 'dark', id: string) {
|
||||
if (which === 'light') systemLightTheme = id;
|
||||
else systemDarkTheme = id;
|
||||
systemPairOpen = null;
|
||||
if ($appConfig) {
|
||||
$appConfig.system_light_theme = systemLightTheme;
|
||||
$appConfig.system_dark_theme = systemDarkTheme;
|
||||
$appConfig = { ...$appConfig };
|
||||
}
|
||||
setSystemThemes(systemLightTheme, systemDarkTheme).catch((e) =>
|
||||
console.error('Failed to save system themes:', e)
|
||||
);
|
||||
}
|
||||
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
|
||||
let isCustomAccent = $derived(activeAccent.startsWith('#'));
|
||||
let activeAccentColor = $derived(
|
||||
@@ -552,7 +655,7 @@
|
||||
let customThemeImporting = $state(false);
|
||||
|
||||
function openNewCustomThemeEditor() {
|
||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) || $theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false);
|
||||
const isDark = isThemeDark;
|
||||
customThemeEditing = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: '',
|
||||
@@ -584,18 +687,20 @@
|
||||
root.style.setProperty('--text-primary', customThemeEditing.colors.text_primary);
|
||||
root.style.setProperty('--text-secondary', customThemeEditing.colors.text_secondary);
|
||||
root.style.setProperty('--border-color', customThemeEditing.colors.border_color);
|
||||
root.style.setProperty('--border-light', customThemeEditing.colors.border_color);
|
||||
root.style.setProperty('--text-tertiary', customThemeEditing.colors.text_secondary);
|
||||
root.classList.toggle('dark', customThemeEditing.is_dark);
|
||||
}
|
||||
|
||||
function restoreCurrentTheme() {
|
||||
const root = document.documentElement;
|
||||
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
|
||||
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color','--border-light','--text-tertiary'];
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
for (const v of varsToClear) root.style.removeProperty(v);
|
||||
const namedThemes = ['solarized-light','solarized-dark','catppuccin','nord','tokyo-night','github-light','github-dark','dracula','blueberry','forest-green','gruvbox','midnight-tide','cherry-blossom','synthwave','ember','moonlit','light-coffee','dark-coffee','cotton-candy','crimson','cloud','peach','material-dark','material-light','monokai','rose-pine','everforest','horizon','cyberpunk','black','one-dark'];
|
||||
if ($theme.startsWith('custom-')) {
|
||||
const ct = $customThemes.find(c => c.id === $theme);
|
||||
if ($resolvedTheme.startsWith('custom-')) {
|
||||
const ct = $customThemes.find(c => c.id === $resolvedTheme);
|
||||
if (ct) {
|
||||
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
||||
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
||||
@@ -606,12 +711,14 @@
|
||||
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
||||
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
||||
root.style.setProperty('--border-color', ct.colors.border_color);
|
||||
root.style.setProperty('--border-light', ct.colors.border_color);
|
||||
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
|
||||
if (ct.is_dark) root.classList.add('dark');
|
||||
}
|
||||
} else if (namedThemes.includes($theme)) {
|
||||
root.setAttribute('data-theme', $theme);
|
||||
if (darkThemes.includes($theme)) root.classList.add('dark');
|
||||
} else if ($theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
} else if (namedThemes.includes($resolvedTheme)) {
|
||||
root.setAttribute('data-theme', $resolvedTheme);
|
||||
if (darkThemes.includes($resolvedTheme)) root.classList.add('dark');
|
||||
} else if ($resolvedTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -638,6 +745,10 @@
|
||||
closeCustomThemeEditor();
|
||||
}
|
||||
|
||||
function cancelCustomThemeFromOverlay(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) cancelCustomThemeEditor();
|
||||
}
|
||||
|
||||
async function selectCustomTheme(ct: CustomTheme) {
|
||||
$theme = ct.id;
|
||||
setTheme(ct.id);
|
||||
@@ -646,9 +757,15 @@
|
||||
async function removeCustomTheme(ct: CustomTheme) {
|
||||
try {
|
||||
await deleteCustomTheme(ct.id);
|
||||
if (systemLightTheme === ct.id) systemLightTheme = 'light';
|
||||
if (systemDarkTheme === ct.id) systemDarkTheme = 'dark';
|
||||
if ($appConfig) {
|
||||
$appConfig.custom_themes = $appConfig.custom_themes.filter(t => t.id !== ct.id);
|
||||
$appConfig = { ...$appConfig };
|
||||
$appConfig = {
|
||||
...$appConfig,
|
||||
custom_themes: $appConfig.custom_themes.filter(t => t.id !== ct.id),
|
||||
system_light_theme: systemLightTheme,
|
||||
system_dark_theme: systemDarkTheme,
|
||||
};
|
||||
}
|
||||
if ($theme === ct.id) {
|
||||
$theme = 'system';
|
||||
@@ -700,6 +817,8 @@
|
||||
// General settings
|
||||
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
||||
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
|
||||
let showNoteSwitcher = $state($appConfig?.show_note_switcher ?? false);
|
||||
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
|
||||
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
|
||||
let timeFormat = $state($appConfig?.time_format ?? 'relative');
|
||||
let weekStart = $state($appConfig?.week_start ?? 'monday');
|
||||
@@ -715,6 +834,7 @@
|
||||
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
|
||||
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
|
||||
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
|
||||
let newNotesInSourceMode = $state($appConfig?.new_notes_in_source_mode ?? false);
|
||||
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
|
||||
let closeToTray = $state($appConfig?.close_to_tray ?? false);
|
||||
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
|
||||
@@ -773,6 +893,8 @@
|
||||
if ($appConfig) {
|
||||
$appConfig.compact_notes = compactNotes;
|
||||
$appConfig.show_note_dates = showNoteDates;
|
||||
$appConfig.show_note_switcher = showNoteSwitcher;
|
||||
$appConfig.startup_view = startupView;
|
||||
$appConfig.restore_last_session = restoreLastSession;
|
||||
$appConfig.time_format = timeFormat;
|
||||
$appConfig.week_start = weekStart;
|
||||
@@ -786,6 +908,7 @@
|
||||
$appConfig.show_line_numbers = showLineNumbers;
|
||||
$appConfig.show_link_arrows = showLinkArrows;
|
||||
$appConfig.default_view_mode = defaultViewMode;
|
||||
$appConfig.new_notes_in_source_mode = newNotesInSourceMode;
|
||||
$appConfig.show_tray_icon = showTrayIcon;
|
||||
$appConfig.close_to_tray = closeToTray;
|
||||
$appConfig.enable_wiki_links = enableWikiLinks;
|
||||
@@ -795,7 +918,7 @@
|
||||
$appConfig.show_daily_notes = showDailyNotes;
|
||||
$appConfig.show_trash = showTrash;
|
||||
}
|
||||
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
||||
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, showNoteSwitcher, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
||||
.catch((e) => console.error('Failed to save general settings:', e));
|
||||
}
|
||||
|
||||
@@ -807,7 +930,9 @@
|
||||
$theme = preset.id;
|
||||
setTheme(preset.id);
|
||||
themeDropdownOpen = false;
|
||||
selectCustomAccent(preset.accent);
|
||||
// "System" has no palette of its own, it defers to whichever paired theme is active, so it
|
||||
// must not overwrite the accent the way a concrete theme does.
|
||||
if (preset.id !== 'system') selectCustomAccent(preset.accent);
|
||||
}
|
||||
|
||||
function selectAccent(preset: typeof accentPresets[0]) {
|
||||
@@ -818,7 +943,7 @@
|
||||
|
||||
function applyAccent(preset: typeof accentPresets[0]) {
|
||||
const root = document.documentElement;
|
||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isDark = isThemeDark;
|
||||
const color = isDark ? preset.dark : preset.light;
|
||||
|
||||
root.style.setProperty('--accent', color);
|
||||
@@ -841,7 +966,7 @@
|
||||
|
||||
function applyCustomAccent(hex: string) {
|
||||
const root = document.documentElement;
|
||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isDark = isThemeDark;
|
||||
root.style.setProperty('--accent', hex);
|
||||
root.style.setProperty('--text-accent', hex);
|
||||
root.style.setProperty('--accent-hover', hex);
|
||||
@@ -863,6 +988,13 @@
|
||||
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
|
||||
}
|
||||
|
||||
function toggleScrollToChangeFontSize() {
|
||||
scrollToChangeFontSize = !scrollToChangeFontSize;
|
||||
if ($appConfig) $appConfig.scroll_to_change_font_size = scrollToChangeFontSize;
|
||||
setScrollToChangeFontSize(scrollToChangeFontSize)
|
||||
.catch((e) => console.error('Failed to save scroll font size setting:', e));
|
||||
}
|
||||
|
||||
function applyFontSize(size: number) {
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
|
||||
}
|
||||
@@ -932,6 +1064,14 @@
|
||||
$showSettings = false;
|
||||
}
|
||||
|
||||
function closeSettingsFromOverlay(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function dismissRestoreConfirm(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) restoreConfirm = null;
|
||||
}
|
||||
|
||||
// Apply saved settings on mount
|
||||
$effect(() => {
|
||||
const savedAccent = $appConfig?.accent_color;
|
||||
@@ -948,6 +1088,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
|
||||
const savedSize = $appConfig?.font_size;
|
||||
if (savedSize) {
|
||||
activeFontSize = savedSize;
|
||||
@@ -975,6 +1116,8 @@
|
||||
if ($appConfig) {
|
||||
compactNotes = $appConfig.compact_notes ?? false;
|
||||
showNoteDates = $appConfig.show_note_dates ?? true;
|
||||
showNoteSwitcher = $appConfig.show_note_switcher ?? false;
|
||||
startupView = normalizeStartupView($appConfig.startup_view);
|
||||
restoreLastSession = $appConfig.restore_last_session ?? false;
|
||||
timeFormat = $appConfig.time_format ?? 'relative';
|
||||
weekStart = $appConfig.week_start ?? 'monday';
|
||||
@@ -984,6 +1127,7 @@
|
||||
pdfPreview = $appConfig.pdf_preview ?? false;
|
||||
pdfHeight = $appConfig.pdf_height ?? 600;
|
||||
titleMode = $appConfig.title_mode ?? 'input';
|
||||
newNotesInSourceMode = $appConfig.new_notes_in_source_mode ?? false;
|
||||
showAllNotes = $appConfig.show_all_notes ?? true;
|
||||
showQuickAccess = $appConfig.show_quick_access ?? true;
|
||||
showTasks = $appConfig.show_tasks ?? true;
|
||||
@@ -992,9 +1136,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Re-apply when theme changes
|
||||
// Re-apply when the concrete theme changes, including an OS appearance switch.
|
||||
$effect(() => {
|
||||
const _ = $theme;
|
||||
const _ = $resolvedTheme;
|
||||
const preset = accentPresets.find(p => p.name === activeAccent);
|
||||
if (preset) {
|
||||
applyAccent(preset);
|
||||
@@ -1004,12 +1148,11 @@
|
||||
|
||||
{#if $showSettings}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="settings-overlay" class:mobile={isMobile} onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="settings-panel" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
|
||||
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||
<div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
|
||||
<div class="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<button class="close-btn" onclick={close}>
|
||||
<h2 id="settings-title">Settings</h2>
|
||||
<button class="close-btn" onclick={close} aria-label="Close settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
@@ -1053,6 +1196,12 @@
|
||||
Backup
|
||||
</button>
|
||||
{/if}
|
||||
<button class="tab-btn" class:active={activeTab === 'maintenance'} onclick={() => activeTab = 'maintenance'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>
|
||||
</svg>
|
||||
Maintenance
|
||||
</button>
|
||||
<button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
|
||||
@@ -1085,7 +1234,7 @@
|
||||
<span class="setting-name">Compact mode</span>
|
||||
<span class="setting-desc">Show notes in a denser layout without preview</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={compactNotes} onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={compactNotes} role="switch" aria-checked={compactNotes} aria-label="Compact notes" onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1094,7 +1243,7 @@
|
||||
<span class="setting-name">Show dates</span>
|
||||
<span class="setting-desc">Show the date next to each note in the list</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showNoteDates} onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showNoteDates} role="switch" aria-checked={showNoteDates} aria-label="Show note dates" onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1102,12 +1251,12 @@
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Notebooks</h3>
|
||||
<label class="setting-toggle">
|
||||
<div class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Sort order</span>
|
||||
<span class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-options" style="margin-top: 8px;">
|
||||
<button class="option-btn" class:active={$notebookSortMode === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button>
|
||||
<button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
|
||||
@@ -1120,37 +1269,75 @@
|
||||
<p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">All Notes</span></span>
|
||||
<button class="toggle-switch" class:on={showAllNotes} onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showAllNotes} role="switch" aria-checked={showAllNotes} aria-label="Show All Notes" onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Quick Access</span></span>
|
||||
<button class="toggle-switch" class:on={showQuickAccess} onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showQuickAccess} role="switch" aria-checked={showQuickAccess} aria-label="Show Quick Access" onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Tasks</span></span>
|
||||
<button class="toggle-switch" class:on={showTasks} onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showTasks} role="switch" aria-checked={showTasks} aria-label="Show Tasks" onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Daily Notes</span></span>
|
||||
<button class="toggle-switch" class:on={showDailyNotes} onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showDailyNotes} role="switch" aria-checked={showDailyNotes} aria-label="Show Daily Notes" onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Trash</span></span>
|
||||
<button class="toggle-switch" class:on={showTrash} onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showTrash} role="switch" aria-checked={showTrash} aria-label="Show Trash" onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !$platformIsMobile}
|
||||
<div class="settings-section">
|
||||
<h3>Interface</h3>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Show note switcher in title bar</span>
|
||||
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showNoteSwitcher} role="switch" aria-checked={showNoteSwitcher} aria-label="Show note switcher in title bar" onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Startup</h3>
|
||||
<div class="setting-label">
|
||||
<span class="setting-name">Default view</span>
|
||||
<span class="setting-desc">Used when session restore is off or the previous view is unavailable.</span>
|
||||
</div>
|
||||
<div class="setting-options" style="margin-top: 8px;">
|
||||
<button class="option-btn" class:active={startupView === 'all'} onclick={() => { startupView = 'all'; saveGeneralSettings(); }}>All Notes</button>
|
||||
<button class="option-btn" class:active={startupView === 'quickaccess'} onclick={() => { startupView = 'quickaccess'; saveGeneralSettings(); }}>Quick Access</button>
|
||||
<button class="option-btn" class:active={startupView === 'tasks'} onclick={() => { startupView = 'tasks'; saveGeneralSettings(); }}>Tasks</button>
|
||||
<button class="option-btn" class:active={startupView === 'daily'} onclick={() => { startupView = 'daily'; saveGeneralSettings(); }}>Daily Notes</button>
|
||||
</div>
|
||||
<label class="setting-toggle" style="margin-top: 12px;">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Restore last session on launch</span>
|
||||
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={restoreLastSession} role="switch" aria-checked={restoreLastSession} aria-label="Restore last session on launch" onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Time Format</h3>
|
||||
<div class="setting-options">
|
||||
@@ -1192,6 +1379,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if !isMobile}
|
||||
<div class="settings-section">
|
||||
<h3>Performance</h3>
|
||||
@@ -1200,7 +1388,7 @@
|
||||
<span class="setting-name">GPU Acceleration</span>
|
||||
<span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={gpuAcceleration} onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={gpuAcceleration} role="switch" aria-checked={gpuAcceleration} aria-label="GPU acceleration" onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1213,7 +1401,7 @@
|
||||
<span class="setting-name">Start at system startup</span>
|
||||
<span class="setting-desc">Launch HelixNotes when your computer starts</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={autostart} onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={autostart} role="switch" aria-checked={autostart} aria-label="Start at system startup" onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1222,7 +1410,7 @@
|
||||
<span class="setting-name">Show in system tray</span>
|
||||
<span class="setting-desc">Show an icon in the notification area (requires restart)</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showTrayIcon} role="switch" aria-checked={showTrayIcon} aria-label="Show in system tray" onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1232,7 +1420,7 @@
|
||||
<span class="setting-name">Close to tray</span>
|
||||
<span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={closeToTray} onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={closeToTray} role="switch" aria-checked={closeToTray} aria-label="Close to tray" onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1240,6 +1428,78 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'maintenance'}
|
||||
<div class="tab-content">
|
||||
<div class="settings-section">
|
||||
<h3>Vault Overview</h3>
|
||||
{#if vaultStats}
|
||||
<div class="maintenance-stats">
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Notes</span>
|
||||
<span class="maintenance-stat-value">{vaultStats.total_notes}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Attachments</span>
|
||||
<span class="maintenance-stat-value">{vaultStats.total_attachments}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Notes size</span>
|
||||
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.notes_size)}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Attachments size</span>
|
||||
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.attachments_size)}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row maintenance-stat-total">
|
||||
<span class="maintenance-stat-label">Total vault size</span>
|
||||
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.total_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if vaultStatsError}
|
||||
<p class="setting-desc">Vault statistics could not be loaded.</p>
|
||||
{:else}
|
||||
<p class="setting-desc">Loading vault statistics…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Orphaned Attachments</h3>
|
||||
<div class="vault-maintenance">
|
||||
<p class="setting-desc">Find files in the vault's attachments folder that are not referenced by any note.</p>
|
||||
{#if orphanedAttachments === null}
|
||||
<button class="import-btn cleanup-action" onclick={scanOrphanedAttachments} disabled={scanningOrphanedAttachments}>
|
||||
{#if scanningOrphanedAttachments}
|
||||
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
||||
Scanning…
|
||||
{:else}
|
||||
Find orphaned attachments
|
||||
{/if}
|
||||
</button>
|
||||
{:else if orphanedAttachments.length === 0}
|
||||
<p class="cleanup-message">No orphaned attachments. Nothing to clean up.</p>
|
||||
{:else}
|
||||
<p class="cleanup-message">{orphanedAttachments.length} {orphanedAttachments.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatVaultSize(orphanedAttachmentTotal)}).</p>
|
||||
<div class="cleanup-list">
|
||||
{#each orphanedAttachments as attachment}
|
||||
<div class="cleanup-row">
|
||||
<span class="cleanup-name" title={attachment.name}>{attachment.name}</span>
|
||||
<span class="cleanup-size">{formatVaultSize(attachment.size)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="import-btn cleanup-action" onclick={trashFoundOrphanedAttachments} disabled={trashingOrphanedAttachments}>
|
||||
{#if trashingOrphanedAttachments}
|
||||
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
||||
Moving…
|
||||
{:else}
|
||||
Move {orphanedAttachments.length} to Trash
|
||||
{/if}
|
||||
</button>
|
||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'editor'}
|
||||
<div class="tab-content">
|
||||
<div class="settings-section">
|
||||
@@ -1249,7 +1509,7 @@
|
||||
<span class="setting-name">Hide title in note body</span>
|
||||
<span class="setting-desc">Hide the first heading when it matches the note title</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={hideTitleInBody} onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={hideTitleInBody} role="switch" aria-checked={hideTitleInBody} aria-label="Hide title in note body" onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1258,7 +1518,7 @@
|
||||
<span class="setting-name">Show link arrows</span>
|
||||
<span class="setting-desc">Display the arrow icon after external links</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showLinkArrows} onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
|
||||
<button class="toggle-switch" class:on={showLinkArrows} role="switch" aria-checked={showLinkArrows} aria-label="Show link arrows" onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1268,7 +1528,7 @@
|
||||
<span class="setting-name">Show line numbers</span>
|
||||
<span class="setting-desc">Display line numbers in the markdown source editor</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showLineNumbers} onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showLineNumbers} role="switch" aria-checked={showLineNumbers} aria-label="Show line numbers" onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1278,21 +1538,22 @@
|
||||
<span class="setting-name">Open notes in View Mode</span>
|
||||
<span class="setting-desc">Notes open as read-only by default. Click the eye icon to switch to editing.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={defaultViewMode} onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={defaultViewMode} role="switch" aria-checked={defaultViewMode} aria-label="Open notes in View Mode" onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<label class="setting-toggle" style="margin-top: 12px;">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Restore last session on launch</span>
|
||||
<span class="setting-desc">Reopen the note and folder you were last using, instead of All Notes.</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>
|
||||
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={newNotesInSourceMode} role="switch" aria-checked={newNotesInSourceMode} aria-label="Open new notes in source mode" onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Wiki Links & Graph</h3>
|
||||
<label class="setting-toggle">
|
||||
@@ -1300,7 +1561,7 @@
|
||||
<span class="setting-name">Enable wiki links</span>
|
||||
<span class="setting-desc">Link notes with [[Note Title]] syntax and visualize connections in a graph view</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={enableWikiLinks} onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={enableWikiLinks} role="switch" aria-checked={enableWikiLinks} aria-label="Enable wiki links" onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1315,7 +1576,7 @@
|
||||
<span class="setting-name">Inline PDF preview</span>
|
||||
<span class="setting-desc">Render embedded PDF files as inline previews inside notes</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={pdfPreview} onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={pdfPreview} role="switch" aria-checked={pdfPreview} aria-label="Inline PDF preview" onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1384,6 +1645,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $theme === 'system'}
|
||||
<span class="setting-hint">Follows the operating system appearance.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
@@ -1432,6 +1696,54 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $theme === 'system'}
|
||||
{#each systemPairRows as row}
|
||||
<div class="settings-section">
|
||||
<h3>{row.title}</h3>
|
||||
<div class="dropdown-wrap">
|
||||
<button
|
||||
class="dropdown-trigger"
|
||||
class:open={systemPairOpen === row.key}
|
||||
onclick={() => { systemPairOpen = systemPairOpen === row.key ? null : row.key; themeDropdownOpen = false; accentDropdownOpen = false; }}
|
||||
>
|
||||
{#each pairPresets.filter(p => p.id === row.selected) as preset}
|
||||
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
|
||||
<span class="preview-sidebar"></span>
|
||||
<span class="preview-main">
|
||||
<span class="preview-accent-bar"></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="dropdown-trigger-label">{preset.label}</span>
|
||||
{/each}
|
||||
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
{#if systemPairOpen === row.key}
|
||||
<div class="dropdown-list" use:clickOutside={() => systemPairOpen = null}>
|
||||
{#each pairPresets as preset}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
class:active={row.selected === preset.id}
|
||||
onclick={() => selectSystemPairTheme(row.key, preset.id)}
|
||||
>
|
||||
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
|
||||
<span class="preview-sidebar"></span>
|
||||
<span class="preview-main">
|
||||
<span class="preview-accent-bar"></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="dropdown-item-label">{preset.label}</span>
|
||||
{#if row.selected === preset.id}
|
||||
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Custom Themes -->
|
||||
@@ -1501,24 +1813,23 @@
|
||||
<!-- Custom Theme Editor Modal -->
|
||||
{#if customThemeEditorOpen && customThemeEditing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeEditor} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="custom-theme-modal" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
|
||||
<div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
|
||||
<div class="custom-theme-modal-header">
|
||||
<h3>{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
|
||||
<button class="close-btn" onclick={cancelCustomThemeEditor}>
|
||||
<h3 id="custom-theme-title">{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
|
||||
<button class="close-btn" onclick={cancelCustomThemeEditor} aria-label="Close theme editor">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="custom-theme-modal-body">
|
||||
<div class="ct-field">
|
||||
<label class="ct-label">Theme Name</label>
|
||||
<input class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
|
||||
<label class="ct-label" for="custom-theme-name">Theme Name</label>
|
||||
<input id="custom-theme-name" class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
|
||||
</div>
|
||||
|
||||
<div class="ct-field">
|
||||
<label class="ct-label">Mode</label>
|
||||
<span class="ct-label">Mode</span>
|
||||
<div class="setting-options">
|
||||
<button class="option-btn" class:active={!customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = false; previewCustomTheme(); } }}>Light</button>
|
||||
<button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button>
|
||||
@@ -1608,6 +1919,17 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !isMobile}
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Scroll to change font size</span>
|
||||
<span class="setting-desc">Allow Cmd/Ctrl + scroll to resize editor text</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={scrollToChangeFontSize} role="switch" aria-checked={scrollToChangeFontSize} aria-label="Scroll to change font size" onclick={toggleScrollToChangeFontSize}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isMobile}
|
||||
@@ -1685,7 +2007,7 @@
|
||||
|
||||
<label class="setting-toggle">
|
||||
<span>Enable automatic backup</span>
|
||||
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
|
||||
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} role="switch" aria-checked={$appConfig?.backup_enabled ?? false} aria-label="Enable automatic backup" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1713,7 +2035,7 @@
|
||||
<span class="setting-name">Include attachments</span>
|
||||
<span class="setting-desc">Include images and files in backups (increases size significantly)</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
|
||||
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} role="switch" aria-checked={$appConfig?.backup_include_attachments ?? false} aria-label="Include attachments in backups" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1799,10 +2121,9 @@
|
||||
|
||||
{#if restoreConfirm}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="restore-confirm-overlay" onclick={() => restoreConfirm = null}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="restore-confirm" onclick={(e) => e.stopPropagation()}>
|
||||
<h4>Restore Backup?</h4>
|
||||
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={(e) => { if (e.key === 'Escape') restoreConfirm = null; }}>
|
||||
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
|
||||
<h4 id="restore-confirm-title">Restore Backup?</h4>
|
||||
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
|
||||
<div class="restore-confirm-actions">
|
||||
<button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button>
|
||||
@@ -2151,14 +2472,14 @@
|
||||
<span class="setting-name">Sync when a note changes</span>
|
||||
<span class="setting-desc">Sync shortly after you edit a note.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={syncOnChange} onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||
<button class="toggle-switch" class:on={syncOnChange} role="switch" aria-checked={syncOnChange} aria-label="Sync when a note changes" onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Sync when the vault opens</span>
|
||||
<span class="setting-desc">Sync once when the app starts.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={syncOnOpen} onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||
<button class="toggle-switch" class:on={syncOnOpen} role="switch" aria-checked={syncOnOpen} aria-label="Sync when the vault opens" onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2811,6 +3132,91 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.maintenance-stats {
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.maintenance-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 7px 16px;
|
||||
}
|
||||
|
||||
.maintenance-stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.maintenance-stat-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.maintenance-stat-total {
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.maintenance-stat-total .maintenance-stat-value {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.vault-maintenance {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cleanup-action {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.cleanup-message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cleanup-list {
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cleanup-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cleanup-name {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cleanup-size {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cleanup-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spinner-icon {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import type { NotebookEntry } from '$lib/types';
|
||||
import { isMobile } from '$lib/platform';
|
||||
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import NotebookGlyph from './NotebookGlyph.svelte';
|
||||
import {
|
||||
NOTEBOOK_ICON_OPTIONS,
|
||||
decodeBuiltinNotebookIcon,
|
||||
encodeBuiltinNotebookIcon,
|
||||
normalizeNotebookIconKey,
|
||||
type NotebookIconId
|
||||
} from '$lib/utils/notebook-icons';
|
||||
|
||||
let { onViewChanged = () => {} }: {
|
||||
onViewChanged?: () => void;
|
||||
@@ -78,6 +87,8 @@
|
||||
}
|
||||
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
|
||||
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
|
||||
let iconPickerNotebook = $state<NotebookEntry | null>(null);
|
||||
let iconPickerElement = $state<HTMLDivElement | null>(null);
|
||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let tagsCollapsed = $state(true);
|
||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||
@@ -324,6 +335,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function dismissDeleteConfirm(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) deleteConfirm = null;
|
||||
}
|
||||
|
||||
async function confirmDelete(nb: NotebookEntry) {
|
||||
deleteConfirm = null;
|
||||
try {
|
||||
@@ -340,22 +355,32 @@
|
||||
async function handleNoteDrop(e: DragEvent, nb: NotebookEntry) {
|
||||
e.preventDefault();
|
||||
dropTargetPath = null;
|
||||
const notePath = e.dataTransfer?.getData('text/plain');
|
||||
if (!notePath) return;
|
||||
// Don't move if already in this notebook
|
||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
||||
if (noteDir === nb.path) return;
|
||||
try {
|
||||
const newPath = await moveNote(notePath, nb.path);
|
||||
$notes = $notes.filter(n => n.path !== notePath);
|
||||
if ($activeNotePath === notePath) {
|
||||
$activeNotePath = newPath;
|
||||
$activeNote = await readNote(newPath);
|
||||
const payload = e.dataTransfer?.getData('text/plain') ?? '';
|
||||
const notePaths = [...new Set(decodeNoteDragPaths(payload))]
|
||||
.filter((path) => norm(parentOf(path)) !== norm(nb.path));
|
||||
if (notePaths.length === 0) return;
|
||||
|
||||
const movedPaths = new Map<string, string>();
|
||||
for (const notePath of notePaths) {
|
||||
try {
|
||||
movedPaths.set(notePath, await moveNote(notePath, nb.path));
|
||||
} catch (e) {
|
||||
console.error('Failed to move note:', notePath, e);
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to move note:', e);
|
||||
}
|
||||
if (movedPaths.size === 0) return;
|
||||
|
||||
$notes = $notes.filter((note) => !movedPaths.has(note.path));
|
||||
const activeNewPath = $activeNotePath ? movedPaths.get($activeNotePath) : undefined;
|
||||
if (activeNewPath) {
|
||||
$activeNotePath = activeNewPath;
|
||||
try {
|
||||
$activeNote = await readNote(activeNewPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to reload moved note:', e);
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
||||
@@ -558,21 +583,40 @@
|
||||
renameInput?.select();
|
||||
}
|
||||
|
||||
async function handleSetIcon(nb: NotebookEntry) {
|
||||
async function openIconPicker(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
iconPickerNotebook = nb;
|
||||
await tick();
|
||||
iconPickerElement?.focus();
|
||||
}
|
||||
|
||||
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
|
||||
try {
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
const value = encodeBuiltinNotebookIcon(icon);
|
||||
await setNotebookIcon(key, value);
|
||||
$notebookIcons = { ...$notebookIcons, [key]: value };
|
||||
iconPickerNotebook = null;
|
||||
} catch (e) {
|
||||
console.error('Failed to set notebook icon:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomIcon(nb: NotebookEntry) {
|
||||
iconPickerNotebook = null;
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
|
||||
});
|
||||
if (!selected) return;
|
||||
const filePath = typeof selected === 'string' ? selected : selected;
|
||||
// Read the file and save as attachment
|
||||
const data = await readFile(filePath as string);
|
||||
const fileName = (filePath as string).split('/').pop() || 'icon.png';
|
||||
const filePath = selected as string;
|
||||
const data = await readFile(filePath);
|
||||
const fileName = baseOf(filePath) || 'icon.png';
|
||||
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
|
||||
await setNotebookIcon(nb.relative_path, iconRelative);
|
||||
$notebookIcons = await getNotebookIcons();
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
await setNotebookIcon(key, iconRelative);
|
||||
$notebookIcons = { ...$notebookIcons, [key]: iconRelative };
|
||||
} catch (e) {
|
||||
console.error('Failed to set notebook icon:', e);
|
||||
}
|
||||
@@ -580,17 +624,21 @@
|
||||
|
||||
async function handleRemoveIcon(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
iconPickerNotebook = null;
|
||||
try {
|
||||
await setNotebookIcon(nb.relative_path, null);
|
||||
$notebookIcons = await getNotebookIcons();
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
await setNotebookIcon(key, null);
|
||||
const icons = { ...$notebookIcons };
|
||||
delete icons[key];
|
||||
$notebookIcons = icons;
|
||||
} catch (e) {
|
||||
console.error('Failed to remove notebook icon:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getNotebookIconSrc(nb: NotebookEntry): string | null {
|
||||
const iconPath = $notebookIcons[nb.relative_path];
|
||||
if (!iconPath) return null;
|
||||
const iconPath = $notebookIcons[normalizeNotebookIconKey(nb.relative_path)];
|
||||
if (!iconPath || iconPath.startsWith('builtin:')) return null;
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (!vaultRoot) return null;
|
||||
return convertFileSrc(`${vaultRoot}/${iconPath}`);
|
||||
@@ -610,6 +658,14 @@
|
||||
contextMenu = { x, y, notebook: nb };
|
||||
}
|
||||
|
||||
function openNotebookMenu(e: Event, nb: NotebookEntry) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 280);
|
||||
contextMenu = { x, y, notebook: nb };
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
@@ -642,7 +698,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
|
||||
|
||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
{#if !isMobile}
|
||||
@@ -892,8 +948,11 @@
|
||||
</aside>
|
||||
|
||||
{#if contextMenu}
|
||||
{#if isMobile}
|
||||
<button type="button" class="context-menu-backdrop" aria-label="Close notebook actions" onclick={closeContextMenu}></button>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
|
||||
New Sub-notebook
|
||||
@@ -902,16 +961,10 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={() => handleSetIcon(contextMenu!.notebook)}>
|
||||
<button onclick={() => openIconPicker(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
|
||||
Set Icon
|
||||
{$notebookIcons[normalizeNotebookIconKey(contextMenu.notebook.relative_path)] ? 'Change Icon...' : 'Set Icon...'}
|
||||
</button>
|
||||
{#if $notebookIcons[contextMenu.notebook.relative_path]}
|
||||
<button onclick={() => handleRemoveIcon(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
Remove Icon
|
||||
</button>
|
||||
{/if}
|
||||
<button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg>
|
||||
Delete
|
||||
@@ -919,9 +972,56 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if iconPickerNotebook}
|
||||
<div class="icon-picker-overlay" class:mobile={isMobile}>
|
||||
<button class="icon-picker-backdrop" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}></button>
|
||||
<div bind:this={iconPickerElement} class="icon-picker" role="dialog" aria-modal="true" aria-label={`Choose an icon for ${iconPickerNotebook.name}`} tabindex="-1">
|
||||
<header class="icon-picker-header">
|
||||
<div>
|
||||
<h3>Notebook icon</h3>
|
||||
<p>{iconPickerNotebook.name}</p>
|
||||
</div>
|
||||
<button class="icon-picker-close" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="icon-picker-grid">
|
||||
{#each NOTEBOOK_ICON_OPTIONS as option}
|
||||
<button
|
||||
class="icon-picker-option"
|
||||
class:active={$notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)] === encodeBuiltinNotebookIcon(option.id)}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
onclick={() => handleBuiltinIcon(iconPickerNotebook!, option.id)}
|
||||
>
|
||||
<NotebookGlyph icon={option.id} size={20} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="icon-picker-actions">
|
||||
<button onclick={() => handleCustomIcon(iconPickerNotebook!)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
|
||||
Custom image...
|
||||
</button>
|
||||
{#if $notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)]}
|
||||
<button class="remove" onclick={() => handleRemoveIcon(iconPickerNotebook!)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
|
||||
Use default
|
||||
</button>
|
||||
{/if}
|
||||
<p class="icon-picker-recommendation">Recommended size: <strong>1:1 aspect ratio</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if trashContextMenu}
|
||||
{#if isMobile}
|
||||
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="context-menu" style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<button class="danger" onclick={handleEmptyTrash}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" />
|
||||
@@ -933,10 +1033,9 @@
|
||||
|
||||
{#if deleteConfirm}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="delete-confirm" onclick={(e) => e.stopPropagation()}>
|
||||
<h4>Delete "{deleteConfirm.name}"?</h4>
|
||||
<div class="delete-confirm-overlay" onclick={dismissDeleteConfirm} onkeydown={(e) => { if (e.key === 'Escape') deleteConfirm = null; }}>
|
||||
<div class="delete-confirm" class:mobile={isMobile} role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" tabindex="-1">
|
||||
<h4 id="delete-confirm-title">Delete "{deleteConfirm.name}"?</h4>
|
||||
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
||||
<div class="delete-confirm-actions">
|
||||
<button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
|
||||
@@ -950,6 +1049,7 @@
|
||||
{@const hasChildren = nb.children.length > 0}
|
||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||
{@const iconSrc = getNotebookIconSrc(nb)}
|
||||
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[normalizeNotebookIconKey(nb.relative_path)])}
|
||||
{#if editingNotebook === nb.path}
|
||||
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
|
||||
<input
|
||||
@@ -965,6 +1065,7 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="notebook-row" data-nb-path={nb.path}>
|
||||
<button
|
||||
class="notebook-item"
|
||||
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
|
||||
@@ -973,7 +1074,9 @@
|
||||
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
||||
style="padding-left: {4 + depth * 16}px"
|
||||
data-nb-path={nb.path}
|
||||
onclick={() => selectNotebook(nb)}
|
||||
onclick={(e) => {
|
||||
if (!(e.target as Element).closest('.nb-drag-handle')) selectNotebook(nb);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
@@ -1037,7 +1140,9 @@
|
||||
{:else}
|
||||
<span class="chevron-spacer"></span>
|
||||
{/if}
|
||||
{#if iconSrc}
|
||||
{#if builtinIcon}
|
||||
<span class="notebook-builtin-icon"><NotebookGlyph icon={builtinIcon} size={18} /></span>
|
||||
{:else if iconSrc}
|
||||
<img class="notebook-icon" src={iconSrc} alt="" />
|
||||
{:else if isCollapsed || nb.children.length === 0}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
|
||||
@@ -1053,17 +1158,27 @@
|
||||
{/if}
|
||||
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
||||
{#if $notebookSortMode === 'manual'}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="nb-drag-handle"
|
||||
title="Drag to reorder"
|
||||
onpointerdown={(e) => nbHandleDown(e, nb)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isMobile}
|
||||
<button
|
||||
type="button"
|
||||
class="notebook-actions-btn"
|
||||
aria-label={`Actions for ${nb.name}`}
|
||||
onclick={(e) => openNotebookMenu(e, nb)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasChildren && !isCollapsed}
|
||||
{#each nb.children as child (child.path)}
|
||||
@@ -1306,6 +1421,10 @@
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.notebook-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notebook-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1378,6 +1497,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notebook-builtin-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notebook-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
@@ -1413,6 +1541,28 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.notebook-actions-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.notebook-actions-btn:active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -1481,6 +1631,15 @@
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.context-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1508,6 +1667,195 @@
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
}
|
||||
|
||||
.icon-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.icon-picker-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(8, 10, 18, 0.42);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-picker {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(360px, 100%);
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.icon-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.icon-picker-header h3,
|
||||
.icon-picker-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-picker-header h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.icon-picker-header p {
|
||||
margin-top: 2px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icon-picker-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-picker-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.icon-picker-option {
|
||||
min-width: 0;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 7px 3px 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-picker-option span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-picker-option:hover {
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-picker-option.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-picker-option.active span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-picker-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 14px 14px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.icon-picker-recommendation {
|
||||
flex: 0 0 100%;
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon-picker-actions button {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-picker-actions button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-picker-actions button.remove {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile {
|
||||
place-items: end center;
|
||||
padding:
|
||||
20px
|
||||
calc(12px + env(safe-area-inset-right, 0px))
|
||||
calc(12px + env(safe-area-inset-bottom, 0px))
|
||||
calc(12px + env(safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile .icon-picker {
|
||||
width: 100%;
|
||||
max-height: calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile .icon-picker-option {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile .icon-picker-actions button {
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.delete-confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1635,7 +1983,7 @@
|
||||
}
|
||||
|
||||
.sidebar.mobile .notebook-item {
|
||||
padding: 12px 12px;
|
||||
padding: 2px 56px 2px 12px;
|
||||
min-height: 48px;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
@@ -1646,6 +1994,11 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .notebook-builtin-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .new-notebook-input {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
@@ -1676,34 +2029,40 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.mobile .context-menu {
|
||||
min-width: 200px;
|
||||
.context-menu.mobile {
|
||||
left: calc(12px + env(safe-area-inset-left, 0px)) !important;
|
||||
right: calc(12px + env(safe-area-inset-right, 0px));
|
||||
top: auto !important;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
min-width: 0;
|
||||
max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
overflow-y: auto;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .context-menu button {
|
||||
.context-menu.mobile button {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm {
|
||||
.delete-confirm.mobile {
|
||||
max-width: calc(100vw - 40px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm h4 {
|
||||
.delete-confirm.mobile h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm p {
|
||||
.delete-confirm.mobile p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm-cancel,
|
||||
.sidebar.mobile .delete-confirm-btn {
|
||||
.delete-confirm.mobile .delete-confirm-cancel,
|
||||
.delete-confirm.mobile .delete-confirm-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
min-height: 44px;
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
|
||||
import { syncNow } from '$lib/api';
|
||||
import NoteSwitcher from './NoteSwitcher.svelte';
|
||||
|
||||
let { onNewNote = () => {}, onDailyNote = () => {} }: {
|
||||
let {
|
||||
onNewNote = () => {},
|
||||
onDailyNote = () => {},
|
||||
onSelectNote
|
||||
}: {
|
||||
onNewNote?: () => void;
|
||||
onDailyNote?: () => void;
|
||||
onSelectNote?: (path: string) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
@@ -30,7 +36,7 @@
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
|
||||
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions') || target.closest('.titlebar-note-switcher')) return;
|
||||
|
||||
// Don't start dragging near window edges - let Tauri handle resize
|
||||
if (!maximized) {
|
||||
@@ -81,6 +87,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $appConfig?.show_note_switcher}
|
||||
<div class="titlebar-note-switcher">
|
||||
<NoteSwitcher {onSelectNote} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="titlebar-actions">
|
||||
<button class="switch-vault-btn" onclick={() => ($vaultReady = false)} title="Switch Vault">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -173,7 +184,13 @@
|
||||
gap: 8px;
|
||||
padding-left: 14px;
|
||||
pointer-events: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.titlebar-note-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.titlebar-title {
|
||||
@@ -205,6 +222,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@@ -214,19 +214,21 @@
|
||||
{/if}
|
||||
|
||||
{#if isAndroid}
|
||||
<div class="location-selector">
|
||||
<label class="location-label">Location</label>
|
||||
<div class="location-selector" role="group" aria-labelledby="storage-location-label">
|
||||
<span id="storage-location-label" class="location-label">Location</span>
|
||||
<div class="location-options">
|
||||
{#each storageLocations as loc}
|
||||
<button
|
||||
class="location-option"
|
||||
class:active={selectedLocation === loc.label}
|
||||
aria-pressed={selectedLocation === loc.label}
|
||||
onclick={() => selectedLocation = loc.label}
|
||||
>{loc.label}</button>
|
||||
{/each}
|
||||
<button
|
||||
class="location-option"
|
||||
class:active={selectedLocation === 'Custom'}
|
||||
aria-pressed={selectedLocation === 'Custom'}
|
||||
onclick={() => selectedLocation = 'Custom'}
|
||||
>Custom</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Schema } from '@tiptap/pm/model';
|
||||
import type { Transaction } from '@tiptap/pm/state';
|
||||
import { liftTarget } from '@tiptap/pm/transform';
|
||||
|
||||
// Marks that carry content (URLs, note references) rather than visual
|
||||
// formatting. Clearing formatting must never destroy these.
|
||||
const CONTENT_MARKS: Record<string, true> = { link: true, wikiLink: true };
|
||||
|
||||
// Block types reset to plain paragraphs. Lists, callouts, collapsible
|
||||
// sections and tables keep their structure so no state (checkboxes,
|
||||
// nesting, cells) is lost.
|
||||
const DEMOTE_BLOCKS: Record<string, true> = { heading: true, codeBlock: true };
|
||||
|
||||
// Outermost-first position of the first blockquote intersecting [from, to].
|
||||
function findBlockquote(tr: Transaction, from: number, to: number): number | null {
|
||||
let found: number | null = null;
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (found !== null) return false;
|
||||
if (node.type.name === 'blockquote') {
|
||||
found = pos;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips inline formatting and demotes heading/code-block/blockquote
|
||||
* structure over the transaction's selection. An empty (cursor) selection
|
||||
* is expanded to the whole current text block, so a single invocation can
|
||||
* reset one messy line without highlighting it first.
|
||||
*/
|
||||
export function applyClearFormatting(schema: Schema, tr: Transaction): void {
|
||||
const { selection } = tr;
|
||||
const { $from } = selection;
|
||||
const expandToBlock = selection.empty && $from.parent.isTextblock;
|
||||
const from = expandToBlock ? $from.start() : selection.from;
|
||||
const to = expandToBlock ? $from.end() : selection.to;
|
||||
|
||||
// 1. Strip formatting marks, keep content-bearing ones.
|
||||
for (const mark of Object.values(schema.marks)) {
|
||||
if (!CONTENT_MARKS[mark.name]) tr.removeMark(from, to, mark);
|
||||
}
|
||||
|
||||
// 2. Demote headings and code blocks to paragraphs. Mark removal and
|
||||
// retyping never move content, so the original positions stay valid.
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (DEMOTE_BLOCKS[node.type.name]) tr.setNodeMarkup(pos, schema.nodes.paragraph);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3. Lift blockquote contents out of the quote wrapper, one pass per
|
||||
// nesting level. Lifts move positions, so remap and rescan each pass.
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const quotePos = findBlockquote(tr, tr.mapping.map(from, 1), tr.mapping.map(to, -1));
|
||||
if (quotePos === null) break;
|
||||
// The range must span the blockquote's content: a collapsed
|
||||
// blockRange starts one depth up and wraps the quote itself.
|
||||
const quote = tr.doc.nodeAt(quotePos);
|
||||
if (!quote) break;
|
||||
const range = tr.doc.resolve(quotePos + 1).blockRange(tr.doc.resolve(quotePos + quote.nodeSize - 1));
|
||||
if (!range) break;
|
||||
const target = liftTarget(range);
|
||||
if (target === null) break;
|
||||
tr.lift(range, target);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears formatting for the editor's current selection (or current line). */
|
||||
export function clearFormatting(editor: Editor): boolean {
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ state, tr }) => {
|
||||
applyClearFormatting(state.schema, tr);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
|
||||
const CODE_FENCE_PATTERN = /^(?:```|~~~)(?:[a-z]+)?$/;
|
||||
|
||||
function isInputRulesPlugin(plugin: Plugin) {
|
||||
return 'isInputRules' in plugin.spec && plugin.spec.isInputRules === true;
|
||||
}
|
||||
|
||||
export function createCodeBlockInputScrollPlugin() {
|
||||
return new Plugin({
|
||||
key: new PluginKey('codeBlockInputScroll'),
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
const inputRulesPlugin = oldState.plugins.find(isInputRulesPlugin);
|
||||
const wasKeyboardInputRule =
|
||||
inputRulesPlugin !== undefined &&
|
||||
transactions.some((transaction) => {
|
||||
const metadata: unknown = transaction.getMeta(inputRulesPlugin);
|
||||
|
||||
return (
|
||||
typeof metadata === 'object' &&
|
||||
metadata !== null &&
|
||||
'transform' in metadata &&
|
||||
metadata.transform === transaction &&
|
||||
'text' in metadata &&
|
||||
metadata.text === '\n'
|
||||
);
|
||||
});
|
||||
const { selection } = oldState;
|
||||
const wasFenceParagraph =
|
||||
selection.empty &&
|
||||
selection.$from.parent.type.name === 'paragraph' &&
|
||||
CODE_FENCE_PATTERN.test(selection.$from.parent.textContent);
|
||||
const isCodeBlock = newState.selection.$from.parent.type.name === 'codeBlock';
|
||||
|
||||
return wasKeyboardInputRule && wasFenceParagraph && isCodeBlock
|
||||
? newState.tr.scrollIntoView()
|
||||
: null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const CodeBlockInputScroll = Extension.create({
|
||||
name: 'codeBlockInputScroll',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [createCodeBlockInputScrollPlugin()];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Mark, Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
|
||||
type AssetSourceNormalizer = (source: string) => string;
|
||||
|
||||
type MarkdownMark = {
|
||||
key: string;
|
||||
priority: number;
|
||||
open: string;
|
||||
close: string;
|
||||
};
|
||||
|
||||
const identityAssetSource: AssetSourceNormalizer = (source) => source;
|
||||
|
||||
function stringAttr(mark: Mark, name: string): string {
|
||||
const value = mark.attrs?.[name];
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function markdownMark(mark: Mark, text: string): MarkdownMark | null {
|
||||
switch (mark.type.name) {
|
||||
case 'bold':
|
||||
return { key: 'bold', priority: 10, open: '**', close: '**' };
|
||||
case 'italic':
|
||||
return { key: 'italic', priority: 20, open: '*', close: '*' };
|
||||
case 'strike':
|
||||
return { key: 'strike', priority: 30, open: '~~', close: '~~' };
|
||||
case 'underline':
|
||||
return { key: 'underline', priority: 40, open: '<u>', close: '</u>' };
|
||||
case 'subscript':
|
||||
return { key: 'subscript', priority: 50, open: '~', close: '~' };
|
||||
case 'superscript':
|
||||
return { key: 'superscript', priority: 60, open: '^', close: '^' };
|
||||
case 'highlight': {
|
||||
const color = stringAttr(mark, 'color');
|
||||
return color
|
||||
? {
|
||||
key: `highlight:${color}`,
|
||||
priority: 70,
|
||||
open: `<mark data-color="${color}">`,
|
||||
close: '</mark>',
|
||||
}
|
||||
: { key: 'highlight', priority: 70, open: '==', close: '==' };
|
||||
}
|
||||
case 'textStyle': {
|
||||
const color = stringAttr(mark, 'color');
|
||||
return color
|
||||
? {
|
||||
key: `textStyle:${color}`,
|
||||
priority: 80,
|
||||
open: `<span style="color: ${color}">`,
|
||||
close: '</span>',
|
||||
}
|
||||
: null;
|
||||
}
|
||||
case 'code':
|
||||
return { key: 'code', priority: 90, open: '`', close: '`' };
|
||||
case 'link': {
|
||||
const href = stringAttr(mark, 'href');
|
||||
return {
|
||||
key: `link:${href}`,
|
||||
priority: 100,
|
||||
open: '[',
|
||||
close: `](${href})`,
|
||||
};
|
||||
}
|
||||
case 'wikiLink': {
|
||||
const title = stringAttr(mark, 'title') || text;
|
||||
const aliased = mark.attrs?.aliased === true || title !== text;
|
||||
return {
|
||||
key: `wikiLink:${title}:${aliased}`,
|
||||
priority: 110,
|
||||
open: aliased ? `[[${title}|` : '[[',
|
||||
close: ']]',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function marksForText(node: ProseMirrorNode): MarkdownMark[] {
|
||||
return node.marks
|
||||
.map((mark) => markdownMark(mark, node.text || ''))
|
||||
.filter((mark): mark is MarkdownMark => mark !== null)
|
||||
.sort((left, right) => left.priority - right.priority || left.key.localeCompare(right.key));
|
||||
}
|
||||
|
||||
function commonMarkCount(active: MarkdownMark[], next: MarkdownMark[]): number {
|
||||
const length = Math.min(active.length, next.length);
|
||||
let index = 0;
|
||||
while (index < length && active[index].key === next[index].key) index += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
export function serializeInlineMarkdown(
|
||||
node: ProseMirrorNode,
|
||||
normalizeAssetSource: AssetSourceNormalizer = identityAssetSource,
|
||||
): string {
|
||||
let result = '';
|
||||
let activeMarks: MarkdownMark[] = [];
|
||||
|
||||
const transitionMarks = (nextMarks: MarkdownMark[]) => {
|
||||
const shared = commonMarkCount(activeMarks, nextMarks);
|
||||
for (let index = activeMarks.length - 1; index >= shared; index -= 1) {
|
||||
result += activeMarks[index].close;
|
||||
}
|
||||
for (let index = shared; index < nextMarks.length; index += 1) {
|
||||
result += nextMarks[index].open;
|
||||
}
|
||||
activeMarks = nextMarks;
|
||||
};
|
||||
|
||||
node.forEach((child, _offset, index) => {
|
||||
if (child.isText) {
|
||||
transitionMarks(marksForText(child));
|
||||
let text = child.text || '';
|
||||
if (index === 0) {
|
||||
text = text.replace(/^[\t\u2003]+/, (whitespace) => ' '.repeat(whitespace.length));
|
||||
}
|
||||
result += text;
|
||||
return;
|
||||
}
|
||||
|
||||
transitionMarks([]);
|
||||
|
||||
if (child.type.name === 'image') {
|
||||
const source = normalizeAssetSource(child.attrs.src || '');
|
||||
if (!source) return;
|
||||
const alt = child.attrs.alt || '';
|
||||
const size = child.attrs['data-size'] || child.attrs.size || 'full';
|
||||
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
||||
if (result) result += '\n';
|
||||
result += ``;
|
||||
} else if (child.type.name === 'mathInline') {
|
||||
result += `$${child.attrs.tex || ''}$`;
|
||||
} else if (child.type.name === 'hardBreak') {
|
||||
result += ' \n';
|
||||
}
|
||||
});
|
||||
|
||||
transitionMarks([]);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Node as ProseMirrorNode, Schema } from '@tiptap/pm/model';
|
||||
|
||||
export type MixedListName = 'bulletList' | 'taskList';
|
||||
|
||||
export function convertListNode(
|
||||
schema: Schema,
|
||||
listNode: ProseMirrorNode,
|
||||
targetListName: MixedListName,
|
||||
): ProseMirrorNode | null {
|
||||
if (
|
||||
(listNode.type.name !== 'bulletList' && listNode.type.name !== 'taskList') ||
|
||||
listNode.type.name === targetListName
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetListType = schema.nodes[targetListName];
|
||||
const targetItemType = schema.nodes[targetListName === 'taskList' ? 'taskItem' : 'listItem'];
|
||||
if (!targetListType || !targetItemType) return null;
|
||||
|
||||
const itemAttrs = targetListName === 'taskList' ? { checked: false } : null;
|
||||
const convertedItems = listNode.content.content.map((item) =>
|
||||
targetItemType.create(itemAttrs, item.content, item.marks)
|
||||
);
|
||||
|
||||
return targetListType.create(null, convertedItems, listNode.marks);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { TextSelection, type EditorState, type Transaction } from '@tiptap/pm/state';
|
||||
|
||||
export type WikiLinkAttributes = {
|
||||
title: string;
|
||||
path: string;
|
||||
aliased: boolean;
|
||||
};
|
||||
|
||||
export function replaceWithWikiLink(
|
||||
state: EditorState,
|
||||
from: number,
|
||||
to: number,
|
||||
text: string,
|
||||
attrs: WikiLinkAttributes
|
||||
): Transaction {
|
||||
const wikiLinkMark = state.schema.marks.wikiLink.create(attrs);
|
||||
const textNode = state.schema.text(text, [wikiLinkMark]);
|
||||
const transaction = state.tr.replaceWith(from, to, textNode);
|
||||
transaction.setSelection(TextSelection.create(transaction.doc, from + text.length));
|
||||
transaction.setStoredMarks([]);
|
||||
return transaction;
|
||||
}
|
||||
+25
-1
@@ -1,5 +1,6 @@
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { isMobile } from "$lib/platform";
|
||||
import { compareNaturalNames } from "$lib/utils/natural-sort";
|
||||
import type {
|
||||
AppConfig,
|
||||
CustomTheme,
|
||||
@@ -89,6 +90,29 @@ export const readOnly = writable(false);
|
||||
export const theme = writable<string>("system");
|
||||
export const customThemes = derived(appConfig, ($c): CustomTheme[] => $c?.custom_themes ?? []);
|
||||
|
||||
// Whether the OS is currently in dark mode. Held in a store (rather than read at each call site)
|
||||
// so the theme re-resolves when the user flips appearance while the app is open.
|
||||
const darkQuery =
|
||||
typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null;
|
||||
export const systemPrefersDark = writable<boolean>(darkQuery?.matches ?? false);
|
||||
darkQuery?.addEventListener("change", (e) => systemPrefersDark.set(e.matches));
|
||||
|
||||
// The theme to actually render: "system" resolves to the configured light/dark pair, everything
|
||||
// else passes through. Falls back to the plain schemes when no pair is configured yet.
|
||||
export const resolvedTheme = derived(
|
||||
[theme, appConfig, systemPrefersDark],
|
||||
([$theme, $config, $prefersDark]): string => {
|
||||
if ($theme !== "system") return $theme;
|
||||
const fallback = $prefersDark ? "dark" : "light";
|
||||
const paired = $prefersDark ? $config?.system_dark_theme : $config?.system_light_theme;
|
||||
if (!paired) return fallback;
|
||||
if (paired.startsWith("custom-") && !$config?.custom_themes.some((item) => item.id === paired)) {
|
||||
return fallback;
|
||||
}
|
||||
return paired;
|
||||
},
|
||||
);
|
||||
|
||||
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
|
||||
// whoever triggered it (manual button, settings, interval, on-change).
|
||||
export const syncState = writable<{ running: boolean; error: string | null }>({
|
||||
@@ -216,7 +240,7 @@ export const sortedNotes = derived(
|
||||
return a.meta.title.localeCompare(b.meta.title);
|
||||
}
|
||||
case "title":
|
||||
return a.meta.title.localeCompare(b.meta.title);
|
||||
return compareNaturalNames(a.meta.title, b.meta.title);
|
||||
case "created":
|
||||
return (
|
||||
new Date(b.meta.created).getTime() -
|
||||
|
||||
@@ -81,19 +81,25 @@ export interface CustomTheme {
|
||||
colors: CustomThemeColors;
|
||||
}
|
||||
|
||||
export type StartupView = "all" | "quickaccess" | "tasks" | "daily";
|
||||
|
||||
export interface AppConfig {
|
||||
vaults: VaultConfig[];
|
||||
active_vault: string | null;
|
||||
active_bookmark_id?: string | null;
|
||||
theme: string;
|
||||
system_light_theme: string;
|
||||
system_dark_theme: string;
|
||||
accent_color: string | null;
|
||||
font_size: number | null;
|
||||
scroll_to_change_font_size: boolean;
|
||||
font_family: string | null;
|
||||
line_height: number | null;
|
||||
ui_scale: number | null;
|
||||
content_width: number | null;
|
||||
compact_notes: boolean;
|
||||
show_note_dates: boolean;
|
||||
show_note_switcher: boolean;
|
||||
time_format: string;
|
||||
week_start: string;
|
||||
daily_title_format: string;
|
||||
@@ -127,9 +133,11 @@ export interface AppConfig {
|
||||
ai_model: string;
|
||||
ai_writing_style: string | null;
|
||||
default_view_mode: boolean;
|
||||
new_notes_in_source_mode: boolean;
|
||||
show_tray_icon: boolean;
|
||||
close_to_tray: boolean;
|
||||
enable_wiki_links: boolean;
|
||||
startup_view: StartupView;
|
||||
restore_last_session: boolean;
|
||||
sync_provider: string | null;
|
||||
webdav_url: string | null;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const { getWheelFontSizeAction } = await import(
|
||||
new URL('./editor-zoom.ts', import.meta.url)
|
||||
);
|
||||
|
||||
test('blocks modified wheel input when scroll font sizing is disabled', () => {
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, false),
|
||||
'block'
|
||||
);
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, false),
|
||||
'block'
|
||||
);
|
||||
});
|
||||
|
||||
test('changes font size from modified wheel input when enabled', () => {
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, true),
|
||||
'increase'
|
||||
);
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, true),
|
||||
'decrease'
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores unmodified wheel input', () => {
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: false, metaKey: false, deltaY: -1 }, false),
|
||||
'ignore'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type WheelFontSizeAction = 'ignore' | 'block' | 'increase' | 'decrease';
|
||||
|
||||
interface WheelModifierInput {
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
deltaY: number;
|
||||
}
|
||||
|
||||
export function getWheelFontSizeAction(
|
||||
event: WheelModifierInput,
|
||||
scrollToChangeFontSize: boolean,
|
||||
): WheelFontSizeAction {
|
||||
if (!event.ctrlKey && !event.metaKey) return 'ignore';
|
||||
if (!scrollToChangeFontSize) return 'block';
|
||||
return event.deltaY < 0 ? 'increase' : 'decrease';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const naturalNameCollator = new Intl.Collator(undefined, { numeric: true });
|
||||
|
||||
export function compareNaturalNames(left: string, right: string): number {
|
||||
return naturalNameCollator.compare(left, right);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function encodeNoteDragPaths(paths: Iterable<string>): string {
|
||||
return [...paths].join("\n");
|
||||
}
|
||||
|
||||
export function decodeNoteDragPaths(payload: string): string[] {
|
||||
return payload.split(/\r?\n/).filter((path) => path.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export interface NoteSwitcherNote {
|
||||
path: string;
|
||||
title: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export interface NoteSwitcherRow {
|
||||
path: string;
|
||||
title: string;
|
||||
folder: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface NoteSwitcherSections {
|
||||
recent: NoteSwitcherRow[];
|
||||
quickAccess: NoteSwitcherRow[];
|
||||
}
|
||||
|
||||
interface BuildNoteSwitcherSectionsOptions {
|
||||
currentPath: string | null;
|
||||
historyPaths: readonly string[];
|
||||
knownNotes: readonly NoteSwitcherNote[];
|
||||
quickAccessNotes: readonly NoteSwitcherNote[];
|
||||
recentLimit?: number;
|
||||
}
|
||||
|
||||
function pathKey(path: string): string {
|
||||
return path.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function buildNoteSwitcherRequestPaths(
|
||||
currentPath: string | null,
|
||||
historyPaths: readonly string[]
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addPath = (path: string | null) => {
|
||||
if (!path) return;
|
||||
const key = pathKey(path);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
paths.push(path);
|
||||
};
|
||||
|
||||
addPath(currentPath);
|
||||
for (let index = historyPaths.length - 1; index >= 0; index -= 1) {
|
||||
addPath(historyPaths[index]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function folderLabel(relativePath: string): string {
|
||||
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.join('/') || 'Unfiled';
|
||||
}
|
||||
|
||||
function toRow(note: NoteSwitcherNote, currentPathKey: string | null): NoteSwitcherRow {
|
||||
return {
|
||||
path: note.path,
|
||||
title: note.title,
|
||||
folder: folderLabel(note.relativePath),
|
||||
current: currentPathKey !== null && pathKey(note.path) === currentPathKey
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNoteSwitcherSections({
|
||||
currentPath,
|
||||
historyPaths,
|
||||
knownNotes,
|
||||
quickAccessNotes,
|
||||
recentLimit = 6
|
||||
}: BuildNoteSwitcherSectionsOptions): NoteSwitcherSections {
|
||||
const knownByPath = new Map(knownNotes.map((note) => [pathKey(note.path), note]));
|
||||
const currentPathKey = currentPath ? pathKey(currentPath) : null;
|
||||
const recent: NoteSwitcherRow[] = [];
|
||||
const shownPaths = new Set<string>();
|
||||
const limit = Math.max(0, Math.floor(recentLimit));
|
||||
|
||||
const addRecent = (path: string | null) => {
|
||||
if (!path || recent.length >= limit) return;
|
||||
const key = pathKey(path);
|
||||
if (shownPaths.has(key)) return;
|
||||
const note = knownByPath.get(key);
|
||||
if (!note) return;
|
||||
shownPaths.add(key);
|
||||
recent.push(toRow(note, currentPathKey));
|
||||
};
|
||||
|
||||
for (const path of buildNoteSwitcherRequestPaths(currentPath, historyPaths)) {
|
||||
if (recent.length >= limit) break;
|
||||
addRecent(path);
|
||||
}
|
||||
|
||||
const quickAccess: NoteSwitcherRow[] = [];
|
||||
for (const note of quickAccessNotes) {
|
||||
const key = pathKey(note.path);
|
||||
if (shownPaths.has(key) || !knownByPath.has(key)) continue;
|
||||
shownPaths.add(key);
|
||||
quickAccess.push(toRow(note, currentPathKey));
|
||||
}
|
||||
|
||||
return { recent, quickAccess };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const NOTEBOOK_ICON_OPTIONS = [
|
||||
{ id: 'book', label: 'Book' },
|
||||
{ id: 'folder', label: 'Folder' },
|
||||
{ id: 'briefcase', label: 'Work' },
|
||||
{ id: 'home', label: 'Home' },
|
||||
{ id: 'star', label: 'Star' },
|
||||
{ id: 'heart', label: 'Heart' },
|
||||
{ id: 'lightbulb', label: 'Ideas' },
|
||||
{ id: 'code', label: 'Code' },
|
||||
{ id: 'flask', label: 'Research' },
|
||||
{ id: 'graduation', label: 'Study' },
|
||||
{ id: 'calendar', label: 'Calendar' },
|
||||
{ id: 'tasks', label: 'Tasks' },
|
||||
{ id: 'archive', label: 'Archive' },
|
||||
{ id: 'globe', label: 'Globe' },
|
||||
{ id: 'plane', label: 'Travel' },
|
||||
{ id: 'palette', label: 'Creative' }
|
||||
] as const;
|
||||
|
||||
export type NotebookIconId = (typeof NOTEBOOK_ICON_OPTIONS)[number]['id'];
|
||||
|
||||
const BUILTIN_PREFIX = 'builtin:';
|
||||
const BUILTIN_IDS: Record<NotebookIconId, true> = {
|
||||
book: true,
|
||||
folder: true,
|
||||
briefcase: true,
|
||||
home: true,
|
||||
star: true,
|
||||
heart: true,
|
||||
lightbulb: true,
|
||||
code: true,
|
||||
flask: true,
|
||||
graduation: true,
|
||||
calendar: true,
|
||||
tasks: true,
|
||||
archive: true,
|
||||
globe: true,
|
||||
plane: true,
|
||||
palette: true
|
||||
};
|
||||
|
||||
export function normalizeNotebookIconKey(path: string): string {
|
||||
return path.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function encodeBuiltinNotebookIcon(icon: NotebookIconId): string {
|
||||
return `${BUILTIN_PREFIX}${icon}`;
|
||||
}
|
||||
|
||||
export function decodeBuiltinNotebookIcon(value: string | null | undefined): NotebookIconId | null {
|
||||
if (!value?.startsWith(BUILTIN_PREFIX)) return null;
|
||||
const icon = value.slice(BUILTIN_PREFIX.length);
|
||||
return Object.hasOwn(BUILTIN_IDS, icon) ? icon as NotebookIconId : null;
|
||||
}
|
||||
@@ -26,3 +26,102 @@ export function relativePath(fromDirectory: string, targetPath: string): string
|
||||
const result = [...Array(from.length - common).fill('..'), ...target.slice(common)];
|
||||
return result.join('/') || '.';
|
||||
}
|
||||
|
||||
export function normalizeLocalAssetPath(path: string): string {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/([A-Za-z]:\/)/, '$1');
|
||||
}
|
||||
|
||||
export function assetUrlToLocalPath(source: string): string | null {
|
||||
if (!source.startsWith('asset:') && !source.startsWith('http://asset.localhost') && !source.startsWith('https://asset.localhost')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const path = normalizeLocalAssetPath(decodeURIComponent(new URL(source).pathname));
|
||||
// Tauri prepends a URL-path slash to POSIX and UNC paths.
|
||||
return path.startsWith('//') ? path.substring(1) : path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveVaultFilePath(
|
||||
targetPath: string,
|
||||
notePath: string | null,
|
||||
vaultRoot: string | null,
|
||||
): string {
|
||||
const target = normalizeLocalAssetPath(targetPath);
|
||||
if (target.startsWith('/') || /^[A-Za-z]:\//.test(target)) return target;
|
||||
|
||||
const root = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '') ?? '';
|
||||
if (target.startsWith('.helixnotes/') && root) {
|
||||
return resolvePathFromFile(`${root}/.vault-root`, target);
|
||||
}
|
||||
if (notePath) return resolvePathFromFile(notePath, target);
|
||||
if (root) return resolvePathFromFile(`${root}/.vault-root`, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function assetSourceToMarkdown(
|
||||
source: string,
|
||||
notePath: string | null,
|
||||
vaultRoot: string | null,
|
||||
): string {
|
||||
if (source.startsWith('blob:')) return '';
|
||||
if (source.startsWith('imgproxy:') || source.startsWith('http://imgproxy.localhost') || source.startsWith('https://imgproxy.localhost')) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(source).pathname.substring(1));
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
const absolutePath = assetUrlToLocalPath(source);
|
||||
if (absolutePath === null) return source;
|
||||
|
||||
const normalizedRoot = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
if (!normalizedRoot || !absolutePath.startsWith(normalizedRoot + '/')) return absolutePath;
|
||||
const vaultRelative = absolutePath.substring(normalizedRoot.length + 1);
|
||||
if (vaultRelative.startsWith('.helixnotes/')) return vaultRelative;
|
||||
if (!notePath) return vaultRelative;
|
||||
const normalizedNotePath = notePath.replace(/\\/g, '/');
|
||||
const noteDirectory = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
|
||||
return relativePath(noteDirectory, absolutePath);
|
||||
}
|
||||
|
||||
export function resolvePathFromFile(filePath: string, targetPath: string): string {
|
||||
const normalizedFile = filePath.replace(/\\/g, '/');
|
||||
const normalizedTarget = targetPath.replace(/\\/g, '/');
|
||||
const lastSeparator = normalizedFile.lastIndexOf('/');
|
||||
const fileDirectory = lastSeparator >= 0 ? normalizedFile.slice(0, lastSeparator) : '';
|
||||
const combined = normalizedTarget.startsWith('/') || /^[A-Za-z]:\//.test(normalizedTarget)
|
||||
? normalizedTarget
|
||||
: `${fileDirectory}/${normalizedTarget}`;
|
||||
const drive = combined.match(/^([A-Za-z]:)\//);
|
||||
const unc = combined.startsWith('//');
|
||||
const absolute = !unc && combined.startsWith('/');
|
||||
let prefix = '';
|
||||
let remainder = combined;
|
||||
if (drive) {
|
||||
prefix = `${drive[1]}/`;
|
||||
remainder = combined.slice(drive[0].length);
|
||||
} else if (unc) {
|
||||
prefix = '//';
|
||||
remainder = combined.slice(2);
|
||||
} else if (absolute) {
|
||||
prefix = '/';
|
||||
remainder = combined.slice(1);
|
||||
}
|
||||
const resolved: string[] = [];
|
||||
|
||||
for (const segment of remainder.split('/')) {
|
||||
if (!segment || segment === '.') continue;
|
||||
if (segment === '..') {
|
||||
resolved.pop();
|
||||
} else {
|
||||
resolved.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return prefix + resolved.join('/');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { StartupView } from "../types";
|
||||
|
||||
type RestorableListView = StartupView | "trash";
|
||||
|
||||
export type StartupTarget =
|
||||
| { mode: RestorableListView }
|
||||
| { mode: "notebook"; notebookPath: string }
|
||||
| { mode: "tag"; tag: string };
|
||||
|
||||
export interface StartupState {
|
||||
startupView: unknown;
|
||||
restoreLastSession: boolean;
|
||||
lastViewMode: unknown;
|
||||
lastNotebook: string | null;
|
||||
lastTag: string | null;
|
||||
}
|
||||
|
||||
export function normalizeStartupView(value: unknown): StartupView {
|
||||
switch (value) {
|
||||
case "daily":
|
||||
case "quickaccess":
|
||||
case "tasks":
|
||||
return value;
|
||||
default:
|
||||
return "all";
|
||||
}
|
||||
}
|
||||
|
||||
function restorableListView(value: unknown): RestorableListView | null {
|
||||
switch (value) {
|
||||
case "all":
|
||||
case "daily":
|
||||
case "quickaccess":
|
||||
case "tasks":
|
||||
case "trash":
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveStartupTarget(state: StartupState): StartupTarget {
|
||||
const fallback: StartupTarget = {
|
||||
mode: normalizeStartupView(state.startupView),
|
||||
};
|
||||
if (!state.restoreLastSession) return fallback;
|
||||
|
||||
if (state.lastViewMode === "notebook" && typeof state.lastNotebook === "string") {
|
||||
return { mode: "notebook", notebookPath: state.lastNotebook };
|
||||
}
|
||||
if (state.lastViewMode === "tag" && state.lastTag) {
|
||||
return { mode: "tag", tag: state.lastTag };
|
||||
}
|
||||
|
||||
const listView = restorableListView(state.lastViewMode);
|
||||
return listView ? { mode: listView } : fallback;
|
||||
}
|
||||
+18
-30
@@ -1,18 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import '../app.css';
|
||||
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { resolvedTheme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
|
||||
import { get } from 'svelte/store';
|
||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||
import type { CustomTheme } from '$lib/types';
|
||||
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
|
||||
import { resolveVaultFilePath } from '$lib/utils/paths';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Reactively apply theme class to <html> whenever $theme or custom themes change
|
||||
// Reactively apply theme class to <html> whenever the resolved theme or custom themes change.
|
||||
// $resolvedTheme already maps "system" onto the configured light/dark pair, so flipping the OS
|
||||
// appearance re-runs this effect.
|
||||
$effect(() => {
|
||||
applyTheme($theme, $customThemes);
|
||||
applyTheme($resolvedTheme, $customThemes);
|
||||
});
|
||||
|
||||
// Apply link arrow visibility from config
|
||||
@@ -26,8 +29,13 @@
|
||||
'--bg-primary', '--bg-secondary', '--bg-tertiary',
|
||||
'--bg-hover', '--bg-active', '--bg-editor',
|
||||
'--text-primary', '--text-secondary', '--border-color',
|
||||
'--border-light', '--text-tertiary',
|
||||
];
|
||||
|
||||
// A custom theme carries nine colors, but the stylesheet also reads --border-light and
|
||||
// --text-tertiary. Named themes set those in their own :root[data-theme] block; a custom theme
|
||||
// has no such block, so without these they fall back to the light defaults in app.css and a dark
|
||||
// custom theme draws near-white dividers. Derive them from the closest color the theme does have.
|
||||
function applyCustomThemeVars(root: HTMLElement, ct: CustomTheme) {
|
||||
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
||||
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
||||
@@ -38,6 +46,8 @@
|
||||
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
||||
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
||||
root.style.setProperty('--border-color', ct.colors.border_color);
|
||||
root.style.setProperty('--border-light', ct.colors.border_color);
|
||||
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
|
||||
}
|
||||
|
||||
function clearCustomThemeVars(root: HTMLElement) {
|
||||
@@ -64,16 +74,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
const parts = p.split('/');
|
||||
const resolved: string[] = [];
|
||||
for (const seg of parts) {
|
||||
if (seg === '..') resolved.pop();
|
||||
else if (seg !== '.') resolved.push(seg);
|
||||
}
|
||||
return resolved.join('/');
|
||||
}
|
||||
|
||||
function openLocalFile(path: string) {
|
||||
if (isAndroid) {
|
||||
const bridge = (window as any).Android;
|
||||
@@ -89,24 +89,12 @@
|
||||
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) {
|
||||
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
|
||||
} else if (!href.startsWith('#')) {
|
||||
const decoded = decodeURIComponent(href);
|
||||
const config = get(appConfig);
|
||||
const vaultRoot = config?.active_vault;
|
||||
let absPath = decoded;
|
||||
if (!decoded.startsWith('/') && vaultRoot) {
|
||||
// .helixnotes/ paths are always relative to vault root, not the note's directory
|
||||
if (decoded.startsWith('.helixnotes/')) {
|
||||
absPath = normalizePath(`${vaultRoot}/${decoded}`);
|
||||
} else {
|
||||
const notePath = get(activeNotePath);
|
||||
if (notePath) {
|
||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
||||
absPath = normalizePath(`${noteDir}/${decoded}`);
|
||||
} else {
|
||||
absPath = normalizePath(`${vaultRoot}/${decoded}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const absPath = resolveVaultFilePath(
|
||||
decodeURIComponent(href),
|
||||
get(activeNotePath),
|
||||
config?.active_vault ?? null,
|
||||
);
|
||||
// Internal .md note link - navigate within the app
|
||||
if (absPath.endsWith('.md')) {
|
||||
readNote(absPath).then((content) => {
|
||||
|
||||
+60
-10
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes, isIOS } 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 { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
||||
import AppLayout from '$lib/components/AppLayout.svelte';
|
||||
import NoteWindow from '$lib/components/NoteWindow.svelte';
|
||||
@@ -15,6 +17,29 @@
|
||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||
let removeFontSizeListener: (() => void) | null = null;
|
||||
let removeScrollFontSizeListener: (() => void) | null = null;
|
||||
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let startupWindowRevealed = false;
|
||||
|
||||
const STARTUP_REVEAL_FAILSAFE_MS = 4000;
|
||||
|
||||
async function revealStartupWindow() {
|
||||
if (isMobile || startupWindowRevealed) return;
|
||||
await tick();
|
||||
// Force style resolution while the native window is still hidden so its first
|
||||
// compositor frame already uses the selected theme.
|
||||
getComputedStyle(document.body).backgroundColor;
|
||||
try {
|
||||
await getCurrentWindow().show();
|
||||
startupWindowRevealed = true;
|
||||
if (startupRevealTimer) {
|
||||
clearTimeout(startupRevealTimer);
|
||||
startupRevealTimer = null;
|
||||
}
|
||||
} catch {
|
||||
// Plain-browser development has no Tauri window to reveal.
|
||||
}
|
||||
}
|
||||
|
||||
const defaultEditorFontSize = 14;
|
||||
const minEditorFontSize = 10;
|
||||
@@ -98,10 +123,15 @@
|
||||
};
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
const action = getWheelFontSizeAction(
|
||||
event,
|
||||
$appConfig?.scroll_to_change_font_size ?? true
|
||||
);
|
||||
if (action === 'ignore') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zoomEditor(event.deltaY < 0 ? 1 : -1);
|
||||
if (action === 'increase') zoomEditor(1);
|
||||
else if (action === 'decrease') zoomEditor(-1);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
@@ -114,6 +144,9 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!isMobile) {
|
||||
startupRevealTimer = setTimeout(() => void revealStartupWindow(), STARTUP_REVEAL_FAILSAFE_MS);
|
||||
}
|
||||
// Check for note window mode
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const notePath = params.get('note');
|
||||
@@ -127,10 +160,18 @@
|
||||
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
|
||||
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
|
||||
});
|
||||
removeScrollFontSizeListener = await listen<boolean>('scroll-to-change-font-size-changed', (event) => {
|
||||
if ($appConfig) $appConfig.scroll_to_change_font_size = event.payload;
|
||||
});
|
||||
$theme = config.theme || 'system';
|
||||
|
||||
// Apply theme immediately to prevent flash
|
||||
const themeValue = config.theme || 'system';
|
||||
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
|
||||
// "system" against the configured pair here the same way resolvedTheme does.
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const rawTheme = config.theme || 'system';
|
||||
const themeValue = rawTheme === 'system'
|
||||
? (prefersDark ? config.system_dark_theme || 'dark' : config.system_light_theme || 'light')
|
||||
: rawTheme;
|
||||
const root = document.documentElement;
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
root.classList.remove('dark');
|
||||
@@ -138,7 +179,7 @@
|
||||
if (namedThemes.includes(themeValue)) {
|
||||
root.setAttribute('data-theme', themeValue);
|
||||
if (darkThemes.includes(themeValue)) root.classList.add('dark');
|
||||
} else if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
} else if (themeValue === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
|
||||
@@ -166,8 +207,8 @@
|
||||
}
|
||||
// Apply saved accent
|
||||
if (config.accent_color) {
|
||||
const themeVal = config.theme || 'light';
|
||||
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const customTheme = config.custom_themes.find(theme => theme.id === themeValue);
|
||||
const isDark = darkThemes.includes(themeValue) || (customTheme?.is_dark ?? false);
|
||||
let color: string | null = null;
|
||||
if (config.accent_color.startsWith('#')) {
|
||||
color = config.accent_color;
|
||||
@@ -209,6 +250,7 @@
|
||||
console.error('Failed to apply interface scale:', e);
|
||||
}
|
||||
}
|
||||
await revealStartupWindow();
|
||||
removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
||||
|
||||
// Auto-open last vault if available
|
||||
@@ -242,14 +284,22 @@
|
||||
}
|
||||
} catch {
|
||||
// First launch or no config
|
||||
} finally {
|
||||
loading = false;
|
||||
await revealStartupWindow();
|
||||
if (startupRevealTimer) {
|
||||
clearTimeout(startupRevealTimer);
|
||||
startupRevealTimer = null;
|
||||
}
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
removeEditorZoomShortcuts?.();
|
||||
removeFontSizeListener?.();
|
||||
removeScrollFontSizeListener?.();
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { Schema } from '@tiptap/pm/model';
|
||||
import { transformWithEsbuild } from 'vite';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/editor/markdown.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const { code } = await transformWithEsbuild(source, 'markdown.ts', {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'esnext'
|
||||
});
|
||||
const { serializeInlineMarkdown } = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||
);
|
||||
|
||||
// Link intentionally precedes formatting marks in the schema. This matches the
|
||||
// mark order produced by the app and reproduces the original per-text-node bug.
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
paragraph: { content: 'text*', group: 'block' },
|
||||
text: { group: 'inline' }
|
||||
},
|
||||
marks: {
|
||||
link: { attrs: { href: {} } },
|
||||
bold: {},
|
||||
italic: {},
|
||||
wikiLink: {
|
||||
attrs: {
|
||||
title: { default: null },
|
||||
aliased: { default: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const markdown = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||
const bold = schema.marks.bold.create();
|
||||
const italic = schema.marks.italic.create();
|
||||
const link = schema.marks.link.create({ href: 'https://example.com' });
|
||||
|
||||
function paragraph(segments) {
|
||||
return schema.node(
|
||||
'paragraph',
|
||||
null,
|
||||
segments.map(({ text, marks }) => schema.text(text, marks))
|
||||
);
|
||||
}
|
||||
|
||||
function assertMarkdown(segments, expected, expectedHtml) {
|
||||
const serialized = serializeInlineMarkdown(paragraph(segments));
|
||||
assert.equal(serialized, expected);
|
||||
assert.equal(markdown.render(serialized), `${expectedHtml}\n`);
|
||||
}
|
||||
|
||||
test('keeps bold open when a link ends the marked span', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'Bold ', marks: [bold] },
|
||||
{ text: 'link', marks: [link, bold] }
|
||||
],
|
||||
'**Bold [link](https://example.com)**',
|
||||
'<p><strong>Bold <a href="https://example.com">link</a></strong></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps bold open when a link starts the marked span', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'link', marks: [link, bold] },
|
||||
{ text: ' bold', marks: [bold] }
|
||||
],
|
||||
'**[link](https://example.com) bold**',
|
||||
'<p><strong><a href="https://example.com">link</a> bold</strong></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps one bold span around a link in the middle', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'Before ', marks: [bold] },
|
||||
{ text: 'link', marks: [link, bold] },
|
||||
{ text: ' after', marks: [bold] }
|
||||
],
|
||||
'**Before [link](https://example.com) after**',
|
||||
'<p><strong>Before <a href="https://example.com">link</a> after</strong></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps nested bold and italic marks open across a link', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'Nested ', marks: [bold, italic] },
|
||||
{ text: 'link', marks: [link, bold, italic] },
|
||||
{ text: ' marks', marks: [bold, italic] }
|
||||
],
|
||||
'***Nested [link](https://example.com) marks***',
|
||||
'<p><em><strong>Nested <a href="https://example.com">link</a> marks</strong></em></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves wiki-link aliases inside a bold span', () => {
|
||||
const wikiLink = schema.marks.wikiLink.create({ title: 'Target note', aliased: true });
|
||||
const serialized = serializeInlineMarkdown(
|
||||
paragraph([
|
||||
{ text: 'See ', marks: [bold] },
|
||||
{ text: 'this note', marks: [wikiLink, bold] },
|
||||
{ text: ' now', marks: [bold] }
|
||||
])
|
||||
);
|
||||
|
||||
assert.equal(serialized, '**See [[Target note|this note]] now**');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const { compareNaturalNames } = await import(
|
||||
new URL('../src/lib/utils/natural-sort.ts', import.meta.url)
|
||||
);
|
||||
|
||||
test('sorts numeric name segments by numeric value', () => {
|
||||
const names = ['10g', '11g', '12g', '5g', '6g', '7g', '8g', '9g'];
|
||||
|
||||
assert.deepEqual(names.sort(compareNaturalNames), [
|
||||
'5g',
|
||||
'6g',
|
||||
'7g',
|
||||
'8g',
|
||||
'9g',
|
||||
'10g',
|
||||
'11g',
|
||||
'12g'
|
||||
]);
|
||||
});
|
||||
|
||||
test('sorts numbers naturally wherever they appear in a name', () => {
|
||||
const names = ['Class 10b', 'Class 2b', 'Class 10a', 'Class 2a'];
|
||||
|
||||
assert.deepEqual(names.sort(compareNaturalNames), [
|
||||
'Class 2a',
|
||||
'Class 2b',
|
||||
'Class 10a',
|
||||
'Class 10b'
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { transformWithEsbuild } from 'vite';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/utils/paths.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const { code } = await transformWithEsbuild(source, 'paths.ts', {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'esnext'
|
||||
});
|
||||
const {
|
||||
assetSourceToMarkdown,
|
||||
assetUrlToLocalPath,
|
||||
normalizeLocalAssetPath,
|
||||
resolvePathFromFile,
|
||||
resolveVaultFilePath
|
||||
} = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||
);
|
||||
|
||||
test('resolves note links relative to Windows note paths', () => {
|
||||
assert.equal(
|
||||
resolvePathFromFile('C:\\Vault\\Current.md', './Target.md'),
|
||||
'C:/Vault/Target.md'
|
||||
);
|
||||
assert.equal(
|
||||
resolvePathFromFile('C:\\Vault\\Folder\\Current.md', '../Target.md'),
|
||||
'C:/Vault/Target.md'
|
||||
);
|
||||
assert.equal(
|
||||
resolvePathFromFile('\\\\server\\share\\Folder\\Current.md', '../Target.md'),
|
||||
'//server/share/Target.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves Linux relative note-link resolution', () => {
|
||||
assert.equal(
|
||||
resolvePathFromFile('/vault/folder/Current.md', '../Target.md'),
|
||||
'/vault/Target.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('converts Windows asset URLs back to portable attachment paths', () => {
|
||||
assert.equal(
|
||||
normalizeLocalAssetPath('/C:\\Users\\user\\Vault\\image.png'),
|
||||
'C:/Users/user/Vault/image.png'
|
||||
);
|
||||
assert.equal(
|
||||
assetSourceToMarkdown(
|
||||
'http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5C.helixnotes%5Cattachments%5Cimage.png',
|
||||
'C:\\Users\\user\\Vault\\Note.md',
|
||||
'C:\\Users\\user\\Vault'
|
||||
),
|
||||
'.helixnotes/attachments/image.png'
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves portable asset paths on Linux and macOS', () => {
|
||||
assert.equal(
|
||||
assetSourceToMarkdown(
|
||||
'asset://localhost/%2Fhome%2Fuser%2FVault%2F.helixnotes%2Fattachments%2Fimage.png',
|
||||
'/home/user/Vault/Note.md',
|
||||
'/home/user/Vault'
|
||||
),
|
||||
'.helixnotes/attachments/image.png'
|
||||
);
|
||||
assert.equal(
|
||||
assetSourceToMarkdown(
|
||||
'asset://localhost/%2FUsers%2Fuser%2FVault%2Fassets%2Fimage.png',
|
||||
'/Users/user/Vault/notes/Note.md',
|
||||
'/Users/user/Vault'
|
||||
),
|
||||
'../assets/image.png'
|
||||
);
|
||||
assert.equal(
|
||||
assetSourceToMarkdown('../assets/image.png', '/vault/notes/Note.md', '/vault'),
|
||||
'../assets/image.png'
|
||||
);
|
||||
});
|
||||
|
||||
test('decodes local asset URLs without damaging platform roots', () => {
|
||||
assert.equal(
|
||||
assetUrlToLocalPath('http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5Cimage.png'),
|
||||
'C:/Users/user/Vault/image.png'
|
||||
);
|
||||
assert.equal(
|
||||
assetUrlToLocalPath('asset://localhost/%2Fhome%2Fuser%2FVault%2Fimage.png'),
|
||||
'/home/user/Vault/image.png'
|
||||
);
|
||||
assert.equal(
|
||||
assetUrlToLocalPath('http://asset.localhost/%5C%5Cserver%5Cshare%5CVault%5Cimage.png'),
|
||||
'//server/share/Vault/image.png'
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves vault files consistently across platforms', () => {
|
||||
assert.equal(
|
||||
resolveVaultFilePath('../assets/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
|
||||
'C:/Vault/assets/image.png'
|
||||
);
|
||||
assert.equal(
|
||||
resolveVaultFilePath('.helixnotes/attachments/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
|
||||
'C:/Vault/.helixnotes/attachments/image.png'
|
||||
);
|
||||
assert.equal(
|
||||
resolveVaultFilePath('../assets/image.png', '/vault/notes/Note.md', '/vault'),
|
||||
'/vault/assets/image.png'
|
||||
);
|
||||
assert.equal(
|
||||
resolveVaultFilePath('../assets/image.png', '\\\\server\\share\\Vault\\notes\\Note.md', '\\\\server\\share\\Vault'),
|
||||
'//server/share/Vault/assets/image.png'
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user