Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1a880a4f96 | ||
|
|
f4e05ed9c4 | ||
|
|
4a1045b41e | ||
|
|
c305b5f8dd | ||
|
|
b5962af466 | ||
|
|
13e83b6689 | ||
|
|
432e4f472a | ||
|
|
8731ca4ec1 | ||
|
|
6b165059cc | ||
|
|
213797a38e | ||
|
|
fc3e388b80 | ||
|
|
1c0359e540 | ||
|
|
da859e581b | ||
|
|
763e673738 | ||
|
|
550d3ab3e9 | ||
|
|
e62404d4c0 | ||
|
|
54e051ba02 | ||
|
|
344728dbe8 | ||
|
|
818e9ad82a | ||
|
|
4931ee3fab | ||
|
|
65e09e8fdd | ||
|
|
64e521d549 | ||
|
|
b8308d89e8 | ||
|
|
0e5b380f8c | ||
|
|
e76919ab5c | ||
|
|
84ab90fd3f | ||
|
|
87b191eb63 | ||
|
|
0def9d6d6d | ||
|
|
e708d8ee63 | ||
|
|
461001442a | ||
|
|
6b5e923e8e | ||
|
|
289606a746 | ||
|
|
9c53b3b594 | ||
|
|
d497ff8aaf | ||
|
|
51acd51cb2 | ||
|
|
b4bb3b89d6 | ||
|
|
9b51aef073 | ||
|
|
4aa0b200b9 | ||
|
|
8769b56fd1 | ||
|
|
06d2cd744f | ||
|
|
7428cc7dfb | ||
|
|
6c89d668fe | ||
|
|
2c76a4747e | ||
|
|
fd55c00015 | ||
|
|
28ef37f354 | ||
|
|
bf5a9f134d | ||
|
|
2991940ba8 | ||
|
|
54352a03d8 | ||
|
|
c4d1f12b2e | ||
|
|
aa699007f7 | ||
|
|
047a8b3c3d | ||
|
|
6098dd01b4 | ||
|
|
b16beb9af5 | ||
|
|
95565d7506 | ||
|
|
a13d17ed81 | ||
|
|
95515cac9d | ||
|
|
21dd2d1681 | ||
|
|
6d5f12c47c | ||
|
|
d01bd4ea01 | ||
|
|
bd7d66b1e8 | ||
|
|
0b9fe69819 | ||
|
|
936c38a21d | ||
|
|
e60e637437 | ||
|
|
479c957698 | ||
|
|
13fd70f944 | ||
|
|
015726f896 | ||
|
|
5e2c31598c | ||
|
|
24d1ba9f8f | ||
|
|
5345442de2 | ||
|
|
6ea2ca0ae2 | ||
|
|
6f4f526d07 | ||
|
|
13bad87500 | ||
|
|
acecc9c68d | ||
|
|
35a8501f2d | ||
|
|
09f9df968d | ||
|
|
2c58bf3d9a | ||
|
|
e063f1c73a | ||
|
|
cafc0ce1ec | ||
|
|
a192723fbd | ||
|
|
b643eeb884 | ||
|
|
eb042e5760 | ||
|
|
6251d96193 | ||
|
|
db92dabba5 | ||
|
|
2469a971ca | ||
|
|
a43bf05bf0 | ||
|
|
ffd9ebad7e | ||
|
|
d2100c8500 | ||
|
|
7e63d721bc | ||
|
|
2c774c51c6 | ||
|
|
8eba4527af | ||
|
|
181b3eb6c2 | ||
|
|
879d2f42c6 | ||
|
|
85ea26b57a | ||
|
|
800c81e598 | ||
|
|
30c3523722 | ||
|
|
761bf5b42e | ||
|
|
b10a7b1bfc | ||
|
|
313d86df23 | ||
|
|
95627cbb5c | ||
|
|
b9ea14f330 | ||
|
|
812d025a9d | ||
|
|
11cc2daa4b | ||
|
|
5c36f2aa71 | ||
|
|
987b1b9ed7 | ||
|
|
3ef0e14581 | ||
|
|
f98b3415bc | ||
|
|
16805c96aa | ||
|
|
9f2e0ba8b6 | ||
|
|
7b3609afbb | ||
|
|
84ff75f223 | ||
|
|
56dc0eefd7 | ||
|
|
eeea16531a | ||
|
|
ac5ab2a27a | ||
|
|
fef4ece2c0 | ||
|
|
613451f4d8 | ||
|
|
04dee78b27 | ||
|
|
69330bf23c | ||
|
|
4843bcacd5 | ||
|
|
3d28665e1e | ||
|
|
209585a0b4 | ||
|
|
84eab75ec8 | ||
|
|
ce17b38509 | ||
|
|
85c1e1e7d3 | ||
|
|
ebf38e2281 | ||
|
|
32ec62419d | ||
|
|
e46fda91a4 | ||
|
|
62d5388176 | ||
|
|
db2bef892a | ||
|
|
1c46935cc5 | ||
|
|
f0f19125d5 | ||
|
|
828f83d2e3 | ||
|
|
002fdb763e | ||
|
|
d3ff1fb931 | ||
|
|
7951472ae3 | ||
|
|
5cffe8846b | ||
|
|
806e975e31 | ||
|
|
e4232269f6 | ||
|
|
ab759a006d | ||
|
|
ca7f25003c | ||
|
|
08ab0b90f2 | ||
|
|
e275f5c0bb | ||
|
|
683a01627e | ||
|
|
3c11a7f63e | ||
|
|
90a96552d3 | ||
|
|
a710603b0a | ||
|
|
80a6fdca69 | ||
|
|
1dc78ca916 | ||
|
|
3118af1e41 | ||
|
|
a669d96aed | ||
|
|
a0b1fa0481 | ||
|
|
2e8c048a3e | ||
|
|
d2ddc7f0de | ||
|
|
3b88b20c61 | ||
|
|
f8371aaa57 | ||
|
|
c5a45131a5 | ||
|
|
1681fade3b | ||
|
|
4f324ad682 | ||
|
|
c1a9871703 | ||
|
|
83390f5ed2 | ||
|
|
5bf6d8063e | ||
|
|
a1913558b9 | ||
|
|
93e96829f4 | ||
|
|
11673bd8c4 | ||
|
|
4f4e2854a6 | ||
|
|
856ac94a76 | ||
|
|
28263de53d | ||
|
|
b6e855ce3e | ||
|
|
51148efe2e | ||
|
|
32e056dc24 | ||
|
|
c14ad6387d | ||
|
|
93122ade7b | ||
|
|
80bdabaf1b | ||
|
|
cea98cffde | ||
|
|
922bcee1e2 | ||
|
|
d33bb8d96e | ||
|
|
93ebb1987e | ||
|
|
cc0134dd22 | ||
|
|
1164a6bd86 | ||
|
|
6fcf7bdb5e | ||
|
|
8454f2eca8 | ||
|
|
7e480ad7e2 | ||
|
|
bc7c985489 | ||
|
|
a94c33edaf | ||
|
|
a1404062c5 | ||
|
|
9bb55afccc | ||
|
|
173e25dadc | ||
|
|
2fa9458eab | ||
|
|
0a3b7a13e3 | ||
|
|
50d27d5636 | ||
|
|
d050ac907f | ||
|
|
80ca411a08 | ||
|
|
35894cdfd1 | ||
|
|
4df96362f4 | ||
|
|
fc7d34612d | ||
|
|
1b7dc77792 | ||
|
|
5f311f2404 | ||
|
|
f19c3c2caa | ||
|
|
0f7b1debbb | ||
|
|
4aa399e96e | ||
|
|
15c6e6388a | ||
|
|
b501d2bd5c | ||
|
|
fb4c5748a8 | ||
|
|
d9cb615f1f | ||
|
|
0e09d1f9f1 | ||
|
|
db924f7352 | ||
|
|
2fea44bde6 | ||
|
|
784248f984 | ||
|
|
ee16ef07c5 | ||
|
|
3648a2dc90 | ||
|
|
8f250d1ab9 | ||
|
|
67d5ae87c3 | ||
|
|
08abbe25ef |
@@ -13,6 +13,9 @@ bun.lockb
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Local tests
|
||||
/tests/
|
||||
|
||||
# Nix
|
||||
/result
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -10,23 +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.2.9)
|
||||
## 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
|
||||
```
|
||||
|
||||
#### NixOS
|
||||
##### Arch / Manjaro (AUR)
|
||||
|
||||
```bash
|
||||
yay -S helixnotes-appimage-bin
|
||||
```
|
||||
|
||||
##### Solus (EOPKG)
|
||||
|
||||
```bash
|
||||
sudo eopkg it helixnotes
|
||||
```
|
||||
|
||||
##### NixOS
|
||||
|
||||
<details>
|
||||
<summary>flake.nix</summary>
|
||||
@@ -36,7 +58,7 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
helix-notes = {
|
||||
url = "git+https://codeberg.org/ArkHost/HelixNotes";
|
||||
url = "git+https://gitlab.com/ArkHost/HelixNotes";
|
||||
# inputs.nixpkgs.follows = "nixpkgs";
|
||||
}
|
||||
};
|
||||
@@ -84,46 +106,55 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
|
||||
```
|
||||
</details>
|
||||
|
||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||
#### Manual package downloads
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.2.9/HelixNotes_1.2.9_amd64.AppImage)
|
||||
|
||||
#### .deb (manual)
|
||||
|
||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.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://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_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](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_aarch64.dmg) (M-series Macs)
|
||||
|
||||
[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://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_android.apk)
|
||||
[Download APK](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_android.apk)
|
||||
|
||||
---
|
||||
|
||||
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
All releases: [gitlab.com/ArkHost/HelixNotes/-/releases](https://gitlab.com/ArkHost/HelixNotes/-/releases)
|
||||
|
||||
## Features
|
||||
|
||||
- Markdown editor with toolbar, slash commands, source mode, code highlighting
|
||||
- **Tasks view**: aggregate `- [ ]` checklists from across all notes, set priority and due dates, work in a list or a calendar (drag a task to reschedule)
|
||||
- `[[Wiki-links]]` and graph view
|
||||
- Full-text search (Tantivy)
|
||||
- Outline panel, daily notes with calendar view, tags, drag-and-drop
|
||||
- Full-text search (Tantivy), CJK-aware for Chinese, Japanese, and Korean
|
||||
- Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop
|
||||
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
|
||||
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
|
||||
- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences
|
||||
- Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start
|
||||
- Manual notebook sorting (drag to reorder above, into, or below)
|
||||
- External `.md` viewer mode with import-to-vault flow
|
||||
- PDF preview, Obsidian import, "Show in File Manager"
|
||||
- AI writing tools (Ollama / Anthropic / OpenAI)
|
||||
- AI writing tools (Ollama / OpenAI-compatible / Anthropic / OpenAI)
|
||||
- **Optional WebDAV sync** to your own server (Nextcloud, ownCloud, a NAS): manual or automatic, with keep-both conflict copies
|
||||
- Version history with diffs, automatic backups
|
||||
- Multi-window, file associations, focus mode, view mode
|
||||
- Themes, accent colors, fonts
|
||||
- Local files, no cloud
|
||||
- Themes (light, dark, and 14 palettes), accent colors, fonts, 80-200% interface scale
|
||||
- Local plain-text files, no company cloud
|
||||
|
||||
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
||||
|
||||
@@ -137,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/)
|
||||
@@ -149,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
|
||||
@@ -158,10 +197,11 @@ pnpm tauri build
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[AGPL-3.0](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777268161,
|
||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
||||
"lastModified": 1785571196,
|
||||
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
||||
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -6,71 +6,77 @@
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs {inherit system;};
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
in {
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
cargo-tauri.hook
|
||||
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
|
||||
|
||||
pnpmConfigHook
|
||||
pnpm
|
||||
nodejs
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
|
||||
};
|
||||
|
||||
pkg-config
|
||||
jq
|
||||
moreutils
|
||||
wrapGAppsHook3
|
||||
];
|
||||
nativeBuildInputs = with pkgs; [
|
||||
cargo-tauri.hook
|
||||
|
||||
buildInputs = with pkgs;
|
||||
lib.optionals stdenv.hostPlatform.isLinux [
|
||||
webkitgtk_4_1
|
||||
libayatana-appindicator
|
||||
pnpmConfigHook
|
||||
pnpm
|
||||
nodejs
|
||||
|
||||
pkg-config
|
||||
jq
|
||||
moreutils
|
||||
wrapGAppsHook3
|
||||
];
|
||||
|
||||
cargoRoot = "src-tauri";
|
||||
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
|
||||
buildInputs =
|
||||
with pkgs;
|
||||
lib.optionals stdenv.hostPlatform.isLinux [
|
||||
webkitgtk_4_1
|
||||
libayatana-appindicator
|
||||
];
|
||||
|
||||
# Deactivate the upstream update mechanism
|
||||
postPatch = ''
|
||||
jq '
|
||||
.bundle.createUpdaterArtifacts = false |
|
||||
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
|
||||
' \
|
||||
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
|
||||
'';
|
||||
cargoRoot = "src-tauri";
|
||||
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "HelixNotes desktop application";
|
||||
homepage = "https://helixnotes.com/";
|
||||
license = licenses.agpl3Plus;
|
||||
platforms = platforms.linux ++ platforms.darwin;
|
||||
mainProgram = "helixnotes";
|
||||
# Deactivate the upstream update mechanism
|
||||
postPatch = ''
|
||||
jq '
|
||||
.bundle.createUpdaterArtifacts = false |
|
||||
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
|
||||
' \
|
||||
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "HelixNotes desktop application";
|
||||
homepage = "https://helixnotes.com/";
|
||||
license = licenses.agpl3Plus;
|
||||
platforms = platforms.linux ++ platforms.darwin;
|
||||
mainProgram = "helixnotes";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
formatter = pkgs.alejandra;
|
||||
});
|
||||
formatter = pkgs.nixfmt;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.3.0",
|
||||
"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",
|
||||
@@ -59,13 +63,14 @@
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
overrides:
|
||||
cookie: 0.7.2
|
||||
@@ -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.0"
|
||||
version = "1.3.5"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1860,8 +1786,9 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"notify",
|
||||
"objc2-app-kit",
|
||||
"png 0.17.16",
|
||||
"quick-xml 0.36.2",
|
||||
"quick-xml",
|
||||
"rayon",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -1876,6 +1803,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-ios-vault-access",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
@@ -2552,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"
|
||||
@@ -2573,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"
|
||||
@@ -3415,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",
|
||||
]
|
||||
@@ -3574,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"
|
||||
@@ -3617,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",
|
||||
]
|
||||
@@ -3655,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",
|
||||
@@ -3703,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"
|
||||
@@ -3720,7 +3611,7 @@ dependencies = [
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
"rand_pcg",
|
||||
"rand_pcg 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3736,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]]
|
||||
@@ -3764,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"
|
||||
@@ -3794,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"
|
||||
@@ -3829,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"
|
||||
@@ -3933,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"
|
||||
@@ -4061,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"
|
||||
@@ -4100,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"
|
||||
@@ -4229,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",
|
||||
@@ -4325,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"
|
||||
@@ -4572,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4598,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"
|
||||
@@ -5005,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",
|
||||
@@ -5201,14 +5017,22 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-ios-vault-access"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[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",
|
||||
@@ -5953,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"
|
||||
@@ -5977,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"
|
||||
@@ -6235,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",
|
||||
]
|
||||
|
||||
@@ -6996,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"
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.3.0"
|
||||
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,10 +45,16 @@ 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"] }
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-app-kit = "0.3.2"
|
||||
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
tauri-plugin-ios-vault-access = { path = "plugins/ios-vault-access" }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Hardened runtime entitlements for Developer ID distribution (outside Mac App Store).
|
||||
Required for notarization. These do NOT enable App Sandbox.
|
||||
Without these, the WebKit webview and bundled Rust libraries get killed by the
|
||||
hardened runtime's library validation. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- Network access for auto-updater, AI features, WebDAV sync, image proxy -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -26,6 +26,13 @@ class MainActivity : TauriActivity() {
|
||||
super.onWebViewCreate(webView)
|
||||
this.webView = webView
|
||||
webView.addJavascriptInterface(StorageBridge(this), "Android")
|
||||
// Render text at the size CSS specifies, ignoring the device's system font-size
|
||||
// accessibility setting (textZoom is otherwise tied to the system font scale). (#100)
|
||||
webView.settings.textZoom = 100
|
||||
// The actual fix: disable WebView "text autosizing" / font-boosting, which recomputes
|
||||
// paragraph font-size and line-height from viewport heuristics and ignores our CSS (only
|
||||
// size + line-height were affected; font-family was fine). NORMAL turns it off. (#100)
|
||||
webView.settings.layoutAlgorithm = android.webkit.WebSettings.LayoutAlgorithm.NORMAL
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
<color name="ic_launcher_background">#626ED4</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 709 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 984 B |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(85.333 85.333) scale(0.666667)">
|
||||
<line x1="178.45584" y1="178.45584" x2="333.54416" y2="333.54416" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<line x1="337.53708" y1="174.46292" x2="174.46294" y2="337.53708" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<circle cx="153" cy="153" r="36" fill="#ffffff"/>
|
||||
<circle cx="359" cy="153" r="36" fill="#ffffff"/>
|
||||
<circle cx="153" cy="359" r="36" fill="#ffffff"/>
|
||||
<circle cx="359" cy="359" r="36" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 706 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 512 512"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xmlns="http://www.w3.org/2000/svg"><rect
|
||||
width="512"
|
||||
height="512"
|
||||
rx="112"
|
||||
ry="112"
|
||||
fill="#626ED4"
|
||||
id="rect1" /><line
|
||||
x1="178.45584"
|
||||
y1="178.45584"
|
||||
x2="333.54416"
|
||||
y2="333.54416"
|
||||
stroke="#ffffff"
|
||||
stroke-width="25"
|
||||
stroke-opacity="0.5"
|
||||
stroke-linecap="round"
|
||||
id="line1" /><line
|
||||
x1="337.53708"
|
||||
y1="174.46292"
|
||||
x2="174.46294"
|
||||
y2="337.53708"
|
||||
stroke="#ffffff"
|
||||
stroke-width="25"
|
||||
stroke-opacity="0.5"
|
||||
stroke-linecap="round"
|
||||
id="line2" /><circle
|
||||
cx="153"
|
||||
cy="153"
|
||||
r="36"
|
||||
fill="#ffffff"
|
||||
id="circle2" /><circle
|
||||
cx="359"
|
||||
cy="153"
|
||||
r="36"
|
||||
fill="#ffffff"
|
||||
id="circle3" /><circle
|
||||
cx="153"
|
||||
cy="359"
|
||||
r="36"
|
||||
fill="#ffffff"
|
||||
id="circle4" /><circle
|
||||
cx="359"
|
||||
cy="359"
|
||||
r="36"
|
||||
fill="#ffffff"
|
||||
id="circle5" /></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 475 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"themes": [
|
||||
{
|
||||
"colors": {
|
||||
"bg_active": "#373a52",
|
||||
"bg_editor": "#1a1b26",
|
||||
"bg_hover": "#2f3145",
|
||||
"bg_primary": "#1a1b26",
|
||||
"bg_secondary": "#1f2029",
|
||||
"bg_tertiary": "#2a2b3d",
|
||||
"border_color": "#2f3145",
|
||||
"text_primary": "#c8cde4",
|
||||
"text_secondary": "#6b7094"
|
||||
},
|
||||
"id": "custom-1782146306429",
|
||||
"is_dark": true,
|
||||
"name": "My theme"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "tauri-plugin-ios-vault-access"
|
||||
version = "0.1.0"
|
||||
authors = ["HelixNotes"]
|
||||
description = "iOS Files folder access for HelixNotes"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
links = "tauri-plugin-ios-vault-access"
|
||||
|
||||
[dependencies]
|
||||
tauri = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2", features = ["build"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_plugin::Builder::new(&[]).ios_path("ios").build();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// swift-tools-version:5.3
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "tauri-plugin-ios-vault-access",
|
||||
platforms: [.iOS(.v14)],
|
||||
products: [
|
||||
.library(
|
||||
name: "tauri-plugin-ios-vault-access",
|
||||
type: .static,
|
||||
targets: ["tauri-plugin-ios-vault-access"]
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Tauri", path: "../.tauri/tauri-api")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "tauri-plugin-ios-vault-access",
|
||||
dependencies: [.byName(name: "Tauri")],
|
||||
path: "Sources"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,312 @@
|
||||
import Foundation
|
||||
import SwiftRs
|
||||
import Tauri
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
private struct BookmarkRequest: Decodable {
|
||||
let bookmarkId: String
|
||||
}
|
||||
|
||||
final class IosVaultAccessPlugin: Plugin, UIDocumentPickerDelegate {
|
||||
private let fileManager = FileManager.default
|
||||
private let lock = NSLock()
|
||||
private var pickerInvoke: Invoke?
|
||||
private var activeBookmarkId: String?
|
||||
private var activeURL: URL?
|
||||
private var stagedBookmarkId: String?
|
||||
private var stagedURL: URL?
|
||||
|
||||
deinit {
|
||||
rollbackStagedScope()
|
||||
releaseCurrentScope()
|
||||
}
|
||||
|
||||
@objc func chooseFolder(_ invoke: Invoke) {
|
||||
DispatchQueue.main.async {
|
||||
guard self.pickerInvoke == nil else {
|
||||
invoke.reject("A folder picker is already open.")
|
||||
return
|
||||
}
|
||||
guard let viewController = self.manager.viewController else {
|
||||
self.pickerInvoke = nil
|
||||
invoke.reject("Files is unavailable right now. Try again.")
|
||||
return
|
||||
}
|
||||
self.pickerInvoke = invoke
|
||||
let picker = UIDocumentPickerViewController(
|
||||
forOpeningContentTypes: [.folder],
|
||||
asCopy: false
|
||||
)
|
||||
picker.delegate = self
|
||||
picker.allowsMultipleSelection = false
|
||||
viewController.present(picker, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func resolveFolder(_ invoke: Invoke) throws {
|
||||
let request = try invoke.parseArgs(BookmarkRequest.self)
|
||||
do {
|
||||
invoke.resolve(try resolveBookmark(request.bookmarkId))
|
||||
} catch {
|
||||
invoke.reject(message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
@objc func releaseActive(_ invoke: Invoke) {
|
||||
rollbackStagedScope()
|
||||
releaseCurrentScope()
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@objc func commitStaged(_ invoke: Invoke) {
|
||||
commitStagedScope()
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@objc func rollbackStaged(_ invoke: Invoke) {
|
||||
rollbackStagedScope()
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@objc func forgetBookmark(_ invoke: Invoke) throws {
|
||||
let request = try invoke.parseArgs(BookmarkRequest.self)
|
||||
do {
|
||||
let file = try bookmarkFile(for: request.bookmarkId, createDirectory: false)
|
||||
lock.lock()
|
||||
let isActive = activeBookmarkId == request.bookmarkId
|
||||
let isStaged = stagedBookmarkId == request.bookmarkId
|
||||
lock.unlock()
|
||||
if fileManager.fileExists(atPath: file.path) {
|
||||
try fileManager.removeItem(at: file)
|
||||
}
|
||||
if isActive {
|
||||
releaseCurrentScope()
|
||||
}
|
||||
if isStaged {
|
||||
rollbackStagedScope()
|
||||
}
|
||||
invoke.resolve()
|
||||
} catch {
|
||||
invoke.reject(message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
func documentPicker(
|
||||
_ controller: UIDocumentPickerViewController,
|
||||
didPickDocumentsAt urls: [URL]
|
||||
) {
|
||||
guard let invoke = takePickerInvoke() else { return }
|
||||
guard let url = urls.first else {
|
||||
invoke.resolve(cancelledResult())
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw AccessError.denied
|
||||
}
|
||||
var adopted = false
|
||||
defer {
|
||||
if !adopted { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
|
||||
let name = try validateFolder(url)
|
||||
let bookmarkId = existingBookmarkId(for: url) ?? UUID().uuidString.lowercased()
|
||||
let bookmark = try url.bookmarkData(
|
||||
options: .minimalBookmark,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
try bookmark.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
|
||||
stageScope(url: url, bookmarkId: bookmarkId)
|
||||
adopted = true
|
||||
invoke.resolve(result(bookmarkId: bookmarkId, url: url, name: name))
|
||||
} catch {
|
||||
invoke.reject(message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
takePickerInvoke()?.resolve(cancelledResult())
|
||||
}
|
||||
|
||||
private func resolveBookmark(_ bookmarkId: String) throws -> [String: Any] {
|
||||
guard UUID(uuidString: bookmarkId) != nil else { throw AccessError.missing }
|
||||
|
||||
lock.lock()
|
||||
let currentURL = activeBookmarkId == bookmarkId ? activeURL : nil
|
||||
lock.unlock()
|
||||
if let currentURL {
|
||||
return result(bookmarkId: bookmarkId, url: currentURL, name: try validateFolder(currentURL))
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: bookmarkFile(for: bookmarkId, createDirectory: false))
|
||||
var stale = false
|
||||
let url = try URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &stale
|
||||
)
|
||||
guard url.startAccessingSecurityScopedResource() else { throw AccessError.denied }
|
||||
var adopted = false
|
||||
defer {
|
||||
if !adopted { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
|
||||
let name = try validateFolder(url)
|
||||
if stale {
|
||||
let refreshed = try url.bookmarkData(
|
||||
options: .minimalBookmark,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
try refreshed.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
|
||||
}
|
||||
stageScope(url: url, bookmarkId: bookmarkId)
|
||||
adopted = true
|
||||
return result(bookmarkId: bookmarkId, url: url, name: name)
|
||||
}
|
||||
|
||||
private func validateFolder(_ url: URL) throws -> String {
|
||||
let values = try url.resourceValues(forKeys: [.isDirectoryKey, .nameKey])
|
||||
guard values.isDirectory == true else { throw AccessError.notFolder }
|
||||
guard try url.checkResourceIsReachable() else { throw AccessError.unavailable }
|
||||
return values.name ?? url.lastPathComponent
|
||||
}
|
||||
|
||||
private func stageScope(url: URL, bookmarkId: String) {
|
||||
lock.lock()
|
||||
let previous = stagedURL
|
||||
stagedURL = url
|
||||
stagedBookmarkId = bookmarkId
|
||||
lock.unlock()
|
||||
previous?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
private func commitStagedScope() {
|
||||
lock.lock()
|
||||
guard let next = stagedURL else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
let previous = activeURL
|
||||
activeURL = next
|
||||
activeBookmarkId = stagedBookmarkId
|
||||
stagedURL = nil
|
||||
stagedBookmarkId = nil
|
||||
lock.unlock()
|
||||
previous?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
private func rollbackStagedScope() {
|
||||
lock.lock()
|
||||
let staged = stagedURL
|
||||
stagedURL = nil
|
||||
stagedBookmarkId = nil
|
||||
lock.unlock()
|
||||
staged?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
private func releaseCurrentScope() {
|
||||
lock.lock()
|
||||
let previous = activeURL
|
||||
activeURL = nil
|
||||
activeBookmarkId = nil
|
||||
lock.unlock()
|
||||
previous?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
private func takePickerInvoke() -> Invoke? {
|
||||
let invoke = pickerInvoke
|
||||
pickerInvoke = nil
|
||||
return invoke
|
||||
}
|
||||
|
||||
private func bookmarkDirectory(create: Bool) throws -> URL {
|
||||
let base = try fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let directory = base.appendingPathComponent("VaultBookmarks", isDirectory: true)
|
||||
if create {
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
private func bookmarkFile(for id: String, createDirectory: Bool) throws -> URL {
|
||||
guard UUID(uuidString: id) != nil else { throw AccessError.missing }
|
||||
let directory = try bookmarkDirectory(create: createDirectory)
|
||||
return directory.appendingPathComponent("\(id).bookmark")
|
||||
}
|
||||
|
||||
private func existingBookmarkId(for selectedURL: URL) -> String? {
|
||||
guard
|
||||
let directory = try? bookmarkDirectory(create: true),
|
||||
let files = try? fileManager.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
else { return nil }
|
||||
|
||||
let selectedPath = selectedURL.standardizedFileURL.path
|
||||
for file in files where file.pathExtension == "bookmark" {
|
||||
guard let data = try? Data(contentsOf: file) else { continue }
|
||||
var stale = false
|
||||
guard
|
||||
let resolved = try? URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &stale
|
||||
),
|
||||
resolved.standardizedFileURL.path == selectedPath
|
||||
else { continue }
|
||||
let id = file.deletingPathExtension().lastPathComponent
|
||||
if UUID(uuidString: id) != nil { return id }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func result(bookmarkId: String, url: URL, name: String) -> [String: Any] {
|
||||
["bookmarkId": bookmarkId, "path": url.path, "name": name, "cancelled": false]
|
||||
}
|
||||
|
||||
private func cancelledResult() -> [String: Any] {
|
||||
["bookmarkId": "", "path": "", "name": "", "cancelled": true]
|
||||
}
|
||||
|
||||
private func message(for error: Error) -> String {
|
||||
if let accessError = error as? AccessError { return accessError.localizedDescription }
|
||||
return "The selected folder is unavailable. Open Files and try again."
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccessError: LocalizedError {
|
||||
case denied
|
||||
case missing
|
||||
case notFolder
|
||||
case unavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .denied:
|
||||
return "HelixNotes no longer has access to this folder. Choose it again in Files."
|
||||
case .missing:
|
||||
return "Saved access to this folder is missing. Choose it again in Files."
|
||||
case .notFolder:
|
||||
return "The selected item is not a folder."
|
||||
case .unavailable:
|
||||
return "The selected folder is unavailable. Open Files and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_ios_vault_access")
|
||||
func initPlugin() -> Plugin {
|
||||
IosVaultAccessPlugin()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{
|
||||
plugin::{Builder, PluginHandle, TauriPlugin},
|
||||
Manager, Runtime,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FolderSelection {
|
||||
pub bookmark_id: String,
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BookmarkRequest<'a> {
|
||||
bookmark_id: &'a str,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
tauri::ios_plugin_binding!(init_plugin_ios_vault_access);
|
||||
|
||||
pub struct IosVaultAccess<R: Runtime>(PluginHandle<R>);
|
||||
|
||||
impl<R: Runtime> IosVaultAccess<R> {
|
||||
pub fn choose_folder(&self) -> Result<FolderSelection, String> {
|
||||
self.0
|
||||
.run_mobile_plugin("chooseFolder", ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn resolve_folder(&self, bookmark_id: &str) -> Result<FolderSelection, String> {
|
||||
self.0
|
||||
.run_mobile_plugin("resolveFolder", BookmarkRequest { bookmark_id })
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn release_active(&self) -> Result<(), String> {
|
||||
self.0
|
||||
.run_mobile_plugin("releaseActive", ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn commit_staged(&self) -> Result<(), String> {
|
||||
self.0
|
||||
.run_mobile_plugin("commitStaged", ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn rollback_staged(&self) -> Result<(), String> {
|
||||
self.0
|
||||
.run_mobile_plugin("rollbackStaged", ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn forget_bookmark(&self, bookmark_id: &str) -> Result<(), String> {
|
||||
self.0
|
||||
.run_mobile_plugin("forgetBookmark", BookmarkRequest { bookmark_id })
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IosVaultAccessExt<R: Runtime> {
|
||||
fn ios_vault_access(&self) -> &IosVaultAccess<R>;
|
||||
}
|
||||
|
||||
impl<R: Runtime, T: Manager<R>> IosVaultAccessExt<R> for T {
|
||||
fn ios_vault_access(&self) -> &IosVaultAccess<R> {
|
||||
self.state::<IosVaultAccess<R>>().inner()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("ios-vault-access")
|
||||
.setup(|app, api| {
|
||||
let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?;
|
||||
app.manage(IosVaultAccess(handle));
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
@@ -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(
|
||||
@@ -55,8 +60,8 @@ pub fn ai_request(
|
||||
if url.is_empty() {
|
||||
Err("No base URL configured for OpenAI Compatible provider".to_string())
|
||||
} else {
|
||||
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
|
||||
stream_completions(
|
||||
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
|
||||
stream_openai(
|
||||
&app,
|
||||
&url,
|
||||
key_opt,
|
||||
@@ -94,6 +99,16 @@ pub fn ai_request(
|
||||
});
|
||||
}
|
||||
|
||||
/// Normalize an OpenAI-compatible base URL: drop a trailing slash and a trailing `/v1`,
|
||||
/// 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()
|
||||
}
|
||||
|
||||
async fn stream_anthropic(
|
||||
app: &AppHandle,
|
||||
api_key: &str,
|
||||
@@ -261,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));
|
||||
@@ -373,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" => {
|
||||
@@ -386,8 +403,8 @@ pub async fn test_connection(
|
||||
if url.is_empty() {
|
||||
return Err("No base URL configured for OpenAI Compatible provider".to_string());
|
||||
}
|
||||
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
|
||||
test_completions(&url, key_opt, model).await
|
||||
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
|
||||
test_openai(&url, key_opt, model).await
|
||||
}
|
||||
_ => test_anthropic(api_key, model).await,
|
||||
}
|
||||
@@ -398,7 +415,7 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 10,
|
||||
"max_tokens": 20,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -426,173 +443,18 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_completions(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
api_key: Option<&str>,
|
||||
model: &str,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
_request_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let client = Client::new();
|
||||
|
||||
let prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, user_message);
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"max_tokens": 4096,
|
||||
"stream": true,
|
||||
"temperature": 0.7
|
||||
});
|
||||
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
|
||||
let response = req
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
return Err(format!("API error {}: {}", status, body_text));
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
use futures::StreamExt;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
// Process complete SSE events from buffer
|
||||
while let Some(event_end) = buffer.find("\n\n") {
|
||||
let event_str = buffer[..event_end].to_string();
|
||||
buffer = buffer[event_end + 2..].to_string();
|
||||
|
||||
for line in event_str.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if data == "[DONE]" {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "done".to_string(),
|
||||
text: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
// Completions streaming: choices[0].text
|
||||
if let Some(text) = parsed["choices"][0]["text"].as_str() {
|
||||
if !text.is_empty() {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "text".to_string(),
|
||||
text: Some(text.to_string()),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check finish_reason
|
||||
if let Some(reason) = parsed["choices"][0]["finish_reason"].as_str() {
|
||||
if reason == "stop" || reason == "length" {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "done".to_string(),
|
||||
text: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for error in stream
|
||||
if let Some(err) = parsed["error"]["message"].as_str() {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "error".to_string(),
|
||||
text: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
);
|
||||
return Err(err.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "done".to_string(),
|
||||
text: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn test_completions(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"prompt": "Hi",
|
||||
"max_tokens": 10,
|
||||
});
|
||||
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
|
||||
let response = req
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok("Connection successful".to_string())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
Err(format!("API error {}: {}", status, body_text))
|
||||
}
|
||||
}
|
||||
|
||||
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 body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 10,
|
||||
token_key: 20,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -601,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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
@@ -38,11 +41,48 @@ pub fn run() {
|
||||
let close_to_tray = config.close_to_tray && show_tray;
|
||||
let app_state = AppState::new(config);
|
||||
|
||||
// Inject the compile-time platform so the frontend never sniffs the (sometimes
|
||||
// mobile-looking) WebKitGTK user-agent. (#63)
|
||||
let platform_init = format!(
|
||||
"window.__HELIX_PLATFORM__={{mobile:{},android:{},ios:{}}};",
|
||||
cfg!(mobile),
|
||||
cfg!(target_os = "android"),
|
||||
cfg!(target_os = "ios"),
|
||||
);
|
||||
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri::plugin::Builder::<tauri::Wry>::new("helix-platform")
|
||||
.js_init_script(platform_init)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.on_webview_event(|webview, event| {
|
||||
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
|
||||
let scope = webview.fs_scope();
|
||||
for path in paths {
|
||||
if path.is_file() {
|
||||
let _ = scope.allow_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if webview.label() == "main"
|
||||
&& matches!(payload.event(), tauri::webview::PageLoadEvent::Finished)
|
||||
{
|
||||
queue_macos_traffic_light_fix(&webview.window());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (webview, payload);
|
||||
})
|
||||
.setup(move |app| {
|
||||
#[cfg(target_os = "ios")]
|
||||
app.handle().plugin(tauri_plugin_ios_vault_access::init())?;
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
@@ -66,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)?;
|
||||
@@ -85,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());
|
||||
@@ -98,13 +160,23 @@ pub fn run() {
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::open_vault,
|
||||
commands::choose_external_vault,
|
||||
commands::restore_external_vault,
|
||||
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,
|
||||
commands::set_content_width,
|
||||
commands::get_notebooks,
|
||||
commands::count_root_notes,
|
||||
commands::create_notebook,
|
||||
@@ -115,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,
|
||||
@@ -135,6 +209,7 @@ pub fn run() {
|
||||
commands::empty_trash,
|
||||
commands::load_vault_state,
|
||||
commands::save_vault_state,
|
||||
commands::copy_text_to_clipboard,
|
||||
commands::read_clipboard_image,
|
||||
commands::copy_image_to_clipboard,
|
||||
commands::save_image,
|
||||
@@ -147,8 +222,11 @@ pub fn run() {
|
||||
commands::remove_quick_access,
|
||||
commands::reorder_quick_access,
|
||||
commands::get_vault_stats,
|
||||
commands::find_orphaned_attachments,
|
||||
commands::trash_orphaned_attachments,
|
||||
commands::import_obsidian,
|
||||
commands::open_file,
|
||||
commands::reveal_file,
|
||||
commands::open_url,
|
||||
commands::copy_file_to,
|
||||
commands::write_bytes_to,
|
||||
@@ -168,6 +246,7 @@ pub fn run() {
|
||||
commands::test_sync_connection,
|
||||
commands::sync_now,
|
||||
commands::get_install_type,
|
||||
commands::is_mobile_platform,
|
||||
commands::get_pending_open_file,
|
||||
])
|
||||
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
|
||||
@@ -175,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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,15 +295,46 @@ pub fn run() {
|
||||
std::path::Path::new(&cwd).join(path)
|
||||
};
|
||||
if let Some(resolved_str) = resolved.to_str() {
|
||||
if resolved.is_file() {
|
||||
let _ = app.fs_scope().allow_file(&resolved);
|
||||
if let Some(parent) = resolved.parent() {
|
||||
let _ = app.fs_scope().allow_directory(parent, true);
|
||||
}
|
||||
let _ = asset_scope::allow_external_note_assets(app, &resolved);
|
||||
}
|
||||
let _ = app.emit("open-file", resolved_str.to_string());
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||
let window_state_builder = tauri_plugin_window_state::Builder::default();
|
||||
#[cfg(target_os = "linux")]
|
||||
let window_state_builder = window_state_builder.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::SIZE
|
||||
| tauri_plugin_window_state::StateFlags::POSITION
|
||||
| tauri_plugin_window_state::StateFlags::MAXIMIZED
|
||||
| tauri_plugin_window_state::StateFlags::DECORATIONS
|
||||
| tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||
);
|
||||
builder = builder.plugin(window_state_builder.build());
|
||||
|
||||
builder = builder.on_window_event(move |window, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if window.label() == "main"
|
||||
&& matches!(
|
||||
event,
|
||||
tauri::WindowEvent::Resized(_)
|
||||
| tauri::WindowEvent::Focused(true)
|
||||
| tauri::WindowEvent::ScaleFactorChanged { .. }
|
||||
| tauri::WindowEvent::ThemeChanged(_)
|
||||
)
|
||||
{
|
||||
// AppKit can restore the default button Y position after window-state
|
||||
// changes. Queue this pass so it runs after AppKit finishes its layout.
|
||||
queue_macos_traffic_light_fix(window);
|
||||
}
|
||||
|
||||
match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
// Only hide to tray for the main window
|
||||
@@ -284,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-") {
|
||||
@@ -294,7 +353,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
@@ -364,3 +422,59 @@ fn percent_decode(input: &str) -> String {
|
||||
}
|
||||
String::from_utf8_lossy(&output).to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn queue_macos_traffic_light_fix(window: &tauri::Window) {
|
||||
let window_on_main = window.clone();
|
||||
let _ = window.run_on_main_thread(move || {
|
||||
fix_macos_traffic_lights(&window_on_main);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn fix_macos_traffic_lights(window: &tauri::Window) {
|
||||
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
|
||||
|
||||
const TITLEBAR_HEIGHT: f64 = 34.0;
|
||||
const LEFT_INSET: f64 = 12.0;
|
||||
|
||||
let ns_window_ptr = match window.ns_window() {
|
||||
Ok(ptr) => ptr,
|
||||
Err(_) => return,
|
||||
};
|
||||
let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) };
|
||||
|
||||
let close = match ns_window.standardWindowButton(NSWindowButton::CloseButton) {
|
||||
Some(button) => button,
|
||||
None => return,
|
||||
};
|
||||
let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) {
|
||||
Some(button) => button,
|
||||
None => return,
|
||||
};
|
||||
let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) {
|
||||
Some(button) => button,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let window_height = ns_window.frame().size.height;
|
||||
if window_height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let close_frame = NSView::frame(&close);
|
||||
let button_spacing = NSView::frame(&miniaturize).origin.x - close_frame.origin.x;
|
||||
for (index, button) in [&close, &miniaturize, &zoom].into_iter().enumerate() {
|
||||
let button_superview = match unsafe { button.superview() } {
|
||||
Some(view) => view,
|
||||
None => return,
|
||||
};
|
||||
let mut frame = NSView::frame(button);
|
||||
let top_inset = ((TITLEBAR_HEIGHT - frame.size.height) / 2.0).max(0.0);
|
||||
let mut target = frame.origin;
|
||||
target.y = window_height - top_inset - frame.size.height;
|
||||
frame.origin.y = button_superview.convertPoint_fromView(target, None).y;
|
||||
frame.origin.x = LEFT_INSET + index as f64 * button_spacing;
|
||||
button.setFrameOrigin(frame.origin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
app_lib::run();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(vault_path.as_bytes());
|
||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let key: String = hasher.finalize()[..8]
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect();
|
||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||
}
|
||||
|
||||
@@ -235,7 +238,9 @@ impl SearchIndex {
|
||||
// before the writer is created, so both indexing and querying use it).
|
||||
index.tokenizers().register(
|
||||
"cjk",
|
||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
||||
TextAnalyzer::builder(CjkTokenizer)
|
||||
.filter(LowerCaser)
|
||||
.build(),
|
||||
);
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -290,7 +295,7 @@ impl SearchIndex {
|
||||
doc.add_text(self.path_field, &path_str);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
doc.add_text(self.body_field, &content);
|
||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
||||
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
}
|
||||
}
|
||||
@@ -321,7 +326,7 @@ impl SearchIndex {
|
||||
doc.add_text(self.path_field, path);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
doc.add_text(self.body_field, &content);
|
||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
||||
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
|
||||
writer.commit().map_err(|e| e.to_string())?;
|
||||
@@ -341,7 +346,7 @@ impl SearchIndex {
|
||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
||||
let fields = [self.title_field, self.body_field, self.tags_field];
|
||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||
@@ -376,9 +381,10 @@ impl SearchIndex {
|
||||
));
|
||||
vec![(Occur::Should, exact)]
|
||||
} else {
|
||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
||||
vec![Term::from_field_text(field, term)],
|
||||
));
|
||||
let prefix: Box<dyn Query> =
|
||||
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
|
||||
field, term,
|
||||
)]));
|
||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
1,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use crate::search::SearchIndex;
|
||||
use crate::types::AppConfig;
|
||||
use notify::RecommendedWatcher;
|
||||
use crate::vault::watcher::VaultWatcher;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub watcher: Mutex<Option<VaultWatcher>>,
|
||||
pub vault_transition: tokio::sync::Mutex<()>,
|
||||
pub importing: AtomicBool,
|
||||
pub syncing: AtomicBool,
|
||||
pub pending_open_file: Mutex<Option<String>>,
|
||||
@@ -20,6 +21,7 @@ impl AppState {
|
||||
config: Mutex::new(config),
|
||||
search_index: Mutex::new(None),
|
||||
watcher: Mutex::new(None),
|
||||
vault_transition: tokio::sync::Mutex::new(()),
|
||||
importing: AtomicBool::new(false),
|
||||
syncing: AtomicBool::new(false),
|
||||
pending_open_file: Mutex::new(None),
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||
// conflict copies so nothing is ever lost.
|
||||
//
|
||||
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
||||
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
||||
// manifest itself) is local-only and never synced.
|
||||
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
|
||||
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
|
||||
// and the manifest itself remain local-only.
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
||||
}
|
||||
|
||||
fn normalize_etag(s: &str) -> String {
|
||||
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
||||
s.trim()
|
||||
.trim_start_matches("W/")
|
||||
.trim_matches('"')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Percent-encode each path segment, keeping the `/` separators.
|
||||
@@ -119,12 +122,12 @@ struct LocalFile {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
||||
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
||||
/// folder with unrelated files never drags them into the vault.
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
|
||||
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
|
||||
/// and remote so pointing at a folder with unrelated files never imports them.
|
||||
fn is_synced_relpath(rel: &str) -> bool {
|
||||
if rel.starts_with(".helixnotes/") {
|
||||
rel.starts_with(".helixnotes/attachments/")
|
||||
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
|
||||
} else {
|
||||
rel.ends_with(".md")
|
||||
}
|
||||
@@ -265,7 +268,11 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
return Err(format!(
|
||||
"GET {} failed: HTTP {}",
|
||||
relpath,
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||
}
|
||||
@@ -279,7 +286,11 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
return Err(format!(
|
||||
"PUT {} failed: HTTP {}",
|
||||
relpath,
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(resp
|
||||
.headers()
|
||||
@@ -312,8 +323,9 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = resp.status().as_u16();
|
||||
// 201 created; 405 already exists; 301 redirect-to-existing.
|
||||
if resp.status().is_success() || s == 405 || s == 301 {
|
||||
// 201 created; 405 already exists; 301 redirect-to-existing;
|
||||
// 403 some servers (openmediavault et al.) return 403 instead of 405.
|
||||
if resp.status().is_success() || s == 405 || s == 301 || s == 403 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
||||
@@ -339,31 +351,72 @@ impl WebdavClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put_with_dirs(
|
||||
/// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
|
||||
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
|
||||
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
|
||||
/// to keep it consistent with future listings. (issue #147)
|
||||
fn etag_for(&self, relpath: &str) -> String {
|
||||
self.propfind(relpath, "0")
|
||||
.ok()
|
||||
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// PUT a file (creating parent collections first), then read back its authoritative
|
||||
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
|
||||
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
|
||||
fn upload(
|
||||
&self,
|
||||
relpath: &str,
|
||||
bytes: &[u8],
|
||||
created: &mut HashSet<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<String, String> {
|
||||
self.ensure_dirs(relpath, created)?;
|
||||
self.put(relpath, bytes)
|
||||
self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
|
||||
Ok(self.etag_for(relpath))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a WebDAV `href` to a vault-relative path (forward slash). Returns None for
|
||||
/// the base collection itself.
|
||||
fn href_to_relpath(href: &str, base_path: &str) -> Option<String> {
|
||||
let path = if href.starts_with("http://") || href.starts_with("https://") {
|
||||
reqwest::Url::parse(href).ok()?.path().to_string()
|
||||
/// Map a WebDAV `href` to a vault-relative path (forward slash).
|
||||
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
|
||||
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
|
||||
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
|
||||
/// unmappable href makes that file look deleted on the remote, and the reconciler then
|
||||
/// wipes the local copy. (issue #102)
|
||||
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
|
||||
// Pull out just the path component (some servers return absolute-URL hrefs).
|
||||
let raw = if href.starts_with("http://") || href.starts_with("https://") {
|
||||
reqwest::Url::parse(href)
|
||||
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
|
||||
.path()
|
||||
.to_string()
|
||||
} else {
|
||||
href.to_string()
|
||||
};
|
||||
let path = path.trim_end_matches('/');
|
||||
let rest = path.strip_prefix(base_path)?.trim_start_matches('/');
|
||||
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
|
||||
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
|
||||
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
|
||||
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
|
||||
// both sides first makes the prefix match encoding-agnostic. (issue #102)
|
||||
let path = urlencoding::decode(raw.trim_end_matches('/'))
|
||||
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
|
||||
.into_owned();
|
||||
let base = urlencoding::decode(base_path.trim_end_matches('/'))
|
||||
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
|
||||
.into_owned();
|
||||
let rest = match path.strip_prefix(&base) {
|
||||
Some(r) => r.trim_start_matches('/'),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
|
||||
Aborting before deleting anything locally - please report this URL."
|
||||
))
|
||||
}
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
return Ok(None); // the base collection itself
|
||||
}
|
||||
Some(urlencoding::decode(rest).ok()?.into_owned())
|
||||
Ok(Some(rest.to_string()))
|
||||
}
|
||||
|
||||
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||
@@ -411,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,7 +482,7 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
||||
}
|
||||
b"response" => {
|
||||
if let Some(h) = href.take() {
|
||||
if let Some(rel) = href_to_relpath(&h, base_path) {
|
||||
if let Some(rel) = href_to_relpath(&h, base_path)? {
|
||||
entries.push(RemoteEntry {
|
||||
relpath: rel,
|
||||
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
|
||||
@@ -473,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(),
|
||||
@@ -485,12 +546,12 @@ fn apply_changes(
|
||||
);
|
||||
} else if local_changed && !remote_changed {
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag.unwrap_or_else(|| re.clone()),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
@@ -530,12 +591,12 @@ fn apply_changes(
|
||||
);
|
||||
let ckey = conflict_relpath(key, &stamp);
|
||||
write_local(vault_path, &ckey, &lbytes)?;
|
||||
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
|
||||
let cetag = client.upload(&ckey, &lbytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
ckey,
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&lbytes),
|
||||
remote_etag: cetag.unwrap_or_default(),
|
||||
remote_etag: cetag,
|
||||
},
|
||||
);
|
||||
sum.conflicts += 1;
|
||||
@@ -546,12 +607,12 @@ fn apply_changes(
|
||||
None => {
|
||||
// new local -> upload
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag.unwrap_or_default(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
@@ -560,12 +621,12 @@ fn apply_changes(
|
||||
if me.local_hash != lf.hash {
|
||||
// remote deleted but local edited -> resurrect (upload)
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag.unwrap_or_default(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
@@ -627,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>();
|
||||
@@ -642,6 +707,20 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
||||
let local = scan_local(&vault)?;
|
||||
let manifest = load_manifest(&vault);
|
||||
|
||||
// Safety guard (issue #102): never treat an empty remote listing as "everything was
|
||||
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
|
||||
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
|
||||
// map), not a real mass-deletion - so abort instead of wiping local notes.
|
||||
if remote.is_empty() && !manifest.files.is_empty() {
|
||||
return Err(
|
||||
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
|
||||
even though files were synced before, so no local files were deleted. This usually \
|
||||
means the server reports paths in a format the app can't read yet - please report \
|
||||
your WebDAV provider and folder URL."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
|
||||
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
|
||||
state.importing.store(true, Ordering::Relaxed);
|
||||
@@ -668,3 +747,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
||||
);
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_synced_relpath;
|
||||
|
||||
#[test]
|
||||
fn syncs_notebook_icon_mapping_and_assets_only() {
|
||||
for path in [
|
||||
"Notes/plan.md",
|
||||
".helixnotes/attachments/notebook-icon.png",
|
||||
".helixnotes/notebook_icons.json",
|
||||
] {
|
||||
assert!(is_synced_relpath(path), "expected {path} to be synced");
|
||||
}
|
||||
|
||||
for path in [
|
||||
"Notes/image.png",
|
||||
".helixnotes/sync_state.json",
|
||||
".helixnotes/notebook_icons.json.bak",
|
||||
".helixnotes/attachments-old/icon.png",
|
||||
] {
|
||||
assert!(!is_synced_relpath(path), "expected {path} to stay local");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,21 +50,82 @@ pub struct NoteContent {
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct VaultConfig {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub bookmark_id: Option<String>,
|
||||
// Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration).
|
||||
#[serde(default)]
|
||||
pub sync_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_on_open: bool,
|
||||
#[serde(default)]
|
||||
pub sync_on_change: bool,
|
||||
#[serde(default)]
|
||||
pub sync_interval_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub last_sync_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomThemeColors {
|
||||
pub bg_primary: String,
|
||||
pub bg_secondary: String,
|
||||
pub bg_tertiary: String,
|
||||
pub bg_hover: String,
|
||||
pub bg_active: String,
|
||||
pub bg_editor: String,
|
||||
pub text_primary: String,
|
||||
pub text_secondary: String,
|
||||
pub border_color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomTheme {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub is_dark: bool,
|
||||
pub colors: CustomThemeColors,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StartupView {
|
||||
Daily,
|
||||
QuickAccess,
|
||||
Tasks,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub vaults: Vec<VaultConfig>,
|
||||
pub active_vault: Option<String>,
|
||||
#[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)]
|
||||
@@ -72,13 +133,19 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub ui_scale: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub content_width: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub compact_notes: bool,
|
||||
#[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,
|
||||
#[serde(default = "default_daily_title_format")]
|
||||
pub daily_title_format: String,
|
||||
#[serde(default)]
|
||||
pub gpu_acceleration: bool,
|
||||
#[serde(default)]
|
||||
@@ -93,6 +160,18 @@ pub struct AppConfig {
|
||||
pub hide_title_in_body: bool,
|
||||
#[serde(default)]
|
||||
pub show_line_numbers: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_link_arrows: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_all_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_quick_access: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_tasks: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_daily_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_trash: bool,
|
||||
#[serde(default)]
|
||||
pub backup_enabled: bool,
|
||||
#[serde(default = "default_backup_frequency")]
|
||||
@@ -128,14 +207,18 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub default_view_mode: bool,
|
||||
#[serde(default)]
|
||||
pub new_notes_in_source_mode: bool,
|
||||
#[serde(default)]
|
||||
pub show_tray_icon: bool,
|
||||
#[serde(default)]
|
||||
pub close_to_tray: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub enable_wiki_links: bool,
|
||||
#[serde(default)]
|
||||
pub startup_view: StartupView,
|
||||
#[serde(default)]
|
||||
pub restore_last_session: bool,
|
||||
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured.
|
||||
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
|
||||
#[serde(default)]
|
||||
pub sync_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -152,12 +235,18 @@ pub struct AppConfig {
|
||||
pub sync_interval_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub last_sync_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_themes: Vec<CustomTheme>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_outline_width() -> f64 {
|
||||
220.0
|
||||
}
|
||||
|
||||
fn default_pdf_height() -> u32 {
|
||||
600
|
||||
}
|
||||
@@ -178,6 +267,10 @@ fn default_week_start() -> String {
|
||||
"monday".to_string()
|
||||
}
|
||||
|
||||
fn default_daily_title_format() -> String {
|
||||
"localized".to_string()
|
||||
}
|
||||
|
||||
fn default_max_versions() -> u32 {
|
||||
20
|
||||
}
|
||||
@@ -186,21 +279,36 @@ 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 {
|
||||
vaults: Vec::new(),
|
||||
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(),
|
||||
gpu_acceleration: true,
|
||||
autostart: false,
|
||||
pdf_preview: false,
|
||||
@@ -208,6 +316,12 @@ impl Default for AppConfig {
|
||||
title_mode: "input".to_string(),
|
||||
hide_title_in_body: false,
|
||||
show_line_numbers: false,
|
||||
show_link_arrows: true,
|
||||
show_all_notes: true,
|
||||
show_quick_access: true,
|
||||
show_tasks: true,
|
||||
show_daily_notes: true,
|
||||
show_trash: true,
|
||||
backup_enabled: false,
|
||||
backup_frequency: "24h".to_string(),
|
||||
backup_max_count: 10,
|
||||
@@ -225,9 +339,11 @@ impl Default for AppConfig {
|
||||
ai_model: "claude-sonnet-4-6".to_string(),
|
||||
ai_writing_style: None,
|
||||
default_view_mode: false,
|
||||
new_notes_in_source_mode: false,
|
||||
show_tray_icon: false,
|
||||
close_to_tray: false,
|
||||
enable_wiki_links: true,
|
||||
startup_view: StartupView::All,
|
||||
restore_last_session: false,
|
||||
sync_provider: None,
|
||||
webdav_url: None,
|
||||
@@ -237,6 +353,7 @@ impl Default for AppConfig {
|
||||
sync_on_change: false,
|
||||
sync_interval_minutes: 0,
|
||||
last_sync_time: None,
|
||||
custom_themes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,14 +363,20 @@ pub struct VaultState {
|
||||
pub last_open_note: Option<String>,
|
||||
pub sidebar_width: f64,
|
||||
pub notelist_width: f64,
|
||||
#[serde(default = "default_outline_width")]
|
||||
pub outline_width: f64,
|
||||
pub sidebar_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub notelist_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub collapsed_notebooks: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub notebook_sort_mode: String,
|
||||
#[serde(default)]
|
||||
pub notebook_order: std::collections::HashMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub note_order: std::collections::HashMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub sort_mode: String,
|
||||
#[serde(default)]
|
||||
pub last_view_mode: String,
|
||||
@@ -263,6 +386,12 @@ pub struct VaultState {
|
||||
pub last_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tasks_layout: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub tasks_hide_completed: bool,
|
||||
#[serde(default)]
|
||||
pub tasks_only_flagged: bool,
|
||||
#[serde(default)]
|
||||
pub tasks_sort: String,
|
||||
}
|
||||
|
||||
impl Default for VaultState {
|
||||
@@ -271,15 +400,21 @@ impl Default for VaultState {
|
||||
last_open_note: None,
|
||||
sidebar_width: 220.0,
|
||||
notelist_width: 280.0,
|
||||
outline_width: default_outline_width(),
|
||||
sidebar_collapsed: false,
|
||||
notelist_collapsed: false,
|
||||
collapsed_notebooks: Vec::new(),
|
||||
notebook_sort_mode: String::new(),
|
||||
notebook_order: std::collections::HashMap::new(),
|
||||
note_order: std::collections::HashMap::new(),
|
||||
sort_mode: String::new(),
|
||||
last_view_mode: String::new(),
|
||||
last_notebook: None,
|
||||
last_tag: None,
|
||||
tasks_layout: String::new(),
|
||||
tasks_hide_completed: true,
|
||||
tasks_only_flagged: false,
|
||||
tasks_sort: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,6 +437,9 @@ pub struct FileEvent {
|
||||
pub struct ImportResult {
|
||||
pub files_converted: u64,
|
||||
pub links_converted: u64,
|
||||
pub frontmatter_normalized: u64,
|
||||
pub syntax_converted: u64,
|
||||
pub attachments_moved: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -370,3 +508,68 @@ pub struct TaskItem {
|
||||
pub due: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_view_tests {
|
||||
use super::{AppConfig, StartupView};
|
||||
|
||||
#[test]
|
||||
fn serializes_supported_startup_views() {
|
||||
for (view, expected) in [
|
||||
(StartupView::All, "\"all\""),
|
||||
(StartupView::QuickAccess, "\"quickaccess\""),
|
||||
(StartupView::Tasks, "\"tasks\""),
|
||||
(StartupView::Daily, "\"daily\""),
|
||||
] {
|
||||
assert_eq!(serde_json::to_string(&view).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_startup_view_falls_back_to_all_notes() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<StartupView>("\"future-view\"").unwrap(),
|
||||
StartupView::All
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_configs_default_system_theme_pair() {
|
||||
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
|
||||
let object = value.as_object_mut().unwrap();
|
||||
object.remove("system_light_theme");
|
||||
object.remove("system_dark_theme");
|
||||
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(config.system_light_theme, "light");
|
||||
assert_eq!(config.system_dark_theme, "dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_switcher_is_opt_in_for_new_and_existing_configs() {
|
||||
let config = AppConfig::default();
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value.as_object_mut().unwrap().remove("show_note_switcher");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(!config.show_note_switcher);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
|
||||
let config = AppConfig::default();
|
||||
assert!(config.scroll_to_change_font_size);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("scroll_to_change_font_size");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(config.scroll_to_change_font_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct RawFrontmatter {
|
||||
}
|
||||
|
||||
/// Try to parse a date string in multiple common formats.
|
||||
fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
pub fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
let s = s.trim();
|
||||
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
||||
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
|
||||
@@ -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(", ")
|
||||
)
|
||||
@@ -191,7 +191,7 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
|
||||
format!("---\n{}---\n{}", yaml_str, body_with_heading)
|
||||
}
|
||||
|
||||
fn filename_to_title(filename: &str) -> String {
|
||||
pub fn filename_to_title(filename: &str) -> String {
|
||||
let stem = filename.trim_end_matches(".md");
|
||||
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
||||
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod frontmatter;
|
||||
pub mod import;
|
||||
pub mod operations;
|
||||
pub mod watcher;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
@@ -8,14 +8,57 @@ use crate::state::AppState;
|
||||
use crate::types::FileEvent;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
|
||||
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WatcherBackend {
|
||||
Recommended,
|
||||
Poll,
|
||||
}
|
||||
|
||||
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
|
||||
if is_ios {
|
||||
WatcherBackend::Poll
|
||||
} else {
|
||||
WatcherBackend::Recommended
|
||||
}
|
||||
}
|
||||
|
||||
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
|
||||
match backend {
|
||||
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
|
||||
WatcherBackend::Poll => IOS_POLL_INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum VaultWatcher {
|
||||
Recommended(RecommendedWatcher),
|
||||
Poll(PollWatcher),
|
||||
}
|
||||
|
||||
impl VaultWatcher {
|
||||
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
|
||||
match self {
|
||||
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
|
||||
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
tx,
|
||||
Config::default().with_poll_interval(Duration::from_secs(1)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
|
||||
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
|
||||
let mut watcher = match backend {
|
||||
WatcherBackend::Recommended => VaultWatcher::Recommended(
|
||||
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
|
||||
),
|
||||
WatcherBackend::Poll => {
|
||||
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
|
||||
watcher
|
||||
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
||||
@@ -61,6 +104,15 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
||||
};
|
||||
let _ = app.emit("file-changed", &fe);
|
||||
}
|
||||
|
||||
// On mobile, throttle event emission to prevent IPC flooding
|
||||
// on FUSE filesystems where Syncthing/other apps generate
|
||||
// constant file activity that blocks the Tauri command channel.
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
while rx.try_recv().is_ok() {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("File watcher error: {}", e);
|
||||
@@ -71,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ios_uses_polling_backend() {
|
||||
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_platforms_keep_recommended_backend() {
|
||||
assert_eq!(
|
||||
watcher_backend_for_target(false),
|
||||
WatcherBackend::Recommended
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_backends_use_the_expected_intervals() {
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Poll),
|
||||
Duration::from_secs(10)
|
||||
);
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Recommended),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.3.0",
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -55,6 +59,9 @@
|
||||
"appimage": {
|
||||
"bundleMediaFramework": false
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
AppConfig,
|
||||
CustomTheme,
|
||||
NoteContent,
|
||||
NoteEntry,
|
||||
NoteMeta,
|
||||
@@ -14,12 +15,26 @@ import type {
|
||||
BackupEntry,
|
||||
VersionEntry,
|
||||
TaskItem,
|
||||
ExternalVaultResult,
|
||||
StartupView,
|
||||
} from "./types";
|
||||
|
||||
export async function openVault(path: string): Promise<void> {
|
||||
return invoke("open_vault", { path });
|
||||
}
|
||||
|
||||
export async function chooseExternalVault(): Promise<ExternalVaultResult | null> {
|
||||
return invoke("choose_external_vault");
|
||||
}
|
||||
|
||||
export async function restoreExternalVault(bookmarkId: string): Promise<ExternalVaultResult> {
|
||||
return invoke("restore_external_vault", { bookmarkId });
|
||||
}
|
||||
|
||||
export async function removeVault(path: string, bookmarkId?: string | null): Promise<void> {
|
||||
return invoke("remove_vault", { path, bookmarkId: bookmarkId ?? null });
|
||||
}
|
||||
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
return invoke("get_app_config");
|
||||
}
|
||||
@@ -28,14 +43,38 @@ 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 });
|
||||
}
|
||||
|
||||
export async function saveCustomTheme(theme: CustomTheme): Promise<void> {
|
||||
return invoke("save_custom_theme", { theme });
|
||||
}
|
||||
|
||||
export async function deleteCustomTheme(id: string): Promise<void> {
|
||||
return invoke("delete_custom_theme", { id });
|
||||
}
|
||||
|
||||
export async function exportCustomTheme(id: string, path: string): Promise<void> {
|
||||
return invoke("export_custom_theme", { id, path });
|
||||
}
|
||||
|
||||
export async function importCustomThemes(path: string): Promise<CustomTheme[]> {
|
||||
return invoke("import_custom_themes", { path });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -48,6 +87,10 @@ export async function setUiScale(scale: number): Promise<void> {
|
||||
return invoke("set_ui_scale", { scale });
|
||||
}
|
||||
|
||||
export async function setContentWidth(width: number | null): Promise<void> {
|
||||
return invoke("set_content_width", { width });
|
||||
}
|
||||
|
||||
export async function getNotebooks(): Promise<NotebookEntry[]> {
|
||||
return invoke("get_notebooks");
|
||||
}
|
||||
@@ -106,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 });
|
||||
}
|
||||
@@ -136,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");
|
||||
}
|
||||
|
||||
@@ -182,6 +235,10 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
|
||||
return invoke("save_vault_state", { vaultState });
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
return invoke("copy_text_to_clipboard", { text });
|
||||
}
|
||||
|
||||
export async function readClipboardImage(): Promise<number[]> {
|
||||
return invoke("read_clipboard_image");
|
||||
}
|
||||
@@ -216,6 +273,7 @@ export async function setGeneralSettings(
|
||||
compactNotes: boolean,
|
||||
timeFormat: string,
|
||||
weekStart: string,
|
||||
dailyTitleFormat: string,
|
||||
gpuAcceleration: boolean,
|
||||
autostart: boolean,
|
||||
pdfPreview: boolean,
|
||||
@@ -223,17 +281,27 @@ export async function setGeneralSettings(
|
||||
titleMode: string,
|
||||
hideTitleInBody: boolean,
|
||||
showLineNumbers: boolean,
|
||||
showLinkArrows: boolean,
|
||||
defaultViewMode: boolean,
|
||||
newNotesInSourceMode: boolean,
|
||||
showTrayIcon: boolean,
|
||||
closeToTray: boolean,
|
||||
enableWikiLinks: boolean,
|
||||
showNoteDates: boolean,
|
||||
showNoteSwitcher: boolean,
|
||||
startupView: StartupView,
|
||||
restoreLastSession: boolean,
|
||||
showAllNotes: boolean,
|
||||
showQuickAccess: boolean,
|
||||
showTasks: boolean,
|
||||
showDailyNotes: boolean,
|
||||
showTrash: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("set_general_settings", {
|
||||
compactNotes,
|
||||
timeFormat,
|
||||
weekStart,
|
||||
dailyTitleFormat,
|
||||
gpuAcceleration,
|
||||
autostart,
|
||||
pdfPreview,
|
||||
@@ -241,12 +309,21 @@ export async function setGeneralSettings(
|
||||
titleMode,
|
||||
hideTitleInBody,
|
||||
showLineNumbers,
|
||||
showLinkArrows,
|
||||
defaultViewMode,
|
||||
newNotesInSourceMode,
|
||||
showTrayIcon,
|
||||
closeToTray,
|
||||
enableWikiLinks,
|
||||
showNoteDates,
|
||||
showNoteSwitcher,
|
||||
startupView,
|
||||
restoreLastSession,
|
||||
showAllNotes,
|
||||
showQuickAccess,
|
||||
showTasks,
|
||||
showDailyNotes,
|
||||
showTrash,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,6 +347,19 @@ export async function getVaultStats(): Promise<VaultStats> {
|
||||
return invoke("get_vault_stats");
|
||||
}
|
||||
|
||||
export interface OrphanAttachment {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function findOrphanedAttachments(): Promise<OrphanAttachment[]> {
|
||||
return invoke("find_orphaned_attachments");
|
||||
}
|
||||
|
||||
export async function trashOrphanedAttachments(names: string[]): Promise<number> {
|
||||
return invoke("trash_orphaned_attachments", { names });
|
||||
}
|
||||
|
||||
export async function importObsidian(): Promise<void> {
|
||||
return invoke("import_obsidian");
|
||||
}
|
||||
@@ -278,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 });
|
||||
}
|
||||
@@ -444,6 +538,10 @@ export async function getInstallType(): Promise<string> {
|
||||
return invoke("get_install_type");
|
||||
}
|
||||
|
||||
export async function isMobilePlatform(): Promise<boolean> {
|
||||
return invoke("is_mobile_platform");
|
||||
}
|
||||
|
||||
export async function getPendingOpenFile(): Promise<string | null> {
|
||||
return invoke("get_pending_open_file");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import Sidebar from './Sidebar.svelte';
|
||||
import NoteList from './NoteList.svelte';
|
||||
@@ -14,11 +14,15 @@
|
||||
import {
|
||||
sidebarWidth,
|
||||
notelistWidth,
|
||||
outlineWidth,
|
||||
sidebarCollapsed,
|
||||
notelistCollapsed,
|
||||
collapsedNotebooks,
|
||||
showSearch,
|
||||
showCommandPalette,
|
||||
theme,
|
||||
resolvedTheme,
|
||||
customThemes,
|
||||
focusMode,
|
||||
readOnly,
|
||||
activeNote,
|
||||
@@ -37,7 +41,11 @@
|
||||
rootNoteCount,
|
||||
viewMode,
|
||||
sortMode,
|
||||
groupNotesByDate,
|
||||
tasksLayout,
|
||||
tasksHideCompleted,
|
||||
tasksOnlyFlagged,
|
||||
tasksSort,
|
||||
activeTag,
|
||||
updateAvailable as globalUpdateAvailable,
|
||||
settingsTab,
|
||||
@@ -45,18 +53,24 @@
|
||||
viewerNote,
|
||||
notebookSortMode,
|
||||
notebookOrder,
|
||||
syncState
|
||||
noteOrder,
|
||||
syncState,
|
||||
platformIsMobile,
|
||||
activeVaultConfig,
|
||||
} from '$lib/stores/app';
|
||||
import { keybindings, matchAction } from '$lib/keybindings';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
const isAndroid = /android/i.test(navigator.userAgent);
|
||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
|
||||
const isMobile = $derived($platformIsMobile);
|
||||
import { loadVaultState, saveVaultState, readNote, deleteNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isAndroid } from '$lib/platform';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { normalizeStartupView, resolveStartupTarget } from '$lib/utils/startup-view';
|
||||
import { get } from 'svelte/store';
|
||||
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
|
||||
import type { StartupTarget } from '$lib/utils/startup-view';
|
||||
|
||||
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
|
||||
for (const nb of list) {
|
||||
@@ -67,10 +81,42 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
let sidebar: Sidebar;
|
||||
let noteList: NoteList;
|
||||
let editor: Editor;
|
||||
let sidebar = $state<Sidebar>();
|
||||
let noteList = $state<NoteList>();
|
||||
let editor = $state<Editor>();
|
||||
let unlistenFileChange: (() => void) | null = null;
|
||||
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
|
||||
if (target.mode === 'notebook') {
|
||||
const vault = $appConfig?.active_vault;
|
||||
const notebook = target.notebookPath === '' && vault
|
||||
? { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount }
|
||||
: findNotebookByPath($notebooks, target.notebookPath);
|
||||
if (!notebook) return false;
|
||||
const changed = $viewMode !== 'notebook' || $activeNotebook?.relative_path !== notebook.relative_path;
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = notebook;
|
||||
$activeTag = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (target.mode === 'tag') {
|
||||
const changed = $viewMode !== 'tag' || $activeTag !== target.tag;
|
||||
$viewMode = 'tag';
|
||||
$activeTag = target.tag;
|
||||
$activeNotebook = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
const changed = $viewMode !== target.mode || $activeNotebook !== null || $activeTag !== null;
|
||||
$viewMode = target.mode;
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
let unlistenOpenFile: (() => void) | null = null;
|
||||
|
||||
// Mobile editor header helpers
|
||||
@@ -112,16 +158,16 @@
|
||||
}
|
||||
|
||||
function syncConfigured(): boolean {
|
||||
return get(appConfig)?.sync_provider === 'webdav';
|
||||
return activeVaultConfig(get(appConfig))?.sync_provider === 'webdav';
|
||||
}
|
||||
|
||||
// Auto-sync on a timer (only when configured and an interval is set).
|
||||
async function checkScheduledSync() {
|
||||
const config = get(appConfig);
|
||||
if (config?.sync_provider !== 'webdav') return;
|
||||
const mins = config.sync_interval_minutes ?? 0;
|
||||
const vc = activeVaultConfig(get(appConfig));
|
||||
if (vc?.sync_provider !== 'webdav') return;
|
||||
const mins = vc.sync_interval_minutes ?? 0;
|
||||
if (!mins || get(syncState).running) return;
|
||||
const last = config.last_sync_time ? new Date(config.last_sync_time).getTime() : 0;
|
||||
const last = vc.last_sync_time ? new Date(vc.last_sync_time).getTime() : 0;
|
||||
if (Date.now() - last >= mins * 60 * 1000) {
|
||||
try { await syncNow(); } catch (_) {}
|
||||
}
|
||||
@@ -194,15 +240,22 @@
|
||||
last_open_note: $activeNotePath,
|
||||
sidebar_width: $sidebarWidth,
|
||||
notelist_width: $notelistWidth,
|
||||
outline_width: $outlineWidth,
|
||||
sidebar_collapsed: $sidebarCollapsed,
|
||||
notelist_collapsed: $notelistCollapsed,
|
||||
collapsed_notebooks: $collapsedNotebooks,
|
||||
notebook_sort_mode: $notebookSortMode,
|
||||
notebook_order: $notebookOrder,
|
||||
note_order: $noteOrder,
|
||||
sort_mode: $sortMode,
|
||||
group_notes_by_date: $groupNotesByDate,
|
||||
last_view_mode: $viewMode,
|
||||
last_notebook: $activeNotebook?.relative_path ?? null,
|
||||
last_tag: $activeTag,
|
||||
tasks_layout: $tasksLayout
|
||||
tasks_layout: $tasksLayout,
|
||||
tasks_hide_completed: $tasksHideCompleted,
|
||||
tasks_only_flagged: $tasksOnlyFlagged,
|
||||
tasks_sort: $tasksSort
|
||||
};
|
||||
try {
|
||||
await saveVaultState(state);
|
||||
@@ -219,20 +272,58 @@
|
||||
persistState();
|
||||
}
|
||||
|
||||
function revealNoteList() {
|
||||
$notelistCollapsed = false;
|
||||
tick().then(() => noteList?.refresh());
|
||||
}
|
||||
|
||||
function toggleNoteList() {
|
||||
const nextCollapsed = !$notelistCollapsed;
|
||||
$notelistCollapsed = nextCollapsed;
|
||||
if (!nextCollapsed) {
|
||||
tick().then(() => noteList?.refresh());
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
||||
let taskNoteOpened = $state(false);
|
||||
|
||||
function handleNoteSelected(path: string, content: string) {
|
||||
function handleNoteSelected(path: string, content: string, task?: TaskItem) {
|
||||
// Selecting a real vault note exits viewer mode
|
||||
$viewerNote = null;
|
||||
taskNoteOpened = true;
|
||||
editor?.loadNote(path, content);
|
||||
editor?.loadNote(path, content, task);
|
||||
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;
|
||||
noteList?.refresh();
|
||||
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||
// notes - so if the notes list was hidden, bring it back rather than navigating blindly
|
||||
// into a panel the user can't see. revealNoteList() re-mounts the list and refreshes it.
|
||||
if ($notelistCollapsed) {
|
||||
revealNoteList();
|
||||
} else {
|
||||
noteList?.refresh();
|
||||
}
|
||||
if (isMobile) $mobileView = 'notelist';
|
||||
}
|
||||
|
||||
@@ -323,7 +414,7 @@
|
||||
let historyDepth = 0;
|
||||
let navFromPopstate = false;
|
||||
|
||||
if (isMobile) {
|
||||
if (get(platformIsMobile)) {
|
||||
history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
|
||||
|
||||
$effect(() => {
|
||||
@@ -353,6 +444,16 @@
|
||||
navFromPopstate = false;
|
||||
return;
|
||||
}
|
||||
// If a modal is open, close it instead of navigating
|
||||
if ($showSettings || $showInfo || $showSearch || $showCommandPalette) {
|
||||
$showSettings = false;
|
||||
$showInfo = false;
|
||||
$showSearch = false;
|
||||
$showCommandPalette = false;
|
||||
// Re-push the current state so the next back still works
|
||||
history.pushState({ mobileView: $mobileView, depth: historyDepth }, '');
|
||||
return;
|
||||
}
|
||||
const state = e.state;
|
||||
const targetDepth = state?.depth ?? 0;
|
||||
historyDepth = targetDepth;
|
||||
@@ -368,6 +469,28 @@
|
||||
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
|
||||
}
|
||||
|
||||
async function trashOpenNote(path: string): Promise<boolean> {
|
||||
if (path !== $activeNotePath || $viewerNote || $viewMode === 'trash') return false;
|
||||
try {
|
||||
await deleteNote(path);
|
||||
if (Object.hasOwn($noteOrder, path)) {
|
||||
const { [path]: _, ...rest } = $noteOrder;
|
||||
$noteOrder = rest;
|
||||
}
|
||||
$notes = $notes.filter((note) => note.path !== path);
|
||||
if ($activeNotePath === path) {
|
||||
$activeNote = null;
|
||||
$activeNotePath = null;
|
||||
}
|
||||
noteList?.refresh(true).catch((error) => console.error('Failed to refresh notes after trashing:', error));
|
||||
if (isMobile) $mobileView = 'notelist';
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to move open note to Trash:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
|
||||
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
|
||||
@@ -376,89 +499,90 @@
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const code = e.code;
|
||||
if (e.altKey && e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
navigateHistory(-1);
|
||||
return;
|
||||
}
|
||||
if (e.altKey && e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
navigateHistory(1);
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyN') {
|
||||
e.preventDefault();
|
||||
createAndFocusNote();
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyN') {
|
||||
e.preventDefault();
|
||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const next = isDark ? 'light' : 'dark';
|
||||
$theme = next;
|
||||
setTheme(next);
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyF') {
|
||||
e.preventDefault();
|
||||
$showSearch = true;
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyF') {
|
||||
e.preventDefault();
|
||||
if ($activeNotePath) {
|
||||
editor?.openNoteSearch();
|
||||
} else {
|
||||
$showSearch = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyP') {
|
||||
e.preventDefault();
|
||||
$showCommandPalette = true;
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyS') {
|
||||
e.preventDefault();
|
||||
editor?.forceSave();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
|
||||
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
||||
e.preventDefault();
|
||||
editor?.addLinkFromToolbar();
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyM') {
|
||||
|
||||
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||
e.preventDefault();
|
||||
$sourceMode = !$sourceMode;
|
||||
editor?.moveOpenNoteToTrash();
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyW') {
|
||||
|
||||
const action = matchAction(e, $keybindings);
|
||||
if (action) {
|
||||
e.preventDefault();
|
||||
if ($activeNotePath && $activeNote) {
|
||||
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
||||
switch (action) {
|
||||
case 'nav-back':
|
||||
navigateHistory(-1);
|
||||
return;
|
||||
case 'nav-forward':
|
||||
navigateHistory(1);
|
||||
return;
|
||||
case 'new-note':
|
||||
createAndFocusNote();
|
||||
return;
|
||||
case 'toggle-theme': {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
case 'search-vault':
|
||||
$showSearch = true;
|
||||
return;
|
||||
case 'find-in-note':
|
||||
if ($activeNotePath) {
|
||||
editor?.openNoteSearch();
|
||||
} else {
|
||||
$showSearch = true;
|
||||
}
|
||||
return;
|
||||
case 'quick-open':
|
||||
$showCommandPalette = true;
|
||||
return;
|
||||
case 'save':
|
||||
editor?.forceSave();
|
||||
return;
|
||||
case 'toggle-source':
|
||||
$sourceMode = !$sourceMode;
|
||||
return;
|
||||
case 'open-new-window':
|
||||
if ($activeNotePath && $activeNote) {
|
||||
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
||||
}
|
||||
return;
|
||||
case 'toggle-sidebar':
|
||||
$sidebarCollapsed = !$sidebarCollapsed;
|
||||
return;
|
||||
case 'toggle-notelist':
|
||||
toggleNoteList();
|
||||
return;
|
||||
case 'toggle-focus':
|
||||
$focusMode = !$focusMode;
|
||||
return;
|
||||
case 'toggle-readonly':
|
||||
if ($viewerNote) return; // viewer mode is always read-only
|
||||
$readOnly = !$readOnly;
|
||||
return;
|
||||
case 'fullscreen':
|
||||
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mod && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
$sidebarCollapsed = !$sidebarCollapsed;
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyE') {
|
||||
e.preventDefault();
|
||||
$focusMode = !$focusMode;
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyR') {
|
||||
e.preventDefault();
|
||||
if ($viewerNote) return; // viewer mode is always read-only
|
||||
$readOnly = !$readOnly;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -467,24 +591,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
|
||||
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();
|
||||
@@ -495,15 +601,31 @@
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$sidebarCollapsed;
|
||||
$notelistCollapsed;
|
||||
$outlineWidth;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$notebookSortMode;
|
||||
$notebookOrder;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$noteOrder;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$sortMode;
|
||||
$groupNotesByDate;
|
||||
$tasksLayout;
|
||||
$tasksHideCompleted;
|
||||
$tasksOnlyFlagged;
|
||||
$tasksSort;
|
||||
persistState();
|
||||
});
|
||||
|
||||
@@ -523,59 +645,79 @@
|
||||
const state = await loadVaultState();
|
||||
$sidebarWidth = state.sidebar_width;
|
||||
$notelistWidth = state.notelist_width;
|
||||
if (typeof state.outline_width === 'number') $outlineWidth = state.outline_width;
|
||||
$sidebarCollapsed = state.sidebar_collapsed;
|
||||
$notelistCollapsed = state.notelist_collapsed ?? false;
|
||||
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
||||
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
|
||||
$notebookOrder = state.notebook_order ?? {};
|
||||
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
|
||||
$noteOrder = state.note_order ?? {};
|
||||
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
|
||||
if (typeof state.group_notes_by_date === 'boolean') $groupNotesByDate = state.group_notes_by_date;
|
||||
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
|
||||
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
|
||||
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
|
||||
if (state.tasks_sort === 'due' || state.tasks_sort === 'priority' || state.tasks_sort === 'note') $tasksSort = state.tasks_sort;
|
||||
lastNotePath = state.last_open_note ?? null;
|
||||
lastViewMode = state.last_view_mode ?? '';
|
||||
lastNotebook = state.last_notebook ?? null;
|
||||
lastTag = state.last_tag ?? null;
|
||||
} catch (_) {}
|
||||
|
||||
const restoreLastSession = $appConfig?.restore_last_session === true;
|
||||
|
||||
// On mobile, prefetch last-opened note so first tap is instant
|
||||
let prefetchPromise: Promise<any> | null = null;
|
||||
if (isMobile && lastNotePath) {
|
||||
if (isMobile && restoreLastSession && lastNotePath) {
|
||||
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
||||
}
|
||||
|
||||
applyTheme($theme);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if ($theme === 'system') applyTheme('system');
|
||||
});
|
||||
|
||||
// Run sidebar and note list refresh in parallel
|
||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||
|
||||
// Restore the last session (view + open note) if enabled.
|
||||
if ($appConfig?.restore_last_session) {
|
||||
const vault = $appConfig?.active_vault;
|
||||
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
|
||||
$activeTag = null;
|
||||
await noteList?.refresh();
|
||||
} else if (lastViewMode === 'notebook' && lastNotebook) {
|
||||
const nb = findNotebookByPath($notebooks, lastNotebook);
|
||||
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
|
||||
} else if (lastViewMode === 'tag' && lastTag) {
|
||||
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
|
||||
} else if (lastViewMode === 'quickaccess') {
|
||||
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
|
||||
}
|
||||
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
|
||||
if (!isMobile && lastNotePath) {
|
||||
// Full-vault attachment scans cause navigation stalls on mobile storage.
|
||||
// Mobile users can run the same cleanup explicitly from the Info panel.
|
||||
if (!isMobile) {
|
||||
setTimeout(async () => {
|
||||
if (get(editorDirty)) return; // skip if user is actively editing
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
$activeNotePath = lastNotePath;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
const orphans = await findOrphanedAttachments();
|
||||
if (orphans.length > 0) {
|
||||
if (get(editorDirty)) return; // re-check after async scan
|
||||
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||
if (moved > 0) {
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-secondary);color:var(--text-primary);padding:10px 18px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);border:1px solid var(--border-color);font-size:13px;z-index:10000;opacity:0;transition:opacity .3s;pointer-events:none';
|
||||
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(() => (toast.style.opacity = '1'));
|
||||
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const startupTarget = resolveStartupTarget({
|
||||
startupView: $appConfig?.startup_view,
|
||||
restoreLastSession,
|
||||
lastViewMode,
|
||||
lastNotebook,
|
||||
lastTag
|
||||
});
|
||||
if (!(await applyStartupTarget(startupTarget))) {
|
||||
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
|
||||
}
|
||||
|
||||
// Reopen the last note only when session restoration is enabled.
|
||||
if (restoreLastSession && !isMobile && lastNotePath) {
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
$activeNotePath = lastNotePath;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
||||
@@ -615,7 +757,7 @@
|
||||
}
|
||||
}
|
||||
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
||||
}, 3000);
|
||||
}, 10000);
|
||||
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
||||
debouncedRefresh();
|
||||
});
|
||||
@@ -645,15 +787,16 @@
|
||||
|
||||
// ── WebDAV sync: global status + auto-sync triggers ──
|
||||
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
|
||||
unlistenSync.push(await listen('sync-done', (event: any) => {
|
||||
unlistenSync.push(await listen('sync-done', async () => {
|
||||
syncState.set({ running: false, error: null });
|
||||
const cur = get(appConfig);
|
||||
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time });
|
||||
try {
|
||||
appConfig.set(await getAppConfig());
|
||||
} catch {}
|
||||
}));
|
||||
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
|
||||
|
||||
// Sync when the vault opens (if enabled)
|
||||
if (syncConfigured() && get(appConfig)?.sync_on_open) {
|
||||
if (syncConfigured() && activeVaultConfig(get(appConfig))?.sync_on_open) {
|
||||
try { await syncNow(); } catch (_) {}
|
||||
}
|
||||
|
||||
@@ -663,8 +806,8 @@
|
||||
|
||||
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
|
||||
unsubDirty = editorDirty.subscribe((d) => {
|
||||
const config = get(appConfig);
|
||||
if (prevDirty && !d && config?.sync_provider === 'webdav' && config?.sync_on_change) {
|
||||
const vc = activeVaultConfig(get(appConfig));
|
||||
if (prevDirty && !d && vc?.sync_provider === 'webdav' && vc?.sync_on_change) {
|
||||
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
|
||||
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
|
||||
}
|
||||
@@ -691,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>
|
||||
@@ -712,7 +855,7 @@
|
||||
{#if $mobileView === 'sidebar'}
|
||||
HelixNotes
|
||||
{:else}
|
||||
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
|
||||
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
|
||||
{/if}
|
||||
</span>
|
||||
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
|
||||
@@ -780,7 +923,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if $appConfig?.sync_provider === 'webdav'}
|
||||
{#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
|
||||
<button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
|
||||
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
|
||||
@@ -807,6 +950,13 @@
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mobile-header-btn" onclick={() => ($showInfo = true)} title="Info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
@@ -823,10 +973,10 @@
|
||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||
<Editor bind:this={editor} />
|
||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -870,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}
|
||||
@@ -882,15 +1032,28 @@
|
||||
<ResizeHandle onResize={handleSidebarResize} />
|
||||
{/if}
|
||||
|
||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
{#if !$notelistCollapsed}
|
||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||
<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} />
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
{:else}
|
||||
<div class="notelist-restore-panel">
|
||||
<button class="notelist-restore-btn" onclick={revealNoteList} title={`Show notes list (${isMac ? '⌘' : 'Ctrl'}+Shift+\\)`} aria-label="Show notes list">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="8" y1="6" x2="20" y2="6" />
|
||||
<line x1="8" y1="12" x2="20" y2="12" />
|
||||
<line x1="8" y1="18" x2="20" y2="18" />
|
||||
<polyline points="5 8 3 12 5 16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="editor-panel">
|
||||
<Editor bind:this={editor} />
|
||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||
{#if $viewMode === 'tasks' && !taskNoteOpened}
|
||||
<div class="tasks-editor-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -905,7 +1068,7 @@
|
||||
{/if}
|
||||
|
||||
<SearchPanel />
|
||||
<CommandPalette />
|
||||
<CommandPalette onNavigate={handleViewChanged} />
|
||||
<SettingsPanel />
|
||||
<InfoPanel />
|
||||
|
||||
@@ -936,6 +1099,36 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notelist-restore-panel {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 100%;
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.notelist-restore-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notelist-restore-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
@@ -1022,7 +1215,13 @@
|
||||
.mobile-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
padding:
|
||||
env(safe-area-inset-top, 0px)
|
||||
env(safe-area-inset-right, 0px)
|
||||
env(safe-area-inset-bottom, 0px)
|
||||
env(safe-area-inset-left, 0px);
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { showCommandPalette, showSearch, theme, sourceMode } from '$lib/stores/app';
|
||||
import { showCommandPalette, showSearch, theme, sourceMode, viewMode, activeNotebook, activeTag } from '$lib/stores/app';
|
||||
import { setTheme, reindex } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
|
||||
// onNavigate runs the parent's view-change handler (refreshes the note list and reveals it
|
||||
// if it was hidden), so opening a view here behaves exactly like clicking it in the sidebar.
|
||||
let { onNavigate = () => {} }: { onNavigate?: () => void } = $props();
|
||||
|
||||
function openView(mode: 'all' | 'quickaccess' | 'tasks' | 'daily' | 'trash') {
|
||||
$viewMode = mode;
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
$showCommandPalette = false;
|
||||
onNavigate();
|
||||
}
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
@@ -25,6 +38,31 @@
|
||||
$showSearch = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open-all-notes',
|
||||
label: 'Open All Notes',
|
||||
action: () => openView('all')
|
||||
},
|
||||
{
|
||||
id: 'open-quick-access',
|
||||
label: 'Open Quick Access',
|
||||
action: () => openView('quickaccess')
|
||||
},
|
||||
{
|
||||
id: 'open-tasks',
|
||||
label: 'Open Tasks',
|
||||
action: () => openView('tasks')
|
||||
},
|
||||
{
|
||||
id: 'open-daily',
|
||||
label: 'Open Daily Notes',
|
||||
action: () => openView('daily')
|
||||
},
|
||||
{
|
||||
id: 'open-trash',
|
||||
label: 'Open Trash (restore deleted notes)',
|
||||
action: () => openView('trash')
|
||||
},
|
||||
{
|
||||
id: 'theme-light',
|
||||
label: 'Switch to Light Theme',
|
||||
@@ -51,7 +89,6 @@
|
||||
action: () => {
|
||||
$theme = 'system';
|
||||
setTheme('system');
|
||||
applyTheme('system');
|
||||
$showCommandPalette = false;
|
||||
}
|
||||
},
|
||||
@@ -127,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') {
|
||||
@@ -142,15 +184,14 @@
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
|
||||
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)) {
|
||||
} else if (t === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -158,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">
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getGraphData } from '$lib/api';
|
||||
import { activeNotePath, appConfig } from '$lib/stores/app';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
let { onclose, onnavigate }: {
|
||||
onclose: () => void;
|
||||
onnavigate: (path: string, title: string) => void;
|
||||
} = $props();
|
||||
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
|
||||
let canvas = $state<HTMLCanvasElement>(null!);
|
||||
let loading = $state(true);
|
||||
let searchQuery = $state('');
|
||||
@@ -21,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 };
|
||||
@@ -41,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,14 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { showInfo, appConfig } from '$lib/stores/app';
|
||||
import { getVaultStats } from '$lib/api';
|
||||
import { showInfo } from '$lib/stores/app';
|
||||
import { openUrl } from '$lib/api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { VaultStats } from '$lib/types';
|
||||
import {
|
||||
ACTIONS,
|
||||
keybindings,
|
||||
setBinding,
|
||||
resetBinding,
|
||||
bindingFromEvent,
|
||||
bindingToKeys,
|
||||
bindingsEqual,
|
||||
type ActionDef,
|
||||
} from '$lib/keybindings';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
|
||||
let stats = $state<VaultStats | null>(null);
|
||||
// Customizable shortcuts, grouped for display.
|
||||
const actionGroups: { title: string; actions: ActionDef[] }[] = [
|
||||
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
|
||||
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
|
||||
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
|
||||
];
|
||||
|
||||
// id of the action currently listening for a new key combo, or null.
|
||||
let capturingId = $state<string | null>(null);
|
||||
// id of an action whose row should briefly flash a "already in use" warning.
|
||||
let conflictId = $state<string | null>(null);
|
||||
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function startCapture(id: string) {
|
||||
conflictId = null;
|
||||
capturingId = id;
|
||||
}
|
||||
|
||||
function stopCapture() {
|
||||
capturingId = null;
|
||||
}
|
||||
|
||||
function flashConflict(id: string) {
|
||||
conflictId = id;
|
||||
if (conflictTimer) clearTimeout(conflictTimer);
|
||||
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
|
||||
}
|
||||
|
||||
function captureKeydown(e: KeyboardEvent) {
|
||||
if (!capturingId) return;
|
||||
// Capture phase + stopImmediatePropagation keeps the combo from also
|
||||
// triggering the app-level shortcut handler on window.
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
if (e.key === 'Escape') { stopCapture(); return; }
|
||||
|
||||
const binding = bindingFromEvent(e);
|
||||
if (!binding) return; // modifier-only press; keep listening
|
||||
|
||||
// Reject combos already bound to a different action.
|
||||
const map = $keybindings;
|
||||
const target = capturingId;
|
||||
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
|
||||
if (clash) {
|
||||
flashConflict(target);
|
||||
stopCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
setBinding(target, binding);
|
||||
stopCapture();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!capturingId) return;
|
||||
window.addEventListener('keydown', captureKeydown, true);
|
||||
return () => window.removeEventListener('keydown', captureKeydown, true);
|
||||
});
|
||||
|
||||
function resetKey(e: MouseEvent, id: string) {
|
||||
e.preventDefault();
|
||||
resetBinding(id);
|
||||
if (capturingId === id) stopCapture();
|
||||
}
|
||||
|
||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||
let appVersion = $state('...');
|
||||
|
||||
@@ -16,37 +88,34 @@
|
||||
|
||||
function close() {
|
||||
$showInfo = false;
|
||||
activeTab = 'shortcuts';
|
||||
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;
|
||||
}
|
||||
});
|
||||
</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}>
|
||||
<button class="close-btn" onclick={close} aria-label="Close info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
@@ -78,30 +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}
|
||||
|
||||
|
||||
<div class="info-credits">
|
||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||
@@ -116,27 +162,39 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="shortcuts-section">
|
||||
<h4 class="shortcuts-group-title">General</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Interface</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
|
||||
{#if !isMobile}
|
||||
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
|
||||
{/if}
|
||||
{#each actionGroups as group}
|
||||
<h4 class="shortcuts-group-title">{group.title}</h4>
|
||||
{#each group.actions as action}
|
||||
<div class="shortcut-row">
|
||||
<span class="shortcut-desc">{action.label}</span>
|
||||
{#if isMobile}
|
||||
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||
{:else}
|
||||
<button
|
||||
class="shortcut-key-btn"
|
||||
class:capturing={capturingId === action.id}
|
||||
class:conflict={conflictId === action.id}
|
||||
title="Click to rebind · right-click to reset"
|
||||
onclick={() => startCapture(action.id)}
|
||||
oncontextmenu={(e) => resetKey(e, action.id)}
|
||||
>
|
||||
{#if capturingId === action.id}
|
||||
<span class="capture-prompt">Press keys…</span>
|
||||
{:else if conflictId === action.id}
|
||||
<span class="conflict-msg">Already in use</span>
|
||||
{:else}
|
||||
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Navigation</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>←</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>→</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Formatting</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
|
||||
@@ -144,9 +202,11 @@
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Collapsible section</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>.</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Move list item up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>↑↓</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Editor Commands</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
|
||||
@@ -286,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;
|
||||
@@ -394,6 +421,48 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.shortcuts-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.shortcut-key-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shortcut-key-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.shortcut-key-btn.capturing {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.shortcut-key-btn.conflict {
|
||||
border-color: var(--danger, #e11d48);
|
||||
}
|
||||
|
||||
.capture-prompt {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.conflict-msg {
|
||||
font-size: 12px;
|
||||
color: var(--danger, #e11d48);
|
||||
}
|
||||
|
||||
.shortcut-keys kbd,
|
||||
.shortcut-desc kbd {
|
||||
display: inline-block;
|
||||
|
||||