mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 09:27:29 +02:00
Compare commits
69
Commits
1a880a4f96
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2983e3db29 | ||
|
|
cc7c9e2b49 | ||
|
|
2240231f61 | ||
|
|
b2d0c7175e | ||
|
|
1bc0026731 | ||
|
|
41c0e4c993 | ||
|
|
34a0f9efa3 | ||
|
|
9d92489586 | ||
|
|
c45efb60ab | ||
|
|
0d11fd01dd | ||
|
|
0aaa05ce5c | ||
|
|
77e92ef25c | ||
|
|
d7e4be7f3a | ||
|
|
2aa572dcaa | ||
|
|
32f270ce9e | ||
|
|
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 |
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -10,29 +10,45 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
||||
Your notes are stored as standard Markdown files on your local filesystem.
|
||||
No cloud, no lock-in.
|
||||
|
||||
## Download (v1.3.4)
|
||||
## Download (v1.3.5)
|
||||
|
||||
### 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.5/HelixNotes_1.3.5_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,35 +106,30 @@ 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.5/HelixNotes_1.3.5_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.rpm](https://download.helixnotes.com/releases/v1.3.5/HelixNotes-1.3.5-1.x86_64.rpm)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
|
||||
[Download Installer](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64-setup.exe) (Windows 10/11)
|
||||
|
||||
### macOS
|
||||
|
||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
|
||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_aarch64.dmg) (M-series Macs)
|
||||
|
||||
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
|
||||
>
|
||||
> ```bash
|
||||
> xattr -cr /Applications/HelixNotes.app
|
||||
> ```
|
||||
[Download .dmg (Intel)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64.dmg)
|
||||
|
||||
The Apple Silicon build is signed and notarized by Apple. The Intel build may still trigger a Gatekeeper warning. If macOS reports that the Intel build is damaged, run this once after installing or updating it:
|
||||
|
||||
```bash
|
||||
xattr -cr /Applications/HelixNotes.app
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
|
||||
[Download APK](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_android.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -157,7 +168,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 +180,14 @@ pnpm install
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
Run the frontend checks and tests, Rust tests and lints, and production frontend build with one command:
|
||||
|
||||
```bash
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
|
||||
+14
-10
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -11,6 +11,10 @@
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
|
||||
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
|
||||
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
|
||||
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
@@ -18,22 +22,22 @@
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"svelte": "^5.49.2",
|
||||
"@types/node": "26.2.0",
|
||||
"svelte": "^5.56.9",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
||||
@@ -62,11 +66,11 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"lowlight": "^3.3.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it": "^14.3.0",
|
||||
"markdown-it-mark": "^4.0.0",
|
||||
"markdown-it-sub": "^2.0.0",
|
||||
"markdown-it-sup": "^2.0.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"mermaid": "^11.14.0"
|
||||
"mermaid": "^11.16.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+598
-692
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
overrides:
|
||||
cookie: 0.7.2
|
||||
|
||||
Generated
+67
-275
@@ -8,17 +8,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -138,12 +127,6 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -346,18 +329,6 @@ dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitvec"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
|
||||
dependencies = [
|
||||
"funty",
|
||||
"radium",
|
||||
"tap",
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -389,29 +360,6 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
@@ -439,40 +387,6 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byte-unit"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
|
||||
dependencies = [
|
||||
"rust_decimal",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
|
||||
dependencies = [
|
||||
"bytecheck_derive",
|
||||
"ptr_meta",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck_derive"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -624,6 +538,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -738,6 +663,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -768,9 +702,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -1314,12 +1248,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -1590,11 +1518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1604,10 +1530,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1796,9 +1725,6 @@ name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash 0.7.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -1806,7 +1732,7 @@ version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
@@ -1850,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.3.4"
|
||||
version = "1.3.5"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1862,7 +1788,7 @@ dependencies = [
|
||||
"notify",
|
||||
"objc2-app-kit",
|
||||
"png 0.17.16",
|
||||
"quick-xml 0.36.2",
|
||||
"quick-xml",
|
||||
"rayon",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2554,9 +2480,6 @@ name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
@@ -2575,9 +2498,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.5"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
|
||||
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
@@ -3417,13 +3340,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -3576,26 +3499,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
|
||||
dependencies = [
|
||||
"ptr_meta_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta_derive"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pure-rust-locales"
|
||||
version = "0.8.2"
|
||||
@@ -3619,18 +3522,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.36.2"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -3657,14 +3551,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.1",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg 0.10.2",
|
||||
"ring",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
@@ -3705,12 +3600,6 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "radium"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
@@ -3722,7 +3611,7 @@ dependencies = [
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
"rand_pcg",
|
||||
"rand_pcg 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3738,12 +3627,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
"chacha20",
|
||||
"getrandom 0.4.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3766,16 +3656,6 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3796,12 +3676,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
@@ -3831,6 +3708,15 @@ dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3935,15 +3821,6 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
||||
dependencies = [
|
||||
"bytecheck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
@@ -4063,35 +3940,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
|
||||
dependencies = [
|
||||
"bitvec",
|
||||
"bytecheck",
|
||||
"bytes",
|
||||
"hashbrown 0.12.3",
|
||||
"ptr_meta",
|
||||
"rend",
|
||||
"rkyv_derive",
|
||||
"seahash",
|
||||
"tinyvec",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv_derive"
|
||||
version = "0.7.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-stemmers"
|
||||
version = "1.2.0"
|
||||
@@ -4102,22 +3950,6 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -4231,9 +4063,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4327,12 +4159,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "seahash"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4574,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4600,12 +4426,6 @@ version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -5007,17 +4827,11 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -5214,12 +5028,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.8.0"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
|
||||
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"byte-unit",
|
||||
"fern",
|
||||
"log",
|
||||
"objc2",
|
||||
@@ -5964,12 +5777,6 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -5988,12 +5795,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -6246,12 +6047,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.8"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -7007,15 +6808,6 @@ dependencies = [
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||
dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11"
|
||||
version = "2.21.0"
|
||||
|
||||
+10
-3
@@ -1,11 +1,15 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.3.4"
|
||||
version = "1.3.5"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
rust-version = "1.88"
|
||||
|
||||
[lints.clippy]
|
||||
# This style lint is version-dependent and produces over 100 legacy-only diagnostics on Rust 1.88.
|
||||
uninlined_format_args = "allow"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
@@ -14,6 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
@@ -38,7 +45,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
||||
futures = "0.3"
|
||||
rayon = "1"
|
||||
quick-xml = "0.36"
|
||||
quick-xml = "0.41"
|
||||
urlencoding = "2"
|
||||
sha2 = "0.10"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+649
-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());
|
||||
}
|
||||
}
|
||||
+70
-66
@@ -1,7 +1,9 @@
|
||||
mod ai;
|
||||
mod asset_scope;
|
||||
mod backup;
|
||||
mod commands;
|
||||
mod history;
|
||||
mod image_proxy;
|
||||
mod search;
|
||||
mod state;
|
||||
mod sync;
|
||||
@@ -11,6 +13,7 @@ mod vault;
|
||||
use state::AppState;
|
||||
#[allow(unused_imports)]
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_fs::FsExt;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use tauri::{
|
||||
@@ -56,6 +59,16 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.on_webview_event(|webview, event| {
|
||||
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
|
||||
let scope = webview.fs_scope();
|
||||
for path in paths {
|
||||
if path.is_file() {
|
||||
let _ = scope.allow_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if webview.label() == "main"
|
||||
@@ -93,6 +106,20 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
let active_vault = app
|
||||
.state::<AppState>()
|
||||
.config
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|config| config.active_vault.clone());
|
||||
if let Some(vault_path) = active_vault {
|
||||
if let Err(error) =
|
||||
asset_scope::allow_vault_assets(app.handle(), std::path::Path::new(&vault_path))
|
||||
{
|
||||
log::warn!("Failed to restore the vault asset scope: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
if show_tray {
|
||||
setup_tray(app)?;
|
||||
@@ -112,6 +139,14 @@ pub fn run() {
|
||||
std::env::current_dir().unwrap_or_default().join(&path)
|
||||
};
|
||||
if let Some(resolved_str) = resolved.to_str() {
|
||||
if resolved.is_file() {
|
||||
let _ = app.fs_scope().allow_file(&resolved);
|
||||
if let Some(parent) = resolved.parent() {
|
||||
let _ = app.fs_scope().allow_directory(parent, true);
|
||||
}
|
||||
let _ =
|
||||
asset_scope::allow_external_note_assets(app.handle(), &resolved);
|
||||
}
|
||||
let app_state = app.state::<AppState>();
|
||||
let _ = app_state.pending_open_file.lock().map(|mut p| {
|
||||
*p = Some(resolved_str.to_string());
|
||||
@@ -130,12 +165,14 @@ pub fn run() {
|
||||
commands::remove_vault,
|
||||
commands::get_app_config,
|
||||
commands::set_theme,
|
||||
commands::set_system_themes,
|
||||
commands::set_accent_color,
|
||||
commands::save_custom_theme,
|
||||
commands::delete_custom_theme,
|
||||
commands::export_custom_theme,
|
||||
commands::import_custom_themes,
|
||||
commands::set_font_size,
|
||||
commands::set_scroll_to_change_font_size,
|
||||
commands::set_font_family,
|
||||
commands::set_line_height,
|
||||
commands::set_ui_scale,
|
||||
@@ -150,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,
|
||||
@@ -187,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,
|
||||
@@ -214,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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -306,13 +295,29 @@ 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")]
|
||||
@@ -338,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-") {
|
||||
@@ -348,7 +353,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
app_lib::run();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(vault_path.as_bytes());
|
||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let key: String = hasher.finalize()[..8]
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect();
|
||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||
}
|
||||
|
||||
@@ -235,7 +238,9 @@ impl SearchIndex {
|
||||
// before the writer is created, so both indexing and querying use it).
|
||||
index.tokenizers().register(
|
||||
"cjk",
|
||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
||||
TextAnalyzer::builder(CjkTokenizer)
|
||||
.filter(LowerCaser)
|
||||
.build(),
|
||||
);
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -290,7 +295,7 @@ impl SearchIndex {
|
||||
doc.add_text(self.path_field, &path_str);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
doc.add_text(self.body_field, &content);
|
||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
||||
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
}
|
||||
}
|
||||
@@ -321,7 +326,7 @@ impl SearchIndex {
|
||||
doc.add_text(self.path_field, path);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
doc.add_text(self.body_field, &content);
|
||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
||||
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
|
||||
writer.commit().map_err(|e| e.to_string())?;
|
||||
@@ -341,7 +346,7 @@ impl SearchIndex {
|
||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
||||
let fields = [self.title_field, self.body_field, self.tags_field];
|
||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||
@@ -376,9 +381,10 @@ impl SearchIndex {
|
||||
));
|
||||
vec![(Occur::Should, exact)]
|
||||
} else {
|
||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
||||
vec![Term::from_field_text(field, term)],
|
||||
));
|
||||
let prefix: Box<dyn Query> =
|
||||
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
|
||||
field, term,
|
||||
)]));
|
||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
1,
|
||||
|
||||
+31
-8
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
||||
}
|
||||
|
||||
fn normalize_etag(s: &str) -> String {
|
||||
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
||||
s.trim()
|
||||
.trim_start_matches("W/")
|
||||
.trim_matches('"')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Percent-encode each path segment, keeping the `/` separators.
|
||||
@@ -265,7 +268,11 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
return Err(format!(
|
||||
"GET {} failed: HTTP {}",
|
||||
relpath,
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||
}
|
||||
@@ -279,7 +286,11 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
return Err(format!(
|
||||
"PUT {} failed: HTTP {}",
|
||||
relpath,
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(resp
|
||||
.headers()
|
||||
@@ -453,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if cap != Cap::None {
|
||||
if let Ok(t) = e.unescape() {
|
||||
buf.push_str(&t);
|
||||
if let Ok(decoded) = e.decode() {
|
||||
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
|
||||
buf.push_str(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,8 +528,14 @@ fn apply_changes(
|
||||
|
||||
match (l, r) {
|
||||
(Some(lf), Some(re)) => {
|
||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
||||
let local_changed = match m {
|
||||
Some(entry) => entry.local_hash != lf.hash,
|
||||
None => true,
|
||||
};
|
||||
let remote_changed = match m {
|
||||
Some(entry) => &entry.remote_etag != re,
|
||||
None => true,
|
||||
};
|
||||
if !local_changed && !remote_changed {
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
@@ -669,7 +688,11 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
||||
|
||||
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||
/// rebuilds the search index. Returns a summary of what changed.
|
||||
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
|
||||
pub fn run_sync(
|
||||
app: tauri::AppHandle,
|
||||
vault: String,
|
||||
cfg: WebdavConfig,
|
||||
) -> Result<SyncSummary, String> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
+63
-1
@@ -114,10 +114,18 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub active_bookmark_id: Option<String>,
|
||||
pub theme: String,
|
||||
/// Themes used when `theme` is "system": the frontend picks one by the OS color scheme.
|
||||
/// Default to the plain "light"/"dark" schemes so existing configs keep their behavior.
|
||||
#[serde(default = "default_system_light_theme")]
|
||||
pub system_light_theme: String,
|
||||
#[serde(default = "default_system_dark_theme")]
|
||||
pub system_dark_theme: String,
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub font_size: Option<u32>,
|
||||
#[serde(default = "default_true")]
|
||||
pub scroll_to_change_font_size: bool,
|
||||
#[serde(default)]
|
||||
pub font_family: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -131,6 +139,8 @@ pub struct AppConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub show_note_dates: bool,
|
||||
#[serde(default)]
|
||||
pub show_note_switcher: bool,
|
||||
#[serde(default)]
|
||||
pub time_format: String,
|
||||
#[serde(default = "default_week_start")]
|
||||
pub week_start: String,
|
||||
@@ -269,6 +279,14 @@ fn default_ai_model() -> String {
|
||||
"claude-sonnet-4-6".to_string()
|
||||
}
|
||||
|
||||
fn default_system_light_theme() -> String {
|
||||
"light".to_string()
|
||||
}
|
||||
|
||||
fn default_system_dark_theme() -> String {
|
||||
"dark".to_string()
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -276,14 +294,18 @@ impl Default for AppConfig {
|
||||
active_vault: None,
|
||||
active_bookmark_id: None,
|
||||
theme: "system".to_string(),
|
||||
system_light_theme: default_system_light_theme(),
|
||||
system_dark_theme: default_system_dark_theme(),
|
||||
accent_color: None,
|
||||
font_size: None,
|
||||
scroll_to_change_font_size: true,
|
||||
font_family: None,
|
||||
line_height: None,
|
||||
ui_scale: None,
|
||||
content_width: None,
|
||||
compact_notes: false,
|
||||
show_note_dates: true,
|
||||
show_note_switcher: false,
|
||||
time_format: "relative".to_string(),
|
||||
week_start: "monday".to_string(),
|
||||
daily_title_format: "localized".to_string(),
|
||||
@@ -489,7 +511,7 @@ pub struct TaskItem {
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_view_tests {
|
||||
use super::StartupView;
|
||||
use super::{AppConfig, StartupView};
|
||||
|
||||
#[test]
|
||||
fn serializes_supported_startup_views() {
|
||||
@@ -510,4 +532,44 @@ mod startup_view_tests {
|
||||
StartupView::All
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_configs_default_system_theme_pair() {
|
||||
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
|
||||
let object = value.as_object_mut().unwrap();
|
||||
object.remove("system_light_theme");
|
||||
object.remove("system_dark_theme");
|
||||
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(config.system_light_theme, "light");
|
||||
assert_eq!(config.system_dark_theme, "dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_switcher_is_opt_in_for_new_and_existing_configs() {
|
||||
let config = AppConfig::default();
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value.as_object_mut().unwrap().remove("show_note_switcher");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(!config.show_note_switcher);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
|
||||
let config = AppConfig::default();
|
||||
assert!(config.scroll_to_change_font_size);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("scroll_to_change_font_size");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(config.scroll_to_change_font_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
+665
-104
File diff suppressed because it is too large
Load Diff
@@ -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,18 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "HelixNotes",
|
||||
"label": "main",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -43,6 +43,10 @@ export async function setTheme(theme: string): Promise<void> {
|
||||
return invoke("set_theme", { theme });
|
||||
}
|
||||
|
||||
export async function setSystemThemes(light: string, dark: string): Promise<void> {
|
||||
return invoke("set_system_themes", { light, dark });
|
||||
}
|
||||
|
||||
export async function setAccentColor(color: string): Promise<void> {
|
||||
return invoke("set_accent_color", { color });
|
||||
}
|
||||
@@ -67,6 +71,10 @@ export async function setFontSize(size: number): Promise<void> {
|
||||
return invoke("set_font_size", { size });
|
||||
}
|
||||
|
||||
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
|
||||
return invoke("set_scroll_to_change_font_size", { enabled });
|
||||
}
|
||||
|
||||
export async function setFontFamily(family: string): Promise<void> {
|
||||
return invoke("set_font_family", { family });
|
||||
}
|
||||
@@ -141,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 });
|
||||
}
|
||||
@@ -171,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");
|
||||
}
|
||||
|
||||
@@ -270,6 +288,7 @@ export async function setGeneralSettings(
|
||||
closeToTray: boolean,
|
||||
enableWikiLinks: boolean,
|
||||
showNoteDates: boolean,
|
||||
showNoteSwitcher: boolean,
|
||||
startupView: StartupView,
|
||||
restoreLastSession: boolean,
|
||||
showAllNotes: boolean,
|
||||
@@ -297,6 +316,7 @@ export async function setGeneralSettings(
|
||||
closeToTray,
|
||||
enableWikiLinks,
|
||||
showNoteDates,
|
||||
showNoteSwitcher,
|
||||
startupView,
|
||||
restoreLastSession,
|
||||
showAllNotes,
|
||||
@@ -348,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,
|
||||
@@ -79,9 +81,9 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
let sidebar: Sidebar;
|
||||
let noteList: NoteList;
|
||||
let editor: Editor;
|
||||
let sidebar = $state<Sidebar>();
|
||||
let noteList = $state<NoteList>();
|
||||
let editor = $state<Editor>();
|
||||
let unlistenFileChange: (() => void) | null = null;
|
||||
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
|
||||
if (target.mode === 'notebook') {
|
||||
@@ -294,6 +296,24 @@
|
||||
if (isMobile) $mobileView = 'editor';
|
||||
}
|
||||
|
||||
async function selectNoteFromSwitcher(path: string): Promise<boolean> {
|
||||
const currentPath = $activeNotePath;
|
||||
if (currentPath && currentPath.replace(/\\/g, '/') === path.replace(/\\/g, '/')) return true;
|
||||
editor?.flushSave();
|
||||
try {
|
||||
const content = await readNote(path);
|
||||
$viewerNote = null;
|
||||
$activeNote = content;
|
||||
$activeNotePath = path;
|
||||
$editorDirty = false;
|
||||
handleNoteSelected(path, content.content);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to switch note:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewChanged() {
|
||||
taskNoteOpened = false;
|
||||
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||
@@ -507,7 +527,8 @@
|
||||
createAndFocusNote();
|
||||
return;
|
||||
case 'toggle-theme': {
|
||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const customTheme = $customThemes.find(theme => theme.id === $resolvedTheme);
|
||||
const isDark = darkThemes.includes($resolvedTheme) || (customTheme?.is_dark ?? false);
|
||||
const next = isDark ? 'light' : 'dark';
|
||||
$theme = next;
|
||||
setTheme(next);
|
||||
@@ -557,6 +578,11 @@
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
const dismissesAppUi = $showSettings || $showInfo || $focusMode || $showSearch || $showCommandPalette;
|
||||
if (!dismissesAppUi) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ($showSettings) $showSettings = false;
|
||||
else if ($showInfo) $showInfo = false;
|
||||
else if ($focusMode) $focusMode = false;
|
||||
@@ -565,23 +591,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
applyTheme($theme);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$collapsedNotebooks;
|
||||
persistState();
|
||||
@@ -663,12 +672,6 @@
|
||||
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
||||
}
|
||||
|
||||
applyTheme($theme);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if ($theme === 'system') applyTheme('system');
|
||||
});
|
||||
|
||||
// Run sidebar and note list refresh in parallel
|
||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||
|
||||
@@ -831,7 +834,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>
|
||||
@@ -970,7 +973,7 @@
|
||||
<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} onMoveToTrash={trashOpenNote} />
|
||||
@@ -1017,7 +1020,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
|
||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} onSelectNote={selectNoteFromSwitcher} />
|
||||
{/if}
|
||||
<div class="app-layout">
|
||||
{#if !$focusMode}
|
||||
@@ -1031,7 +1034,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} />
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
action: () => {
|
||||
$theme = 'system';
|
||||
setTheme('system');
|
||||
applyTheme('system');
|
||||
$showCommandPalette = false;
|
||||
}
|
||||
},
|
||||
@@ -165,10 +164,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$showCommandPalette = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
$showCommandPalette = false;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
@@ -187,7 +191,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');
|
||||
}
|
||||
}
|
||||
@@ -195,7 +199,7 @@
|
||||
|
||||
{#if $showCommandPalette}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydown={handleKeydown}>
|
||||
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="palette-input-wrapper">
|
||||
|
||||
+343
-450
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 } from '$lib/stores/app';
|
||||
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import type { OrphanAttachment } from '$lib/api';
|
||||
import { openUrl } from '$lib/api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { VaultStats } from '$lib/types';
|
||||
import {
|
||||
ACTIONS,
|
||||
keybindings,
|
||||
@@ -84,13 +81,8 @@
|
||||
if (capturingId === id) stopCapture();
|
||||
}
|
||||
|
||||
let stats = $state<VaultStats | null>(null);
|
||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||
let appVersion = $state('...');
|
||||
let orphans = $state<OrphanAttachment[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
let trashing = $state(false);
|
||||
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
|
||||
|
||||
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
||||
|
||||
@@ -99,49 +91,28 @@
|
||||
activeTab = isMobile ? 'about' : 'shortcuts';
|
||||
}
|
||||
|
||||
function closeFromOverlay(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
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} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
|
||||
<div class="info-panel">
|
||||
<div class="info-header">
|
||||
<h2>Info</h2>
|
||||
<button class="close-btn" onclick={close} aria-label="Close info">
|
||||
@@ -176,49 +147,7 @@
|
||||
<p class="app-version">v{appVersion}</p>
|
||||
<p class="app-description">A local markdown note-taking app.</p>
|
||||
|
||||
{#if stats}
|
||||
<div class="info-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Notes</span>
|
||||
<span class="stat-value">{stats.total_notes}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Attachments</span>
|
||||
<span class="stat-value">{stats.total_attachments}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Notes size</span>
|
||||
<span class="stat-value">{formatSize(stats.notes_size)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Attachments size</span>
|
||||
<span class="stat-value">{formatSize(stats.attachments_size)}</span>
|
||||
</div>
|
||||
<div class="stat-row stat-total">
|
||||
<span class="stat-label">Total vault size</span>
|
||||
<span class="stat-value">{formatSize(stats.total_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stats}
|
||||
<div class="info-cleanup">
|
||||
{#if orphans === null}
|
||||
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
|
||||
{:else if orphans.length === 0}
|
||||
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
|
||||
{:else}
|
||||
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
|
||||
<div class="cleanup-list">
|
||||
{#each orphans as o}
|
||||
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
|
||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info-credits">
|
||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||
@@ -417,39 +346,6 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.info-stats {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 7px 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.stat-total .stat-value {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.info-credits {
|
||||
margin-top: 20px;
|
||||
@@ -481,71 +377,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,21 +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 { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||
import TasksView from './TasksView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
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>;
|
||||
@@ -468,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;
|
||||
@@ -943,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();
|
||||
@@ -1053,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>
|
||||
@@ -1082,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>
|
||||
@@ -1385,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'}
|
||||
@@ -1536,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>
|
||||
@@ -1549,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"/>
|
||||
@@ -1582,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">
|
||||
@@ -2291,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;
|
||||
@@ -2334,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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -44,10 +44,15 @@
|
||||
if (item) item.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$showSearch = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
$showSearch = false;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||
scrollToSelected();
|
||||
@@ -128,7 +133,7 @@
|
||||
|
||||
{#if $showSearch}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="search-overlay" onclick={close} onkeydown={handleKeydown}>
|
||||
<div class="search-overlay" onclick={close} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="search-input-wrapper">
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
|
||||
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setScrollToChangeFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { openUrl } from '$lib/api';
|
||||
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView } from '$lib/types';
|
||||
import type { OrphanAttachment } from '$lib/api';
|
||||
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView, VaultStats } from '$lib/types';
|
||||
import { normalizeStartupView } from '$lib/utils/startup-view';
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
|
||||
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
|
||||
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'maintenance' | 'ai' | 'sync' | 'updates';
|
||||
let activeTab = $state<Tab>('styling');
|
||||
|
||||
// Updates state
|
||||
@@ -116,6 +117,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Vault maintenance state
|
||||
let vaultStats = $state<VaultStats | null>(null);
|
||||
let vaultStatsError = $state(false);
|
||||
let orphanedAttachments = $state<OrphanAttachment[] | null>(null);
|
||||
let scanningOrphanedAttachments = $state(false);
|
||||
let trashingOrphanedAttachments = $state(false);
|
||||
const orphanedAttachmentTotal = $derived((orphanedAttachments ?? []).reduce((total, attachment) => total + attachment.size, 0));
|
||||
|
||||
$effect(() => {
|
||||
if (!$showSettings) {
|
||||
vaultStats = null;
|
||||
vaultStatsError = false;
|
||||
orphanedAttachments = null;
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'maintenance') void loadVaultStats();
|
||||
});
|
||||
|
||||
async function loadVaultStats() {
|
||||
vaultStatsError = false;
|
||||
try {
|
||||
vaultStats = await getVaultStats();
|
||||
} catch (error) {
|
||||
vaultStats = null;
|
||||
vaultStatsError = true;
|
||||
console.error('Failed to load vault statistics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatVaultSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function scanOrphanedAttachments() {
|
||||
scanningOrphanedAttachments = true;
|
||||
try {
|
||||
orphanedAttachments = await findOrphanedAttachments();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
scanningOrphanedAttachments = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function trashFoundOrphanedAttachments() {
|
||||
if (!orphanedAttachments || orphanedAttachments.length === 0) return;
|
||||
trashingOrphanedAttachments = true;
|
||||
try {
|
||||
await trashOrphanedAttachments(orphanedAttachments.map((attachment) => attachment.name));
|
||||
orphanedAttachments = null;
|
||||
void loadVaultStats();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
trashingOrphanedAttachments = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Backup state
|
||||
let backups = $state<BackupEntry[]>([]);
|
||||
let backupLoading = $state(false);
|
||||
@@ -418,11 +480,12 @@
|
||||
}
|
||||
|
||||
let isThemeDark = $derived(
|
||||
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||
($theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false))
|
||||
darkThemes.includes($resolvedTheme) ||
|
||||
($resolvedTheme.startsWith('custom-') && ($customThemes.find(c => c.id === $resolvedTheme)?.is_dark ?? false))
|
||||
);
|
||||
|
||||
const themePresets = [
|
||||
{ id: 'system', label: 'System', bg: '#ffffff', sidebar: '#1a1b26', accent: '#5b6abf' },
|
||||
{ id: 'light', label: 'Light', bg: '#ffffff', sidebar: '#f8f9fa', accent: '#5b6abf' },
|
||||
{ id: 'dark', label: 'Dark', bg: '#1a1b26', sidebar: '#1f2029', accent: '#89b4fa' },
|
||||
{ id: 'solarized-light', label: 'Solarized Light', bg: '#fdf6e3', sidebar: '#eee8d5', accent: '#268bd2' },
|
||||
@@ -458,6 +521,17 @@
|
||||
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
|
||||
];
|
||||
|
||||
// Keep the default themes pinned at the top and sort the rest alphabetically.
|
||||
const priorityThemeIds = ['system', 'light', 'dark'];
|
||||
themePresets.sort((a, b) => {
|
||||
const pa = priorityThemeIds.indexOf(a.id);
|
||||
const pb = priorityThemeIds.indexOf(b.id);
|
||||
if (pa !== -1 && pb !== -1) return pa - pb;
|
||||
if (pa !== -1) return -1;
|
||||
if (pb !== -1) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
const accentPresets = [
|
||||
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
|
||||
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' },
|
||||
@@ -520,6 +594,7 @@
|
||||
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
|
||||
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
|
||||
let activeFontSize = $state($appConfig?.font_size ?? 14);
|
||||
let scrollToChangeFontSize = $state($appConfig?.scroll_to_change_font_size ?? true);
|
||||
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
|
||||
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
|
||||
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
|
||||
@@ -527,7 +602,45 @@
|
||||
let themeDropdownOpen = $state(false);
|
||||
let accentDropdownOpen = $state(false);
|
||||
let activeCustomTheme = $derived($customThemes.find(c => c.id === $theme) ?? null);
|
||||
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
|
||||
let activeThemePreset = $derived(
|
||||
themePresets.find(p => p.id === $theme) ?? themePresets.find(p => p.id === 'light')!
|
||||
);
|
||||
|
||||
// Which theme each OS appearance maps to while `theme` is "system". Custom themes are offered
|
||||
// alongside the built-ins so a light/dark pair can be built out of them.
|
||||
let systemLightTheme = $state($appConfig?.system_light_theme ?? 'light');
|
||||
let systemDarkTheme = $state($appConfig?.system_dark_theme ?? 'dark');
|
||||
let systemPairOpen = $state<'light' | 'dark' | null>(null);
|
||||
|
||||
let pairPresets = $derived([
|
||||
...themePresets.filter(p => p.id !== 'system'),
|
||||
...$customThemes.map(ct => ({
|
||||
id: ct.id,
|
||||
label: ct.name,
|
||||
bg: ct.colors.bg_primary,
|
||||
sidebar: ct.colors.bg_secondary,
|
||||
accent: ct.colors.text_primary,
|
||||
})),
|
||||
]);
|
||||
|
||||
let systemPairRows = $derived([
|
||||
{ key: 'light' as const, title: 'Light appearance', selected: systemLightTheme },
|
||||
{ key: 'dark' as const, title: 'Dark appearance', selected: systemDarkTheme },
|
||||
]);
|
||||
|
||||
function selectSystemPairTheme(which: 'light' | 'dark', id: string) {
|
||||
if (which === 'light') systemLightTheme = id;
|
||||
else systemDarkTheme = id;
|
||||
systemPairOpen = null;
|
||||
if ($appConfig) {
|
||||
$appConfig.system_light_theme = systemLightTheme;
|
||||
$appConfig.system_dark_theme = systemDarkTheme;
|
||||
$appConfig = { ...$appConfig };
|
||||
}
|
||||
setSystemThemes(systemLightTheme, systemDarkTheme).catch((e) =>
|
||||
console.error('Failed to save system themes:', e)
|
||||
);
|
||||
}
|
||||
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
|
||||
let isCustomAccent = $derived(activeAccent.startsWith('#'));
|
||||
let activeAccentColor = $derived(
|
||||
@@ -553,7 +666,7 @@
|
||||
let customThemeImporting = $state(false);
|
||||
|
||||
function openNewCustomThemeEditor() {
|
||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) || $theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false);
|
||||
const isDark = isThemeDark;
|
||||
customThemeEditing = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: '',
|
||||
@@ -585,18 +698,20 @@
|
||||
root.style.setProperty('--text-primary', customThemeEditing.colors.text_primary);
|
||||
root.style.setProperty('--text-secondary', customThemeEditing.colors.text_secondary);
|
||||
root.style.setProperty('--border-color', customThemeEditing.colors.border_color);
|
||||
root.style.setProperty('--border-light', customThemeEditing.colors.border_color);
|
||||
root.style.setProperty('--text-tertiary', customThemeEditing.colors.text_secondary);
|
||||
root.classList.toggle('dark', customThemeEditing.is_dark);
|
||||
}
|
||||
|
||||
function restoreCurrentTheme() {
|
||||
const root = document.documentElement;
|
||||
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
|
||||
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color','--border-light','--text-tertiary'];
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
for (const v of varsToClear) root.style.removeProperty(v);
|
||||
const namedThemes = ['solarized-light','solarized-dark','catppuccin','nord','tokyo-night','github-light','github-dark','dracula','blueberry','forest-green','gruvbox','midnight-tide','cherry-blossom','synthwave','ember','moonlit','light-coffee','dark-coffee','cotton-candy','crimson','cloud','peach','material-dark','material-light','monokai','rose-pine','everforest','horizon','cyberpunk','black','one-dark'];
|
||||
if ($theme.startsWith('custom-')) {
|
||||
const ct = $customThemes.find(c => c.id === $theme);
|
||||
if ($resolvedTheme.startsWith('custom-')) {
|
||||
const ct = $customThemes.find(c => c.id === $resolvedTheme);
|
||||
if (ct) {
|
||||
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
||||
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
||||
@@ -607,12 +722,14 @@
|
||||
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
||||
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
||||
root.style.setProperty('--border-color', ct.colors.border_color);
|
||||
root.style.setProperty('--border-light', ct.colors.border_color);
|
||||
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
|
||||
if (ct.is_dark) root.classList.add('dark');
|
||||
}
|
||||
} else if (namedThemes.includes($theme)) {
|
||||
root.setAttribute('data-theme', $theme);
|
||||
if (darkThemes.includes($theme)) root.classList.add('dark');
|
||||
} else if ($theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
} else if (namedThemes.includes($resolvedTheme)) {
|
||||
root.setAttribute('data-theme', $resolvedTheme);
|
||||
if (darkThemes.includes($resolvedTheme)) root.classList.add('dark');
|
||||
} else if ($resolvedTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -639,6 +756,10 @@
|
||||
closeCustomThemeEditor();
|
||||
}
|
||||
|
||||
function cancelCustomThemeFromOverlay(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) cancelCustomThemeEditor();
|
||||
}
|
||||
|
||||
async function selectCustomTheme(ct: CustomTheme) {
|
||||
$theme = ct.id;
|
||||
setTheme(ct.id);
|
||||
@@ -647,9 +768,15 @@
|
||||
async function removeCustomTheme(ct: CustomTheme) {
|
||||
try {
|
||||
await deleteCustomTheme(ct.id);
|
||||
if (systemLightTheme === ct.id) systemLightTheme = 'light';
|
||||
if (systemDarkTheme === ct.id) systemDarkTheme = 'dark';
|
||||
if ($appConfig) {
|
||||
$appConfig.custom_themes = $appConfig.custom_themes.filter(t => t.id !== ct.id);
|
||||
$appConfig = { ...$appConfig };
|
||||
$appConfig = {
|
||||
...$appConfig,
|
||||
custom_themes: $appConfig.custom_themes.filter(t => t.id !== ct.id),
|
||||
system_light_theme: systemLightTheme,
|
||||
system_dark_theme: systemDarkTheme,
|
||||
};
|
||||
}
|
||||
if ($theme === ct.id) {
|
||||
$theme = 'system';
|
||||
@@ -701,6 +828,7 @@
|
||||
// General settings
|
||||
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
||||
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
|
||||
let showNoteSwitcher = $state($appConfig?.show_note_switcher ?? false);
|
||||
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
|
||||
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
|
||||
let timeFormat = $state($appConfig?.time_format ?? 'relative');
|
||||
@@ -776,6 +904,7 @@
|
||||
if ($appConfig) {
|
||||
$appConfig.compact_notes = compactNotes;
|
||||
$appConfig.show_note_dates = showNoteDates;
|
||||
$appConfig.show_note_switcher = showNoteSwitcher;
|
||||
$appConfig.startup_view = startupView;
|
||||
$appConfig.restore_last_session = restoreLastSession;
|
||||
$appConfig.time_format = timeFormat;
|
||||
@@ -800,7 +929,7 @@
|
||||
$appConfig.show_daily_notes = showDailyNotes;
|
||||
$appConfig.show_trash = showTrash;
|
||||
}
|
||||
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
||||
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, showNoteSwitcher, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
||||
.catch((e) => console.error('Failed to save general settings:', e));
|
||||
}
|
||||
|
||||
@@ -812,7 +941,9 @@
|
||||
$theme = preset.id;
|
||||
setTheme(preset.id);
|
||||
themeDropdownOpen = false;
|
||||
selectCustomAccent(preset.accent);
|
||||
// "System" has no palette of its own, it defers to whichever paired theme is active, so it
|
||||
// must not overwrite the accent the way a concrete theme does.
|
||||
if (preset.id !== 'system') selectCustomAccent(preset.accent);
|
||||
}
|
||||
|
||||
function selectAccent(preset: typeof accentPresets[0]) {
|
||||
@@ -823,7 +954,7 @@
|
||||
|
||||
function applyAccent(preset: typeof accentPresets[0]) {
|
||||
const root = document.documentElement;
|
||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isDark = isThemeDark;
|
||||
const color = isDark ? preset.dark : preset.light;
|
||||
|
||||
root.style.setProperty('--accent', color);
|
||||
@@ -846,7 +977,7 @@
|
||||
|
||||
function applyCustomAccent(hex: string) {
|
||||
const root = document.documentElement;
|
||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isDark = isThemeDark;
|
||||
root.style.setProperty('--accent', hex);
|
||||
root.style.setProperty('--text-accent', hex);
|
||||
root.style.setProperty('--accent-hover', hex);
|
||||
@@ -868,6 +999,13 @@
|
||||
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
|
||||
}
|
||||
|
||||
function toggleScrollToChangeFontSize() {
|
||||
scrollToChangeFontSize = !scrollToChangeFontSize;
|
||||
if ($appConfig) $appConfig.scroll_to_change_font_size = scrollToChangeFontSize;
|
||||
setScrollToChangeFontSize(scrollToChangeFontSize)
|
||||
.catch((e) => console.error('Failed to save scroll font size setting:', e));
|
||||
}
|
||||
|
||||
function applyFontSize(size: number) {
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
|
||||
}
|
||||
@@ -937,6 +1075,23 @@
|
||||
$showSettings = false;
|
||||
}
|
||||
|
||||
function closeSettingsFromOverlay(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (restoreConfirm) restoreConfirm = null;
|
||||
else if (customThemeEditorOpen) cancelCustomThemeEditor();
|
||||
else close();
|
||||
}
|
||||
|
||||
function dismissRestoreConfirm(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) restoreConfirm = null;
|
||||
}
|
||||
|
||||
// Apply saved settings on mount
|
||||
$effect(() => {
|
||||
const savedAccent = $appConfig?.accent_color;
|
||||
@@ -953,6 +1108,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
|
||||
const savedSize = $appConfig?.font_size;
|
||||
if (savedSize) {
|
||||
activeFontSize = savedSize;
|
||||
@@ -980,6 +1136,7 @@
|
||||
if ($appConfig) {
|
||||
compactNotes = $appConfig.compact_notes ?? false;
|
||||
showNoteDates = $appConfig.show_note_dates ?? true;
|
||||
showNoteSwitcher = $appConfig.show_note_switcher ?? false;
|
||||
startupView = normalizeStartupView($appConfig.startup_view);
|
||||
restoreLastSession = $appConfig.restore_last_session ?? false;
|
||||
timeFormat = $appConfig.time_format ?? 'relative';
|
||||
@@ -999,9 +1156,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Re-apply when theme changes
|
||||
// Re-apply when the concrete theme changes, including an OS appearance switch.
|
||||
$effect(() => {
|
||||
const _ = $theme;
|
||||
const _ = $resolvedTheme;
|
||||
const preset = accentPresets.find(p => p.name === activeAccent);
|
||||
if (preset) {
|
||||
applyAccent(preset);
|
||||
@@ -1011,12 +1168,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} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
|
||||
<div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
|
||||
<div class="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<button class="close-btn" onclick={close}>
|
||||
<h2 id="settings-title">Settings</h2>
|
||||
<button class="close-btn" onclick={close} aria-label="Close settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
@@ -1060,6 +1216,12 @@
|
||||
Backup
|
||||
</button>
|
||||
{/if}
|
||||
<button class="tab-btn" class:active={activeTab === 'maintenance'} onclick={() => activeTab = 'maintenance'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>
|
||||
</svg>
|
||||
Maintenance
|
||||
</button>
|
||||
<button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
|
||||
@@ -1092,7 +1254,7 @@
|
||||
<span class="setting-name">Compact mode</span>
|
||||
<span class="setting-desc">Show notes in a denser layout without preview</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={compactNotes} onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={compactNotes} role="switch" aria-checked={compactNotes} aria-label="Compact notes" onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1101,7 +1263,7 @@
|
||||
<span class="setting-name">Show dates</span>
|
||||
<span class="setting-desc">Show the date next to each note in the list</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showNoteDates} onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showNoteDates} role="switch" aria-checked={showNoteDates} aria-label="Show note dates" onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1109,12 +1271,12 @@
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Notebooks</h3>
|
||||
<label class="setting-toggle">
|
||||
<div class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Sort order</span>
|
||||
<span class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-options" style="margin-top: 8px;">
|
||||
<button class="option-btn" class:active={$notebookSortMode === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button>
|
||||
<button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
|
||||
@@ -1127,31 +1289,46 @@
|
||||
<p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">All Notes</span></span>
|
||||
<button class="toggle-switch" class:on={showAllNotes} onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showAllNotes} role="switch" aria-checked={showAllNotes} aria-label="Show All Notes" onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Quick Access</span></span>
|
||||
<button class="toggle-switch" class:on={showQuickAccess} onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showQuickAccess} role="switch" aria-checked={showQuickAccess} aria-label="Show Quick Access" onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Tasks</span></span>
|
||||
<button class="toggle-switch" class:on={showTasks} onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showTasks} role="switch" aria-checked={showTasks} aria-label="Show Tasks" onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Daily Notes</span></span>
|
||||
<button class="toggle-switch" class:on={showDailyNotes} onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showDailyNotes} role="switch" aria-checked={showDailyNotes} aria-label="Show Daily Notes" onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Trash</span></span>
|
||||
<button class="toggle-switch" class:on={showTrash} onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showTrash} role="switch" aria-checked={showTrash} aria-label="Show Trash" onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !$platformIsMobile}
|
||||
<div class="settings-section">
|
||||
<h3>Interface</h3>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Show note switcher in title bar</span>
|
||||
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showNoteSwitcher} role="switch" aria-checked={showNoteSwitcher} aria-label="Show note switcher in title bar" onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1175,7 +1352,7 @@
|
||||
<span class="setting-name">Restore last session on launch</span>
|
||||
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={restoreLastSession} role="switch" aria-checked={restoreLastSession} aria-label="Restore last session on launch" onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1222,6 +1399,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if !isMobile}
|
||||
<div class="settings-section">
|
||||
<h3>Performance</h3>
|
||||
@@ -1230,7 +1408,7 @@
|
||||
<span class="setting-name">GPU Acceleration</span>
|
||||
<span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={gpuAcceleration} onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={gpuAcceleration} role="switch" aria-checked={gpuAcceleration} aria-label="GPU acceleration" onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1243,7 +1421,7 @@
|
||||
<span class="setting-name">Start at system startup</span>
|
||||
<span class="setting-desc">Launch HelixNotes when your computer starts</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={autostart} onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={autostart} role="switch" aria-checked={autostart} aria-label="Start at system startup" onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1252,7 +1430,7 @@
|
||||
<span class="setting-name">Show in system tray</span>
|
||||
<span class="setting-desc">Show an icon in the notification area (requires restart)</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showTrayIcon} role="switch" aria-checked={showTrayIcon} aria-label="Show in system tray" onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1262,7 +1440,7 @@
|
||||
<span class="setting-name">Close to tray</span>
|
||||
<span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={closeToTray} onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={closeToTray} role="switch" aria-checked={closeToTray} aria-label="Close to tray" onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1270,6 +1448,78 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'maintenance'}
|
||||
<div class="tab-content">
|
||||
<div class="settings-section">
|
||||
<h3>Vault Overview</h3>
|
||||
{#if vaultStats}
|
||||
<div class="maintenance-stats">
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Notes</span>
|
||||
<span class="maintenance-stat-value">{vaultStats.total_notes}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Attachments</span>
|
||||
<span class="maintenance-stat-value">{vaultStats.total_attachments}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Notes size</span>
|
||||
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.notes_size)}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Attachments size</span>
|
||||
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.attachments_size)}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row maintenance-stat-total">
|
||||
<span class="maintenance-stat-label">Total vault size</span>
|
||||
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.total_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if vaultStatsError}
|
||||
<p class="setting-desc">Vault statistics could not be loaded.</p>
|
||||
{:else}
|
||||
<p class="setting-desc">Loading vault statistics…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Orphaned Attachments</h3>
|
||||
<div class="vault-maintenance">
|
||||
<p class="setting-desc">Find files in the vault's attachments folder that are not referenced by any note.</p>
|
||||
{#if orphanedAttachments === null}
|
||||
<button class="import-btn cleanup-action" onclick={scanOrphanedAttachments} disabled={scanningOrphanedAttachments}>
|
||||
{#if scanningOrphanedAttachments}
|
||||
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
||||
Scanning…
|
||||
{:else}
|
||||
Find orphaned attachments
|
||||
{/if}
|
||||
</button>
|
||||
{:else if orphanedAttachments.length === 0}
|
||||
<p class="cleanup-message">No orphaned attachments. Nothing to clean up.</p>
|
||||
{:else}
|
||||
<p class="cleanup-message">{orphanedAttachments.length} {orphanedAttachments.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatVaultSize(orphanedAttachmentTotal)}).</p>
|
||||
<div class="cleanup-list">
|
||||
{#each orphanedAttachments as attachment}
|
||||
<div class="cleanup-row">
|
||||
<span class="cleanup-name" title={attachment.name}>{attachment.name}</span>
|
||||
<span class="cleanup-size">{formatVaultSize(attachment.size)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="import-btn cleanup-action" onclick={trashFoundOrphanedAttachments} disabled={trashingOrphanedAttachments}>
|
||||
{#if trashingOrphanedAttachments}
|
||||
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
||||
Moving…
|
||||
{:else}
|
||||
Move {orphanedAttachments.length} to Trash
|
||||
{/if}
|
||||
</button>
|
||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'editor'}
|
||||
<div class="tab-content">
|
||||
<div class="settings-section">
|
||||
@@ -1279,7 +1529,7 @@
|
||||
<span class="setting-name">Hide title in note body</span>
|
||||
<span class="setting-desc">Hide the first heading when it matches the note title</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={hideTitleInBody} onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={hideTitleInBody} role="switch" aria-checked={hideTitleInBody} aria-label="Hide title in note body" onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1288,7 +1538,7 @@
|
||||
<span class="setting-name">Show link arrows</span>
|
||||
<span class="setting-desc">Display the arrow icon after external links</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showLinkArrows} onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
|
||||
<button class="toggle-switch" class:on={showLinkArrows} role="switch" aria-checked={showLinkArrows} aria-label="Show link arrows" onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1298,7 +1548,7 @@
|
||||
<span class="setting-name">Show line numbers</span>
|
||||
<span class="setting-desc">Display line numbers in the markdown source editor</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showLineNumbers} onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={showLineNumbers} role="switch" aria-checked={showLineNumbers} aria-label="Show line numbers" onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1308,7 +1558,7 @@
|
||||
<span class="setting-name">Open notes in View Mode</span>
|
||||
<span class="setting-desc">Notes open as read-only by default. Click the eye icon to switch to editing.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={defaultViewMode} onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={defaultViewMode} role="switch" aria-checked={defaultViewMode} aria-label="Open notes in View Mode" onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1317,7 +1567,7 @@
|
||||
<span class="setting-name">Open new notes in source mode</span>
|
||||
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={newNotesInSourceMode} onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={newNotesInSourceMode} role="switch" aria-checked={newNotesInSourceMode} aria-label="Open new notes in source mode" onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1331,7 +1581,7 @@
|
||||
<span class="setting-name">Enable wiki links</span>
|
||||
<span class="setting-desc">Link notes with [[Note Title]] syntax and visualize connections in a graph view</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={enableWikiLinks} onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={enableWikiLinks} role="switch" aria-checked={enableWikiLinks} aria-label="Enable wiki links" onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1346,7 +1596,7 @@
|
||||
<span class="setting-name">Inline PDF preview</span>
|
||||
<span class="setting-desc">Render embedded PDF files as inline previews inside notes</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={pdfPreview} onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={pdfPreview} role="switch" aria-checked={pdfPreview} aria-label="Inline PDF preview" onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1415,6 +1665,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $theme === 'system'}
|
||||
<span class="setting-hint">Follows the operating system appearance.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
@@ -1463,6 +1716,54 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $theme === 'system'}
|
||||
{#each systemPairRows as row}
|
||||
<div class="settings-section">
|
||||
<h3>{row.title}</h3>
|
||||
<div class="dropdown-wrap">
|
||||
<button
|
||||
class="dropdown-trigger"
|
||||
class:open={systemPairOpen === row.key}
|
||||
onclick={() => { systemPairOpen = systemPairOpen === row.key ? null : row.key; themeDropdownOpen = false; accentDropdownOpen = false; }}
|
||||
>
|
||||
{#each pairPresets.filter(p => p.id === row.selected) as preset}
|
||||
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
|
||||
<span class="preview-sidebar"></span>
|
||||
<span class="preview-main">
|
||||
<span class="preview-accent-bar"></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="dropdown-trigger-label">{preset.label}</span>
|
||||
{/each}
|
||||
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
{#if systemPairOpen === row.key}
|
||||
<div class="dropdown-list" use:clickOutside={() => systemPairOpen = null}>
|
||||
{#each pairPresets as preset}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
class:active={row.selected === preset.id}
|
||||
onclick={() => selectSystemPairTheme(row.key, preset.id)}
|
||||
>
|
||||
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
|
||||
<span class="preview-sidebar"></span>
|
||||
<span class="preview-main">
|
||||
<span class="preview-accent-bar"></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="dropdown-item-label">{preset.label}</span>
|
||||
{#if row.selected === preset.id}
|
||||
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Custom Themes -->
|
||||
@@ -1532,24 +1833,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={handleEscape}>
|
||||
<div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
|
||||
<div class="custom-theme-modal-header">
|
||||
<h3>{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
|
||||
<button class="close-btn" onclick={cancelCustomThemeEditor}>
|
||||
<h3 id="custom-theme-title">{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
|
||||
<button class="close-btn" onclick={cancelCustomThemeEditor} aria-label="Close theme editor">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="custom-theme-modal-body">
|
||||
<div class="ct-field">
|
||||
<label class="ct-label">Theme Name</label>
|
||||
<input class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
|
||||
<label class="ct-label" for="custom-theme-name">Theme Name</label>
|
||||
<input id="custom-theme-name" class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
|
||||
</div>
|
||||
|
||||
<div class="ct-field">
|
||||
<label class="ct-label">Mode</label>
|
||||
<span class="ct-label">Mode</span>
|
||||
<div class="setting-options">
|
||||
<button class="option-btn" class:active={!customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = false; previewCustomTheme(); } }}>Light</button>
|
||||
<button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button>
|
||||
@@ -1639,6 +1939,17 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !isMobile}
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Scroll to change font size</span>
|
||||
<span class="setting-desc">Allow Cmd/Ctrl + scroll to resize editor text</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={scrollToChangeFontSize} role="switch" aria-checked={scrollToChangeFontSize} aria-label="Scroll to change font size" onclick={toggleScrollToChangeFontSize}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isMobile}
|
||||
@@ -1716,7 +2027,7 @@
|
||||
|
||||
<label class="setting-toggle">
|
||||
<span>Enable automatic backup</span>
|
||||
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
|
||||
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} role="switch" aria-checked={$appConfig?.backup_enabled ?? false} aria-label="Enable automatic backup" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1744,7 +2055,7 @@
|
||||
<span class="setting-name">Include attachments</span>
|
||||
<span class="setting-desc">Include images and files in backups (increases size significantly)</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
|
||||
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} role="switch" aria-checked={$appConfig?.backup_include_attachments ?? false} aria-label="Include attachments in backups" onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
@@ -1830,10 +2141,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={handleEscape}>
|
||||
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
|
||||
<h4 id="restore-confirm-title">Restore Backup?</h4>
|
||||
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
|
||||
<div class="restore-confirm-actions">
|
||||
<button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button>
|
||||
@@ -2182,14 +2492,14 @@
|
||||
<span class="setting-name">Sync when a note changes</span>
|
||||
<span class="setting-desc">Sync shortly after you edit a note.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={syncOnChange} onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||
<button class="toggle-switch" class:on={syncOnChange} role="switch" aria-checked={syncOnChange} aria-label="Sync when a note changes" onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Sync when the vault opens</span>
|
||||
<span class="setting-desc">Sync once when the app starts.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={syncOnOpen} onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||
<button class="toggle-switch" class:on={syncOnOpen} role="switch" aria-checked={syncOnOpen} aria-label="Sync when the vault opens" onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2842,6 +3152,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;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
NOTEBOOK_ICON_OPTIONS,
|
||||
decodeBuiltinNotebookIcon,
|
||||
encodeBuiltinNotebookIcon,
|
||||
normalizeNotebookIconKey,
|
||||
type NotebookIconId
|
||||
} from '$lib/utils/notebook-icons';
|
||||
|
||||
@@ -334,6 +335,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function dismissDeleteConfirm(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) deleteConfirm = null;
|
||||
}
|
||||
|
||||
async function confirmDelete(nb: NotebookEntry) {
|
||||
deleteConfirm = null;
|
||||
try {
|
||||
@@ -587,9 +592,10 @@
|
||||
|
||||
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
|
||||
try {
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
const value = encodeBuiltinNotebookIcon(icon);
|
||||
await setNotebookIcon(nb.relative_path, value);
|
||||
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: value };
|
||||
await setNotebookIcon(key, value);
|
||||
$notebookIcons = { ...$notebookIcons, [key]: value };
|
||||
iconPickerNotebook = null;
|
||||
} catch (e) {
|
||||
console.error('Failed to set notebook icon:', e);
|
||||
@@ -608,8 +614,9 @@
|
||||
const data = await readFile(filePath);
|
||||
const fileName = baseOf(filePath) || 'icon.png';
|
||||
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
|
||||
await setNotebookIcon(nb.relative_path, iconRelative);
|
||||
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: iconRelative };
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
await setNotebookIcon(key, iconRelative);
|
||||
$notebookIcons = { ...$notebookIcons, [key]: iconRelative };
|
||||
} catch (e) {
|
||||
console.error('Failed to set notebook icon:', e);
|
||||
}
|
||||
@@ -619,9 +626,10 @@
|
||||
contextMenu = null;
|
||||
iconPickerNotebook = null;
|
||||
try {
|
||||
await setNotebookIcon(nb.relative_path, null);
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
await setNotebookIcon(key, null);
|
||||
const icons = { ...$notebookIcons };
|
||||
delete icons[nb.relative_path];
|
||||
delete icons[key];
|
||||
$notebookIcons = icons;
|
||||
} catch (e) {
|
||||
console.error('Failed to remove notebook icon:', e);
|
||||
@@ -629,7 +637,7 @@
|
||||
}
|
||||
|
||||
function getNotebookIconSrc(nb: NotebookEntry): string | null {
|
||||
const iconPath = $notebookIcons[nb.relative_path];
|
||||
const iconPath = $notebookIcons[normalizeNotebookIconKey(nb.relative_path)];
|
||||
if (!iconPath || iconPath.startsWith('builtin:')) return null;
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (!vaultRoot) return null;
|
||||
@@ -692,7 +700,7 @@
|
||||
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
|
||||
|
||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
<aside class="sidebar" class:collapsed={!isMobile && $sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
{#if !isMobile}
|
||||
<div class="sidebar-header">
|
||||
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
||||
@@ -713,7 +721,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !$sidebarCollapsed}
|
||||
{#if isMobile || !$sidebarCollapsed}
|
||||
{#if anyNavItem}
|
||||
<nav class="sidebar-nav">
|
||||
{#if $appConfig?.show_all_notes !== false}
|
||||
@@ -955,7 +963,7 @@
|
||||
</button>
|
||||
<button onclick={() => openIconPicker(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
|
||||
{$notebookIcons[contextMenu.notebook.relative_path] ? 'Change Icon...' : 'Set Icon...'}
|
||||
{$notebookIcons[normalizeNotebookIconKey(contextMenu.notebook.relative_path)] ? 'Change Icon...' : 'Set Icon...'}
|
||||
</button>
|
||||
<button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg>
|
||||
@@ -981,7 +989,7 @@
|
||||
{#each NOTEBOOK_ICON_OPTIONS as option}
|
||||
<button
|
||||
class="icon-picker-option"
|
||||
class:active={$notebookIcons[iconPickerNotebook.relative_path] === encodeBuiltinNotebookIcon(option.id)}
|
||||
class:active={$notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)] === encodeBuiltinNotebookIcon(option.id)}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
onclick={() => handleBuiltinIcon(iconPickerNotebook!, option.id)}
|
||||
@@ -996,7 +1004,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
|
||||
Custom image...
|
||||
</button>
|
||||
{#if $notebookIcons[iconPickerNotebook.relative_path]}
|
||||
{#if $notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)]}
|
||||
<button class="remove" onclick={() => handleRemoveIcon(iconPickerNotebook!)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
|
||||
Use default
|
||||
@@ -1025,10 +1033,9 @@
|
||||
|
||||
{#if deleteConfirm}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
|
||||
<h4>Delete "{deleteConfirm.name}"?</h4>
|
||||
<div class="delete-confirm-overlay" onclick={dismissDeleteConfirm} onkeydown={(e) => { if (e.key === 'Escape') deleteConfirm = null; }}>
|
||||
<div class="delete-confirm" class:mobile={isMobile} role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" tabindex="-1">
|
||||
<h4 id="delete-confirm-title">Delete "{deleteConfirm.name}"?</h4>
|
||||
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
||||
<div class="delete-confirm-actions">
|
||||
<button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
|
||||
@@ -1042,7 +1049,7 @@
|
||||
{@const hasChildren = nb.children.length > 0}
|
||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||
{@const iconSrc = getNotebookIconSrc(nb)}
|
||||
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[nb.relative_path])}
|
||||
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[normalizeNotebookIconKey(nb.relative_path)])}
|
||||
{#if editingNotebook === nb.path}
|
||||
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
|
||||
<input
|
||||
@@ -1067,7 +1074,9 @@
|
||||
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
||||
style="padding-left: {4 + depth * 16}px"
|
||||
data-nb-path={nb.path}
|
||||
onclick={() => selectNotebook(nb)}
|
||||
onclick={(e) => {
|
||||
if (!(e.target as Element).closest('.nb-drag-handle')) selectNotebook(nb);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
@@ -1149,12 +1158,11 @@
|
||||
{/if}
|
||||
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
||||
{#if $notebookSortMode === 'manual'}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="nb-drag-handle"
|
||||
title="Drag to reorder"
|
||||
onpointerdown={(e) => nbHandleDown(e, nb)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
|
||||
</span>
|
||||
|
||||
@@ -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,46 @@
|
||||
export type HiddenTitleHeading = {
|
||||
headingPrefix: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type TitleHeadingResult = {
|
||||
markdown: string;
|
||||
hiddenTitle: HiddenTitleHeading | null;
|
||||
};
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[\s\-_\u2014]+/g, ' ');
|
||||
}
|
||||
|
||||
export function stripTitleHeading(
|
||||
markdown: string,
|
||||
title: string | undefined,
|
||||
hideTitle: boolean,
|
||||
): TitleHeadingResult {
|
||||
if (!hideTitle || !title) return { markdown, hiddenTitle: null };
|
||||
|
||||
const lines = markdown.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line === '') continue;
|
||||
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match && normalizeTitle(match[2]) === normalizeTitle(title)) {
|
||||
const hiddenTitle = { headingPrefix: match[1], title: title.trim() };
|
||||
lines.splice(i, 1);
|
||||
if (i < lines.length && lines[i].trim() === '') lines.splice(i, 1);
|
||||
return { markdown: lines.join('\n'), hiddenTitle };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return { markdown, hiddenTitle: null };
|
||||
}
|
||||
|
||||
export function restoreTitleHeading(
|
||||
markdown: string,
|
||||
hiddenTitle: HiddenTitleHeading | null,
|
||||
): string {
|
||||
if (!hiddenTitle) return markdown;
|
||||
return `${hiddenTitle.headingPrefix} ${hiddenTitle.title}\n\n${markdown}`;
|
||||
}
|
||||
@@ -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() -
|
||||
|
||||
@@ -88,14 +88,18 @@ export interface AppConfig {
|
||||
active_vault: string | null;
|
||||
active_bookmark_id?: string | null;
|
||||
theme: string;
|
||||
system_light_theme: string;
|
||||
system_dark_theme: string;
|
||||
accent_color: string | null;
|
||||
font_size: number | null;
|
||||
scroll_to_change_font_size: boolean;
|
||||
font_family: string | null;
|
||||
line_height: number | null;
|
||||
ui_scale: number | null;
|
||||
content_width: number | null;
|
||||
compact_notes: boolean;
|
||||
show_note_dates: boolean;
|
||||
show_note_switcher: boolean;
|
||||
time_format: string;
|
||||
week_start: string;
|
||||
daily_title_format: string;
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
@@ -39,6 +39,10 @@ const BUILTIN_IDS: Record<NotebookIconId, true> = {
|
||||
palette: true
|
||||
};
|
||||
|
||||
export function normalizeNotebookIconKey(path: string): string {
|
||||
return path.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function encodeBuiltinNotebookIcon(icon: NotebookIconId): string {
|
||||
return `${BUILTIN_PREFIX}${icon}`;
|
||||
}
|
||||
|
||||
@@ -26,3 +26,102 @@ export function relativePath(fromDirectory: string, targetPath: string): string
|
||||
const result = [...Array(from.length - common).fill('..'), ...target.slice(common)];
|
||||
return result.join('/') || '.';
|
||||
}
|
||||
|
||||
export function normalizeLocalAssetPath(path: string): string {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/([A-Za-z]:\/)/, '$1');
|
||||
}
|
||||
|
||||
export function assetUrlToLocalPath(source: string): string | null {
|
||||
if (!source.startsWith('asset:') && !source.startsWith('http://asset.localhost') && !source.startsWith('https://asset.localhost')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const path = normalizeLocalAssetPath(decodeURIComponent(new URL(source).pathname));
|
||||
// Tauri prepends a URL-path slash to POSIX and UNC paths.
|
||||
return path.startsWith('//') ? path.substring(1) : path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveVaultFilePath(
|
||||
targetPath: string,
|
||||
notePath: string | null,
|
||||
vaultRoot: string | null,
|
||||
): string {
|
||||
const target = normalizeLocalAssetPath(targetPath);
|
||||
if (target.startsWith('/') || /^[A-Za-z]:\//.test(target)) return target;
|
||||
|
||||
const root = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '') ?? '';
|
||||
if (target.startsWith('.helixnotes/') && root) {
|
||||
return resolvePathFromFile(`${root}/.vault-root`, target);
|
||||
}
|
||||
if (notePath) return resolvePathFromFile(notePath, target);
|
||||
if (root) return resolvePathFromFile(`${root}/.vault-root`, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function assetSourceToMarkdown(
|
||||
source: string,
|
||||
notePath: string | null,
|
||||
vaultRoot: string | null,
|
||||
): string {
|
||||
if (source.startsWith('blob:')) return '';
|
||||
if (source.startsWith('imgproxy:') || source.startsWith('http://imgproxy.localhost') || source.startsWith('https://imgproxy.localhost')) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(source).pathname.substring(1));
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
const absolutePath = assetUrlToLocalPath(source);
|
||||
if (absolutePath === null) return source;
|
||||
|
||||
const normalizedRoot = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
if (!normalizedRoot || !absolutePath.startsWith(normalizedRoot + '/')) return absolutePath;
|
||||
const vaultRelative = absolutePath.substring(normalizedRoot.length + 1);
|
||||
if (vaultRelative.startsWith('.helixnotes/')) return vaultRelative;
|
||||
if (!notePath) return vaultRelative;
|
||||
const normalizedNotePath = notePath.replace(/\\/g, '/');
|
||||
const noteDirectory = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
|
||||
return relativePath(noteDirectory, absolutePath);
|
||||
}
|
||||
|
||||
export function resolvePathFromFile(filePath: string, targetPath: string): string {
|
||||
const normalizedFile = filePath.replace(/\\/g, '/');
|
||||
const normalizedTarget = targetPath.replace(/\\/g, '/');
|
||||
const lastSeparator = normalizedFile.lastIndexOf('/');
|
||||
const fileDirectory = lastSeparator >= 0 ? normalizedFile.slice(0, lastSeparator) : '';
|
||||
const combined = normalizedTarget.startsWith('/') || /^[A-Za-z]:\//.test(normalizedTarget)
|
||||
? normalizedTarget
|
||||
: `${fileDirectory}/${normalizedTarget}`;
|
||||
const drive = combined.match(/^([A-Za-z]:)\//);
|
||||
const unc = combined.startsWith('//');
|
||||
const absolute = !unc && combined.startsWith('/');
|
||||
let prefix = '';
|
||||
let remainder = combined;
|
||||
if (drive) {
|
||||
prefix = `${drive[1]}/`;
|
||||
remainder = combined.slice(drive[0].length);
|
||||
} else if (unc) {
|
||||
prefix = '//';
|
||||
remainder = combined.slice(2);
|
||||
} else if (absolute) {
|
||||
prefix = '/';
|
||||
remainder = combined.slice(1);
|
||||
}
|
||||
const resolved: string[] = [];
|
||||
|
||||
for (const segment of remainder.split('/')) {
|
||||
if (!segment || segment === '.') continue;
|
||||
if (segment === '..') {
|
||||
resolved.pop();
|
||||
} else {
|
||||
resolved.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return prefix + resolved.join('/');
|
||||
}
|
||||
|
||||
+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,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { createContext, Script } from 'node:vm';
|
||||
import { compile, parse } from 'svelte/compiler';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const ast = parse(source, { modern: true });
|
||||
|
||||
function elementWithClass(nodes, className) {
|
||||
return nodes.find((node) => node.attributes?.some((attribute) =>
|
||||
attribute.name === 'class' && attribute.value[0]?.data === className
|
||||
));
|
||||
}
|
||||
|
||||
const aside = elementWithClass(ast.fragment.nodes, 'sidebar');
|
||||
const blocks = aside.fragment.nodes.filter((node) => node.type === 'IfBlock');
|
||||
const content = blocks.find((node) => elementWithClass(node.consequent.nodes, 'section'));
|
||||
const header = blocks.find((node) => elementWithClass(node.consequent.nodes, 'sidebar-header'));
|
||||
const toggle = elementWithClass(
|
||||
elementWithClass(header.consequent.nodes, 'sidebar-header').fragment.nodes,
|
||||
'collapse-btn'
|
||||
);
|
||||
|
||||
function expressionScript(expression) {
|
||||
assert.ok(expression, 'expected a component expression');
|
||||
return new Script(`(${source.slice(expression.start, expression.end)})`);
|
||||
}
|
||||
|
||||
// Execute the actual template conditions, not a copy of the collapse logic.
|
||||
const expressions = {
|
||||
contentVisible: expressionScript(content.test),
|
||||
collapsedClass: expressionScript(aside.attributes.find((attribute) =>
|
||||
attribute.type === 'ClassDirective' && attribute.name === 'collapsed'
|
||||
).expression),
|
||||
toggleVisible: expressionScript(header.test)
|
||||
};
|
||||
const toggleScript = expressionScript(
|
||||
toggle.attributes.find((attribute) => attribute.name === 'onclick').value.expression
|
||||
);
|
||||
|
||||
function sidebarContext(isMobile, stored) {
|
||||
const sidebarCollapsed = writable(stored);
|
||||
const context = createContext({
|
||||
isMobile,
|
||||
get $sidebarCollapsed() { return get(sidebarCollapsed); },
|
||||
set $sidebarCollapsed(value) { sidebarCollapsed.set(value); }
|
||||
});
|
||||
return { context, sidebarCollapsed };
|
||||
}
|
||||
|
||||
function evaluate(context) {
|
||||
return Object.fromEntries(Object.entries(expressions).map(([name, script]) =>
|
||||
[name, script.runInContext(context)]
|
||||
));
|
||||
}
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
platform: 'desktop', isMobile: false,
|
||||
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: true },
|
||||
collapsed: { contentVisible: false, collapsedClass: true, toggleVisible: true }
|
||||
},
|
||||
{
|
||||
platform: 'mobile', isMobile: true,
|
||||
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: false },
|
||||
collapsed: { contentVisible: true, collapsedClass: false, toggleVisible: false }
|
||||
}
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
for (const stored of [false, true]) {
|
||||
test(`${scenario.platform} navigation with stored sidebar_collapsed=${stored}`, () => {
|
||||
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, stored);
|
||||
|
||||
assert.deepEqual(evaluate(context), stored ? scenario.collapsed : scenario.expanded);
|
||||
assert.equal(get(sidebarCollapsed), stored, 'rendering must preserve the stored preference');
|
||||
});
|
||||
}
|
||||
|
||||
test(`${scenario.platform} navigation follows late false -> true -> false store updates`, () => {
|
||||
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, false);
|
||||
|
||||
assert.deepEqual(evaluate(context), scenario.expanded);
|
||||
sidebarCollapsed.set(true);
|
||||
assert.deepEqual(evaluate(context), scenario.collapsed);
|
||||
assert.equal(get(sidebarCollapsed), true, 'rendering must not reset the loaded preference');
|
||||
sidebarCollapsed.set(false);
|
||||
assert.deepEqual(evaluate(context), scenario.expanded);
|
||||
assert.equal(get(sidebarCollapsed), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('desktop collapse toggle still collapses and expands the sidebar', () => {
|
||||
const { context, sidebarCollapsed } = sidebarContext(false, false);
|
||||
const toggleSidebar = toggleScript.runInContext(context);
|
||||
|
||||
toggleSidebar();
|
||||
assert.equal(get(sidebarCollapsed), true);
|
||||
assert.deepEqual(evaluate(context), scenarios[0].collapsed);
|
||||
toggleSidebar();
|
||||
assert.equal(get(sidebarCollapsed), false);
|
||||
assert.deepEqual(evaluate(context), scenarios[0].expanded);
|
||||
});
|
||||
|
||||
test('Sidebar compiles for the client', () => {
|
||||
assert.doesNotThrow(() => compile(source, {
|
||||
filename: 'src/lib/components/Sidebar.svelte',
|
||||
generate: 'client'
|
||||
}));
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/components/SettingsPanel.svelte', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const initialization = source.match(
|
||||
/(const\s+themePresets\s*=\s*\[[\s\S]*?\];)[\s\S]*?(?=\bconst\s+accentPresets\s*=)/
|
||||
);
|
||||
assert.ok(initialization, 'theme presets initialization was not found');
|
||||
|
||||
// Evaluate only the trusted repository declaration and its initialization code.
|
||||
const unsorted = new Function(`${initialization[1]}\nreturn themePresets;`)();
|
||||
const presets = new Function(`${initialization[0]}\nreturn themePresets;`)();
|
||||
|
||||
test('pins System, Light, and Dark in that exact order', () => {
|
||||
assert.deepEqual(presets.slice(0, 3).map(({ id, label }) => ({ id, label })), [
|
||||
{ id: 'system', label: 'System' },
|
||||
{ id: 'light', label: 'Light' },
|
||||
{ id: 'dark', label: 'Dark' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('orders remaining built-in labels alphabetically, including Rosé Pine', () => {
|
||||
const labels = presets.slice(3).map((preset) => preset.label);
|
||||
assert.ok(labels.includes('Rosé Pine'));
|
||||
assert.deepEqual(labels, [...labels].sort());
|
||||
});
|
||||
|
||||
test('preserves every theme ID, label, and color without duplicates', () => {
|
||||
assert.equal(presets.length, unsorted.length);
|
||||
for (const themes of [unsorted, presets]) {
|
||||
assert.equal(new Set(themes.map((preset) => preset.id)).size, themes.length);
|
||||
assert.equal(new Set(themes.map((preset) => preset.label)).size, themes.length);
|
||||
}
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(presets.map((preset) => [preset.id, preset])),
|
||||
Object.fromEntries(unsorted.map((preset) => [preset.id, preset]))
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const { restoreTitleHeading, stripTitleHeading } = await import(
|
||||
new URL('../src/lib/editor/titleVisibility.ts', import.meta.url)
|
||||
);
|
||||
const editorSource = await readFile(
|
||||
new URL('../src/lib/components/Editor.svelte', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
test('rich to source transition strips the restored title before display', () => {
|
||||
const start = editorSource.indexOf('if (isSource && !lastSourceMode) {');
|
||||
const end = editorSource.indexOf('} else if (!isSource && lastSourceMode)', start);
|
||||
|
||||
assert.notEqual(start, -1, 'rich to source transition was not found');
|
||||
assert.notEqual(end, -1, 'rich to source transition boundary was not found');
|
||||
assert.match(
|
||||
editorSource.slice(start, end),
|
||||
/sourceContent\s*=\s*stripTitleH1\(\s*editor\s*\?\s*editorToMarkdown\(\)\s*:\s*\(\$activeNote\?\.content\s*\?\?\s*''\)\s*\)\s*;/
|
||||
);
|
||||
});
|
||||
|
||||
test('source to rich transition preserves the hidden title on desktop and mobile', () => {
|
||||
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
|
||||
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
|
||||
const transition = editorSource.slice(start, end);
|
||||
|
||||
assert.notEqual(start, -1, 'source to rich transition was not found');
|
||||
assert.notEqual(end, -1, 'source to rich transition boundary was not found');
|
||||
assert.match(
|
||||
transition,
|
||||
/editor\.commands\.setContent\(\s*markdownToHtml\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*\)\s*;/
|
||||
);
|
||||
assert.match(transition, /createEditor\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*;/);
|
||||
});
|
||||
|
||||
test('source to rich transition preserves an empty source body on desktop and mobile', () => {
|
||||
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
|
||||
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
|
||||
const transition = editorSource.slice(start, end);
|
||||
const contentAssignments = [...transition.matchAll(/const content = ([^;]+);/g)]
|
||||
.map((match) => match[1].trim());
|
||||
|
||||
assert.deepEqual(contentAssignments, ['srcText', 'srcText']);
|
||||
assert.doesNotMatch(transition, /srcText\s*\|\|/);
|
||||
});
|
||||
|
||||
test('keeps a hidden title through source to rich to source and save', () => {
|
||||
const persisted = '# Note title\n\n## Something else\n\nBody\n';
|
||||
const body = '## Something else\n\nBody\n';
|
||||
|
||||
const initialSource = stripTitleHeading(persisted, 'Note title', true);
|
||||
const rich = stripTitleHeading(
|
||||
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
|
||||
'Note title',
|
||||
true
|
||||
);
|
||||
const toggledSource = stripTitleHeading(
|
||||
restoreTitleHeading(rich.markdown, rich.hiddenTitle),
|
||||
'Note title',
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(rich.markdown, body);
|
||||
assert.equal(toggledSource.markdown, body);
|
||||
assert.equal(restoreTitleHeading(toggledSource.markdown, toggledSource.hiddenTitle), persisted);
|
||||
});
|
||||
|
||||
test('keeps a title-only note visually empty and saves one title heading', () => {
|
||||
const initialSource = stripTitleHeading('# Note title\n', 'Note title', true);
|
||||
const rich = stripTitleHeading(
|
||||
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
|
||||
'Note title',
|
||||
true
|
||||
);
|
||||
const saved = restoreTitleHeading(rich.markdown, rich.hiddenTitle);
|
||||
|
||||
assert.equal(initialSource.markdown, '');
|
||||
assert.equal(rich.markdown, '');
|
||||
assert.equal((saved.match(/^# Note title$/gm) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test('replaces hidden title state when an unrelated note loads', () => {
|
||||
const markdown = '## Something else\n\nOther body\n';
|
||||
const start = editorSource.indexOf('function stripTitleH1(md: string): string {');
|
||||
const end = editorSource.indexOf('function restoreTitleH1(md: string): string {', start);
|
||||
|
||||
assert.deepEqual(stripTitleHeading(markdown, 'Another note', true), {
|
||||
markdown,
|
||||
hiddenTitle: null
|
||||
});
|
||||
assert.match(editorSource.slice(start, end), /hiddenTitleHeading\s*=\s*result\.hiddenTitle\s*;/);
|
||||
});
|
||||
|
||||
test('leaves the title visible when title hiding is disabled', () => {
|
||||
const persisted = '# Note title\n\n## Something else\n';
|
||||
|
||||
assert.deepEqual(stripTitleHeading(persisted, 'Note title', false), {
|
||||
markdown: persisted,
|
||||
hiddenTitle: null
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user