Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2983e3db29 | ||
|
|
cc7c9e2b49 | ||
|
|
2240231f61 | ||
|
|
b2d0c7175e | ||
|
|
1bc0026731 | ||
|
|
41c0e4c993 | ||
|
|
34a0f9efa3 | ||
|
|
9d92489586 | ||
|
|
c45efb60ab | ||
|
|
0d11fd01dd | ||
|
|
0aaa05ce5c | ||
|
|
77e92ef25c | ||
|
|
d7e4be7f3a | ||
|
|
2aa572dcaa | ||
|
|
32f270ce9e | ||
|
|
a15b267614 | ||
|
|
ce48d77cd8 | ||
|
|
221625aabd | ||
|
|
1bd4cd8138 | ||
|
|
dd748776b2 | ||
|
|
0391a60c1b | ||
|
|
33d77cf2fd | ||
|
|
81e4e26290 | ||
|
|
111d819f92 | ||
|
|
c37818de1a | ||
|
|
95dab8cf74 | ||
|
|
62365f52b1 | ||
|
|
6eb1ec6eb2 | ||
|
|
527c6a7a91 | ||
|
|
45e3c389e4 | ||
|
|
9ccbf3aff5 | ||
|
|
695664913a | ||
|
|
d0fb87cf3a | ||
|
|
ac7c79af04 | ||
|
|
1ebf2a9843 | ||
|
|
44dcfbe24a | ||
|
|
00196ecedb | ||
|
|
c4d2d1b2d1 | ||
|
|
3a9a5129c4 | ||
|
|
a964ee1930 | ||
|
|
2d57577338 | ||
|
|
206faed113 | ||
|
|
b3c292d6e2 | ||
|
|
43f15962f7 | ||
|
|
ade146f9f3 | ||
|
|
b253ec4121 | ||
|
|
c4a91b8f1c | ||
|
|
87c879bcac | ||
|
|
27609fb4f3 | ||
|
|
b0555fe648 | ||
|
|
32ef51161d | ||
|
|
23abd0a19a | ||
|
|
5f726eb01f | ||
|
|
73f7604187 | ||
|
|
7ca226d918 | ||
|
|
1cbdf704bd | ||
|
|
b5bd5ea151 | ||
|
|
bdf94e2494 | ||
|
|
1950c4cc3c | ||
|
|
aeddcc6e58 | ||
|
|
bb9d01f57c | ||
|
|
ac25dfd773 | ||
|
|
e61c66dc26 | ||
|
|
3e8aa7f10d | ||
|
|
2dd0985ea9 | ||
|
|
18bb6bb702 | ||
|
|
fcf48a5b7a | ||
|
|
c7906f4069 | ||
|
|
e6e1ed1ed6 | ||
|
|
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 |
@@ -13,6 +13,9 @@ bun.lockb
|
|||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
# Local tests
|
||||||
|
/tests/
|
||||||
|
|
||||||
# Nix
|
# Nix
|
||||||
/result
|
/result
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# HelixNotes
|
# HelixNotes
|
||||||
|
|
||||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
|
||||||
[](https://helixnotes.com)
|
[](https://helixnotes.com)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
@@ -10,29 +10,45 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
|||||||
Your notes are stored as standard Markdown files on your local filesystem.
|
Your notes are stored as standard Markdown files on your local filesystem.
|
||||||
No cloud, no lock-in.
|
No cloud, no lock-in.
|
||||||
|
|
||||||
## Download (v1.3.2)
|
## Download (v1.3.5)
|
||||||
|
|
||||||
### Linux
|
### 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
|
```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
|
```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
|
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Solus (EOPKG)
|
##### Arch / Manjaro (AUR)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S helixnotes-appimage-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Solus (EOPKG)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo eopkg it helixnotes
|
sudo eopkg it helixnotes
|
||||||
```
|
```
|
||||||
|
|
||||||
#### NixOS
|
##### NixOS
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>flake.nix</summary>
|
<summary>flake.nix</summary>
|
||||||
@@ -42,7 +58,7 @@ sudo eopkg it helixnotes
|
|||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
helix-notes = {
|
helix-notes = {
|
||||||
url = "git+https://codeberg.org/ArkHost/HelixNotes";
|
url = "git+https://gitlab.com/ArkHost/HelixNotes";
|
||||||
# inputs.nixpkgs.follows = "nixpkgs";
|
# inputs.nixpkgs.follows = "nixpkgs";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -90,29 +106,34 @@ sudo eopkg it helixnotes
|
|||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
#### Manual package downloads
|
||||||
|
|
||||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage)
|
- [.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)
|
||||||
#### .deb (manual)
|
|
||||||
|
|
||||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+)
|
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_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
|
### macOS
|
||||||
|
|
||||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_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
|
### Android
|
||||||
|
|
||||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_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
|
## Features
|
||||||
|
|
||||||
@@ -147,7 +168,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Rust](https://rustup.rs/) (1.77+)
|
- [Rust](https://rustup.rs/) (1.88+)
|
||||||
- [Node.js](https://nodejs.org/) (18+)
|
- [Node.js](https://nodejs.org/) (18+)
|
||||||
- [pnpm](https://pnpm.io/)
|
- [pnpm](https://pnpm.io/)
|
||||||
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
|
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||||
@@ -159,6 +180,14 @@ pnpm install
|
|||||||
pnpm tauri dev
|
pnpm tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
Run the frontend checks and tests, Rust tests and lints, and production frontend build with one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm verify
|
||||||
|
```
|
||||||
|
|
||||||
### Production Build
|
### Production Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -175,4 +204,4 @@ pnpm tauri build
|
|||||||
|
|
||||||
## License
|
## 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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777268161,
|
"lastModified": 1785571196,
|
||||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -6,29 +6,33 @@
|
|||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs =
|
||||||
|
{
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
flake-utils,
|
flake-utils,
|
||||||
}:
|
}:
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||||
pname = cargoToml.package.name;
|
pname = cargoToml.package.name;
|
||||||
version = cargoToml.package.version;
|
version = cargoToml.package.version;
|
||||||
in {
|
in
|
||||||
|
{
|
||||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
|
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 4;
|
||||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
@@ -44,7 +48,8 @@
|
|||||||
wrapGAppsHook3
|
wrapGAppsHook3
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs = with pkgs;
|
buildInputs =
|
||||||
|
with pkgs;
|
||||||
lib.optionals stdenv.hostPlatform.isLinux [
|
lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
webkitgtk_4_1
|
webkitgtk_4_1
|
||||||
libayatana-appindicator
|
libayatana-appindicator
|
||||||
@@ -71,6 +76,7 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
formatter = pkgs.alejandra;
|
formatter = pkgs.nixfmt;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.3.2",
|
"version": "1.3.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
|
||||||
|
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
|
||||||
|
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
|
||||||
|
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build"
|
"tauri:build": "tauri build"
|
||||||
@@ -18,22 +22,22 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.70.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tauri-apps/cli": "^2.10.0",
|
"@tauri-apps/cli": "^2.10.0",
|
||||||
"svelte": "^5.49.2",
|
"@types/node": "26.2.0",
|
||||||
|
"svelte": "^5.56.9",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte-check": "^4.3.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
|
||||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||||
"@tiptap/core": "^3.19.0",
|
"@tiptap/core": "^3.19.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
||||||
@@ -62,11 +66,11 @@
|
|||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.3.0",
|
||||||
"markdown-it-mark": "^4.0.0",
|
"markdown-it-mark": "^4.0.0",
|
||||||
"markdown-it-sub": "^2.0.0",
|
"markdown-it-sub": "^2.0.0",
|
||||||
"markdown-it-sup": "^2.0.0",
|
"markdown-it-sup": "^2.0.0",
|
||||||
"markdown-it-task-lists": "^2.1.1",
|
"markdown-it-task-lists": "^2.1.1",
|
||||||
"mermaid": "^11.14.0"
|
"mermaid": "^11.16.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ahash"
|
|
||||||
version = "0.7.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"once_cell",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -138,12 +127,6 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arrayvec"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -346,18 +329,6 @@ dependencies = [
|
|||||||
"crunchy",
|
"crunchy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitvec"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
|
|
||||||
dependencies = [
|
|
||||||
"funty",
|
|
||||||
"radium",
|
|
||||||
"tap",
|
|
||||||
"wyz",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -389,29 +360,6 @@ dependencies = [
|
|||||||
"piper",
|
"piper",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "borsh"
|
|
||||||
version = "1.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
|
|
||||||
dependencies = [
|
|
||||||
"borsh-derive",
|
|
||||||
"cfg_aliases",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "borsh-derive"
|
|
||||||
version = "1.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
|
|
||||||
dependencies = [
|
|
||||||
"once_cell",
|
|
||||||
"proc-macro-crate 3.4.0",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.2"
|
version = "8.0.2"
|
||||||
@@ -439,40 +387,6 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byte-unit"
|
|
||||||
version = "5.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
|
|
||||||
dependencies = [
|
|
||||||
"rust_decimal",
|
|
||||||
"schemars 1.2.1",
|
|
||||||
"serde",
|
|
||||||
"utf8-width",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bytecheck"
|
|
||||||
version = "0.6.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
|
|
||||||
dependencies = [
|
|
||||||
"bytecheck_derive",
|
|
||||||
"ptr_meta",
|
|
||||||
"simdutf8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bytecheck_derive"
|
|
||||||
version = "0.6.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 1.0.109",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.0"
|
version = "1.25.0"
|
||||||
@@ -624,6 +538,17 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -738,6 +663,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -768,9 +702,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
@@ -1314,12 +1248,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "funty"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1590,11 +1518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1604,10 +1530,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
|
"rand_core 0.10.1",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1796,9 +1725,6 @@ name = "hashbrown"
|
|||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
dependencies = [
|
|
||||||
"ahash 0.7.8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
@@ -1806,7 +1732,7 @@ version = "0.14.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash 0.8.12",
|
"ahash",
|
||||||
"allocator-api2",
|
"allocator-api2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1850,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.2"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1860,8 +1786,9 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"log",
|
"log",
|
||||||
"notify",
|
"notify",
|
||||||
|
"objc2-app-kit",
|
||||||
"png 0.17.16",
|
"png 0.17.16",
|
||||||
"quick-xml 0.36.2",
|
"quick-xml",
|
||||||
"rayon",
|
"rayon",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -1876,6 +1803,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
|
"tauri-plugin-ios-vault-access",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
@@ -2552,9 +2480,6 @@ name = "log"
|
|||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
dependencies = [
|
|
||||||
"value-bag",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
@@ -2573,9 +2498,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lz4_flex"
|
name = "lz4_flex"
|
||||||
version = "0.11.5"
|
version = "0.11.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
|
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
@@ -3415,13 +3340,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plist"
|
name = "plist"
|
||||||
version = "1.8.0"
|
version = "1.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"quick-xml 0.38.4",
|
"quick-xml",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -3574,26 +3499,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ptr_meta"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
|
|
||||||
dependencies = [
|
|
||||||
"ptr_meta_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ptr_meta_derive"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 1.0.109",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pure-rust-locales"
|
name = "pure-rust-locales"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -3617,18 +3522,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.36.2"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quick-xml"
|
|
||||||
version = "0.38.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -3655,14 +3551,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.13"
|
version = "0.11.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.2",
|
"rand 0.10.2",
|
||||||
|
"rand_pcg 0.10.2",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash 2.1.1",
|
"rustc-hash 2.1.1",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -3703,12 +3600,6 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "radium"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -3720,7 +3611,7 @@ dependencies = [
|
|||||||
"rand_chacha 0.2.2",
|
"rand_chacha 0.2.2",
|
||||||
"rand_core 0.5.1",
|
"rand_core 0.5.1",
|
||||||
"rand_hc",
|
"rand_hc",
|
||||||
"rand_pcg",
|
"rand_pcg 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3736,12 +3627,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.2"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha 0.9.0",
|
"chacha20",
|
||||||
"rand_core 0.9.5",
|
"getrandom 0.4.1",
|
||||||
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3764,16 +3656,6 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_chacha"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
|
||||||
dependencies = [
|
|
||||||
"ppv-lite86",
|
|
||||||
"rand_core 0.9.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3794,12 +3676,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.9.5"
|
version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_distr"
|
name = "rand_distr"
|
||||||
@@ -3829,6 +3708,15 @@ dependencies = [
|
|||||||
"rand_core 0.5.1",
|
"rand_core 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3933,15 +3821,6 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rend"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
|
||||||
dependencies = [
|
|
||||||
"bytecheck",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
@@ -4061,35 +3940,6 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rkyv"
|
|
||||||
version = "0.7.46"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
|
|
||||||
dependencies = [
|
|
||||||
"bitvec",
|
|
||||||
"bytecheck",
|
|
||||||
"bytes",
|
|
||||||
"hashbrown 0.12.3",
|
|
||||||
"ptr_meta",
|
|
||||||
"rend",
|
|
||||||
"rkyv_derive",
|
|
||||||
"seahash",
|
|
||||||
"tinyvec",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rkyv_derive"
|
|
||||||
version = "0.7.46"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 1.0.109",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-stemmers"
|
name = "rust-stemmers"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -4100,22 +3950,6 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rust_decimal"
|
|
||||||
version = "1.40.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
|
|
||||||
dependencies = [
|
|
||||||
"arrayvec",
|
|
||||||
"borsh",
|
|
||||||
"bytes",
|
|
||||||
"num-traits",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"rkyv",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -4229,9 +4063,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.9"
|
version = "0.103.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -4325,12 +4159,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "seahash"
|
|
||||||
version = "4.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -4572,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4598,12 +4426,6 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "simdutf8"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@@ -5005,17 +4827,11 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tap"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.44"
|
version = "0.4.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5201,14 +5017,22 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-ios-vault-access"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-log"
|
name = "tauri-plugin-log"
|
||||||
version = "2.8.0"
|
version = "2.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
|
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"byte-unit",
|
|
||||||
"fern",
|
"fern",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -5953,12 +5777,6 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
|
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8-width"
|
|
||||||
version = "0.1.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -5977,12 +5795,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "value-bag"
|
|
||||||
version = "1.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -6235,12 +6047,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-scanner"
|
name = "wayland-scanner"
|
||||||
version = "0.31.8"
|
version = "0.31.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml 0.38.4",
|
"quick-xml",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6996,15 +6808,6 @@ dependencies = [
|
|||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wyz"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
|
||||||
dependencies = [
|
|
||||||
"tap",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11"
|
name = "x11"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.2"
|
version = "1.3.5"
|
||||||
description = "Local markdown note-taking app"
|
description = "Local markdown note-taking app"
|
||||||
authors = ["HelixNotes"]
|
authors = ["HelixNotes"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.88"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
# This style lint is version-dependent and produces over 100 legacy-only diagnostics on Rust 1.88.
|
||||||
|
uninlined_format_args = "allow"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "app_lib"
|
name = "app_lib"
|
||||||
@@ -14,6 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tauri = { version = "2", features = ["test"] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
@@ -38,10 +45,16 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
quick-xml = "0.36"
|
quick-xml = "0.41"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
|
[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]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-single-instance = "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>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-show",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-set-decorations",
|
"core:window:allow-set-decorations",
|
||||||
"core:window:allow-is-fullscreen",
|
"core:window:allow-is-fullscreen",
|
||||||
@@ -23,32 +24,6 @@
|
|||||||
"dialog:allow-message",
|
"dialog:allow-message",
|
||||||
"dialog:allow-ask",
|
"dialog:allow-ask",
|
||||||
"dialog:allow-confirm",
|
"dialog:allow-confirm",
|
||||||
"fs:default",
|
"fs:allow-read-file"
|
||||||
"fs:allow-read",
|
|
||||||
"fs:allow-write",
|
|
||||||
"fs:allow-exists",
|
|
||||||
"fs:allow-mkdir",
|
|
||||||
"fs:allow-remove",
|
|
||||||
"fs:allow-rename",
|
|
||||||
"fs:allow-copy-file",
|
|
||||||
"fs:allow-stat",
|
|
||||||
"fs:allow-read-dir",
|
|
||||||
"fs:allow-read-file",
|
|
||||||
"fs:allow-write-file",
|
|
||||||
"fs:allow-watch",
|
|
||||||
"opener:default",
|
|
||||||
{
|
|
||||||
"identifier": "opener:allow-open-path",
|
|
||||||
"allow": [{ "path": "/**" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "opener:allow-open-url",
|
|
||||||
"allow": [
|
|
||||||
{ "url": "https://**" },
|
|
||||||
{ "url": "http://**" },
|
|
||||||
{ "url": "mailto:*" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"opener:allow-reveal-item-in-dir"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#fff</color>
|
<color name="ic_launcher_background">#626ED4</color>
|
||||||
</resources>
|
</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 |
@@ -1,9 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
|
||||||
<rect width="512" height="512" rx="112" ry="112" fill="#626ED4"/>
|
|
||||||
<line x1="128" y1="128" x2="384" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
|
||||||
<line x1="384" y1="128" x2="128" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
|
||||||
<circle cx="128" cy="128" r="36" fill="#ffffff"/>
|
|
||||||
<circle cx="384" cy="128" r="36" fill="#ffffff"/>
|
|
||||||
<circle cx="128" cy="384" r="36" fill="#ffffff"/>
|
|
||||||
<circle cx="384" cy="384" r="36" fill="#ffffff"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 633 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 OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
||||||
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn ai_request(
|
pub fn ai_request(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
provider: String,
|
provider: String,
|
||||||
@@ -22,7 +23,11 @@ pub fn ai_request(
|
|||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
||||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
|
let key_opt = if api_key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(api_key.as_str())
|
||||||
|
};
|
||||||
let result = match provider.as_str() {
|
let result = match provider.as_str() {
|
||||||
"openai" => {
|
"openai" => {
|
||||||
stream_openai(
|
stream_openai(
|
||||||
@@ -98,7 +103,10 @@ pub fn ai_request(
|
|||||||
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
||||||
fn normalize_openai_base(base: &str) -> String {
|
fn normalize_openai_base(base: &str) -> String {
|
||||||
let b = base.trim().trim_end_matches('/');
|
let b = base.trim().trim_end_matches('/');
|
||||||
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
|
b.strip_suffix("/v1")
|
||||||
|
.unwrap_or(b)
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_anthropic(
|
async fn stream_anthropic(
|
||||||
@@ -268,9 +276,7 @@ async fn stream_openai(
|
|||||||
body["temperature"] = json!(0.7);
|
body["temperature"] = json!(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut req = client
|
let mut req = client.post(url).header("content-type", "application/json");
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/json");
|
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
req = req.header("Authorization", format!("Bearer {}", key));
|
req = req.header("Authorization", format!("Bearer {}", key));
|
||||||
@@ -380,7 +386,11 @@ pub async fn test_connection(
|
|||||||
model: &str,
|
model: &str,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
|
let key_opt = if api_key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(api_key)
|
||||||
|
};
|
||||||
match provider {
|
match provider {
|
||||||
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
||||||
"ollama" => {
|
"ollama" => {
|
||||||
@@ -405,7 +415,7 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
|||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 10,
|
"max_tokens": 20,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
@@ -435,10 +445,16 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
|||||||
|
|
||||||
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
let is_gpt5 = model.starts_with("gpt-5");
|
||||||
|
let token_key = if is_gpt5 {
|
||||||
|
"max_completion_tokens"
|
||||||
|
} else {
|
||||||
|
"max_tokens"
|
||||||
|
};
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 10,
|
token_key: 20,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
@@ -447,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut req = client
|
let mut req = client.post(url).header("content-type", "application/json");
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/json");
|
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
req = req.header("Authorization", format!("Bearer {}", key));
|
req = req.header("Authorization", format!("Bearer {}", key));
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
|
|
||||||
|
/// Grants asset-protocol access only to a vault selected by the user.
|
||||||
|
/// `.helixnotes` is added explicitly because Unix glob matching excludes hidden paths.
|
||||||
|
pub fn allow_vault_assets<R: Runtime>(app: &AppHandle<R>, vault_path: &Path) -> Result<(), String> {
|
||||||
|
// Keep the canonical native path: Tauri's scope normalizes Unix paths plus
|
||||||
|
// Windows drive, verbatim, and UNC prefixes when matching asset requests.
|
||||||
|
let vault = std::fs::canonicalize(vault_path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Failed to resolve vault asset path '{}': {error}",
|
||||||
|
vault_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let scope = app.asset_protocol_scope();
|
||||||
|
scope
|
||||||
|
.allow_directory(&vault, true)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
scope
|
||||||
|
.allow_directory(vault.join(".helixnotes"), true)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grants access to relative assets next to a Markdown file explicitly opened by the user.
|
||||||
|
pub fn allow_external_note_assets<R: Runtime>(
|
||||||
|
app: &AppHandle<R>,
|
||||||
|
note_path: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let note = std::fs::canonicalize(note_path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Failed to resolve external note path '{}': {error}",
|
||||||
|
note_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let parent = note
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "External note has no parent directory".to_string())?;
|
||||||
|
app.asset_protocol_scope()
|
||||||
|
.allow_directory(parent, true)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fn test_directory(name: &str) -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-{name}-{}", uuid::Uuid::new_v4()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_asset_scope_does_not_expose_the_filesystem() {
|
||||||
|
let config: serde_json::Value =
|
||||||
|
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||||
|
let scope = &config["app"]["security"]["assetProtocol"]["scope"];
|
||||||
|
|
||||||
|
assert_eq!(scope["allow"], serde_json::json!([]));
|
||||||
|
assert_eq!(scope["deny"], serde_json::json!([]));
|
||||||
|
assert_eq!(scope["requireLiteralLeadingDot"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_scope_allows_only_the_selected_vault() {
|
||||||
|
let root = test_directory("asset-scope");
|
||||||
|
let vault = root.join("Vault [Cross Platform]");
|
||||||
|
let attachments = vault.join(".helixnotes").join("attachments");
|
||||||
|
fs::create_dir_all(&attachments).unwrap();
|
||||||
|
let note_image = vault.join("images").join("note image.png");
|
||||||
|
fs::create_dir_all(note_image.parent().unwrap()).unwrap();
|
||||||
|
fs::write(¬e_image, b"image").unwrap();
|
||||||
|
let attachment = attachments.join("attachment.png");
|
||||||
|
fs::write(&attachment, b"attachment").unwrap();
|
||||||
|
let outside = root.join("outside.png");
|
||||||
|
fs::write(&outside, b"outside").unwrap();
|
||||||
|
|
||||||
|
let app = tauri::test::mock_app();
|
||||||
|
allow_vault_assets(app.handle(), &vault).unwrap();
|
||||||
|
let scope = app.asset_protocol_scope();
|
||||||
|
|
||||||
|
assert!(scope.is_allowed(¬e_image));
|
||||||
|
assert!(scope.is_allowed(&attachment));
|
||||||
|
assert!(!scope.is_allowed(&outside));
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_note_scope_allows_relative_assets_only_below_its_directory() {
|
||||||
|
let root = test_directory("external-note-assets");
|
||||||
|
let note_dir = root.join("shared note");
|
||||||
|
let assets = note_dir.join("images");
|
||||||
|
fs::create_dir_all(&assets).unwrap();
|
||||||
|
let note = note_dir.join("note.md");
|
||||||
|
let image = assets.join("image.png");
|
||||||
|
let outside = root.join("outside.png");
|
||||||
|
fs::write(¬e, b"note").unwrap();
|
||||||
|
fs::write(&image, b"image").unwrap();
|
||||||
|
fs::write(&outside, b"outside").unwrap();
|
||||||
|
|
||||||
|
let app = tauri::test::mock_app();
|
||||||
|
allow_external_note_assets(app.handle(), ¬e).unwrap();
|
||||||
|
let scope = app.asset_protocol_scope();
|
||||||
|
|
||||||
|
assert!(scope.is_allowed(¬e));
|
||||||
|
assert!(scope.is_allowed(&image));
|
||||||
|
assert!(!scope.is_allowed(&outside));
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
|||||||
let entry = entry.map_err(|e| e.to_string())?;
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
if path.extension().map_or(false, |ext| ext == "zip") {
|
if path.extension().is_some_and(|ext| ext == "zip") {
|
||||||
let filename = path
|
let filename = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore a backup by extracting the zip over the vault directory
|
fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
|
||||||
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> {
|
let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
|
||||||
let vault = Path::new(vault_path);
|
let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
|
||||||
let backup = Path::new(backup_path);
|
if backup.parent() != Some(backup_dir.as_path())
|
||||||
|
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
|
||||||
if !backup.exists() {
|
|| !backup.is_file()
|
||||||
return Err("Backup file does not exist".to_string());
|
{
|
||||||
|
return Err(
|
||||||
|
"Backup path must point to a ZIP file in the configured backup directory".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Ok(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a backup by extracting the zip over the vault directory
|
||||||
|
pub fn restore_backup(
|
||||||
|
vault_path: &str,
|
||||||
|
backup_dir: &Path,
|
||||||
|
backup_path: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let vault = Path::new(vault_path);
|
||||||
|
let backup = validated_backup_file(backup_dir, backup_path)?;
|
||||||
|
|
||||||
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
||||||
let mut archive =
|
let mut archive =
|
||||||
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a single backup file
|
/// Delete a single backup file
|
||||||
pub fn delete_backup(backup_path: &str) -> Result<(), String> {
|
pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
|
||||||
let path = Path::new(backup_path);
|
let path = validated_backup_file(backup_dir, backup_path)?;
|
||||||
if path.exists() {
|
fs::remove_file(path).map_err(|error| error.to_string())
|
||||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove old backups keeping only the newest `max_count`
|
/// Remove old backups keeping only the newest `max_count`
|
||||||
@@ -261,9 +272,31 @@ pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), Stri
|
|||||||
if backups.len() as u32 > max_count {
|
if backups.len() as u32 > max_count {
|
||||||
let to_remove = backups.split_off(max_count as usize);
|
let to_remove = backups.split_off(max_count as usize);
|
||||||
for entry in to_remove {
|
for entry in to_remove {
|
||||||
delete_backup(&entry.path)?;
|
delete_backup(backup_dir, &entry.path)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::delete_backup;
|
||||||
|
use std::fs;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_backup_rejects_files_outside_backup_directory() {
|
||||||
|
let root = std::env::temp_dir().join(format!("helixnotes-backup-test-{}", Uuid::new_v4()));
|
||||||
|
let backup_dir = root.join("backups");
|
||||||
|
let outside = root.join("outside.zip");
|
||||||
|
fs::create_dir_all(&backup_dir).unwrap();
|
||||||
|
fs::write(&outside, "must survive").unwrap();
|
||||||
|
|
||||||
|
let result = delete_backup(&backup_dir, &outside.to_string_lossy());
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(outside.exists());
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
use crate::types::VersionEntry;
|
use crate::types::VersionEntry;
|
||||||
|
|
||||||
|
fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
|
||||||
|
let mut components = Path::new(value).components();
|
||||||
|
if value.is_empty()
|
||||||
|
|| !matches!(components.next(), Some(Component::Normal(_)))
|
||||||
|
|| components.next().is_some()
|
||||||
|
{
|
||||||
|
return Err(format!("Invalid {label}"));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
/// Directory: .helixnotes/history/<note-id>/
|
/// Directory: .helixnotes/history/<note-id>/
|
||||||
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
|
fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
|
||||||
Path::new(vault_path)
|
Ok(Path::new(vault_path)
|
||||||
.join(".helixnotes")
|
.join(".helixnotes")
|
||||||
.join("history")
|
.join("history")
|
||||||
.join(note_id)
|
.join(safe_path_component(note_id, "note ID")?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a version snapshot if enough time has passed since the last one.
|
/// Save a version snapshot if enough time has passed since the last one.
|
||||||
/// Minimum interval: 5 minutes between snapshots.
|
/// Minimum interval: 5 minutes between snapshots.
|
||||||
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
||||||
let dir = history_dir(vault_path, note_id);
|
let Ok(dir) = history_dir(vault_path, note_id) else {
|
||||||
|
log::warn!("Skipping history snapshot with an invalid note ID");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
// Check if we should create a snapshot (5 min cooldown)
|
// Check if we should create a snapshot (5 min cooldown)
|
||||||
if let Ok(entries) = fs::read_dir(&dir) {
|
if let Ok(entries) = fs::read_dir(&dir) {
|
||||||
@@ -68,31 +82,30 @@ pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_ve
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Force-create a version snapshot, bypassing the cooldown.
|
/// Force-create a version snapshot, bypassing the cooldown.
|
||||||
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
pub fn force_snapshot(
|
||||||
let dir = history_dir(vault_path, note_id);
|
vault_path: &str,
|
||||||
|
note_id: &str,
|
||||||
if let Err(e) = fs::create_dir_all(&dir) {
|
raw_content: &str,
|
||||||
eprintln!("Failed to create history dir: {}", e);
|
max_versions: u32,
|
||||||
return;
|
) -> Result<(), String> {
|
||||||
}
|
let dir = history_dir(vault_path, note_id)?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
||||||
let filename = format!("{}.md", timestamp);
|
let filename = format!("{}.md", timestamp);
|
||||||
let path = dir.join(&filename);
|
let path = dir.join(&filename);
|
||||||
|
|
||||||
if let Err(e) = fs::write(&path, raw_content) {
|
fs::write(&path, raw_content).map_err(|error| error.to_string())?;
|
||||||
eprintln!("Failed to write version snapshot: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if max_versions > 0 {
|
if max_versions > 0 {
|
||||||
let _ = prune_versions(&dir, max_versions);
|
prune_versions(&dir, max_versions)?;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all version snapshots for a note, newest first.
|
/// List all version snapshots for a note, newest first.
|
||||||
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
|
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
|
||||||
let dir = history_dir(vault_path, note_id);
|
let dir = history_dir(vault_path, note_id)?;
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -102,7 +115,7 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
|||||||
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||||
let entry = entry.map_err(|e| e.to_string())?;
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().map_or(false, |ext| ext == "md") {
|
if path.extension().is_some_and(|ext| ext == "md") {
|
||||||
let filename = path
|
let filename = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -134,17 +147,18 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
|||||||
|
|
||||||
/// Get the raw content of a specific version.
|
/// Get the raw content of a specific version.
|
||||||
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
|
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
|
||||||
|
safe_path_component(timestamp, "version timestamp")?;
|
||||||
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
|
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
|
||||||
let filename = if let Some(t_pos) = timestamp.find('T') {
|
let filename = if let Some(t_pos) = timestamp.find('T') {
|
||||||
let date_part = ×tamp[..t_pos];
|
let date_part = ×tamp[..t_pos];
|
||||||
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
|
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
|
||||||
let time_dashes = time_part.replace(':', "-");
|
let time_dashes = time_part.replace(':', "-");
|
||||||
format!("{}.md", format!("{}T{}", date_part, time_dashes))
|
format!("{date_part}T{time_dashes}.md")
|
||||||
} else {
|
} else {
|
||||||
format!("{}.md", timestamp)
|
format!("{}.md", timestamp)
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = history_dir(vault_path, note_id).join(&filename);
|
let path = history_dir(vault_path, note_id)?.join(&filename);
|
||||||
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
|
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +168,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
|||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.map(|e| e.path())
|
.map(|e| e.path())
|
||||||
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
|
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by name (timestamps sort lexicographically) - newest last
|
// Sort by name (timestamps sort lexicographically) - newest last
|
||||||
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{get_version, list_versions};
|
||||||
|
use std::fs;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_history_path_traversal() {
|
||||||
|
let vault =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-history-test-{}", Uuid::new_v4()));
|
||||||
|
let metadata = vault.join(".helixnotes");
|
||||||
|
let escaped_history = metadata.join("escaped");
|
||||||
|
fs::create_dir_all(&escaped_history).unwrap();
|
||||||
|
fs::write(escaped_history.join("2026-01-01T00-00-00.md"), "escaped").unwrap();
|
||||||
|
fs::create_dir_all(metadata.join("history").join("safe")).unwrap();
|
||||||
|
fs::write(metadata.join("secret.md"), "secret").unwrap();
|
||||||
|
|
||||||
|
assert!(list_versions(&vault.to_string_lossy(), "../escaped").is_err());
|
||||||
|
assert!(get_version(&vault.to_string_lossy(), "safe", "../../secret").is_err());
|
||||||
|
|
||||||
|
fs::remove_dir_all(vault).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
use reqwest::{blocking::Client, redirect::Policy, Url};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub struct ImageResponse {
|
||||||
|
pub content_type: String,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ProxyError {
|
||||||
|
Invalid(String),
|
||||||
|
Blocked(String),
|
||||||
|
Fetch(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyError {
|
||||||
|
pub fn status(&self) -> u16 {
|
||||||
|
match self {
|
||||||
|
Self::Invalid(_) => 400,
|
||||||
|
Self::Blocked(_) => 403,
|
||||||
|
Self::Fetch(_) => 502,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Invalid(message) | Self::Blocked(message) | Self::Fetch(message) => message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
|
||||||
|
let [a, b, c, _] = ip.octets();
|
||||||
|
!(a == 0
|
||||||
|
|| a == 10
|
||||||
|
|| a == 127
|
||||||
|
|| a >= 224
|
||||||
|
|| (a == 100 && (64..=127).contains(&b))
|
||||||
|
|| (a == 169 && b == 254)
|
||||||
|
|| (a == 172 && (16..=31).contains(&b))
|
||||||
|
|| (a == 192 && b == 168)
|
||||||
|
|| (a == 192 && b == 0 && c == 0)
|
||||||
|
|| (a == 192 && b == 0 && c == 2)
|
||||||
|
|| (a == 198 && (b == 18 || b == 19))
|
||||||
|
|| (a == 198 && b == 51 && c == 100)
|
||||||
|
|| (a == 203 && b == 0 && c == 113))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_public_ipv6(ip: Ipv6Addr) -> bool {
|
||||||
|
if ip.is_unspecified() || ip.is_loopback() || ip.to_ipv4().is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let first = ip.segments()[0];
|
||||||
|
(0x2000..=0x3fff).contains(&first) && ip.segments()[..2] != [0x2001, 0x0db8]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_public_ip(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(ip) => is_public_ipv4(ip),
|
||||||
|
IpAddr::V6(ip) => is_public_ipv6(ip),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_host(host: &str) -> String {
|
||||||
|
host.trim_start_matches('[')
|
||||||
|
.trim_end_matches(']')
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_url(raw_url: &str) -> Result<Url, ProxyError> {
|
||||||
|
let url = Url::parse(raw_url).map_err(|_| ProxyError::Invalid("Invalid image URL".into()))?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return Err(ProxyError::Invalid(
|
||||||
|
"Only HTTP and HTTPS images are supported".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
|
return Err(ProxyError::Invalid(
|
||||||
|
"Image URLs must not contain credentials".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let expected_port = if url.scheme() == "https" { 443 } else { 80 };
|
||||||
|
if url.port_or_known_default() != Some(expected_port) {
|
||||||
|
return Err(ProxyError::Blocked("Non-standard image URL port".into()));
|
||||||
|
}
|
||||||
|
let host = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
|
||||||
|
if host.ends_with('.') {
|
||||||
|
return Err(ProxyError::Invalid(
|
||||||
|
"Image URL host must not have a trailing dot".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let normalized = normalized_host(host);
|
||||||
|
if normalized == "localhost" || normalized.ends_with(".localhost") {
|
||||||
|
return Err(ProxyError::Blocked(
|
||||||
|
"Local image hosts are not allowed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Ok(ip) = normalized.parse::<IpAddr>() {
|
||||||
|
if !is_public_ip(ip) {
|
||||||
|
return Err(ProxyError::Blocked(
|
||||||
|
"Private image addresses are not allowed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_public_host(url: &Url) -> Result<(String, Vec<SocketAddr>), ProxyError> {
|
||||||
|
let host = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
|
||||||
|
let host = normalized_host(host);
|
||||||
|
let port = url
|
||||||
|
.port_or_known_default()
|
||||||
|
.ok_or_else(|| ProxyError::Invalid("Image URL has no port".into()))?;
|
||||||
|
let addresses: Vec<_> = (host.as_str(), port)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|error| ProxyError::Fetch(format!("Could not resolve image host: {error}")))?
|
||||||
|
.collect();
|
||||||
|
if addresses.is_empty() {
|
||||||
|
return Err(ProxyError::Fetch(
|
||||||
|
"Image host resolved to no addresses".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if addresses.iter().any(|address| !is_public_ip(address.ip())) {
|
||||||
|
return Err(ProxyError::Blocked(
|
||||||
|
"Image host resolves to a private address".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((host, addresses))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_client(host: &str, addresses: &[SocketAddr]) -> Result<Client, ProxyError> {
|
||||||
|
let redirect_host = host.to_string();
|
||||||
|
Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.redirect(Policy::custom(move |attempt| {
|
||||||
|
let redirected_elsewhere = match attempt.url().host_str() {
|
||||||
|
Some(host) => normalized_host(host) != redirect_host,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if attempt.previous().len() >= 5
|
||||||
|
|| redirected_elsewhere
|
||||||
|
|| validate_url(attempt.url().as_str()).is_err()
|
||||||
|
{
|
||||||
|
attempt.stop()
|
||||||
|
} else {
|
||||||
|
attempt.follow()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.resolve_to_addrs(host, addresses)
|
||||||
|
.build()
|
||||||
|
.map_err(|error| ProxyError::Fetch(format!("Could not create image client: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch(raw_url: &str) -> Result<ImageResponse, ProxyError> {
|
||||||
|
let url = validate_url(raw_url)?;
|
||||||
|
let (host, addresses) = resolve_public_host(&url)?;
|
||||||
|
let client = image_client(&host, &addresses)?;
|
||||||
|
let mut response = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.map_err(|error| ProxyError::Fetch(format!("Image request failed: {error}")))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(ProxyError::Fetch(format!(
|
||||||
|
"Image server returned {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if response
|
||||||
|
.content_length()
|
||||||
|
.is_some_and(|length| length > MAX_IMAGE_BYTES)
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
|
||||||
|
}
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
if !content_type.to_ascii_lowercase().starts_with("image/") {
|
||||||
|
return Err(ProxyError::Blocked(
|
||||||
|
"Remote resource is not an image".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut body = Vec::new();
|
||||||
|
response
|
||||||
|
.by_ref()
|
||||||
|
.take(MAX_IMAGE_BYTES + 1)
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.map_err(|error| ProxyError::Fetch(format!("Could not read image: {error}")))?;
|
||||||
|
if body.len() as u64 > MAX_IMAGE_BYTES {
|
||||||
|
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
|
||||||
|
}
|
||||||
|
Ok(ImageResponse { content_type, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{is_public_ip, validate_url};
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_local_and_private_networks() {
|
||||||
|
for ip in [
|
||||||
|
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||||
|
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
|
||||||
|
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
|
||||||
|
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
|
||||||
|
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||||
|
"fc00::1".parse().unwrap(),
|
||||||
|
"fe80::1".parse().unwrap(),
|
||||||
|
] {
|
||||||
|
assert!(!is_public_ip(ip), "{ip}");
|
||||||
|
}
|
||||||
|
assert!(is_public_ip("93.184.216.34".parse().unwrap()));
|
||||||
|
assert!(is_public_ip(
|
||||||
|
"2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_only_public_web_image_urls() {
|
||||||
|
for url in [
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"http://localhost/image.png",
|
||||||
|
"http://127.0.0.1/image.png",
|
||||||
|
"http://[::1]/image.png",
|
||||||
|
"http://example.com:8080/image.png",
|
||||||
|
"https://example.com./image.png",
|
||||||
|
"https://user:pass@example.com/image.png",
|
||||||
|
] {
|
||||||
|
assert!(validate_url(url).is_err(), "{url}");
|
||||||
|
}
|
||||||
|
assert!(validate_url("https://example.com/image.png").is_ok());
|
||||||
|
assert!(validate_url("http://93.184.216.34/image.png").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
mod ai;
|
mod ai;
|
||||||
|
mod asset_scope;
|
||||||
mod backup;
|
mod backup;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod image_proxy;
|
||||||
mod search;
|
mod search;
|
||||||
mod state;
|
mod state;
|
||||||
mod sync;
|
mod sync;
|
||||||
@@ -11,6 +13,7 @@ mod vault;
|
|||||||
use state::AppState;
|
use state::AppState;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
use tauri_plugin_fs::FsExt;
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
use tauri::{
|
use tauri::{
|
||||||
@@ -38,11 +41,48 @@ pub fn run() {
|
|||||||
let close_to_tray = config.close_to_tray && show_tray;
|
let close_to_tray = config.close_to_tray && show_tray;
|
||||||
let app_state = AppState::new(config);
|
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()
|
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_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.on_webview_event(|webview, event| {
|
||||||
|
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
|
||||||
|
let scope = webview.fs_scope();
|
||||||
|
for path in paths {
|
||||||
|
if path.is_file() {
|
||||||
|
let _ = scope.allow_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_page_load(|webview, payload| {
|
||||||
|
#[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| {
|
.setup(move |app| {
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
app.handle().plugin(tauri_plugin_ios_vault_access::init())?;
|
||||||
|
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
tauri_plugin_log::Builder::default()
|
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)]
|
#[cfg(desktop)]
|
||||||
if show_tray {
|
if show_tray {
|
||||||
setup_tray(app)?;
|
setup_tray(app)?;
|
||||||
@@ -85,6 +139,14 @@ pub fn run() {
|
|||||||
std::env::current_dir().unwrap_or_default().join(&path)
|
std::env::current_dir().unwrap_or_default().join(&path)
|
||||||
};
|
};
|
||||||
if let Some(resolved_str) = resolved.to_str() {
|
if let Some(resolved_str) = resolved.to_str() {
|
||||||
|
if resolved.is_file() {
|
||||||
|
let _ = app.fs_scope().allow_file(&resolved);
|
||||||
|
if let Some(parent) = resolved.parent() {
|
||||||
|
let _ = app.fs_scope().allow_directory(parent, true);
|
||||||
|
}
|
||||||
|
let _ =
|
||||||
|
asset_scope::allow_external_note_assets(app.handle(), &resolved);
|
||||||
|
}
|
||||||
let app_state = app.state::<AppState>();
|
let app_state = app.state::<AppState>();
|
||||||
let _ = app_state.pending_open_file.lock().map(|mut p| {
|
let _ = app_state.pending_open_file.lock().map(|mut p| {
|
||||||
*p = Some(resolved_str.to_string());
|
*p = Some(resolved_str.to_string());
|
||||||
@@ -98,11 +160,19 @@ pub fn run() {
|
|||||||
.manage(app_state)
|
.manage(app_state)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::open_vault,
|
commands::open_vault,
|
||||||
|
commands::choose_external_vault,
|
||||||
|
commands::restore_external_vault,
|
||||||
commands::remove_vault,
|
commands::remove_vault,
|
||||||
commands::get_app_config,
|
commands::get_app_config,
|
||||||
commands::set_theme,
|
commands::set_theme,
|
||||||
|
commands::set_system_themes,
|
||||||
commands::set_accent_color,
|
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_font_size,
|
||||||
|
commands::set_scroll_to_change_font_size,
|
||||||
commands::set_font_family,
|
commands::set_font_family,
|
||||||
commands::set_line_height,
|
commands::set_line_height,
|
||||||
commands::set_ui_scale,
|
commands::set_ui_scale,
|
||||||
@@ -117,12 +187,14 @@ pub fn run() {
|
|||||||
commands::read_note,
|
commands::read_note,
|
||||||
commands::save_note,
|
commands::save_note,
|
||||||
commands::create_note,
|
commands::create_note,
|
||||||
|
commands::duplicate_note,
|
||||||
commands::create_daily_note,
|
commands::create_daily_note,
|
||||||
commands::rename_note,
|
commands::rename_note,
|
||||||
commands::delete_note,
|
commands::delete_note,
|
||||||
commands::move_note,
|
commands::move_note,
|
||||||
commands::get_all_tags,
|
commands::get_all_tags,
|
||||||
commands::get_all_note_titles,
|
commands::get_all_note_titles,
|
||||||
|
commands::get_note_switcher_titles,
|
||||||
commands::get_graph_data,
|
commands::get_graph_data,
|
||||||
commands::get_tasks,
|
commands::get_tasks,
|
||||||
commands::set_task_done,
|
commands::set_task_done,
|
||||||
@@ -137,6 +209,7 @@ pub fn run() {
|
|||||||
commands::empty_trash,
|
commands::empty_trash,
|
||||||
commands::load_vault_state,
|
commands::load_vault_state,
|
||||||
commands::save_vault_state,
|
commands::save_vault_state,
|
||||||
|
commands::copy_text_to_clipboard,
|
||||||
commands::read_clipboard_image,
|
commands::read_clipboard_image,
|
||||||
commands::copy_image_to_clipboard,
|
commands::copy_image_to_clipboard,
|
||||||
commands::save_image,
|
commands::save_image,
|
||||||
@@ -153,6 +226,7 @@ pub fn run() {
|
|||||||
commands::trash_orphaned_attachments,
|
commands::trash_orphaned_attachments,
|
||||||
commands::import_obsidian,
|
commands::import_obsidian,
|
||||||
commands::open_file,
|
commands::open_file,
|
||||||
|
commands::reveal_file,
|
||||||
commands::open_url,
|
commands::open_url,
|
||||||
commands::copy_file_to,
|
commands::copy_file_to,
|
||||||
commands::write_bytes_to,
|
commands::write_bytes_to,
|
||||||
@@ -180,72 +254,21 @@ pub fn run() {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let encoded = path.strip_prefix('/').unwrap_or(&path);
|
let encoded = path.strip_prefix('/').unwrap_or(&path);
|
||||||
let external_url = percent_decode(encoded);
|
let external_url = percent_decode(encoded);
|
||||||
|
let response = match image_proxy::fetch(&external_url) {
|
||||||
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
Ok(image) => tauri::http::Response::builder()
|
||||||
let _ = responder.respond(
|
.status(200)
|
||||||
|
.header("Content-Type", image.content_type)
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
.body(image.body),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Blocked external image request: {}", error.message());
|
||||||
tauri::http::Response::builder()
|
tauri::http::Response::builder()
|
||||||
.status(400)
|
.status(error.status())
|
||||||
.body(Vec::new())
|
.body(Vec::new())
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = match reqwest::blocking::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => {
|
|
||||||
let _ = responder.respond(
|
|
||||||
tauri::http::Response::builder()
|
|
||||||
.status(502)
|
|
||||||
.body(Vec::new())
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
responder.respond(response.expect("valid image proxy response"));
|
||||||
match client.get(&external_url).send() {
|
|
||||||
Ok(resp) => {
|
|
||||||
let content_type = resp
|
|
||||||
.headers()
|
|
||||||
.get("content-type")
|
|
||||||
.and_then(|h| h.to_str().ok())
|
|
||||||
.unwrap_or("application/octet-stream")
|
|
||||||
.to_string();
|
|
||||||
let status = resp.status().as_u16();
|
|
||||||
match resp.bytes() {
|
|
||||||
Ok(bytes) => {
|
|
||||||
let _ = responder.respond(
|
|
||||||
tauri::http::Response::builder()
|
|
||||||
.status(status)
|
|
||||||
.header("Content-Type", &content_type)
|
|
||||||
.header("Access-Control-Allow-Origin", "*")
|
|
||||||
.body(bytes.to_vec())
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = responder.respond(
|
|
||||||
tauri::http::Response::builder()
|
|
||||||
.status(502)
|
|
||||||
.body(Vec::new())
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = responder.respond(
|
|
||||||
tauri::http::Response::builder()
|
|
||||||
.status(502)
|
|
||||||
.body(Vec::new())
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,15 +295,46 @@ pub fn run() {
|
|||||||
std::path::Path::new(&cwd).join(path)
|
std::path::Path::new(&cwd).join(path)
|
||||||
};
|
};
|
||||||
if let Some(resolved_str) = resolved.to_str() {
|
if let Some(resolved_str) = resolved.to_str() {
|
||||||
|
if resolved.is_file() {
|
||||||
|
let _ = app.fs_scope().allow_file(&resolved);
|
||||||
|
if let Some(parent) = resolved.parent() {
|
||||||
|
let _ = app.fs_scope().allow_directory(parent, true);
|
||||||
|
}
|
||||||
|
let _ = asset_scope::allow_external_note_assets(app, &resolved);
|
||||||
|
}
|
||||||
let _ = app.emit("open-file", resolved_str.to_string());
|
let _ = app.emit("open-file", resolved_str.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
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| {
|
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 {
|
match event {
|
||||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||||
// Only hide to tray for the main window
|
// Only hide to tray for the main window
|
||||||
@@ -289,9 +343,9 @@ pub fn run() {
|
|||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tauri::WindowEvent::Destroyed => {
|
tauri::WindowEvent::Destroyed
|
||||||
// When main window is destroyed, close all note windows
|
// When main window is destroyed, close all note windows
|
||||||
if window.label() == "main" {
|
if window.label() == "main" => {
|
||||||
let app = window.app_handle();
|
let app = window.app_handle();
|
||||||
for (label, win) in app.webview_windows() {
|
for (label, win) in app.webview_windows() {
|
||||||
if label.starts_with("note-") {
|
if label.starts_with("note-") {
|
||||||
@@ -299,7 +353,6 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -369,3 +422,59 @@ fn percent_decode(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
String::from_utf8_lossy(&output).to_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(vault_path.as_bytes());
|
hasher.update(vault_path.as_bytes());
|
||||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
let key: String = hasher.finalize()[..8]
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect();
|
||||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +238,9 @@ impl SearchIndex {
|
|||||||
// before the writer is created, so both indexing and querying use it).
|
// before the writer is created, so both indexing and querying use it).
|
||||||
index.tokenizers().register(
|
index.tokenizers().register(
|
||||||
"cjk",
|
"cjk",
|
||||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
TextAnalyzer::builder(CjkTokenizer)
|
||||||
|
.filter(LowerCaser)
|
||||||
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(mobile)]
|
#[cfg(mobile)]
|
||||||
@@ -290,7 +295,7 @@ impl SearchIndex {
|
|||||||
doc.add_text(self.path_field, &path_str);
|
doc.add_text(self.path_field, &path_str);
|
||||||
doc.add_text(self.title_field, &meta.title);
|
doc.add_text(self.title_field, &meta.title);
|
||||||
doc.add_text(self.body_field, &content);
|
doc.add_text(self.body_field, &content);
|
||||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||||
let _ = writer.add_document(doc);
|
let _ = writer.add_document(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +326,7 @@ impl SearchIndex {
|
|||||||
doc.add_text(self.path_field, path);
|
doc.add_text(self.path_field, path);
|
||||||
doc.add_text(self.title_field, &meta.title);
|
doc.add_text(self.title_field, &meta.title);
|
||||||
doc.add_text(self.body_field, &content);
|
doc.add_text(self.body_field, &content);
|
||||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||||
let _ = writer.add_document(doc);
|
let _ = writer.add_document(doc);
|
||||||
|
|
||||||
writer.commit().map_err(|e| e.to_string())?;
|
writer.commit().map_err(|e| e.to_string())?;
|
||||||
@@ -341,7 +346,7 @@ impl SearchIndex {
|
|||||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||||
let searcher = reader.searcher();
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
let fields = [self.title_field, self.body_field, self.tags_field];
|
||||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||||
@@ -376,9 +381,10 @@ impl SearchIndex {
|
|||||||
));
|
));
|
||||||
vec![(Occur::Should, exact)]
|
vec![(Occur::Should, exact)]
|
||||||
} else {
|
} else {
|
||||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
let prefix: Box<dyn Query> =
|
||||||
vec![Term::from_field_text(field, term)],
|
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
|
||||||
));
|
field, term,
|
||||||
|
)]));
|
||||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||||
Term::from_field_text(field, term),
|
Term::from_field_text(field, term),
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use crate::search::SearchIndex;
|
use crate::search::SearchIndex;
|
||||||
use crate::types::AppConfig;
|
use crate::types::AppConfig;
|
||||||
use notify::RecommendedWatcher;
|
use crate::vault::watcher::VaultWatcher;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<AppConfig>,
|
pub config: Mutex<AppConfig>,
|
||||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
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 importing: AtomicBool,
|
||||||
pub syncing: AtomicBool,
|
pub syncing: AtomicBool,
|
||||||
pub pending_open_file: Mutex<Option<String>>,
|
pub pending_open_file: Mutex<Option<String>>,
|
||||||
@@ -20,6 +21,7 @@ impl AppState {
|
|||||||
config: Mutex::new(config),
|
config: Mutex::new(config),
|
||||||
search_index: Mutex::new(None),
|
search_index: Mutex::new(None),
|
||||||
watcher: Mutex::new(None),
|
watcher: Mutex::new(None),
|
||||||
|
vault_transition: tokio::sync::Mutex::new(()),
|
||||||
importing: AtomicBool::new(false),
|
importing: AtomicBool::new(false),
|
||||||
syncing: AtomicBool::new(false),
|
syncing: AtomicBool::new(false),
|
||||||
pending_open_file: Mutex::new(None),
|
pending_open_file: Mutex::new(None),
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||||
// conflict copies so nothing is ever lost.
|
// conflict copies so nothing is ever lost.
|
||||||
//
|
//
|
||||||
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
|
||||||
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
|
||||||
// manifest itself) is local-only and never synced.
|
// and the manifest itself remain local-only.
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::vault::operations::helixnotes_dir;
|
use crate::vault::operations::helixnotes_dir;
|
||||||
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_etag(s: &str) -> String {
|
fn normalize_etag(s: &str) -> String {
|
||||||
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
s.trim()
|
||||||
|
.trim_start_matches("W/")
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percent-encode each path segment, keeping the `/` separators.
|
/// Percent-encode each path segment, keeping the `/` separators.
|
||||||
@@ -119,12 +122,12 @@ struct LocalFile {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
|
||||||
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
|
||||||
/// folder with unrelated files never drags them into the vault.
|
/// and remote so pointing at a folder with unrelated files never imports them.
|
||||||
fn is_synced_relpath(rel: &str) -> bool {
|
fn is_synced_relpath(rel: &str) -> bool {
|
||||||
if rel.starts_with(".helixnotes/") {
|
if rel.starts_with(".helixnotes/") {
|
||||||
rel.starts_with(".helixnotes/attachments/")
|
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
|
||||||
} else {
|
} else {
|
||||||
rel.ends_with(".md")
|
rel.ends_with(".md")
|
||||||
}
|
}
|
||||||
@@ -265,7 +268,11 @@ impl WebdavClient {
|
|||||||
.send()
|
.send()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
return Err(format!(
|
||||||
|
"GET {} failed: HTTP {}",
|
||||||
|
relpath,
|
||||||
|
resp.status().as_u16()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||||
}
|
}
|
||||||
@@ -279,7 +286,11 @@ impl WebdavClient {
|
|||||||
.send()
|
.send()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
return Err(format!(
|
||||||
|
"PUT {} failed: HTTP {}",
|
||||||
|
relpath,
|
||||||
|
resp.status().as_u16()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(resp
|
Ok(resp
|
||||||
.headers()
|
.headers()
|
||||||
@@ -312,8 +323,9 @@ impl WebdavClient {
|
|||||||
.send()
|
.send()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let s = resp.status().as_u16();
|
let s = resp.status().as_u16();
|
||||||
// 201 created; 405 already exists; 301 redirect-to-existing.
|
// 201 created; 405 already exists; 301 redirect-to-existing;
|
||||||
if resp.status().is_success() || s == 405 || s == 301 {
|
// 403 some servers (openmediavault et al.) return 403 instead of 405.
|
||||||
|
if resp.status().is_success() || s == 405 || s == 301 || s == 403 {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
||||||
@@ -452,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
|||||||
}
|
}
|
||||||
Ok(Event::Text(e)) => {
|
Ok(Event::Text(e)) => {
|
||||||
if cap != Cap::None {
|
if cap != Cap::None {
|
||||||
if let Ok(t) = e.unescape() {
|
if let Ok(decoded) = e.decode() {
|
||||||
buf.push_str(&t);
|
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
|
||||||
|
buf.push_str(&text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,8 +528,14 @@ fn apply_changes(
|
|||||||
|
|
||||||
match (l, r) {
|
match (l, r) {
|
||||||
(Some(lf), Some(re)) => {
|
(Some(lf), Some(re)) => {
|
||||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
let local_changed = match m {
|
||||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
Some(entry) => entry.local_hash != lf.hash,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
let remote_changed = match m {
|
||||||
|
Some(entry) => &entry.remote_etag != re,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
if !local_changed && !remote_changed {
|
if !local_changed && !remote_changed {
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
@@ -668,7 +688,11 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
|||||||
|
|
||||||
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||||
/// rebuilds the search index. Returns a summary of what changed.
|
/// rebuilds the search index. Returns a summary of what changed.
|
||||||
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
|
pub fn run_sync(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
vault: String,
|
||||||
|
cfg: WebdavConfig,
|
||||||
|
) -> Result<SyncSummary, String> {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
@@ -723,3 +747,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
|||||||
);
|
);
|
||||||
Ok(summary)
|
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,
|
pub raw: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct VaultConfig {
|
pub struct VaultConfig {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub name: 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub vaults: Vec<VaultConfig>,
|
pub vaults: Vec<VaultConfig>,
|
||||||
pub active_vault: Option<String>,
|
pub active_vault: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_bookmark_id: Option<String>,
|
||||||
pub theme: 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)]
|
#[serde(default)]
|
||||||
pub accent_color: Option<String>,
|
pub accent_color: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub font_size: Option<u32>,
|
pub font_size: Option<u32>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub scroll_to_change_font_size: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub font_family: Option<String>,
|
pub font_family: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -78,6 +139,8 @@ pub struct AppConfig {
|
|||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_note_dates: bool,
|
pub show_note_dates: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub show_note_switcher: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub time_format: String,
|
pub time_format: String,
|
||||||
#[serde(default = "default_week_start")]
|
#[serde(default = "default_week_start")]
|
||||||
pub week_start: String,
|
pub week_start: String,
|
||||||
@@ -144,14 +207,18 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_view_mode: bool,
|
pub default_view_mode: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub new_notes_in_source_mode: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub show_tray_icon: bool,
|
pub show_tray_icon: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub close_to_tray: bool,
|
pub close_to_tray: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enable_wiki_links: bool,
|
pub enable_wiki_links: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub startup_view: StartupView,
|
||||||
|
#[serde(default)]
|
||||||
pub restore_last_session: bool,
|
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)]
|
#[serde(default)]
|
||||||
pub sync_provider: Option<String>,
|
pub sync_provider: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -168,12 +235,18 @@ pub struct AppConfig {
|
|||||||
pub sync_interval_minutes: u32,
|
pub sync_interval_minutes: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_sync_time: Option<String>,
|
pub last_sync_time: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_themes: Vec<CustomTheme>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_outline_width() -> f64 {
|
||||||
|
220.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_pdf_height() -> u32 {
|
fn default_pdf_height() -> u32 {
|
||||||
600
|
600
|
||||||
}
|
}
|
||||||
@@ -206,20 +279,33 @@ fn default_ai_model() -> String {
|
|||||||
"claude-sonnet-4-6".to_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 {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
vaults: Vec::new(),
|
vaults: Vec::new(),
|
||||||
active_vault: None,
|
active_vault: None,
|
||||||
|
active_bookmark_id: None,
|
||||||
theme: "system".to_string(),
|
theme: "system".to_string(),
|
||||||
|
system_light_theme: default_system_light_theme(),
|
||||||
|
system_dark_theme: default_system_dark_theme(),
|
||||||
accent_color: None,
|
accent_color: None,
|
||||||
font_size: None,
|
font_size: None,
|
||||||
|
scroll_to_change_font_size: true,
|
||||||
font_family: None,
|
font_family: None,
|
||||||
line_height: None,
|
line_height: None,
|
||||||
ui_scale: None,
|
ui_scale: None,
|
||||||
content_width: None,
|
content_width: None,
|
||||||
compact_notes: false,
|
compact_notes: false,
|
||||||
show_note_dates: true,
|
show_note_dates: true,
|
||||||
|
show_note_switcher: false,
|
||||||
time_format: "relative".to_string(),
|
time_format: "relative".to_string(),
|
||||||
week_start: "monday".to_string(),
|
week_start: "monday".to_string(),
|
||||||
daily_title_format: "localized".to_string(),
|
daily_title_format: "localized".to_string(),
|
||||||
@@ -253,9 +339,11 @@ impl Default for AppConfig {
|
|||||||
ai_model: "claude-sonnet-4-6".to_string(),
|
ai_model: "claude-sonnet-4-6".to_string(),
|
||||||
ai_writing_style: None,
|
ai_writing_style: None,
|
||||||
default_view_mode: false,
|
default_view_mode: false,
|
||||||
|
new_notes_in_source_mode: false,
|
||||||
show_tray_icon: false,
|
show_tray_icon: false,
|
||||||
close_to_tray: false,
|
close_to_tray: false,
|
||||||
enable_wiki_links: true,
|
enable_wiki_links: true,
|
||||||
|
startup_view: StartupView::All,
|
||||||
restore_last_session: false,
|
restore_last_session: false,
|
||||||
sync_provider: None,
|
sync_provider: None,
|
||||||
webdav_url: None,
|
webdav_url: None,
|
||||||
@@ -265,6 +353,7 @@ impl Default for AppConfig {
|
|||||||
sync_on_change: false,
|
sync_on_change: false,
|
||||||
sync_interval_minutes: 0,
|
sync_interval_minutes: 0,
|
||||||
last_sync_time: None,
|
last_sync_time: None,
|
||||||
|
custom_themes: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +363,8 @@ pub struct VaultState {
|
|||||||
pub last_open_note: Option<String>,
|
pub last_open_note: Option<String>,
|
||||||
pub sidebar_width: f64,
|
pub sidebar_width: f64,
|
||||||
pub notelist_width: f64,
|
pub notelist_width: f64,
|
||||||
|
#[serde(default = "default_outline_width")]
|
||||||
|
pub outline_width: f64,
|
||||||
pub sidebar_collapsed: bool,
|
pub sidebar_collapsed: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notelist_collapsed: bool,
|
pub notelist_collapsed: bool,
|
||||||
@@ -309,6 +400,7 @@ impl Default for VaultState {
|
|||||||
last_open_note: None,
|
last_open_note: None,
|
||||||
sidebar_width: 220.0,
|
sidebar_width: 220.0,
|
||||||
notelist_width: 280.0,
|
notelist_width: 280.0,
|
||||||
|
outline_width: default_outline_width(),
|
||||||
sidebar_collapsed: false,
|
sidebar_collapsed: false,
|
||||||
notelist_collapsed: false,
|
notelist_collapsed: false,
|
||||||
collapsed_notebooks: Vec::new(),
|
collapsed_notebooks: Vec::new(),
|
||||||
@@ -345,6 +437,9 @@ pub struct FileEvent {
|
|||||||
pub struct ImportResult {
|
pub struct ImportResult {
|
||||||
pub files_converted: u64,
|
pub files_converted: u64,
|
||||||
pub links_converted: u64,
|
pub links_converted: u64,
|
||||||
|
pub frontmatter_normalized: u64,
|
||||||
|
pub syntax_converted: u64,
|
||||||
|
pub attachments_moved: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -413,3 +508,68 @@ pub struct TaskItem {
|
|||||||
pub due: Option<String>,
|
pub due: Option<String>,
|
||||||
pub priority: 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.
|
/// 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();
|
let s = s.trim();
|
||||||
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
||||||
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
|
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
|
||||||
@@ -108,7 +108,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
|
|||||||
"[{}]",
|
"[{}]",
|
||||||
meta.tags
|
meta.tags
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| format!("{}", t))
|
.map(|t| t.to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
)
|
)
|
||||||
@@ -191,7 +191,7 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
|
|||||||
format!("---\n{}---\n{}", yaml_str, body_with_heading)
|
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");
|
let stem = filename.trim_end_matches(".md");
|
||||||
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
||||||
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
|
// 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 '['
|
chars.next(); // skip '['
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
// Skip alt text
|
// Skip alt text
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '[' {
|
if c == '[' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
if chars.peek() == Some(&'(') {
|
if chars.peek() == Some(&'(') {
|
||||||
chars.next();
|
chars.next();
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '(' {
|
if c == '(' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
if ch == '[' {
|
if ch == '[' {
|
||||||
let mut link_text = String::new();
|
let mut link_text = String::new();
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '[' {
|
if c == '[' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
if chars.peek() == Some(&'(') {
|
if chars.peek() == Some(&'(') {
|
||||||
chars.next();
|
chars.next();
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '(' {
|
if c == '(' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod frontmatter;
|
pub mod frontmatter;
|
||||||
|
pub mod import;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
pub mod watcher;
|
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::path::Path;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -8,14 +8,57 @@ use crate::state::AppState;
|
|||||||
use crate::types::FileEvent;
|
use crate::types::FileEvent;
|
||||||
use crate::vault::operations::helixnotes_dir;
|
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 (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
let mut watcher = RecommendedWatcher::new(
|
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
|
||||||
tx,
|
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
|
||||||
Config::default().with_poll_interval(Duration::from_secs(1)),
|
let mut watcher = match backend {
|
||||||
)
|
WatcherBackend::Recommended => VaultWatcher::Recommended(
|
||||||
.map_err(|e| e.to_string())?;
|
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
|
watcher
|
||||||
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
.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);
|
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) => {
|
Err(e) => {
|
||||||
log::error!("File watcher error: {}", e);
|
log::error!("File watcher error: {}", e);
|
||||||
@@ -71,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
|||||||
|
|
||||||
Ok(watcher)
|
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",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "HelixNotes",
|
"productName": "HelixNotes",
|
||||||
"version": "1.3.2",
|
"version": "1.3.5",
|
||||||
"identifier": "com.helixnotes.app",
|
"identifier": "com.helixnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../build",
|
"frontendDist": "../build",
|
||||||
@@ -24,10 +24,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
|
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": ["**/*", "/**", "**/.helixnotes/**"]
|
"scope": {
|
||||||
|
"allow": [],
|
||||||
|
"deny": [],
|
||||||
|
"requireLiteralLeadingDot": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -55,6 +59,9 @@
|
|||||||
"appimage": {
|
"appimage": {
|
||||||
"bundleMediaFramework": false
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,6 +173,21 @@
|
|||||||
--danger-hover: #d43d3d;
|
--danger-hover: #d43d3d;
|
||||||
--success: #40c057;
|
--success: #40c057;
|
||||||
--warning: #fab005;
|
--warning: #fab005;
|
||||||
|
/* Obsidian-style callout accent hues (RGB triplets; mixed with the background via opacity, so one set works on every theme) */
|
||||||
|
--callout-note: 68, 138, 255;
|
||||||
|
--callout-abstract: 0, 191, 188;
|
||||||
|
--callout-info: 0, 184, 212;
|
||||||
|
--callout-todo: 0, 184, 212;
|
||||||
|
--callout-tip: 0, 191, 188;
|
||||||
|
--callout-success: 8, 185, 78;
|
||||||
|
--callout-question: 236, 191, 0;
|
||||||
|
--callout-warning: 236, 117, 0;
|
||||||
|
--callout-failure: 233, 49, 71;
|
||||||
|
--callout-danger: 233, 49, 71;
|
||||||
|
--callout-bug: 233, 49, 71;
|
||||||
|
--callout-example: 120, 82, 238;
|
||||||
|
--callout-quote: 158, 158, 158;
|
||||||
|
--callout-custom: 124, 134, 156;
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
@@ -204,6 +219,20 @@
|
|||||||
--danger-hover: #eba0ac;
|
--danger-hover: #eba0ac;
|
||||||
--success: #a6e3a1;
|
--success: #a6e3a1;
|
||||||
--warning: #f9e2af;
|
--warning: #f9e2af;
|
||||||
|
--callout-note: 100, 160, 255;
|
||||||
|
--callout-abstract: 60, 209, 205;
|
||||||
|
--callout-info: 60, 200, 224;
|
||||||
|
--callout-todo: 60, 200, 224;
|
||||||
|
--callout-tip: 60, 209, 205;
|
||||||
|
--callout-success: 80, 205, 120;
|
||||||
|
--callout-question: 240, 205, 80;
|
||||||
|
--callout-warning: 245, 150, 60;
|
||||||
|
--callout-failure: 240, 95, 115;
|
||||||
|
--callout-danger: 240, 95, 115;
|
||||||
|
--callout-bug: 240, 95, 115;
|
||||||
|
--callout-example: 165, 135, 245;
|
||||||
|
--callout-quote: 150, 150, 162;
|
||||||
|
--callout-custom: 150, 160, 182;
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
@@ -1113,10 +1142,6 @@ body.resizing {
|
|||||||
/* ── Mobile (Android/iOS) ── */
|
/* ── Mobile (Android/iOS) ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body {
|
body {
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
padding-left: env(safe-area-inset-left);
|
|
||||||
padding-right: env(safe-area-inset-right);
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
CustomTheme,
|
||||||
NoteContent,
|
NoteContent,
|
||||||
NoteEntry,
|
NoteEntry,
|
||||||
NoteMeta,
|
NoteMeta,
|
||||||
@@ -14,14 +15,24 @@ import type {
|
|||||||
BackupEntry,
|
BackupEntry,
|
||||||
VersionEntry,
|
VersionEntry,
|
||||||
TaskItem,
|
TaskItem,
|
||||||
|
ExternalVaultResult,
|
||||||
|
StartupView,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export async function openVault(path: string): Promise<void> {
|
export async function openVault(path: string): Promise<void> {
|
||||||
return invoke("open_vault", { path });
|
return invoke("open_vault", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeVault(path: string): Promise<void> {
|
export async function chooseExternalVault(): Promise<ExternalVaultResult | null> {
|
||||||
return invoke("remove_vault", { path });
|
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> {
|
export async function getAppConfig(): Promise<AppConfig> {
|
||||||
@@ -32,14 +43,38 @@ export async function setTheme(theme: string): Promise<void> {
|
|||||||
return invoke("set_theme", { theme });
|
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> {
|
export async function setAccentColor(color: string): Promise<void> {
|
||||||
return invoke("set_accent_color", { color });
|
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> {
|
export async function setFontSize(size: number): Promise<void> {
|
||||||
return invoke("set_font_size", { size });
|
return invoke("set_font_size", { size });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
|
||||||
|
return invoke("set_scroll_to_change_font_size", { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
export async function setFontFamily(family: string): Promise<void> {
|
export async function setFontFamily(family: string): Promise<void> {
|
||||||
return invoke("set_font_family", { family });
|
return invoke("set_font_family", { family });
|
||||||
}
|
}
|
||||||
@@ -114,6 +149,10 @@ export async function createNote(
|
|||||||
return invoke("create_note", { notebookRelative, title });
|
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> {
|
export async function createDailyNote(date?: string): Promise<NoteEntry> {
|
||||||
return invoke("create_daily_note", { date: date ?? null });
|
return invoke("create_daily_note", { date: date ?? null });
|
||||||
}
|
}
|
||||||
@@ -144,7 +183,13 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
|
|||||||
return invoke("get_all_note_titles");
|
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");
|
return invoke("get_graph_data");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +235,10 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
|
|||||||
return invoke("save_vault_state", { vaultState });
|
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[]> {
|
export async function readClipboardImage(): Promise<number[]> {
|
||||||
return invoke("read_clipboard_image");
|
return invoke("read_clipboard_image");
|
||||||
}
|
}
|
||||||
@@ -234,10 +283,13 @@ export async function setGeneralSettings(
|
|||||||
showLineNumbers: boolean,
|
showLineNumbers: boolean,
|
||||||
showLinkArrows: boolean,
|
showLinkArrows: boolean,
|
||||||
defaultViewMode: boolean,
|
defaultViewMode: boolean,
|
||||||
|
newNotesInSourceMode: boolean,
|
||||||
showTrayIcon: boolean,
|
showTrayIcon: boolean,
|
||||||
closeToTray: boolean,
|
closeToTray: boolean,
|
||||||
enableWikiLinks: boolean,
|
enableWikiLinks: boolean,
|
||||||
showNoteDates: boolean,
|
showNoteDates: boolean,
|
||||||
|
showNoteSwitcher: boolean,
|
||||||
|
startupView: StartupView,
|
||||||
restoreLastSession: boolean,
|
restoreLastSession: boolean,
|
||||||
showAllNotes: boolean,
|
showAllNotes: boolean,
|
||||||
showQuickAccess: boolean,
|
showQuickAccess: boolean,
|
||||||
@@ -259,10 +311,13 @@ export async function setGeneralSettings(
|
|||||||
showLineNumbers,
|
showLineNumbers,
|
||||||
showLinkArrows,
|
showLinkArrows,
|
||||||
defaultViewMode,
|
defaultViewMode,
|
||||||
|
newNotesInSourceMode,
|
||||||
showTrayIcon,
|
showTrayIcon,
|
||||||
closeToTray,
|
closeToTray,
|
||||||
enableWikiLinks,
|
enableWikiLinks,
|
||||||
showNoteDates,
|
showNoteDates,
|
||||||
|
showNoteSwitcher,
|
||||||
|
startupView,
|
||||||
restoreLastSession,
|
restoreLastSession,
|
||||||
showAllNotes,
|
showAllNotes,
|
||||||
showQuickAccess,
|
showQuickAccess,
|
||||||
@@ -313,6 +368,10 @@ export async function openFile(path: string): Promise<void> {
|
|||||||
return invoke("open_file", { path });
|
return invoke("open_file", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revealFile(path: string): Promise<void> {
|
||||||
|
return invoke("reveal_file", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export async function openUrl(url: string): Promise<void> {
|
export async function openUrl(url: string): Promise<void> {
|
||||||
return invoke("open_url", { url });
|
return invoke("open_url", { url });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,15 @@
|
|||||||
import {
|
import {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
notelistWidth,
|
notelistWidth,
|
||||||
|
outlineWidth,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
notelistCollapsed,
|
notelistCollapsed,
|
||||||
collapsedNotebooks,
|
collapsedNotebooks,
|
||||||
showSearch,
|
showSearch,
|
||||||
showCommandPalette,
|
showCommandPalette,
|
||||||
theme,
|
theme,
|
||||||
|
resolvedTheme,
|
||||||
|
customThemes,
|
||||||
focusMode,
|
focusMode,
|
||||||
readOnly,
|
readOnly,
|
||||||
activeNote,
|
activeNote,
|
||||||
@@ -38,6 +41,7 @@
|
|||||||
rootNoteCount,
|
rootNoteCount,
|
||||||
viewMode,
|
viewMode,
|
||||||
sortMode,
|
sortMode,
|
||||||
|
groupNotesByDate,
|
||||||
tasksLayout,
|
tasksLayout,
|
||||||
tasksHideCompleted,
|
tasksHideCompleted,
|
||||||
tasksOnlyFlagged,
|
tasksOnlyFlagged,
|
||||||
@@ -51,20 +55,22 @@
|
|||||||
notebookOrder,
|
notebookOrder,
|
||||||
noteOrder,
|
noteOrder,
|
||||||
syncState,
|
syncState,
|
||||||
platformIsMobile
|
platformIsMobile,
|
||||||
|
activeVaultConfig,
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
import { keybindings, matchAction } from '$lib/keybindings';
|
import { keybindings, matchAction } from '$lib/keybindings';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
const isMobile = $derived($platformIsMobile);
|
const isMobile = $derived($platformIsMobile);
|
||||||
const isAndroid = /android/i.test(navigator.userAgent);
|
import { loadVaultState, saveVaultState, readNote, deleteNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
|
import { darkThemes, isAndroid } from '$lib/platform';
|
||||||
import { darkThemes } from '$lib/platform';
|
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
|
import { normalizeStartupView, resolveStartupTarget } from '$lib/utils/startup-view';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
|
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 {
|
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
|
||||||
for (const nb of list) {
|
for (const nb of list) {
|
||||||
@@ -75,10 +81,42 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sidebar: Sidebar;
|
let sidebar = $state<Sidebar>();
|
||||||
let noteList: NoteList;
|
let noteList = $state<NoteList>();
|
||||||
let editor: Editor;
|
let editor = $state<Editor>();
|
||||||
let unlistenFileChange: (() => void) | null = null;
|
let unlistenFileChange: (() => void) | null = null;
|
||||||
|
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
|
||||||
|
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;
|
let unlistenOpenFile: (() => void) | null = null;
|
||||||
|
|
||||||
// Mobile editor header helpers
|
// Mobile editor header helpers
|
||||||
@@ -120,16 +158,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncConfigured(): boolean {
|
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).
|
// Auto-sync on a timer (only when configured and an interval is set).
|
||||||
async function checkScheduledSync() {
|
async function checkScheduledSync() {
|
||||||
const config = get(appConfig);
|
const vc = activeVaultConfig(get(appConfig));
|
||||||
if (config?.sync_provider !== 'webdav') return;
|
if (vc?.sync_provider !== 'webdav') return;
|
||||||
const mins = config.sync_interval_minutes ?? 0;
|
const mins = vc.sync_interval_minutes ?? 0;
|
||||||
if (!mins || get(syncState).running) return;
|
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) {
|
if (Date.now() - last >= mins * 60 * 1000) {
|
||||||
try { await syncNow(); } catch (_) {}
|
try { await syncNow(); } catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -202,6 +240,7 @@
|
|||||||
last_open_note: $activeNotePath,
|
last_open_note: $activeNotePath,
|
||||||
sidebar_width: $sidebarWidth,
|
sidebar_width: $sidebarWidth,
|
||||||
notelist_width: $notelistWidth,
|
notelist_width: $notelistWidth,
|
||||||
|
outline_width: $outlineWidth,
|
||||||
sidebar_collapsed: $sidebarCollapsed,
|
sidebar_collapsed: $sidebarCollapsed,
|
||||||
notelist_collapsed: $notelistCollapsed,
|
notelist_collapsed: $notelistCollapsed,
|
||||||
collapsed_notebooks: $collapsedNotebooks,
|
collapsed_notebooks: $collapsedNotebooks,
|
||||||
@@ -209,6 +248,7 @@
|
|||||||
notebook_order: $notebookOrder,
|
notebook_order: $notebookOrder,
|
||||||
note_order: $noteOrder,
|
note_order: $noteOrder,
|
||||||
sort_mode: $sortMode,
|
sort_mode: $sortMode,
|
||||||
|
group_notes_by_date: $groupNotesByDate,
|
||||||
last_view_mode: $viewMode,
|
last_view_mode: $viewMode,
|
||||||
last_notebook: $activeNotebook?.relative_path ?? null,
|
last_notebook: $activeNotebook?.relative_path ?? null,
|
||||||
last_tag: $activeTag,
|
last_tag: $activeTag,
|
||||||
@@ -248,14 +288,32 @@
|
|||||||
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
||||||
let taskNoteOpened = $state(false);
|
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
|
// Selecting a real vault note exits viewer mode
|
||||||
$viewerNote = null;
|
$viewerNote = null;
|
||||||
taskNoteOpened = true;
|
taskNoteOpened = true;
|
||||||
editor?.loadNote(path, content);
|
editor?.loadNote(path, content, task);
|
||||||
if (isMobile) $mobileView = 'editor';
|
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() {
|
function handleViewChanged() {
|
||||||
taskNoteOpened = false;
|
taskNoteOpened = false;
|
||||||
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||||
@@ -411,6 +469,28 @@
|
|||||||
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
|
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) {
|
function handleMouseDown(e: MouseEvent) {
|
||||||
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
|
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
|
||||||
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
|
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
|
||||||
@@ -426,6 +506,13 @@
|
|||||||
editor?.addLinkFromToolbar();
|
editor?.addLinkFromToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||||
|
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||||
|
e.preventDefault();
|
||||||
|
editor?.moveOpenNoteToTrash();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const action = matchAction(e, $keybindings);
|
const action = matchAction(e, $keybindings);
|
||||||
if (action) {
|
if (action) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -440,7 +527,8 @@
|
|||||||
createAndFocusNote();
|
createAndFocusNote();
|
||||||
return;
|
return;
|
||||||
case 'toggle-theme': {
|
case 'toggle-theme': {
|
||||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
const customTheme = $customThemes.find(theme => theme.id === $resolvedTheme);
|
||||||
|
const isDark = darkThemes.includes($resolvedTheme) || (customTheme?.is_dark ?? false);
|
||||||
const next = isDark ? 'light' : 'dark';
|
const next = isDark ? 'light' : 'dark';
|
||||||
$theme = next;
|
$theme = next;
|
||||||
setTheme(next);
|
setTheme(next);
|
||||||
@@ -490,6 +578,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
const dismissesAppUi = $showSettings || $showInfo || $focusMode || $showSearch || $showCommandPalette;
|
||||||
|
if (!dismissesAppUi) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if ($showSettings) $showSettings = false;
|
if ($showSettings) $showSettings = false;
|
||||||
else if ($showInfo) $showInfo = false;
|
else if ($showInfo) $showInfo = false;
|
||||||
else if ($focusMode) $focusMode = false;
|
else if ($focusMode) $focusMode = false;
|
||||||
@@ -498,23 +591,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(t: string) {
|
|
||||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.classList.remove('dark');
|
|
||||||
root.removeAttribute('data-theme');
|
|
||||||
if (namedThemes.includes(t)) {
|
|
||||||
root.setAttribute('data-theme', t);
|
|
||||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
|
||||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
root.classList.add('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
applyTheme($theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$collapsedNotebooks;
|
$collapsedNotebooks;
|
||||||
persistState();
|
persistState();
|
||||||
@@ -528,6 +604,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
$sidebarCollapsed;
|
$sidebarCollapsed;
|
||||||
$notelistCollapsed;
|
$notelistCollapsed;
|
||||||
|
$outlineWidth;
|
||||||
persistState();
|
persistState();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -544,6 +621,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$sortMode;
|
$sortMode;
|
||||||
|
$groupNotesByDate;
|
||||||
$tasksLayout;
|
$tasksLayout;
|
||||||
$tasksHideCompleted;
|
$tasksHideCompleted;
|
||||||
$tasksOnlyFlagged;
|
$tasksOnlyFlagged;
|
||||||
@@ -567,6 +645,7 @@
|
|||||||
const state = await loadVaultState();
|
const state = await loadVaultState();
|
||||||
$sidebarWidth = state.sidebar_width;
|
$sidebarWidth = state.sidebar_width;
|
||||||
$notelistWidth = state.notelist_width;
|
$notelistWidth = state.notelist_width;
|
||||||
|
if (typeof state.outline_width === 'number') $outlineWidth = state.outline_width;
|
||||||
$sidebarCollapsed = state.sidebar_collapsed;
|
$sidebarCollapsed = state.sidebar_collapsed;
|
||||||
$notelistCollapsed = state.notelist_collapsed ?? false;
|
$notelistCollapsed = state.notelist_collapsed ?? false;
|
||||||
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
||||||
@@ -574,6 +653,7 @@
|
|||||||
$notebookOrder = state.notebook_order ?? {};
|
$notebookOrder = state.notebook_order ?? {};
|
||||||
$noteOrder = state.note_order ?? {};
|
$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 (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 (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_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
|
||||||
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
|
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
|
||||||
@@ -584,39 +664,53 @@
|
|||||||
lastTag = state.last_tag ?? null;
|
lastTag = state.last_tag ?? null;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
const restoreLastSession = $appConfig?.restore_last_session === true;
|
||||||
|
|
||||||
// On mobile, prefetch last-opened note so first tap is instant
|
// On mobile, prefetch last-opened note so first tap is instant
|
||||||
let prefetchPromise: Promise<any> | null = null;
|
let prefetchPromise: Promise<any> | null = null;
|
||||||
if (isMobile && lastNotePath) {
|
if (isMobile && restoreLastSession && lastNotePath) {
|
||||||
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
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
|
// Run sidebar and note list refresh in parallel
|
||||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||||
|
|
||||||
// Restore the last session (view + open note) if enabled.
|
// Full-vault attachment scans cause navigation stalls on mobile storage.
|
||||||
if ($appConfig?.restore_last_session) {
|
// Mobile users can run the same cleanup explicitly from the Info panel.
|
||||||
const vault = $appConfig?.active_vault;
|
if (!isMobile) {
|
||||||
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
|
setTimeout(async () => {
|
||||||
$viewMode = 'notebook';
|
if (get(editorDirty)) return; // skip if user is actively editing
|
||||||
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
|
try {
|
||||||
$activeTag = null;
|
const orphans = await findOrphanedAttachments();
|
||||||
await noteList?.refresh();
|
if (orphans.length > 0) {
|
||||||
} else if (lastViewMode === 'notebook' && lastNotebook) {
|
if (get(editorDirty)) return; // re-check after async scan
|
||||||
const nb = findNotebookByPath($notebooks, lastNotebook);
|
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||||
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
|
if (moved > 0) {
|
||||||
} else if (lastViewMode === 'tag' && lastTag) {
|
const toast = document.createElement('div');
|
||||||
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
|
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';
|
||||||
} else if (lastViewMode === 'quickaccess') {
|
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
|
||||||
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
|
document.body.appendChild(toast);
|
||||||
|
requestAnimationFrame(() => (toast.style.opacity = '1'));
|
||||||
|
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
|
||||||
}
|
}
|
||||||
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
|
}
|
||||||
if (!isMobile && lastNotePath) {
|
} 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 {
|
try {
|
||||||
const content = await readNote(lastNotePath);
|
const content = await readNote(lastNotePath);
|
||||||
$activeNote = content;
|
$activeNote = content;
|
||||||
@@ -625,7 +719,6 @@
|
|||||||
editor?.loadNote(lastNotePath, content.content);
|
editor?.loadNote(lastNotePath, content.content);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -664,7 +757,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
||||||
}, 3000);
|
}, 10000);
|
||||||
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
||||||
debouncedRefresh();
|
debouncedRefresh();
|
||||||
});
|
});
|
||||||
@@ -694,15 +787,16 @@
|
|||||||
|
|
||||||
// ── WebDAV sync: global status + auto-sync triggers ──
|
// ── WebDAV sync: global status + auto-sync triggers ──
|
||||||
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
|
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 });
|
syncState.set({ running: false, error: null });
|
||||||
const cur = get(appConfig);
|
try {
|
||||||
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time });
|
appConfig.set(await getAppConfig());
|
||||||
|
} catch {}
|
||||||
}));
|
}));
|
||||||
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
|
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)
|
// 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 (_) {}
|
try { await syncNow(); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,8 +806,8 @@
|
|||||||
|
|
||||||
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
|
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
|
||||||
unsubDirty = editorDirty.subscribe((d) => {
|
unsubDirty = editorDirty.subscribe((d) => {
|
||||||
const config = get(appConfig);
|
const vc = activeVaultConfig(get(appConfig));
|
||||||
if (prevDirty && !d && config?.sync_provider === 'webdav' && config?.sync_on_change) {
|
if (prevDirty && !d && vc?.sync_provider === 'webdav' && vc?.sync_on_change) {
|
||||||
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
|
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
|
||||||
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
|
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
|
||||||
}
|
}
|
||||||
@@ -740,7 +834,7 @@
|
|||||||
<!-- Mobile Header -->
|
<!-- Mobile Header -->
|
||||||
<div class="mobile-header">
|
<div class="mobile-header">
|
||||||
{#if $mobileView !== 'sidebar'}
|
{#if $mobileView !== 'sidebar'}
|
||||||
<button class="mobile-header-btn" onclick={mobileBack}>
|
<button class="mobile-header-btn" onclick={mobileBack} aria-label="Go back">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M15 18l-6-6 6-6"/>
|
<path d="M15 18l-6-6 6-6"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -829,7 +923,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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')}>
|
<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}>
|
<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"/>
|
<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"/>
|
||||||
@@ -856,6 +950,13 @@
|
|||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
@@ -872,10 +973,10 @@
|
|||||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
<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>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||||
<Editor bind:this={editor} />
|
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -919,7 +1020,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
|
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} onSelectNote={selectNoteFromSwitcher} />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="app-layout">
|
<div class="app-layout">
|
||||||
{#if !$focusMode}
|
{#if !$focusMode}
|
||||||
@@ -933,7 +1034,7 @@
|
|||||||
|
|
||||||
{#if !$notelistCollapsed}
|
{#if !$notelistCollapsed}
|
||||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
<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} />
|
<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>
|
||||||
|
|
||||||
<ResizeHandle onResize={handleNotelistResize} />
|
<ResizeHandle onResize={handleNotelistResize} />
|
||||||
@@ -952,7 +1053,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="editor-panel">
|
<div class="editor-panel">
|
||||||
<Editor bind:this={editor} />
|
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||||
{#if $viewMode === 'tasks' && !taskNoteOpened}
|
{#if $viewMode === 'tasks' && !taskNoteOpened}
|
||||||
<div class="tasks-editor-placeholder">
|
<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">
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -1114,7 +1215,13 @@
|
|||||||
.mobile-shell {
|
.mobile-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
overflow: hidden;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@
|
|||||||
action: () => {
|
action: () => {
|
||||||
$theme = 'system';
|
$theme = 'system';
|
||||||
setTheme('system');
|
setTheme('system');
|
||||||
applyTheme('system');
|
|
||||||
$showCommandPalette = false;
|
$showCommandPalette = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -165,10 +164,15 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleEscape(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key !== 'Escape') return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
$showCommandPalette = false;
|
$showCommandPalette = false;
|
||||||
} else if (e.key === 'ArrowDown') {
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
@@ -187,7 +191,7 @@
|
|||||||
if (namedThemes.includes(t)) {
|
if (namedThemes.includes(t)) {
|
||||||
root.setAttribute('data-theme', t);
|
root.setAttribute('data-theme', t);
|
||||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
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');
|
root.classList.add('dark');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +199,7 @@
|
|||||||
|
|
||||||
{#if $showCommandPalette}
|
{#if $showCommandPalette}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||||
<div class="palette-input-wrapper">
|
<div class="palette-input-wrapper">
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { getGraphData } from '$lib/api';
|
import { getGraphData } from '$lib/api';
|
||||||
import { activeNotePath, appConfig } from '$lib/stores/app';
|
import { activeNotePath, appConfig } from '$lib/stores/app';
|
||||||
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
let { onclose, onnavigate }: {
|
let { onclose, onnavigate }: {
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
onnavigate: (path: string, title: string) => void;
|
onnavigate: (path: string, title: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
let canvas = $state<HTMLCanvasElement>(null!);
|
let canvas = $state<HTMLCanvasElement>(null!);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
@@ -21,12 +20,12 @@
|
|||||||
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
|
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
|
||||||
interface LabelRect { x: number; y: number; w: number; h: number; }
|
interface LabelRect { x: number; y: number; w: number; h: number; }
|
||||||
|
|
||||||
let nodes: GraphNode[] = [];
|
let nodes = $state.raw<GraphNode[]>([]);
|
||||||
let edges: GraphEdge[] = [];
|
let edges = $state.raw<GraphEdge[]>([]);
|
||||||
let nodeIndexMap: Map<string, number> = new Map();
|
let nodeIndexMap: Map<string, number> = new Map();
|
||||||
let connectedSet: Set<number> = new Set();
|
let connectedSet: Set<number> = new Set();
|
||||||
let nodeDegree: number[] = [];
|
let nodeDegree: number[] = [];
|
||||||
let searchMatchSet: Set<number> | null = null;
|
let searchMatchSet = $state.raw<Set<number> | null>(null);
|
||||||
let folderColorMap = new Map<string, string>();
|
let folderColorMap = new Map<string, string>();
|
||||||
|
|
||||||
let pan = { x: 0, y: 0 };
|
let pan = { x: 0, y: 0 };
|
||||||
@@ -41,7 +40,7 @@
|
|||||||
let hoveredNeighborSet: Set<number> = new Set();
|
let hoveredNeighborSet: Set<number> = new Set();
|
||||||
let hoveredEdgeSet: Set<number> = new Set();
|
let hoveredEdgeSet: Set<number> = new Set();
|
||||||
let glowPhase = 0;
|
let glowPhase = 0;
|
||||||
let activeNodeIdx = -1;
|
let activeNodeIdx = $state(-1);
|
||||||
let navigatedFromGraph = false;
|
let navigatedFromGraph = false;
|
||||||
|
|
||||||
// Combined animation loop
|
// Combined animation loop
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let { src, alt = '', onclose }: {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
onclose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const MIN_SCALE = 1;
|
||||||
|
const MAX_SCALE = 5;
|
||||||
|
const DOUBLE_TAP_MS = 300;
|
||||||
|
const TAP_MOVE_THRESHOLD = 6;
|
||||||
|
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
type ViewerGesture =
|
||||||
|
| { kind: 'pan'; start: Point; pan: Point }
|
||||||
|
| { kind: 'pinch'; distance: number; imagePoint: Point };
|
||||||
|
|
||||||
|
let canvasElement = $state<HTMLDivElement | null>(null);
|
||||||
|
let imageElement = $state<HTMLImageElement | null>(null);
|
||||||
|
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||||
|
let scale = $state(1);
|
||||||
|
let panX = $state(0);
|
||||||
|
let panY = $state(0);
|
||||||
|
let interacting = $state(false);
|
||||||
|
let gesture: ViewerGesture | null = null;
|
||||||
|
let touchMoved = false;
|
||||||
|
let lastTapAt = 0;
|
||||||
|
let zoomPercent = $derived(Math.round(scale * 100));
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function midpoint(first: Touch, second: Touch): Point {
|
||||||
|
return {
|
||||||
|
x: (first.clientX + second.clientX) / 2,
|
||||||
|
y: (first.clientY + second.clientY) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchDistance(first: Touch, second: Touch): number {
|
||||||
|
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function panLimits(nextScale: number): Point {
|
||||||
|
const canvasWidth = canvasElement?.clientWidth ?? window.innerWidth;
|
||||||
|
const canvasHeight = canvasElement?.clientHeight ?? window.innerHeight;
|
||||||
|
const imageWidth = imageElement?.clientWidth ?? 0;
|
||||||
|
const imageHeight = imageElement?.clientHeight ?? 0;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, (imageWidth * nextScale - canvasWidth) / 2),
|
||||||
|
y: Math.max(0, (imageHeight * nextScale - canvasHeight) / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransform(nextScale: number, nextPanX: number, nextPanY: number) {
|
||||||
|
const clampedScale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
||||||
|
const limits = panLimits(clampedScale);
|
||||||
|
scale = clampedScale;
|
||||||
|
panX = clamp(nextPanX, -limits.x, limits.x);
|
||||||
|
panY = clamp(nextPanY, -limits.y, limits.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetViewer() {
|
||||||
|
interacting = false;
|
||||||
|
gesture = null;
|
||||||
|
applyTransform(MIN_SCALE, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomAt(point: Point, nextScale: number) {
|
||||||
|
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||||
|
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||||
|
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||||
|
const imageX = (point.x - centerX - panX) / scale;
|
||||||
|
const imageY = (point.y - centerY - panY) / scale;
|
||||||
|
applyTransform(
|
||||||
|
nextScale,
|
||||||
|
point.x - centerX - imageX * nextScale,
|
||||||
|
point.y - centerY - imageY * nextScale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginPan(touch: Touch) {
|
||||||
|
gesture = {
|
||||||
|
kind: 'pan',
|
||||||
|
start: { x: touch.clientX, y: touch.clientY },
|
||||||
|
pan: { x: panX, y: panY },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginPinch(first: Touch, second: Touch) {
|
||||||
|
const point = midpoint(first, second);
|
||||||
|
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||||
|
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||||
|
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||||
|
gesture = {
|
||||||
|
kind: 'pinch',
|
||||||
|
distance: Math.max(1, touchDistance(first, second)),
|
||||||
|
imagePoint: {
|
||||||
|
x: (point.x - centerX - panX) / scale,
|
||||||
|
y: (point.y - centerY - panY) / scale,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(event: TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
interacting = true;
|
||||||
|
if (event.touches.length >= 2) {
|
||||||
|
touchMoved = true;
|
||||||
|
beginPinch(event.touches[0], event.touches[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.touches.length === 1) {
|
||||||
|
touchMoved = false;
|
||||||
|
beginPan(event.touches[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(event: TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.touches.length >= 2) {
|
||||||
|
if (gesture?.kind !== 'pinch') beginPinch(event.touches[0], event.touches[1]);
|
||||||
|
if (gesture?.kind !== 'pinch') return;
|
||||||
|
const point = midpoint(event.touches[0], event.touches[1]);
|
||||||
|
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||||
|
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||||
|
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||||
|
const nextScale = clamp(
|
||||||
|
scale * (touchDistance(event.touches[0], event.touches[1]) / gesture.distance),
|
||||||
|
MIN_SCALE,
|
||||||
|
MAX_SCALE,
|
||||||
|
);
|
||||||
|
gesture.distance = Math.max(1, touchDistance(event.touches[0], event.touches[1]));
|
||||||
|
applyTransform(
|
||||||
|
nextScale,
|
||||||
|
point.x - centerX - gesture.imagePoint.x * nextScale,
|
||||||
|
point.y - centerY - gesture.imagePoint.y * nextScale,
|
||||||
|
);
|
||||||
|
touchMoved = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.touches.length === 1 && gesture?.kind === 'pan') {
|
||||||
|
const dx = event.touches[0].clientX - gesture.start.x;
|
||||||
|
const dy = event.touches[0].clientY - gesture.start.y;
|
||||||
|
if (Math.hypot(dx, dy) > TAP_MOVE_THRESHOLD) touchMoved = true;
|
||||||
|
applyTransform(scale, gesture.pan.x + dx, gesture.pan.y + dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(event: TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.touches.length >= 2) {
|
||||||
|
beginPinch(event.touches[0], event.touches[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.touches.length === 1) {
|
||||||
|
beginPan(event.touches[0]);
|
||||||
|
touchMoved = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
interacting = false;
|
||||||
|
gesture = null;
|
||||||
|
if (!touchMoved && event.changedTouches.length > 0) {
|
||||||
|
const now = performance.now();
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (now - lastTapAt <= DOUBLE_TAP_MS) {
|
||||||
|
if (scale > MIN_SCALE + 0.01) resetViewer();
|
||||||
|
else zoomAt({ x: touch.clientX, y: touch.clientY }, 2);
|
||||||
|
lastTapAt = 0;
|
||||||
|
} else {
|
||||||
|
lastTapAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchCancel(event: TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
interacting = false;
|
||||||
|
gesture = null;
|
||||||
|
touchMoved = false;
|
||||||
|
applyTransform(scale, panX, panY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachTouchGestures(node: HTMLElement) {
|
||||||
|
node.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||||
|
node.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
node.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||||
|
node.addEventListener('touchcancel', handleTouchCancel, { passive: false });
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('touchstart', handleTouchStart);
|
||||||
|
node.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
node.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
node.removeEventListener('touchcancel', handleTouchCancel);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
closeButton?.focus();
|
||||||
|
const handleResize = () => applyTransform(scale, panX, panY);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="image-viewer"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={alt ? `Image viewer: ${alt}` : 'Image viewer'}
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="image-viewer-canvas"
|
||||||
|
bind:this={canvasElement}
|
||||||
|
use:attachTouchGestures
|
||||||
|
onclick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onclose();
|
||||||
|
}}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) onclose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
bind:this={imageElement}
|
||||||
|
class:interacting
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
draggable="false"
|
||||||
|
style:transform={`translate3d(${panX}px, ${panY}px, 0) scale(${scale})`}
|
||||||
|
onload={resetViewer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-viewer-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="image-viewer-reset"
|
||||||
|
onclick={resetViewer}
|
||||||
|
disabled={scale === MIN_SCALE && panX === 0 && panY === 0}
|
||||||
|
aria-label={`Reset zoom, currently ${zoomPercent}%`}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||||
|
<path d="M3 4v6h6" />
|
||||||
|
</svg>
|
||||||
|
<span>{zoomPercent}%</span>
|
||||||
|
</button>
|
||||||
|
<button bind:this={closeButton} type="button" class="image-viewer-close" onclick={onclose} aria-label="Close image viewer">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-viewer-hint" aria-hidden="true">Pinch to zoom · drag to move · double-tap to zoom or reset</div>
|
||||||
|
<span class="sr-only" aria-live="polite">Zoom {zoomPercent}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-viewer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
background: #06080c;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-canvas img {
|
||||||
|
display: block;
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
max-height: calc(100dvh - 24px);
|
||||||
|
object-fit: contain;
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: transform 160ms ease-out;
|
||||||
|
will-change: transform;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-canvas img.interacting {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-controls {
|
||||||
|
position: absolute;
|
||||||
|
inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) auto max(12px, env(safe-area-inset-left));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-controls button {
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(20, 23, 30, 0.86);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-reset {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-reset:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-close {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-hint {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: max(18px, calc(env(safe-area-inset-bottom) + 10px));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(20, 23, 30, 0.82);
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.image-viewer-canvas img {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showInfo, appConfig } from '$lib/stores/app';
|
import { showInfo } from '$lib/stores/app';
|
||||||
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
|
||||||
import type { OrphanAttachment } from '$lib/api';
|
|
||||||
import { openUrl } from '$lib/api';
|
import { openUrl } from '$lib/api';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import type { VaultStats } from '$lib/types';
|
|
||||||
import {
|
import {
|
||||||
ACTIONS,
|
ACTIONS,
|
||||||
keybindings,
|
keybindings,
|
||||||
@@ -15,9 +12,9 @@
|
|||||||
bindingsEqual,
|
bindingsEqual,
|
||||||
type ActionDef,
|
type ActionDef,
|
||||||
} from '$lib/keybindings';
|
} from '$lib/keybindings';
|
||||||
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
// Customizable shortcuts, grouped for display.
|
// Customizable shortcuts, grouped for display.
|
||||||
const actionGroups: { title: string; actions: ActionDef[] }[] = [
|
const actionGroups: { title: string; actions: ActionDef[] }[] = [
|
||||||
@@ -84,67 +81,41 @@
|
|||||||
if (capturingId === id) stopCapture();
|
if (capturingId === id) stopCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
let stats = $state<VaultStats | null>(null);
|
|
||||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||||
let appVersion = $state('...');
|
let appVersion = $state('...');
|
||||||
let orphans = $state<OrphanAttachment[] | null>(null);
|
|
||||||
let scanning = $state(false);
|
|
||||||
let trashing = $state(false);
|
|
||||||
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
|
|
||||||
|
|
||||||
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
$showInfo = false;
|
$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) {
|
function openLink(url: string) {
|
||||||
openUrl(url).catch(console.error);
|
openUrl(url).catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($showInfo) {
|
|
||||||
getVaultStats().then((s) => { stats = s; }).catch(console.error);
|
|
||||||
} else {
|
|
||||||
stats = null;
|
|
||||||
orphans = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function scanOrphans() {
|
|
||||||
scanning = true;
|
|
||||||
try { orphans = await findOrphanedAttachments(); }
|
|
||||||
catch (e) { console.error(e); }
|
|
||||||
finally { scanning = false; }
|
|
||||||
}
|
|
||||||
async function trashOrphans() {
|
|
||||||
if (!orphans || orphans.length === 0) return;
|
|
||||||
trashing = true;
|
|
||||||
try {
|
|
||||||
await trashOrphanedAttachments(orphans.map((o) => o.name));
|
|
||||||
orphans = null;
|
|
||||||
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
finally { trashing = false; }
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $showInfo}
|
{#if $showInfo}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="info-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
<div class="info-overlay" onclick={closeFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="info-panel">
|
||||||
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="info-header">
|
<div class="info-header">
|
||||||
<h2>Info</h2>
|
<h2>Info</h2>
|
||||||
<button class="close-btn" onclick={close}>
|
<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">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
@@ -176,49 +147,7 @@
|
|||||||
<p class="app-version">v{appVersion}</p>
|
<p class="app-version">v{appVersion}</p>
|
||||||
<p class="app-description">A local markdown note-taking app.</p>
|
<p class="app-description">A local markdown note-taking app.</p>
|
||||||
|
|
||||||
{#if stats}
|
|
||||||
<div class="info-stats">
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">Notes</span>
|
|
||||||
<span class="stat-value">{stats.total_notes}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">Attachments</span>
|
|
||||||
<span class="stat-value">{stats.total_attachments}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">Notes size</span>
|
|
||||||
<span class="stat-value">{formatSize(stats.notes_size)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">Attachments size</span>
|
|
||||||
<span class="stat-value">{formatSize(stats.attachments_size)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row stat-total">
|
|
||||||
<span class="stat-label">Total vault size</span>
|
|
||||||
<span class="stat-value">{formatSize(stats.total_size)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if stats}
|
|
||||||
<div class="info-cleanup">
|
|
||||||
{#if orphans === null}
|
|
||||||
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
|
|
||||||
{:else if orphans.length === 0}
|
|
||||||
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
|
|
||||||
{:else}
|
|
||||||
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
|
|
||||||
<div class="cleanup-list">
|
|
||||||
{#each orphans as o}
|
|
||||||
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
|
|
||||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="info-credits">
|
<div class="info-credits">
|
||||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||||
@@ -273,6 +202,7 @@
|
|||||||
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">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">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">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">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">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 line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
|
||||||
@@ -416,39 +346,6 @@
|
|||||||
margin-top: 4px;
|
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 {
|
.info-credits {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@@ -480,71 +377,6 @@
|
|||||||
.info-link:hover {
|
.info-link:hover {
|
||||||
background: var(--accent-light);
|
background: var(--accent-light);
|
||||||
}
|
}
|
||||||
.info-cleanup {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.cleanup-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 7px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
|
|
||||||
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
|
|
||||||
.cleanup-msg {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.cleanup-list {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 140px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.cleanup-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.cleanup-name {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.cleanup-size {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.cleanup-hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.spinner-icon {
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcuts-section {
|
.shortcuts-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
activeNotebook,
|
activeNotebook,
|
||||||
activeTag,
|
activeTag,
|
||||||
sortMode,
|
sortMode,
|
||||||
|
groupNotesByDate,
|
||||||
editorDirty,
|
editorDirty,
|
||||||
quickAccessPaths,
|
quickAccessPaths,
|
||||||
appConfig,
|
appConfig,
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
getNotes,
|
getNotes,
|
||||||
readNote,
|
readNote,
|
||||||
createNote,
|
createNote,
|
||||||
|
duplicateNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
renameNote,
|
renameNote,
|
||||||
saveNote,
|
saveNote,
|
||||||
@@ -35,19 +37,22 @@
|
|||||||
reorderQuickAccess,
|
reorderQuickAccess,
|
||||||
moveNote,
|
moveNote,
|
||||||
getAllTags,
|
getAllTags,
|
||||||
createDailyNote
|
createDailyNote,
|
||||||
|
revealFile
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import { formatRelativeTime, formatDate } from '$lib/utils/time';
|
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||||
import TasksView from './TasksView.svelte';
|
import TasksView from './TasksView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
|
import { isMobile, isAndroid } from '$lib/platform';
|
||||||
|
|
||||||
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||||
onNoteSelected?: (path: string, content: string) => void;
|
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
||||||
onNoteMoved?: () => void;
|
onNoteMoved?: () => void;
|
||||||
onBeforeNoteSwitch?: () => void;
|
onBeforeNoteSwitch?: () => void;
|
||||||
|
onBeforeNoteDuplicate?: () => Promise<boolean>;
|
||||||
onNoteCreated?: () => void;
|
onNoteCreated?: () => void;
|
||||||
onToggleTask?: (t: TaskItem) => Promise<void>;
|
onToggleTask?: (t: TaskItem) => Promise<void>;
|
||||||
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
||||||
@@ -55,8 +60,6 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
const isAndroid = /android/i.test(navigator.userAgent);
|
|
||||||
let multiSelectMode = $state(false);
|
let multiSelectMode = $state(false);
|
||||||
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
|
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
|
||||||
let trashBusy = $state<string | null>(null);
|
let trashBusy = $state<string | null>(null);
|
||||||
@@ -213,6 +216,24 @@
|
|||||||
const customOrderViews = new Set(['all', 'notebook', 'tag']);
|
const customOrderViews = new Set(['all', 'notebook', 'tag']);
|
||||||
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
|
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
|
||||||
|
|
||||||
|
// Relative-date grouping (All Notes + date sorts only). Pinned notes form their own leading group.
|
||||||
|
let groupingActive = $derived($groupNotesByDate && $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified'));
|
||||||
|
let noteGroups = $derived.by(() => {
|
||||||
|
if (!groupingActive) return [] as { label: string; notes: NoteEntry[] }[];
|
||||||
|
const pinned: NoteEntry[] = [];
|
||||||
|
const rest: NoteEntry[] = [];
|
||||||
|
for (const n of $sortedNotes) (n.meta.pinned ? pinned : rest).push(n);
|
||||||
|
const groups: { label: string; notes: NoteEntry[] }[] = [];
|
||||||
|
if (pinned.length) groups.push({ label: 'Pinned', notes: pinned });
|
||||||
|
let cur: { label: string; notes: NoteEntry[] } | null = null;
|
||||||
|
for (const n of rest) {
|
||||||
|
const label = dateBucketLabel($sortMode === 'created' ? n.meta.created : n.meta.modified);
|
||||||
|
if (!cur || cur.label !== label) { cur = { label, notes: [] }; groups.push(cur); }
|
||||||
|
cur.notes.push(n);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
// Virtual scroll
|
// Virtual scroll
|
||||||
let listContainer = $state<HTMLDivElement>(null!);
|
let listContainer = $state<HTMLDivElement>(null!);
|
||||||
let scrollTop = $state(0);
|
let scrollTop = $state(0);
|
||||||
@@ -247,6 +268,19 @@
|
|||||||
if (listContainer) listContainer.scrollTop = 0;
|
if (listContainer) listContainer.scrollTop = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notes can also be removed from this list by a drop handled in the sidebar.
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedPaths.size === 0) return;
|
||||||
|
const availablePaths = new Set($notes.map((note) => note.path));
|
||||||
|
const remaining = new Set([...selectedPaths].filter((path) => availablePaths.has(path)));
|
||||||
|
if (remaining.size === selectedPaths.size) return;
|
||||||
|
if (remaining.size === 0) {
|
||||||
|
clearSelection();
|
||||||
|
} else {
|
||||||
|
selectedPaths = remaining;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
|
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$quickAccessPaths;
|
$quickAccessPaths;
|
||||||
@@ -391,13 +425,14 @@
|
|||||||
$activeNote = content;
|
$activeNote = content;
|
||||||
$activeNotePath = task.note_path;
|
$activeNotePath = task.note_path;
|
||||||
$editorDirty = false;
|
$editorDirty = false;
|
||||||
onNoteSelected(task.note_path, content.content);
|
onNoteSelected(task.note_path, content.content, task);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to open task note:', e);
|
console.error('Failed to open task note:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCreateNote() {
|
export async function handleCreateNote() {
|
||||||
|
if ($viewMode === 'quickaccess' || $viewMode === 'trash') return;
|
||||||
if ($viewMode === 'daily') {
|
if ($viewMode === 'daily') {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
@@ -423,14 +458,47 @@
|
|||||||
const entry = await createNote(nbRelative, 'Untitled');
|
const entry = await createNote(nbRelative, 'Untitled');
|
||||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
await refresh();
|
$notes = [entry, ...$notes];
|
||||||
await selectNote(entry);
|
onBeforeNoteSwitch();
|
||||||
|
$activeNote = { path: entry.path, meta: entry.meta, content: '\n', raw: '' };
|
||||||
|
$activeNotePath = entry.path;
|
||||||
|
$editorDirty = false;
|
||||||
|
onNoteSelected(entry.path, '\n');
|
||||||
onNoteCreated();
|
onNoteCreated();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to create note:', e);
|
console.error('Failed to create note:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDuplicate(note: NoteEntry) {
|
||||||
|
contextMenu = null;
|
||||||
|
try {
|
||||||
|
if ($activeNotePath === note.path && $editorDirty) {
|
||||||
|
const saved = await onBeforeNoteDuplicate();
|
||||||
|
if (!saved) {
|
||||||
|
console.error('Failed to save note before duplicating');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await duplicateNote(note.path);
|
||||||
|
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||||
|
noteCache.clear();
|
||||||
|
if ($viewMode !== 'quickaccess') $notes = [entry, ...$notes];
|
||||||
|
await selectNote(entry);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to duplicate note:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleListDoubleClick(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
if ($viewMode === 'tasks' || $viewMode === 'trash' || $viewMode === 'quickaccess') return;
|
||||||
|
handleCreateNote();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRename(note: NoteEntry) {
|
async function handleRename(note: NoteEntry) {
|
||||||
if (!editValue.trim() || editValue.trim() === note.meta.title) {
|
if (!editValue.trim() || editValue.trim() === note.meta.title) {
|
||||||
editingNote = null;
|
editingNote = null;
|
||||||
@@ -474,6 +542,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function handleRestore(note: NoteEntry) {
|
async function handleRestore(note: NoteEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
try {
|
try {
|
||||||
@@ -749,6 +818,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFiled(note: NoteEntry): boolean {
|
||||||
|
const vaultRoot = $appConfig?.active_vault?.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
|
if (!vaultRoot) return false;
|
||||||
|
const normalizedPath = note.path.replace(/\\/g, '/');
|
||||||
|
const noteDir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'));
|
||||||
|
return noteDir !== vaultRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unfileNote(note: NoteEntry) {
|
||||||
|
const vaultRoot = $appConfig?.active_vault;
|
||||||
|
if (vaultRoot) handleMoveNote(note, vaultRoot);
|
||||||
|
}
|
||||||
|
|
||||||
function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 400): { x: number; y: number } {
|
function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 400): { x: number; y: number } {
|
||||||
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
||||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
||||||
@@ -884,11 +966,21 @@
|
|||||||
await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e))));
|
await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWindowClick() {
|
function handleWindowClick(event: MouseEvent) {
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
if (target?.closest('.context-menu, .sort-menu')) return;
|
||||||
if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; }
|
if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; }
|
||||||
if (sortMenu) sortMenu = null;
|
if (sortMenu) sortMenu = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeBatchMoveOverlay(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) batchMovePicker = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBatchTagOverlay(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) batchTagEdit = false;
|
||||||
|
}
|
||||||
|
|
||||||
function openSortMenu(e: MouseEvent) {
|
function openSortMenu(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
@@ -949,7 +1041,14 @@
|
|||||||
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
|
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if $viewMode !== 'trash'}
|
{#if $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified')}
|
||||||
|
<button class="icon-btn" class:active-toggle={$groupNotesByDate} onclick={() => { $groupNotesByDate = !$groupNotesByDate; }} title="Group by date">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /><line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if $viewMode !== 'trash' && $viewMode !== 'quickaccess'}
|
||||||
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
|
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
@@ -965,8 +1064,14 @@
|
|||||||
<span class="selection-count">{selectedPaths.size} selected</span>
|
<span class="selection-count">{selectedPaths.size} selected</span>
|
||||||
<div class="selection-actions">
|
<div class="selection-actions">
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
<button class="selection-action" onclick={handleBatchRestore}>Restore</button>
|
<button class="selection-action" onclick={handleBatchRestore} title="Restore selected">
|
||||||
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button class="selection-action danger" onclick={handleBatchPermanentDelete} title="Delete permanently">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
|
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
|
||||||
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
|
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
|
||||||
@@ -981,12 +1086,11 @@
|
|||||||
|
|
||||||
{#if batchMovePicker}
|
{#if batchMovePicker}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="batch-move-overlay" onclick={() => batchMovePicker = false}>
|
<div class="batch-move-overlay" onclick={closeBatchMoveOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchMovePicker = false; }}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-move-title" tabindex="-1">
|
||||||
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="batch-move-header">
|
<div class="batch-move-header">
|
||||||
<span>Move {selectedPaths.size} notes to...</span>
|
<span id="batch-move-title">Move {selectedPaths.size} notes to...</span>
|
||||||
<button class="selection-close" onclick={() => batchMovePicker = false}>
|
<button class="selection-close" onclick={() => batchMovePicker = false} aria-label="Close move picker">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1010,12 +1114,11 @@
|
|||||||
|
|
||||||
{#if batchTagEdit && !contextMenu}
|
{#if batchTagEdit && !contextMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
|
<div class="batch-move-overlay" onclick={closeBatchTagOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchTagEdit = false; }}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-tags-title" tabindex="-1">
|
||||||
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="batch-move-header">
|
<div class="batch-move-header">
|
||||||
<span>Tags for {selectedPaths.size} notes</span>
|
<span id="batch-tags-title">Tags for {selectedPaths.size} notes</span>
|
||||||
<button class="selection-close" onclick={() => batchTagEdit = false}>
|
<button class="selection-close" onclick={() => batchTagEdit = false} aria-label="Close tag editor">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1047,7 +1150,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="list-content" class:tasks-mode={$viewMode === 'tasks'} bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
|
||||||
{#if $viewMode === 'tasks'}
|
{#if $viewMode === 'tasks'}
|
||||||
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1089,6 +1193,8 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
<p>Trash is empty</p>
|
<p>Trash is empty</p>
|
||||||
|
{:else if $viewMode === 'quickaccess'}
|
||||||
|
<p>No starred notes</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p>No notes yet</p>
|
<p>No notes yet</p>
|
||||||
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
||||||
@@ -1127,9 +1233,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div style="height: {topPad}px"></div>
|
{#snippet noteRow(note: NoteEntry, noteIndex: number)}
|
||||||
{#each visibleNotes as note, i (note.path)}
|
|
||||||
{@const noteIndex = startIndex + i}
|
|
||||||
{#if editingNote === note.path}
|
{#if editingNote === note.path}
|
||||||
<div class="note-item active">
|
<div class="note-item active">
|
||||||
<input
|
<input
|
||||||
@@ -1194,11 +1298,10 @@
|
|||||||
e.dataTransfer!.effectAllowed = 'move';
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
|
||||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
? selectedPaths
|
||||||
} else {
|
: [note.path];
|
||||||
e.dataTransfer!.setData('text/plain', note.path);
|
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
|
||||||
}
|
|
||||||
e.dataTransfer!.effectAllowed = 'move';
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
}}
|
}}
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
@@ -1281,15 +1384,39 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $viewMode === 'trash'}
|
||||||
|
<span class="trash-row-actions">
|
||||||
|
<span class="trash-row-btn" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handleRestore(note); }} title="Restore" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handleRestore(note); } }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="trash-row-btn danger" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handlePermanentDelete(note); }} title="Delete permanently" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handlePermanentDelete(note); } }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if groupingActive}
|
||||||
|
{#each noteGroups as group (group.label)}
|
||||||
|
<div class="note-list-section">{group.label}</div>
|
||||||
|
{#each group.notes as note (note.path)}
|
||||||
|
{@render noteRow(note, -1)}
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div style="height: {topPad}px"></div>
|
||||||
|
{#each visibleNotes as note, i (note.path)}
|
||||||
|
{@render noteRow(note, startIndex + i)}
|
||||||
{/each}
|
{/each}
|
||||||
<div style="height: {bottomPad}px"></div>
|
<div style="height: {bottomPad}px"></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#if contextMenu}
|
||||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" role="group" aria-label="Note actions">
|
||||||
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
||||||
<!-- Batch context menu -->
|
<!-- Batch context menu -->
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
@@ -1440,7 +1567,7 @@
|
|||||||
Open in New Window
|
Open in New Window
|
||||||
</button>
|
</button>
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealItemInDir(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
|
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealFile(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -1453,12 +1580,26 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick={() => handleDuplicate(contextMenu!.note)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v9a2 2 0 002 2h3"/>
|
||||||
|
</svg>
|
||||||
|
Duplicate Note
|
||||||
|
</button>
|
||||||
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
|
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Move to...
|
Move to...
|
||||||
</button>
|
</button>
|
||||||
|
{#if isFiled(contextMenu.note)}
|
||||||
|
<button onclick={() => unfileNote(contextMenu!.note)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><path d="M9 13h6"/>
|
||||||
|
</svg>
|
||||||
|
Unfile Note
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
|
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -1478,8 +1619,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if sortMenu}
|
{#if sortMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="sort-menu" class:mobile={isMobile} style="left: {sortMenu.x}px; top: {sortMenu.y}px">
|
||||||
<div class="sort-menu" style="left: {sortMenu.x}px; top: {sortMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
|
||||||
<div class="sort-menu-title">Sort by</div>
|
<div class="sort-menu-title">Sort by</div>
|
||||||
<button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}>
|
<button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -1581,17 +1721,19 @@
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-content::-webkit-scrollbar {
|
.note-list-section {
|
||||||
width: 8px;
|
padding: 14px 12px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
.note-list-section:first-child {
|
||||||
.list-content::-webkit-scrollbar-thumb {
|
padding-top: 6px;
|
||||||
background: var(--text-tertiary);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-content::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item {
|
.note-item {
|
||||||
@@ -1605,6 +1747,7 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
contain: content;
|
contain: content;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item:hover {
|
.note-item:hover {
|
||||||
@@ -1651,6 +1794,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selection-action {
|
.selection-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1665,6 +1811,37 @@
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trash-row-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: none;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.note-item:hover .trash-row-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.trash-row-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.trash-row-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.trash-row-btn.danger:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||||
|
}
|
||||||
|
.note-list.mobile .trash-row-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.selection-action.danger {
|
.selection-action.danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
||||||
@@ -2079,6 +2256,11 @@
|
|||||||
padding: 4px 8px 180px;
|
padding: 4px 8px 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-list.mobile .list-content.tasks-mode {
|
||||||
|
padding-bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.note-list.mobile .note-item {
|
.note-list.mobile .note-item {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
@@ -2145,26 +2327,28 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .context-menu {
|
.context-menu.mobile {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
max-height: calc(100dvh - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .context-menu button {
|
.context-menu.mobile button {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .sort-menu {
|
.sort-menu.mobile {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .sort-menu button {
|
.sort-menu.mobile button {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@@ -2188,26 +2372,6 @@
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile selection checkboxes */
|
|
||||||
.mobile-select-check {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid var(--text-tertiary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-select-check.checked {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile create note button (round, accent-colored) */
|
/* Mobile create note button (round, accent-colored) */
|
||||||
.mobile-create-btn {
|
.mobile-create-btn {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
|
||||||
|
import { getNoteSwitcherTitles, getQuickAccess } from '$lib/api';
|
||||||
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
|
import {
|
||||||
|
buildNoteSwitcherRequestPaths,
|
||||||
|
buildNoteSwitcherSections,
|
||||||
|
type NoteSwitcherNote,
|
||||||
|
type NoteSwitcherRow,
|
||||||
|
type NoteSwitcherSections
|
||||||
|
} from '$lib/utils/note-switcher';
|
||||||
|
import type { NoteEntry, NoteTitleEntry } from '$lib/types';
|
||||||
|
|
||||||
|
let { onSelectNote = async () => false }: {
|
||||||
|
onSelectNote?: (path: string) => Promise<boolean>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let wrapper: HTMLDivElement;
|
||||||
|
let trigger: HTMLButtonElement;
|
||||||
|
let popover = $state<HTMLDivElement | null>(null);
|
||||||
|
let open = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
|
let selectingPath = $state<string | null>(null);
|
||||||
|
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
|
||||||
|
let loadGeneration = 0;
|
||||||
|
let loadedVaultPath = $state<string | null | undefined>(undefined);
|
||||||
|
|
||||||
|
function normalizedPath(path: string): string {
|
||||||
|
return path.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideVault(path: string, vaultPath: string): boolean {
|
||||||
|
return normalizedPath(path).startsWith(`${normalizedPath(vaultPath)}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultRelativePath(path: string, vaultPath: string): string {
|
||||||
|
return normalizedPath(path).slice(normalizedPath(vaultPath).length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinVaultPath(vaultPath: string, relativePath: string): string {
|
||||||
|
const separator = vaultPath.includes('\\') && !vaultPath.includes('/') ? '\\' : '/';
|
||||||
|
const root = vaultPath.replace(/[\\/]+$/, '');
|
||||||
|
return `${root}${separator}${relativePath.replace(/[\\/]/g, separator)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleEntryToNote(entry: NoteTitleEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||||
|
const relativePath = entry.path.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
if (!relativePath || relativePath.split('/').includes('..')) return null;
|
||||||
|
const path = joinVaultPath(vaultPath, relativePath);
|
||||||
|
if (!isInsideVault(path, vaultPath)) return null;
|
||||||
|
return { path, title: entry.title, relativePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickAccessEntryToNote(entry: NoteEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||||
|
if (!isInsideVault(entry.path, vaultPath)) return null;
|
||||||
|
return {
|
||||||
|
path: entry.path,
|
||||||
|
title: entry.meta.title,
|
||||||
|
relativePath: entry.relative_path.replace(/\\/g, '/')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusInitialRow() {
|
||||||
|
await tick();
|
||||||
|
if (!open) return;
|
||||||
|
const current = wrapper.querySelector<HTMLButtonElement>('.note-primary[aria-current="true"]');
|
||||||
|
const first = wrapper.querySelector<HTMLButtonElement>('.note-primary');
|
||||||
|
(current ?? first ?? popover)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSections(generation: number) {
|
||||||
|
const vaultPath = $appConfig?.active_vault;
|
||||||
|
if (!vaultPath) {
|
||||||
|
sections = { recent: [], quickAccess: [] };
|
||||||
|
loadedVaultPath = null;
|
||||||
|
loading = false;
|
||||||
|
await focusInitialRow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPaths = buildNoteSwitcherRequestPaths($activeNotePath, $navHistory.stack);
|
||||||
|
const [titlesResult, quickAccessResult] = await Promise.allSettled([
|
||||||
|
getNoteSwitcherTitles(requestPaths),
|
||||||
|
getQuickAccess()
|
||||||
|
]);
|
||||||
|
if (!open || generation !== loadGeneration) return;
|
||||||
|
|
||||||
|
if (titlesResult.status === 'rejected') {
|
||||||
|
console.error('Failed to load notes for note switcher:', titlesResult.reason);
|
||||||
|
}
|
||||||
|
if (quickAccessResult.status === 'rejected') {
|
||||||
|
console.error('Failed to load Quick Access for note switcher:', quickAccessResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownNotes = titlesResult.status === 'fulfilled'
|
||||||
|
? titlesResult.value
|
||||||
|
.map((entry) => titleEntryToNote(entry, vaultPath))
|
||||||
|
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||||
|
: [];
|
||||||
|
const currentPath = $activeNotePath;
|
||||||
|
if (currentPath && isInsideVault(currentPath, vaultPath)) {
|
||||||
|
const currentKey = normalizedPath(currentPath);
|
||||||
|
if (!knownNotes.some((note) => normalizedPath(note.path) === currentKey)) {
|
||||||
|
knownNotes.push({
|
||||||
|
path: currentPath,
|
||||||
|
title: $activeNote?.meta.title || currentPath.split(/[\\/]/).pop()?.replace(/\.md$/i, '') || 'Untitled',
|
||||||
|
relativePath: vaultRelativePath(currentPath, vaultPath)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickAccessNotes = quickAccessResult.status === 'fulfilled'
|
||||||
|
? quickAccessResult.value
|
||||||
|
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
|
||||||
|
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||||
|
: [];
|
||||||
|
for (const note of quickAccessNotes) {
|
||||||
|
if (!knownNotes.some((knownNote) => normalizedPath(knownNote.path) === normalizedPath(note.path))) {
|
||||||
|
knownNotes.push(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = buildNoteSwitcherSections({
|
||||||
|
currentPath,
|
||||||
|
historyPaths: $navHistory.stack,
|
||||||
|
knownNotes,
|
||||||
|
quickAccessNotes
|
||||||
|
});
|
||||||
|
loadedVaultPath = vaultPath;
|
||||||
|
loading = false;
|
||||||
|
await focusInitialRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAfterPopoverPaint(generation: number) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!open || generation !== loadGeneration) return;
|
||||||
|
void refreshSections(generation);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSwitcher() {
|
||||||
|
if (open) {
|
||||||
|
open = false;
|
||||||
|
loadGeneration += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vaultPath = $appConfig?.active_vault ?? null;
|
||||||
|
const hasCachedSections = loadedVaultPath === vaultPath;
|
||||||
|
open = true;
|
||||||
|
loading = !hasCachedSections;
|
||||||
|
selectingPath = null;
|
||||||
|
if (!hasCachedSections) sections = { recent: [], quickAccess: [] };
|
||||||
|
loadGeneration += 1;
|
||||||
|
const generation = loadGeneration;
|
||||||
|
if (hasCachedSections) void focusInitialRow();
|
||||||
|
refreshAfterPopoverPaint(generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSwitcher(restoreTriggerFocus: boolean) {
|
||||||
|
open = false;
|
||||||
|
loadGeneration += 1;
|
||||||
|
if (restoreTriggerFocus) {
|
||||||
|
await tick();
|
||||||
|
trigger?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectNote(row: NoteSwitcherRow) {
|
||||||
|
if (selectingPath !== null) return;
|
||||||
|
selectingPath = row.path;
|
||||||
|
try {
|
||||||
|
if (await onSelectNote(row.path)) await closeSwitcher(false);
|
||||||
|
} finally {
|
||||||
|
selectingPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePopoverKeydown(event: KeyboardEvent) {
|
||||||
|
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||||
|
const buttons = Array.from(wrapper.querySelectorAll<HTMLButtonElement>('.note-primary:not(:disabled)'));
|
||||||
|
if (buttons.length === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const activeRow = (document.activeElement as HTMLElement | null)?.closest('.note-row');
|
||||||
|
const activeButton = activeRow?.querySelector<HTMLButtonElement>('.note-primary') ?? null;
|
||||||
|
const currentIndex = activeButton ? buttons.indexOf(activeButton) : -1;
|
||||||
|
let nextIndex: number;
|
||||||
|
if (event.key === 'Home') nextIndex = 0;
|
||||||
|
else if (event.key === 'End') nextIndex = buttons.length - 1;
|
||||||
|
else if (event.key === 'ArrowDown') nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
|
||||||
|
else nextIndex = currentIndex < 0 ? buttons.length - 1 : (currentIndex - 1 + buttons.length) % buttons.length;
|
||||||
|
buttons[nextIndex].focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleOutsideMouseDown = (event: MouseEvent) => {
|
||||||
|
if (!wrapper.contains(event.target as Node)) void closeSwitcher(false);
|
||||||
|
};
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void closeSwitcher(true);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleOutsideMouseDown, true);
|
||||||
|
document.addEventListener('keydown', handleEscape, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleOutsideMouseDown, true);
|
||||||
|
document.removeEventListener('keydown', handleEscape, true);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="note-switcher" bind:this={wrapper}>
|
||||||
|
<button
|
||||||
|
bind:this={trigger}
|
||||||
|
class="switcher-trigger"
|
||||||
|
type="button"
|
||||||
|
aria-label="Switch note"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={toggleSwitcher}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="7" y="4" width="12" height="14" rx="2" />
|
||||||
|
<path d="M5 7H4a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
bind:this={popover}
|
||||||
|
class="switcher-popover"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Note switcher"
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={handlePopoverKeydown}
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<div class="switcher-status">Loading notes…</div>
|
||||||
|
{:else if sections.recent.length === 0 && sections.quickAccess.length === 0}
|
||||||
|
<div class="switcher-status">No notes available</div>
|
||||||
|
{:else}
|
||||||
|
{#each [
|
||||||
|
{ label: 'Recent', rows: sections.recent },
|
||||||
|
{ label: 'Quick Access', rows: sections.quickAccess }
|
||||||
|
] as section (section.label)}
|
||||||
|
{#if section.rows.length > 0}
|
||||||
|
<section class="switcher-section" aria-labelledby={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>
|
||||||
|
<h2 id={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>{section.label}</h2>
|
||||||
|
{#each section.rows as row (row.path)}
|
||||||
|
<div class="note-row" class:current-row={row.current}>
|
||||||
|
{#if row.current}<span class="current-marker" aria-hidden="true"></span>{/if}
|
||||||
|
<button
|
||||||
|
class="note-primary"
|
||||||
|
type="button"
|
||||||
|
aria-current={row.current ? 'true' : undefined}
|
||||||
|
disabled={selectingPath !== null}
|
||||||
|
onclick={() => selectNote(row)}
|
||||||
|
>
|
||||||
|
<span class="note-title">{row.title}</span>
|
||||||
|
<span class="note-folder">{row.folder}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="open-window"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Open ${row.title} in new window`}
|
||||||
|
title={`Open ${row.title} in new window`}
|
||||||
|
onclick={() => openNoteWindow(row.path, row.title)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="4" y="4" width="13" height="13" rx="2" />
|
||||||
|
<path d="M10 14L20 4m-6 0h6v6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.note-switcher {
|
||||||
|
position: relative;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-trigger:hover,
|
||||||
|
.switcher-trigger[aria-expanded='true'] {
|
||||||
|
background: var(--bg-tertiary, var(--bg-hover));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-trigger:focus-visible,
|
||||||
|
.note-primary:focus-visible,
|
||||||
|
.open-window:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
width: min(320px, calc(100vw - 24px));
|
||||||
|
max-height: min(420px, calc(100vh - 58px));
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-section + .switcher-section {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding-top: 5px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 8px 4px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-row:hover,
|
||||||
|
.note-row:focus-within {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-row.current-row {
|
||||||
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: 7px;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-primary {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 38px 6px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-row .note-primary {
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-primary:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title,
|
||||||
|
.note-folder {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-folder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-window {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.12s, background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-row:hover .open-window,
|
||||||
|
.note-row:focus-within .open-window {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-window:hover {
|
||||||
|
background: var(--bg-tertiary, var(--bg-hover));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-status {
|
||||||
|
padding: 22px 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||