Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15b267614 | ||
|
|
ce48d77cd8 | ||
|
|
221625aabd | ||
|
|
1bd4cd8138 | ||
|
|
dd748776b2 | ||
|
|
0391a60c1b | ||
|
|
33d77cf2fd | ||
|
|
81e4e26290 | ||
|
|
111d819f92 | ||
|
|
c37818de1a | ||
|
|
95dab8cf74 | ||
|
|
62365f52b1 | ||
|
|
6eb1ec6eb2 | ||
|
|
527c6a7a91 | ||
|
|
45e3c389e4 | ||
|
|
9ccbf3aff5 | ||
|
|
695664913a | ||
|
|
d0fb87cf3a | ||
|
|
ac7c79af04 | ||
|
|
1ebf2a9843 | ||
|
|
44dcfbe24a | ||
|
|
00196ecedb | ||
|
|
c4d2d1b2d1 | ||
|
|
3a9a5129c4 | ||
|
|
a964ee1930 | ||
|
|
2d57577338 | ||
|
|
206faed113 | ||
|
|
b3c292d6e2 | ||
|
|
43f15962f7 | ||
|
|
ade146f9f3 | ||
|
|
b253ec4121 | ||
|
|
c4a91b8f1c | ||
|
|
87c879bcac | ||
|
|
27609fb4f3 | ||
|
|
b0555fe648 | ||
|
|
32ef51161d | ||
|
|
23abd0a19a | ||
|
|
5f726eb01f | ||
|
|
73f7604187 | ||
|
|
7ca226d918 | ||
|
|
1cbdf704bd | ||
|
|
b5bd5ea151 | ||
|
|
bdf94e2494 | ||
|
|
1950c4cc3c | ||
|
|
aeddcc6e58 | ||
|
|
bb9d01f57c | ||
|
|
ac25dfd773 | ||
|
|
e61c66dc26 | ||
|
|
3e8aa7f10d | ||
|
|
2dd0985ea9 | ||
|
|
18bb6bb702 | ||
|
|
fcf48a5b7a | ||
|
|
c7906f4069 | ||
|
|
e6e1ed1ed6 | ||
|
|
1a880a4f96 | ||
|
|
f4e05ed9c4 | ||
|
|
4a1045b41e | ||
|
|
c305b5f8dd | ||
|
|
b5962af466 | ||
|
|
13e83b6689 | ||
|
|
432e4f472a | ||
|
|
8731ca4ec1 | ||
|
|
6b165059cc | ||
|
|
213797a38e | ||
|
|
fc3e388b80 | ||
|
|
1c0359e540 | ||
|
|
da859e581b | ||
|
|
763e673738 | ||
|
|
550d3ab3e9 | ||
|
|
e62404d4c0 | ||
|
|
54e051ba02 | ||
|
|
344728dbe8 | ||
|
|
818e9ad82a | ||
|
|
4931ee3fab | ||
|
|
65e09e8fdd | ||
|
|
64e521d549 | ||
|
|
b8308d89e8 | ||
|
|
0e5b380f8c | ||
|
|
e76919ab5c | ||
|
|
84ab90fd3f | ||
|
|
87b191eb63 | ||
|
|
0def9d6d6d | ||
|
|
e708d8ee63 | ||
|
|
461001442a | ||
|
|
6b5e923e8e | ||
|
|
289606a746 | ||
|
|
9c53b3b594 | ||
|
|
d497ff8aaf | ||
|
|
51acd51cb2 | ||
|
|
b4bb3b89d6 | ||
|
|
9b51aef073 | ||
|
|
4aa0b200b9 | ||
|
|
8769b56fd1 | ||
|
|
06d2cd744f | ||
|
|
7428cc7dfb | ||
|
|
6c89d668fe | ||
|
|
2c76a4747e | ||
|
|
fd55c00015 | ||
|
|
28ef37f354 | ||
|
|
bf5a9f134d | ||
|
|
2991940ba8 | ||
|
|
54352a03d8 | ||
|
|
c4d1f12b2e | ||
|
|
aa699007f7 | ||
|
|
047a8b3c3d | ||
|
|
6098dd01b4 | ||
|
|
b16beb9af5 | ||
|
|
95565d7506 | ||
|
|
a13d17ed81 | ||
|
|
95515cac9d | ||
|
|
21dd2d1681 | ||
|
|
6d5f12c47c | ||
|
|
d01bd4ea01 | ||
|
|
bd7d66b1e8 | ||
|
|
0b9fe69819 | ||
|
|
936c38a21d | ||
|
|
e60e637437 | ||
|
|
479c957698 | ||
|
|
13fd70f944 | ||
|
|
015726f896 | ||
|
|
5e2c31598c | ||
|
|
24d1ba9f8f | ||
|
|
5345442de2 | ||
|
|
6ea2ca0ae2 | ||
|
|
6f4f526d07 | ||
|
|
13bad87500 | ||
|
|
acecc9c68d | ||
|
|
35a8501f2d | ||
|
|
09f9df968d | ||
|
|
2c58bf3d9a | ||
|
|
e063f1c73a | ||
|
|
cafc0ce1ec | ||
|
|
a192723fbd | ||
|
|
b643eeb884 | ||
|
|
eb042e5760 | ||
|
|
6251d96193 | ||
|
|
db92dabba5 | ||
|
|
2469a971ca | ||
|
|
a43bf05bf0 | ||
|
|
ffd9ebad7e | ||
|
|
d2100c8500 | ||
|
|
7e63d721bc | ||
|
|
2c774c51c6 | ||
|
|
8eba4527af | ||
|
|
181b3eb6c2 | ||
|
|
879d2f42c6 | ||
|
|
85ea26b57a | ||
|
|
800c81e598 | ||
|
|
30c3523722 | ||
|
|
761bf5b42e | ||
|
|
b10a7b1bfc | ||
|
|
313d86df23 | ||
|
|
95627cbb5c | ||
|
|
b9ea14f330 | ||
|
|
812d025a9d | ||
|
|
11cc2daa4b | ||
|
|
5c36f2aa71 | ||
|
|
987b1b9ed7 | ||
|
|
3ef0e14581 | ||
|
|
f98b3415bc | ||
|
|
16805c96aa | ||
|
|
9f2e0ba8b6 | ||
|
|
7b3609afbb | ||
|
|
84ff75f223 | ||
|
|
56dc0eefd7 | ||
|
|
eeea16531a | ||
|
|
ac5ab2a27a | ||
|
|
fef4ece2c0 | ||
|
|
613451f4d8 | ||
|
|
04dee78b27 | ||
|
|
69330bf23c | ||
|
|
4843bcacd5 | ||
|
|
3d28665e1e | ||
|
|
209585a0b4 | ||
|
|
84eab75ec8 | ||
|
|
ce17b38509 | ||
|
|
85c1e1e7d3 | ||
|
|
ebf38e2281 | ||
|
|
32ec62419d | ||
|
|
e46fda91a4 | ||
|
|
62d5388176 | ||
|
|
db2bef892a | ||
|
|
1c46935cc5 | ||
|
|
f0f19125d5 | ||
|
|
828f83d2e3 | ||
|
|
002fdb763e | ||
|
|
d3ff1fb931 | ||
|
|
7951472ae3 | ||
|
|
5cffe8846b | ||
|
|
806e975e31 | ||
|
|
e4232269f6 | ||
|
|
ab759a006d | ||
|
|
ca7f25003c | ||
|
|
08ab0b90f2 | ||
|
|
e275f5c0bb | ||
|
|
683a01627e | ||
|
|
3c11a7f63e | ||
|
|
90a96552d3 | ||
|
|
a710603b0a | ||
|
|
80a6fdca69 | ||
|
|
1dc78ca916 | ||
|
|
3118af1e41 | ||
|
|
a669d96aed | ||
|
|
a0b1fa0481 | ||
|
|
2e8c048a3e | ||
|
|
d2ddc7f0de | ||
|
|
3b88b20c61 | ||
|
|
f8371aaa57 | ||
|
|
c5a45131a5 | ||
|
|
1681fade3b | ||
|
|
4f324ad682 | ||
|
|
c1a9871703 | ||
|
|
83390f5ed2 | ||
|
|
5bf6d8063e | ||
|
|
a1913558b9 | ||
|
|
93e96829f4 | ||
|
|
11673bd8c4 | ||
|
|
4f4e2854a6 | ||
|
|
856ac94a76 | ||
|
|
28263de53d | ||
|
|
b6e855ce3e | ||
|
|
51148efe2e | ||
|
|
32e056dc24 | ||
|
|
c14ad6387d |
@@ -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.4)
|
||||||
[](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.1)
|
## Download (v1.3.4)
|
||||||
|
|
||||||
### 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.4/HelixNotes_1.3.4_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,32 @@ sudo eopkg it helixnotes
|
|||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
#### Manual package downloads
|
||||||
|
|
||||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.1/HelixNotes_1.3.1_amd64.AppImage)
|
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
||||||
|
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
||||||
#### .deb (manual)
|
|
||||||
|
|
||||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_amd64.deb) (Ubuntu 22.04+)
|
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_x64-setup.exe) (Windows 10/11)
|
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
|
||||||
|
|
||||||
|
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> xattr -cr /Applications/HelixNotes.app
|
||||||
|
> ```
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_android.apk)
|
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_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 +166,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Rust](https://rustup.rs/) (1.77+)
|
- [Rust](https://rustup.rs/) (1.88+)
|
||||||
- [Node.js](https://nodejs.org/) (18+)
|
- [Node.js](https://nodejs.org/) (18+)
|
||||||
- [pnpm](https://pnpm.io/)
|
- [pnpm](https://pnpm.io/)
|
||||||
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
|
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||||
@@ -159,6 +178,14 @@ pnpm install
|
|||||||
pnpm tauri dev
|
pnpm tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
Run the frontend checks and tests, Rust tests and lints, and production frontend build with one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm verify
|
||||||
|
```
|
||||||
|
|
||||||
### Production Build
|
### Production Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -175,4 +202,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,71 +6,77 @@
|
|||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs =
|
||||||
self,
|
{
|
||||||
nixpkgs,
|
self,
|
||||||
flake-utils,
|
nixpkgs,
|
||||||
}:
|
flake-utils,
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
}:
|
||||||
pkgs = import nixpkgs {inherit system;};
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
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 {
|
{
|
||||||
inherit pname version;
|
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||||
src = ./.;
|
|
||||||
|
|
||||||
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
|
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
|
||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
fetcherVersion = 3;
|
|
||||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
|
||||||
};
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
|
||||||
cargo-tauri.hook
|
|
||||||
|
|
||||||
pnpmConfigHook
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
pnpm
|
inherit pname version;
|
||||||
nodejs
|
src = ./.;
|
||||||
|
fetcherVersion = 4;
|
||||||
|
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
|
||||||
|
};
|
||||||
|
|
||||||
pkg-config
|
nativeBuildInputs = with pkgs; [
|
||||||
jq
|
cargo-tauri.hook
|
||||||
moreutils
|
|
||||||
wrapGAppsHook3
|
|
||||||
];
|
|
||||||
|
|
||||||
buildInputs = with pkgs;
|
pnpmConfigHook
|
||||||
lib.optionals stdenv.hostPlatform.isLinux [
|
pnpm
|
||||||
webkitgtk_4_1
|
nodejs
|
||||||
libayatana-appindicator
|
|
||||||
|
pkg-config
|
||||||
|
jq
|
||||||
|
moreutils
|
||||||
|
wrapGAppsHook3
|
||||||
];
|
];
|
||||||
|
|
||||||
cargoRoot = "src-tauri";
|
buildInputs =
|
||||||
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
|
with pkgs;
|
||||||
|
lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
|
webkitgtk_4_1
|
||||||
|
libayatana-appindicator
|
||||||
|
];
|
||||||
|
|
||||||
# Deactivate the upstream update mechanism
|
cargoRoot = "src-tauri";
|
||||||
postPatch = ''
|
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
|
||||||
jq '
|
|
||||||
.bundle.createUpdaterArtifacts = false |
|
|
||||||
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
|
|
||||||
' \
|
|
||||||
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
# Deactivate the upstream update mechanism
|
||||||
description = "HelixNotes desktop application";
|
postPatch = ''
|
||||||
homepage = "https://helixnotes.com/";
|
jq '
|
||||||
license = licenses.agpl3Plus;
|
.bundle.createUpdaterArtifacts = false |
|
||||||
platforms = platforms.linux ++ platforms.darwin;
|
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
|
||||||
mainProgram = "helixnotes";
|
' \
|
||||||
|
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "HelixNotes desktop application";
|
||||||
|
homepage = "https://helixnotes.com/";
|
||||||
|
license = licenses.agpl3Plus;
|
||||||
|
platforms = platforms.linux ++ platforms.darwin;
|
||||||
|
mainProgram = "helixnotes";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
formatter = pkgs.alejandra;
|
formatter = pkgs.nixfmt;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.3.1",
|
"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",
|
||||||
@@ -59,13 +63,14 @@
|
|||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"@types/katex": "^0.16.8",
|
"@types/katex": "^0.16.8",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"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.1"
|
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.1"
|
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>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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,14 +160,23 @@ 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,
|
||||||
|
commands::set_content_width,
|
||||||
commands::get_notebooks,
|
commands::get_notebooks,
|
||||||
commands::count_root_notes,
|
commands::count_root_notes,
|
||||||
commands::create_notebook,
|
commands::create_notebook,
|
||||||
@@ -116,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,
|
||||||
@@ -136,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,
|
||||||
@@ -152,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,
|
||||||
@@ -179,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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,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
|
||||||
@@ -288,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-") {
|
||||||
@@ -298,7 +353,6 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -368,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
app_lib::run();
|
app_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(vault_path.as_bytes());
|
hasher.update(vault_path.as_bytes());
|
||||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
let key: String = hasher.finalize()[..8]
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect();
|
||||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +238,9 @@ impl SearchIndex {
|
|||||||
// before the writer is created, so both indexing and querying use it).
|
// before the writer is created, so both indexing and querying use it).
|
||||||
index.tokenizers().register(
|
index.tokenizers().register(
|
||||||
"cjk",
|
"cjk",
|
||||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
TextAnalyzer::builder(CjkTokenizer)
|
||||||
|
.filter(LowerCaser)
|
||||||
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(mobile)]
|
#[cfg(mobile)]
|
||||||
@@ -290,7 +295,7 @@ impl SearchIndex {
|
|||||||
doc.add_text(self.path_field, &path_str);
|
doc.add_text(self.path_field, &path_str);
|
||||||
doc.add_text(self.title_field, &meta.title);
|
doc.add_text(self.title_field, &meta.title);
|
||||||
doc.add_text(self.body_field, &content);
|
doc.add_text(self.body_field, &content);
|
||||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||||
let _ = writer.add_document(doc);
|
let _ = writer.add_document(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +326,7 @@ impl SearchIndex {
|
|||||||
doc.add_text(self.path_field, path);
|
doc.add_text(self.path_field, path);
|
||||||
doc.add_text(self.title_field, &meta.title);
|
doc.add_text(self.title_field, &meta.title);
|
||||||
doc.add_text(self.body_field, &content);
|
doc.add_text(self.body_field, &content);
|
||||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||||
let _ = writer.add_document(doc);
|
let _ = writer.add_document(doc);
|
||||||
|
|
||||||
writer.commit().map_err(|e| e.to_string())?;
|
writer.commit().map_err(|e| e.to_string())?;
|
||||||
@@ -341,7 +346,7 @@ impl SearchIndex {
|
|||||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||||
let searcher = reader.searcher();
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
let fields = [self.title_field, self.body_field, self.tags_field];
|
||||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||||
@@ -376,9 +381,10 @@ impl SearchIndex {
|
|||||||
));
|
));
|
||||||
vec![(Occur::Should, exact)]
|
vec![(Occur::Should, exact)]
|
||||||
} else {
|
} else {
|
||||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
let prefix: Box<dyn Query> =
|
||||||
vec![Term::from_field_text(field, term)],
|
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
|
||||||
));
|
field, term,
|
||||||
|
)]));
|
||||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||||
Term::from_field_text(field, term),
|
Term::from_field_text(field, term),
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -339,14 +351,29 @@ impl WebdavClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn put_with_dirs(
|
/// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
|
||||||
|
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
|
||||||
|
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
|
||||||
|
/// to keep it consistent with future listings. (issue #147)
|
||||||
|
fn etag_for(&self, relpath: &str) -> String {
|
||||||
|
self.propfind(relpath, "0")
|
||||||
|
.ok()
|
||||||
|
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT a file (creating parent collections first), then read back its authoritative
|
||||||
|
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
|
||||||
|
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
|
||||||
|
fn upload(
|
||||||
&self,
|
&self,
|
||||||
relpath: &str,
|
relpath: &str,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
created: &mut HashSet<String>,
|
created: &mut HashSet<String>,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<String, String> {
|
||||||
self.ensure_dirs(relpath, created)?;
|
self.ensure_dirs(relpath, created)?;
|
||||||
self.put(relpath, bytes)
|
self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
|
||||||
|
Ok(self.etag_for(relpath))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,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(),
|
||||||
@@ -511,12 +546,12 @@ fn apply_changes(
|
|||||||
);
|
);
|
||||||
} else if local_changed && !remote_changed {
|
} else if local_changed && !remote_changed {
|
||||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
let etag = client.upload(key, &bytes, &mut created)?;
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
local_hash: lf.hash.clone(),
|
local_hash: lf.hash.clone(),
|
||||||
remote_etag: etag.unwrap_or_else(|| re.clone()),
|
remote_etag: etag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
sum.uploaded += 1;
|
sum.uploaded += 1;
|
||||||
@@ -556,12 +591,12 @@ fn apply_changes(
|
|||||||
);
|
);
|
||||||
let ckey = conflict_relpath(key, &stamp);
|
let ckey = conflict_relpath(key, &stamp);
|
||||||
write_local(vault_path, &ckey, &lbytes)?;
|
write_local(vault_path, &ckey, &lbytes)?;
|
||||||
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
|
let cetag = client.upload(&ckey, &lbytes, &mut created)?;
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
ckey,
|
ckey,
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
local_hash: sha256_hex(&lbytes),
|
local_hash: sha256_hex(&lbytes),
|
||||||
remote_etag: cetag.unwrap_or_default(),
|
remote_etag: cetag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
sum.conflicts += 1;
|
sum.conflicts += 1;
|
||||||
@@ -572,12 +607,12 @@ fn apply_changes(
|
|||||||
None => {
|
None => {
|
||||||
// new local -> upload
|
// new local -> upload
|
||||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
let etag = client.upload(key, &bytes, &mut created)?;
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
local_hash: lf.hash.clone(),
|
local_hash: lf.hash.clone(),
|
||||||
remote_etag: etag.unwrap_or_default(),
|
remote_etag: etag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
sum.uploaded += 1;
|
sum.uploaded += 1;
|
||||||
@@ -586,12 +621,12 @@ fn apply_changes(
|
|||||||
if me.local_hash != lf.hash {
|
if me.local_hash != lf.hash {
|
||||||
// remote deleted but local edited -> resurrect (upload)
|
// remote deleted but local edited -> resurrect (upload)
|
||||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
let etag = client.upload(key, &bytes, &mut created)?;
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
local_hash: lf.hash.clone(),
|
local_hash: lf.hash.clone(),
|
||||||
remote_etag: etag.unwrap_or_default(),
|
remote_etag: etag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
sum.uploaded += 1;
|
sum.uploaded += 1;
|
||||||
@@ -653,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>();
|
||||||
@@ -708,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)]
|
||||||
@@ -72,10 +133,14 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ui_scale: Option<f64>,
|
pub ui_scale: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub content_width: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
pub compact_notes: bool,
|
pub compact_notes: bool,
|
||||||
#[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,
|
||||||
@@ -95,6 +160,18 @@ pub struct AppConfig {
|
|||||||
pub hide_title_in_body: bool,
|
pub hide_title_in_body: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub show_line_numbers: bool,
|
pub show_line_numbers: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_link_arrows: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_all_notes: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_quick_access: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_tasks: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_daily_notes: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_trash: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub backup_enabled: bool,
|
pub backup_enabled: bool,
|
||||||
#[serde(default = "default_backup_frequency")]
|
#[serde(default = "default_backup_frequency")]
|
||||||
@@ -130,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)]
|
||||||
@@ -154,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
|
||||||
}
|
}
|
||||||
@@ -192,19 +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,
|
||||||
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(),
|
||||||
@@ -215,6 +316,12 @@ impl Default for AppConfig {
|
|||||||
title_mode: "input".to_string(),
|
title_mode: "input".to_string(),
|
||||||
hide_title_in_body: false,
|
hide_title_in_body: false,
|
||||||
show_line_numbers: false,
|
show_line_numbers: false,
|
||||||
|
show_link_arrows: true,
|
||||||
|
show_all_notes: true,
|
||||||
|
show_quick_access: true,
|
||||||
|
show_tasks: true,
|
||||||
|
show_daily_notes: true,
|
||||||
|
show_trash: true,
|
||||||
backup_enabled: false,
|
backup_enabled: false,
|
||||||
backup_frequency: "24h".to_string(),
|
backup_frequency: "24h".to_string(),
|
||||||
backup_max_count: 10,
|
backup_max_count: 10,
|
||||||
@@ -232,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,
|
||||||
@@ -244,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,14 +363,20 @@ 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,
|
||||||
|
#[serde(default)]
|
||||||
pub collapsed_notebooks: Vec<String>,
|
pub collapsed_notebooks: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notebook_sort_mode: String,
|
pub notebook_sort_mode: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notebook_order: std::collections::HashMap<String, i32>,
|
pub notebook_order: std::collections::HashMap<String, i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub note_order: std::collections::HashMap<String, i32>,
|
||||||
|
#[serde(default)]
|
||||||
pub sort_mode: String,
|
pub sort_mode: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_view_mode: String,
|
pub last_view_mode: String,
|
||||||
@@ -284,10 +400,13 @@ 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,
|
||||||
collapsed_notebooks: Vec::new(),
|
collapsed_notebooks: Vec::new(),
|
||||||
notebook_sort_mode: String::new(),
|
notebook_sort_mode: String::new(),
|
||||||
notebook_order: std::collections::HashMap::new(),
|
notebook_order: std::collections::HashMap::new(),
|
||||||
|
note_order: std::collections::HashMap::new(),
|
||||||
sort_mode: String::new(),
|
sort_mode: String::new(),
|
||||||
last_view_mode: String::new(),
|
last_view_mode: String::new(),
|
||||||
last_notebook: None,
|
last_notebook: None,
|
||||||
@@ -318,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)]
|
||||||
@@ -386,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.1",
|
"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,10 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"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);
|
||||||
@@ -961,6 +990,31 @@
|
|||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.8);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="one-dark"] {
|
||||||
|
--bg-primary: #282c34;
|
||||||
|
--bg-secondary: #21252b;
|
||||||
|
--bg-tertiary: #2c313a;
|
||||||
|
--bg-hover: #3e4451;
|
||||||
|
--bg-active: #4b5263;
|
||||||
|
--bg-editor: #282c34;
|
||||||
|
--text-primary: #abb2bf;
|
||||||
|
--text-secondary: #828997;
|
||||||
|
--text-tertiary: #5c6370;
|
||||||
|
--text-accent: #61afef;
|
||||||
|
--border-color: #3e4451;
|
||||||
|
--border-light: #2c313a;
|
||||||
|
--accent: #61afef;
|
||||||
|
--accent-hover: #4d9fdf;
|
||||||
|
--accent-light: color-mix(in srgb, #61afef 12%, transparent);
|
||||||
|
--danger: #e06c75;
|
||||||
|
--danger-hover: #cc5560;
|
||||||
|
--success: #98c379;
|
||||||
|
--warning: #e5c07b;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -1088,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 });
|
||||||
}
|
}
|
||||||
@@ -52,6 +87,10 @@ export async function setUiScale(scale: number): Promise<void> {
|
|||||||
return invoke("set_ui_scale", { scale });
|
return invoke("set_ui_scale", { scale });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setContentWidth(width: number | null): Promise<void> {
|
||||||
|
return invoke("set_content_width", { width });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getNotebooks(): Promise<NotebookEntry[]> {
|
export async function getNotebooks(): Promise<NotebookEntry[]> {
|
||||||
return invoke("get_notebooks");
|
return invoke("get_notebooks");
|
||||||
}
|
}
|
||||||
@@ -110,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 });
|
||||||
}
|
}
|
||||||
@@ -140,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");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,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");
|
||||||
}
|
}
|
||||||
@@ -228,12 +281,21 @@ export async function setGeneralSettings(
|
|||||||
titleMode: string,
|
titleMode: string,
|
||||||
hideTitleInBody: boolean,
|
hideTitleInBody: boolean,
|
||||||
showLineNumbers: boolean,
|
showLineNumbers: 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,
|
||||||
|
showQuickAccess: boolean,
|
||||||
|
showTasks: boolean,
|
||||||
|
showDailyNotes: boolean,
|
||||||
|
showTrash: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return invoke("set_general_settings", {
|
return invoke("set_general_settings", {
|
||||||
compactNotes,
|
compactNotes,
|
||||||
@@ -247,12 +309,21 @@ export async function setGeneralSettings(
|
|||||||
titleMode,
|
titleMode,
|
||||||
hideTitleInBody,
|
hideTitleInBody,
|
||||||
showLineNumbers,
|
showLineNumbers,
|
||||||
|
showLinkArrows,
|
||||||
defaultViewMode,
|
defaultViewMode,
|
||||||
|
newNotesInSourceMode,
|
||||||
showTrayIcon,
|
showTrayIcon,
|
||||||
closeToTray,
|
closeToTray,
|
||||||
enableWikiLinks,
|
enableWikiLinks,
|
||||||
showNoteDates,
|
showNoteDates,
|
||||||
|
showNoteSwitcher,
|
||||||
|
startupView,
|
||||||
restoreLastSession,
|
restoreLastSession,
|
||||||
|
showAllNotes,
|
||||||
|
showQuickAccess,
|
||||||
|
showTasks,
|
||||||
|
showDailyNotes,
|
||||||
|
showTrash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import Sidebar from './Sidebar.svelte';
|
import Sidebar from './Sidebar.svelte';
|
||||||
import NoteList from './NoteList.svelte';
|
import NoteList from './NoteList.svelte';
|
||||||
@@ -14,11 +14,15 @@
|
|||||||
import {
|
import {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
notelistWidth,
|
notelistWidth,
|
||||||
|
outlineWidth,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
|
notelistCollapsed,
|
||||||
collapsedNotebooks,
|
collapsedNotebooks,
|
||||||
showSearch,
|
showSearch,
|
||||||
showCommandPalette,
|
showCommandPalette,
|
||||||
theme,
|
theme,
|
||||||
|
resolvedTheme,
|
||||||
|
customThemes,
|
||||||
focusMode,
|
focusMode,
|
||||||
readOnly,
|
readOnly,
|
||||||
activeNote,
|
activeNote,
|
||||||
@@ -37,6 +41,7 @@
|
|||||||
rootNoteCount,
|
rootNoteCount,
|
||||||
viewMode,
|
viewMode,
|
||||||
sortMode,
|
sortMode,
|
||||||
|
groupNotesByDate,
|
||||||
tasksLayout,
|
tasksLayout,
|
||||||
tasksHideCompleted,
|
tasksHideCompleted,
|
||||||
tasksOnlyFlagged,
|
tasksOnlyFlagged,
|
||||||
@@ -48,19 +53,24 @@
|
|||||||
viewerNote,
|
viewerNote,
|
||||||
notebookSortMode,
|
notebookSortMode,
|
||||||
notebookOrder,
|
notebookOrder,
|
||||||
|
noteOrder,
|
||||||
syncState,
|
syncState,
|
||||||
platformIsMobile
|
platformIsMobile,
|
||||||
|
activeVaultConfig,
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
|
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 { 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) {
|
||||||
@@ -71,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
|
||||||
@@ -116,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 (_) {}
|
||||||
}
|
}
|
||||||
@@ -198,11 +240,15 @@
|
|||||||
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,
|
||||||
collapsed_notebooks: $collapsedNotebooks,
|
collapsed_notebooks: $collapsedNotebooks,
|
||||||
notebook_sort_mode: $notebookSortMode,
|
notebook_sort_mode: $notebookSortMode,
|
||||||
notebook_order: $notebookOrder,
|
notebook_order: $notebookOrder,
|
||||||
|
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,
|
||||||
@@ -226,20 +272,58 @@
|
|||||||
persistState();
|
persistState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revealNoteList() {
|
||||||
|
$notelistCollapsed = false;
|
||||||
|
tick().then(() => noteList?.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNoteList() {
|
||||||
|
const nextCollapsed = !$notelistCollapsed;
|
||||||
|
$notelistCollapsed = nextCollapsed;
|
||||||
|
if (!nextCollapsed) {
|
||||||
|
tick().then(() => noteList?.refresh());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
// 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;
|
||||||
noteList?.refresh();
|
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||||
|
// notes - so if the notes list was hidden, bring it back rather than navigating blindly
|
||||||
|
// into a panel the user can't see. revealNoteList() re-mounts the list and refreshes it.
|
||||||
|
if ($notelistCollapsed) {
|
||||||
|
revealNoteList();
|
||||||
|
} else {
|
||||||
|
noteList?.refresh();
|
||||||
|
}
|
||||||
if (isMobile) $mobileView = 'notelist';
|
if (isMobile) $mobileView = 'notelist';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +444,16 @@
|
|||||||
navFromPopstate = false;
|
navFromPopstate = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// If a modal is open, close it instead of navigating
|
||||||
|
if ($showSettings || $showInfo || $showSearch || $showCommandPalette) {
|
||||||
|
$showSettings = false;
|
||||||
|
$showInfo = false;
|
||||||
|
$showSearch = false;
|
||||||
|
$showCommandPalette = false;
|
||||||
|
// Re-push the current state so the next back still works
|
||||||
|
history.pushState({ mobileView: $mobileView, depth: historyDepth }, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const state = e.state;
|
const state = e.state;
|
||||||
const targetDepth = state?.depth ?? 0;
|
const targetDepth = state?.depth ?? 0;
|
||||||
historyDepth = targetDepth;
|
historyDepth = targetDepth;
|
||||||
@@ -375,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); }
|
||||||
@@ -383,88 +499,84 @@
|
|||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
const code = e.code;
|
const code = e.code;
|
||||||
if (e.altKey && e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault();
|
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
|
||||||
navigateHistory(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.altKey && e.key === 'ArrowRight') {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateHistory(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && !e.shiftKey && code === 'KeyN') {
|
|
||||||
e.preventDefault();
|
|
||||||
createAndFocusNote();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && code === 'KeyN') {
|
|
||||||
e.preventDefault();
|
|
||||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
||||||
const next = isDark ? 'light' : 'dark';
|
|
||||||
$theme = next;
|
|
||||||
setTheme(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && code === 'KeyF') {
|
|
||||||
e.preventDefault();
|
|
||||||
$showSearch = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && !e.shiftKey && code === 'KeyF') {
|
|
||||||
e.preventDefault();
|
|
||||||
if ($activeNotePath) {
|
|
||||||
editor?.openNoteSearch();
|
|
||||||
} else {
|
|
||||||
$showSearch = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && !e.shiftKey && code === 'KeyP') {
|
|
||||||
e.preventDefault();
|
|
||||||
$showCommandPalette = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && !e.shiftKey && code === 'KeyS') {
|
|
||||||
e.preventDefault();
|
|
||||||
editor?.forceSave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor?.addLinkFromToolbar();
|
editor?.addLinkFromToolbar();
|
||||||
}
|
}
|
||||||
if (mod && e.shiftKey && code === 'KeyM') {
|
|
||||||
|
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||||
|
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$sourceMode = !$sourceMode;
|
editor?.moveOpenNoteToTrash();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (mod && e.shiftKey && code === 'KeyW') {
|
|
||||||
|
const action = matchAction(e, $keybindings);
|
||||||
|
if (action) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if ($activeNotePath && $activeNote) {
|
switch (action) {
|
||||||
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
case 'nav-back':
|
||||||
|
navigateHistory(-1);
|
||||||
|
return;
|
||||||
|
case 'nav-forward':
|
||||||
|
navigateHistory(1);
|
||||||
|
return;
|
||||||
|
case 'new-note':
|
||||||
|
createAndFocusNote();
|
||||||
|
return;
|
||||||
|
case 'toggle-theme': {
|
||||||
|
const customTheme = $customThemes.find(theme => theme.id === $resolvedTheme);
|
||||||
|
const isDark = darkThemes.includes($resolvedTheme) || (customTheme?.is_dark ?? false);
|
||||||
|
const next = isDark ? 'light' : 'dark';
|
||||||
|
$theme = next;
|
||||||
|
setTheme(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'search-vault':
|
||||||
|
$showSearch = true;
|
||||||
|
return;
|
||||||
|
case 'find-in-note':
|
||||||
|
if ($activeNotePath) {
|
||||||
|
editor?.openNoteSearch();
|
||||||
|
} else {
|
||||||
|
$showSearch = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'quick-open':
|
||||||
|
$showCommandPalette = true;
|
||||||
|
return;
|
||||||
|
case 'save':
|
||||||
|
editor?.forceSave();
|
||||||
|
return;
|
||||||
|
case 'toggle-source':
|
||||||
|
$sourceMode = !$sourceMode;
|
||||||
|
return;
|
||||||
|
case 'open-new-window':
|
||||||
|
if ($activeNotePath && $activeNote) {
|
||||||
|
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'toggle-sidebar':
|
||||||
|
$sidebarCollapsed = !$sidebarCollapsed;
|
||||||
|
return;
|
||||||
|
case 'toggle-notelist':
|
||||||
|
toggleNoteList();
|
||||||
|
return;
|
||||||
|
case 'toggle-focus':
|
||||||
|
$focusMode = !$focusMode;
|
||||||
|
return;
|
||||||
|
case 'toggle-readonly':
|
||||||
|
if ($viewerNote) return; // viewer mode is always read-only
|
||||||
|
$readOnly = !$readOnly;
|
||||||
|
return;
|
||||||
|
case 'fullscreen':
|
||||||
|
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mod && e.key === '\\') {
|
|
||||||
e.preventDefault();
|
|
||||||
$sidebarCollapsed = !$sidebarCollapsed;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && code === 'KeyE') {
|
|
||||||
e.preventDefault();
|
|
||||||
$focusMode = !$focusMode;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && code === 'KeyR') {
|
|
||||||
e.preventDefault();
|
|
||||||
if ($viewerNote) return; // viewer mode is always read-only
|
|
||||||
$readOnly = !$readOnly;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'F11') {
|
|
||||||
e.preventDefault();
|
|
||||||
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if ($showSettings) $showSettings = false;
|
if ($showSettings) $showSettings = false;
|
||||||
else if ($showInfo) $showInfo = false;
|
else if ($showInfo) $showInfo = false;
|
||||||
@@ -474,24 +586,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(t: string) {
|
|
||||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
|
|
||||||
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'];
|
|
||||||
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();
|
||||||
@@ -502,14 +596,27 @@
|
|||||||
persistState();
|
persistState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
$sidebarCollapsed;
|
||||||
|
$notelistCollapsed;
|
||||||
|
$outlineWidth;
|
||||||
|
persistState();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$notebookSortMode;
|
$notebookSortMode;
|
||||||
$notebookOrder;
|
$notebookOrder;
|
||||||
persistState();
|
persistState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
$noteOrder;
|
||||||
|
persistState();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$sortMode;
|
$sortMode;
|
||||||
|
$groupNotesByDate;
|
||||||
$tasksLayout;
|
$tasksLayout;
|
||||||
$tasksHideCompleted;
|
$tasksHideCompleted;
|
||||||
$tasksOnlyFlagged;
|
$tasksOnlyFlagged;
|
||||||
@@ -533,11 +640,15 @@
|
|||||||
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;
|
||||||
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
||||||
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
|
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
|
||||||
$notebookOrder = state.notebook_order ?? {};
|
$notebookOrder = state.notebook_order ?? {};
|
||||||
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
|
$noteOrder = state.note_order ?? {};
|
||||||
|
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
|
||||||
|
if (typeof state.group_notes_by_date === 'boolean') $groupNotesByDate = state.group_notes_by_date;
|
||||||
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
|
if (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;
|
||||||
@@ -548,47 +659,60 @@
|
|||||||
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 };
|
|
||||||
$activeTag = null;
|
|
||||||
await noteList?.refresh();
|
|
||||||
} else if (lastViewMode === 'notebook' && lastNotebook) {
|
|
||||||
const nb = findNotebookByPath($notebooks, lastNotebook);
|
|
||||||
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
|
|
||||||
} else if (lastViewMode === 'tag' && lastTag) {
|
|
||||||
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
|
|
||||||
} else if (lastViewMode === 'quickaccess') {
|
|
||||||
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
|
|
||||||
}
|
|
||||||
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
|
|
||||||
if (!isMobile && lastNotePath) {
|
|
||||||
try {
|
try {
|
||||||
const content = await readNote(lastNotePath);
|
const orphans = await findOrphanedAttachments();
|
||||||
$activeNote = content;
|
if (orphans.length > 0) {
|
||||||
$activeNotePath = lastNotePath;
|
if (get(editorDirty)) return; // re-check after async scan
|
||||||
$editorDirty = false;
|
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||||
editor?.loadNote(lastNotePath, content.content);
|
if (moved > 0) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-secondary);color:var(--text-primary);padding:10px 18px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);border:1px solid var(--border-color);font-size:13px;z-index:10000;opacity:0;transition:opacity .3s;pointer-events:none';
|
||||||
|
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
requestAnimationFrame(() => (toast.style.opacity = '1'));
|
||||||
|
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startupTarget = resolveStartupTarget({
|
||||||
|
startupView: $appConfig?.startup_view,
|
||||||
|
restoreLastSession,
|
||||||
|
lastViewMode,
|
||||||
|
lastNotebook,
|
||||||
|
lastTag
|
||||||
|
});
|
||||||
|
if (!(await applyStartupTarget(startupTarget))) {
|
||||||
|
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen the last note only when session restoration is enabled.
|
||||||
|
if (restoreLastSession && !isMobile && lastNotePath) {
|
||||||
|
try {
|
||||||
|
const content = await readNote(lastNotePath);
|
||||||
|
$activeNote = content;
|
||||||
|
$activeNotePath = lastNotePath;
|
||||||
|
$editorDirty = false;
|
||||||
|
editor?.loadNote(lastNotePath, content.content);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
||||||
@@ -628,7 +752,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();
|
||||||
});
|
});
|
||||||
@@ -658,15 +782,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 (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,8 +801,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);
|
||||||
}
|
}
|
||||||
@@ -704,7 +829,7 @@
|
|||||||
<!-- Mobile Header -->
|
<!-- Mobile Header -->
|
||||||
<div class="mobile-header">
|
<div class="mobile-header">
|
||||||
{#if $mobileView !== 'sidebar'}
|
{#if $mobileView !== 'sidebar'}
|
||||||
<button class="mobile-header-btn" onclick={mobileBack}>
|
<button class="mobile-header-btn" onclick={mobileBack} aria-label="Go back">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M15 18l-6-6 6-6"/>
|
<path d="M15 18l-6-6 6-6"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -793,7 +918,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"/>
|
||||||
@@ -820,6 +945,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" />
|
||||||
@@ -836,10 +968,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>
|
||||||
|
|
||||||
@@ -883,7 +1015,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}
|
||||||
@@ -895,15 +1027,28 @@
|
|||||||
<ResizeHandle onResize={handleSidebarResize} />
|
<ResizeHandle onResize={handleSidebarResize} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
{#if !$notelistCollapsed}
|
||||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||||
</div>
|
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ResizeHandle onResize={handleNotelistResize} />
|
<ResizeHandle onResize={handleNotelistResize} />
|
||||||
|
{:else}
|
||||||
|
<div class="notelist-restore-panel">
|
||||||
|
<button class="notelist-restore-btn" onclick={revealNoteList} title={`Show notes list (${isMac ? '⌘' : 'Ctrl'}+Shift+\\)`} aria-label="Show notes list">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="8" y1="6" x2="20" y2="6" />
|
||||||
|
<line x1="8" y1="12" x2="20" y2="12" />
|
||||||
|
<line x1="8" y1="18" x2="20" y2="18" />
|
||||||
|
<polyline points="5 8 3 12 5 16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/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">
|
||||||
@@ -918,7 +1063,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<SearchPanel />
|
<SearchPanel />
|
||||||
<CommandPalette />
|
<CommandPalette onNavigate={handleViewChanged} />
|
||||||
<SettingsPanel />
|
<SettingsPanel />
|
||||||
<InfoPanel />
|
<InfoPanel />
|
||||||
|
|
||||||
@@ -949,6 +1094,36 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notelist-restore-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 100%;
|
||||||
|
padding-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notelist-restore-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notelist-restore-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-panel {
|
.editor-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1035,7 +1210,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showCommandPalette, showSearch, theme, sourceMode } from '$lib/stores/app';
|
import { showCommandPalette, showSearch, theme, sourceMode, viewMode, activeNotebook, activeTag } from '$lib/stores/app';
|
||||||
import { setTheme, reindex } from '$lib/api';
|
import { setTheme, reindex } from '$lib/api';
|
||||||
|
import { darkThemes } from '$lib/platform';
|
||||||
|
|
||||||
|
// onNavigate runs the parent's view-change handler (refreshes the note list and reveals it
|
||||||
|
// if it was hidden), so opening a view here behaves exactly like clicking it in the sidebar.
|
||||||
|
let { onNavigate = () => {} }: { onNavigate?: () => void } = $props();
|
||||||
|
|
||||||
|
function openView(mode: 'all' | 'quickaccess' | 'tasks' | 'daily' | 'trash') {
|
||||||
|
$viewMode = mode;
|
||||||
|
$activeNotebook = null;
|
||||||
|
$activeTag = null;
|
||||||
|
$showCommandPalette = false;
|
||||||
|
onNavigate();
|
||||||
|
}
|
||||||
|
|
||||||
interface Command {
|
interface Command {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +38,31 @@
|
|||||||
$showSearch = true;
|
$showSearch = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'open-all-notes',
|
||||||
|
label: 'Open All Notes',
|
||||||
|
action: () => openView('all')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-quick-access',
|
||||||
|
label: 'Open Quick Access',
|
||||||
|
action: () => openView('quickaccess')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-tasks',
|
||||||
|
label: 'Open Tasks',
|
||||||
|
action: () => openView('tasks')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-daily',
|
||||||
|
label: 'Open Daily Notes',
|
||||||
|
action: () => openView('daily')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-trash',
|
||||||
|
label: 'Open Trash (restore deleted notes)',
|
||||||
|
action: () => openView('trash')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'theme-light',
|
id: 'theme-light',
|
||||||
label: 'Switch to Light Theme',
|
label: 'Switch to Light Theme',
|
||||||
@@ -51,7 +89,6 @@
|
|||||||
action: () => {
|
action: () => {
|
||||||
$theme = 'system';
|
$theme = 'system';
|
||||||
setTheme('system');
|
setTheme('system');
|
||||||
applyTheme('system');
|
|
||||||
$showCommandPalette = false;
|
$showCommandPalette = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -142,15 +179,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(t: string) {
|
function applyTheme(t: string) {
|
||||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
|
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 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'];
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,75 +1,114 @@
|
|||||||
<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 {
|
||||||
|
ACTIONS,
|
||||||
|
keybindings,
|
||||||
|
setBinding,
|
||||||
|
resetBinding,
|
||||||
|
bindingFromEvent,
|
||||||
|
bindingToKeys,
|
||||||
|
bindingsEqual,
|
||||||
|
type ActionDef,
|
||||||
|
} from '$lib/keybindings';
|
||||||
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
let stats = $state<VaultStats | null>(null);
|
// Customizable shortcuts, grouped for display.
|
||||||
|
const actionGroups: { title: string; actions: ActionDef[] }[] = [
|
||||||
|
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
|
||||||
|
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
|
||||||
|
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// id of the action currently listening for a new key combo, or null.
|
||||||
|
let capturingId = $state<string | null>(null);
|
||||||
|
// id of an action whose row should briefly flash a "already in use" warning.
|
||||||
|
let conflictId = $state<string | null>(null);
|
||||||
|
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function startCapture(id: string) {
|
||||||
|
conflictId = null;
|
||||||
|
capturingId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCapture() {
|
||||||
|
capturingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashConflict(id: string) {
|
||||||
|
conflictId = id;
|
||||||
|
if (conflictTimer) clearTimeout(conflictTimer);
|
||||||
|
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureKeydown(e: KeyboardEvent) {
|
||||||
|
if (!capturingId) return;
|
||||||
|
// Capture phase + stopImmediatePropagation keeps the combo from also
|
||||||
|
// triggering the app-level shortcut handler on window.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
if (e.key === 'Escape') { stopCapture(); return; }
|
||||||
|
|
||||||
|
const binding = bindingFromEvent(e);
|
||||||
|
if (!binding) return; // modifier-only press; keep listening
|
||||||
|
|
||||||
|
// Reject combos already bound to a different action.
|
||||||
|
const map = $keybindings;
|
||||||
|
const target = capturingId;
|
||||||
|
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
|
||||||
|
if (clash) {
|
||||||
|
flashConflict(target);
|
||||||
|
stopCapture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBinding(target, binding);
|
||||||
|
stopCapture();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!capturingId) return;
|
||||||
|
window.addEventListener('keydown', captureKeydown, true);
|
||||||
|
return () => window.removeEventListener('keydown', captureKeydown, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetKey(e: MouseEvent, id: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
resetBinding(id);
|
||||||
|
if (capturingId === id) stopCapture();
|
||||||
|
}
|
||||||
|
|
||||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
let 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 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} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="info-panel">
|
||||||
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="info-header">
|
<div class="info-header">
|
||||||
<h2>Info</h2>
|
<h2>Info</h2>
|
||||||
<button class="close-btn" onclick={close}>
|
<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" />
|
||||||
@@ -101,49 +140,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>
|
||||||
@@ -158,27 +155,39 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="shortcuts-section">
|
<div class="shortcuts-section">
|
||||||
<h4 class="shortcuts-group-title">General</h4>
|
{#if !isMobile}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
|
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
|
{/if}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
|
{#each actionGroups as group}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
|
<h4 class="shortcuts-group-title">{group.title}</h4>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
|
{#each group.actions as action}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
|
<div class="shortcut-row">
|
||||||
|
<span class="shortcut-desc">{action.label}</span>
|
||||||
<h4 class="shortcuts-group-title">Interface</h4>
|
{#if isMobile}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
|
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
|
{:else}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
|
<button
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
|
class="shortcut-key-btn"
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
|
class:capturing={capturingId === action.id}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
|
class:conflict={conflictId === action.id}
|
||||||
|
title="Click to rebind · right-click to reset"
|
||||||
|
onclick={() => startCapture(action.id)}
|
||||||
|
oncontextmenu={(e) => resetKey(e, action.id)}
|
||||||
|
>
|
||||||
|
{#if capturingId === action.id}
|
||||||
|
<span class="capture-prompt">Press keys…</span>
|
||||||
|
{:else if conflictId === action.id}
|
||||||
|
<span class="conflict-msg">Already in use</span>
|
||||||
|
{:else}
|
||||||
|
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
|
||||||
|
|
||||||
<h4 class="shortcuts-group-title">Navigation</h4>
|
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>←</kbd></span></div>
|
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>→</kbd></span></div>
|
|
||||||
|
|
||||||
<h4 class="shortcuts-group-title">Formatting</h4>
|
<h4 class="shortcuts-group-title">Formatting</h4>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
|
||||||
@@ -186,9 +195,11 @@
|
|||||||
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">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>
|
||||||
|
<div class="shortcut-row"><span class="shortcut-desc">Move list item up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>↑↓</kbd></span></div>
|
||||||
|
|
||||||
<h4 class="shortcuts-group-title">Editor Commands</h4>
|
<h4 class="shortcuts-group-title">Editor Commands</h4>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
|
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
|
||||||
@@ -328,39 +339,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;
|
||||||
@@ -392,71 +370,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%;
|
||||||
@@ -501,6 +414,48 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shortcuts-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
margin: -2px -4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-btn.capturing {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-btn.conflict {
|
||||||
|
border-color: var(--danger, #e11d48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-prompt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-msg {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger, #e11d48);
|
||||||
|
}
|
||||||
|
|
||||||
.shortcut-keys kbd,
|
.shortcut-keys kbd,
|
||||||
.shortcut-desc kbd {
|
.shortcut-desc kbd {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import {
|
import {
|
||||||
notes,
|
notes,
|
||||||
sortedNotes,
|
sortedNotes,
|
||||||
@@ -8,9 +9,12 @@
|
|||||||
activeNotebook,
|
activeNotebook,
|
||||||
activeTag,
|
activeTag,
|
||||||
sortMode,
|
sortMode,
|
||||||
|
groupNotesByDate,
|
||||||
editorDirty,
|
editorDirty,
|
||||||
quickAccessPaths,
|
quickAccessPaths,
|
||||||
appConfig,
|
appConfig,
|
||||||
|
notelistCollapsed,
|
||||||
|
noteOrder,
|
||||||
notebooks,
|
notebooks,
|
||||||
tags,
|
tags,
|
||||||
mobileView
|
mobileView
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
getNotes,
|
getNotes,
|
||||||
readNote,
|
readNote,
|
||||||
createNote,
|
createNote,
|
||||||
|
duplicateNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
renameNote,
|
renameNote,
|
||||||
saveNote,
|
saveNote,
|
||||||
@@ -32,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>;
|
||||||
@@ -52,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);
|
||||||
@@ -143,6 +149,7 @@
|
|||||||
let sortMenu = $state<{ x: number; y: number } | null>(null);
|
let sortMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let editingNote = $state<string | null>(null);
|
let editingNote = $state<string | null>(null);
|
||||||
let editValue = $state('');
|
let editValue = $state('');
|
||||||
|
let renameInput: HTMLInputElement | null = $state(null);
|
||||||
let movePickerNote = $state<NoteEntry | null>(null);
|
let movePickerNote = $state<NoteEntry | null>(null);
|
||||||
let tagEditNote = $state<NoteEntry | null>(null);
|
let tagEditNote = $state<NoteEntry | null>(null);
|
||||||
let tagEditTags = $state<string[]>([]);
|
let tagEditTags = $state<string[]>([]);
|
||||||
@@ -203,6 +210,29 @@
|
|||||||
let qaDragFrom = $state<number | null>(null);
|
let qaDragFrom = $state<number | null>(null);
|
||||||
let qaDragOver = $state<number | null>(null);
|
let qaDragOver = $state<number | null>(null);
|
||||||
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
|
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
|
||||||
|
let noteDragFrom = $state<number | null>(null);
|
||||||
|
let noteDragOver = $state<number | null>(null);
|
||||||
|
let noteDragHalf = $state<'top' | 'bottom'>('bottom');
|
||||||
|
const customOrderViews = new Set(['all', 'notebook', 'tag']);
|
||||||
|
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!);
|
||||||
@@ -238,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;
|
||||||
@@ -382,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')}`;
|
||||||
@@ -412,15 +456,49 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const entry = await createNote(nbRelative, 'Untitled');
|
const entry = await createNote(nbRelative, 'Untitled');
|
||||||
|
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;
|
||||||
@@ -429,6 +507,7 @@
|
|||||||
try {
|
try {
|
||||||
const newTitle = editValue.trim();
|
const newTitle = editValue.trim();
|
||||||
const newPath = await renameNote(note.path, newTitle);
|
const newPath = await renameNote(note.path, newTitle);
|
||||||
|
renameManualNoteOrderPath(note.path, newPath);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
editingNote = null;
|
editingNote = null;
|
||||||
// Update local store immediately (avoid full vault re-scan)
|
// Update local store immediately (avoid full vault re-scan)
|
||||||
@@ -450,6 +529,7 @@
|
|||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
try {
|
try {
|
||||||
await deleteNote(note.path);
|
await deleteNote(note.path);
|
||||||
|
removeManualNoteOrderPath(note.path);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
// Remove from local store immediately (avoid full vault re-scan)
|
// Remove from local store immediately (avoid full vault re-scan)
|
||||||
$notes = $notes.filter(n => n.path !== note.path);
|
$notes = $notes.filter(n => n.path !== note.path);
|
||||||
@@ -462,6 +542,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function handleRestore(note: NoteEntry) {
|
async function handleRestore(note: NoteEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
try {
|
try {
|
||||||
@@ -477,6 +558,7 @@
|
|||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
try {
|
try {
|
||||||
await permanentDelete(note.path);
|
await permanentDelete(note.path);
|
||||||
|
removeManualNoteOrderPath(note.path);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
$notes = $notes.filter(n => n.path !== note.path);
|
$notes = $notes.filter(n => n.path !== note.path);
|
||||||
if ($activeNotePath === note.path) {
|
if ($activeNotePath === note.path) {
|
||||||
@@ -670,11 +752,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistManualNoteOrder(orderedNotes: NoteEntry[]) {
|
||||||
|
// Merge into (not replace) the order map so reordering notes in one view does
|
||||||
|
// not wipe the saved custom order of other notebooks / tags / the all-notes view.
|
||||||
|
const updates = Object.fromEntries(orderedNotes.map((note, index) => [note.path, index]));
|
||||||
|
$noteOrder = { ...$noteOrder, ...updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendManualNoteOrder(path: string) {
|
||||||
|
const nextIndex = Math.max(-1, ...Object.values($noteOrder)) + 1;
|
||||||
|
$noteOrder = { ...$noteOrder, [path]: nextIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameManualNoteOrderPath(oldPath: string, newPath: string) {
|
||||||
|
if (!Object.hasOwn($noteOrder, oldPath)) return;
|
||||||
|
const { [oldPath]: index, ...rest } = $noteOrder;
|
||||||
|
$noteOrder = { ...rest, [newPath]: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeManualNoteOrderPath(path: string) {
|
||||||
|
if (!Object.hasOwn($noteOrder, path)) return;
|
||||||
|
const { [path]: _, ...rest } = $noteOrder;
|
||||||
|
$noteOrder = rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNoteDrag() {
|
||||||
|
noteDragFrom = null;
|
||||||
|
noteDragOver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomNoteDrop(targetIndex: number) {
|
||||||
|
if (noteDragFrom === null || !canCustomReorder) {
|
||||||
|
resetNoteDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let insertAt = noteDragHalf === 'bottom' ? targetIndex + 1 : targetIndex;
|
||||||
|
if (noteDragFrom === insertAt || noteDragFrom + 1 === insertAt) {
|
||||||
|
resetNoteDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const arr = [...$sortedNotes];
|
||||||
|
const [moved] = arr.splice(noteDragFrom, 1);
|
||||||
|
if (insertAt > noteDragFrom) insertAt--;
|
||||||
|
arr.splice(insertAt, 0, moved);
|
||||||
|
$notes = arr;
|
||||||
|
persistManualNoteOrder(arr);
|
||||||
|
resetNoteDrag();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleMoveNote(note: NoteEntry, destPath: string) {
|
async function handleMoveNote(note: NoteEntry, destPath: string) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
movePickerNote = null;
|
movePickerNote = null;
|
||||||
try {
|
try {
|
||||||
const newPath = await moveNote(note.path, destPath);
|
const newPath = await moveNote(note.path, destPath);
|
||||||
|
renameManualNoteOrderPath(note.path, newPath);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
$notes = $notes.filter(n => n.path !== note.path);
|
$notes = $notes.filter(n => n.path !== note.path);
|
||||||
if ($activeNotePath === note.path) {
|
if ($activeNotePath === note.path) {
|
||||||
@@ -687,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;
|
||||||
@@ -701,10 +845,13 @@
|
|||||||
contextMenu = { x, y, note };
|
contextMenu = { x, y, note };
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRename(note: NoteEntry) {
|
async function startRename(note: NoteEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
editingNote = note.path;
|
editingNote = note.path;
|
||||||
editValue = note.meta.title;
|
editValue = note.meta.title;
|
||||||
|
await tick();
|
||||||
|
renameInput?.focus();
|
||||||
|
renameInput?.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNotebookPath(note: NoteEntry): string {
|
function getNotebookPath(note: NoteEntry): string {
|
||||||
@@ -773,6 +920,7 @@
|
|||||||
async function handleBatchDelete() {
|
async function handleBatchDelete() {
|
||||||
const toDelete = new Set(selectedPaths);
|
const toDelete = new Set(selectedPaths);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
|
for (const path of toDelete) removeManualNoteOrderPath(path);
|
||||||
$notes = $notes.filter(n => !toDelete.has(n.path));
|
$notes = $notes.filter(n => !toDelete.has(n.path));
|
||||||
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
||||||
$activeNote = null;
|
$activeNote = null;
|
||||||
@@ -808,6 +956,7 @@
|
|||||||
async function handleBatchPermanentDelete() {
|
async function handleBatchPermanentDelete() {
|
||||||
const toDelete = new Set(selectedPaths);
|
const toDelete = new Set(selectedPaths);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
|
for (const path of toDelete) removeManualNoteOrderPath(path);
|
||||||
$notes = $notes.filter(n => !toDelete.has(n.path));
|
$notes = $notes.filter(n => !toDelete.has(n.path));
|
||||||
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
||||||
$activeNote = null;
|
$activeNote = null;
|
||||||
@@ -817,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();
|
||||||
@@ -833,6 +992,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setSortMode(mode: SortMode) {
|
function setSortMode(mode: SortMode) {
|
||||||
|
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
|
||||||
|
persistManualNoteOrder($sortedNotes);
|
||||||
|
}
|
||||||
$sortMode = mode;
|
$sortMode = mode;
|
||||||
sortMenu = null;
|
sortMenu = null;
|
||||||
}
|
}
|
||||||
@@ -856,6 +1018,16 @@
|
|||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<span class="list-title">{viewTitle}</span>
|
<span class="list-title">{viewTitle}</span>
|
||||||
<div class="list-actions">
|
<div class="list-actions">
|
||||||
|
{#if !isMobile}
|
||||||
|
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
|
||||||
|
<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="4" y1="6" x2="16" y2="6" />
|
||||||
|
<line x1="4" y1="12" x2="16" y2="12" />
|
||||||
|
<line x1="4" y1="18" x2="16" y2="18" />
|
||||||
|
<polyline points="19 8 21 12 19 16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if $viewMode !== 'tasks'}
|
{#if $viewMode !== 'tasks'}
|
||||||
{#if isAndroid}
|
{#if isAndroid}
|
||||||
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
|
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
|
||||||
@@ -869,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" />
|
||||||
@@ -885,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>
|
||||||
@@ -901,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>
|
||||||
@@ -930,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>
|
||||||
@@ -967,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}
|
||||||
@@ -1009,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>
|
||||||
@@ -1047,14 +1233,13 @@
|
|||||||
{/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
|
||||||
type="text"
|
type="text"
|
||||||
class="rename-input"
|
class="rename-input"
|
||||||
|
bind:this={renameInput}
|
||||||
bind:value={editValue}
|
bind:value={editValue}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter') handleRename(note);
|
if (e.key === 'Enter') handleRename(note);
|
||||||
@@ -1072,7 +1257,15 @@
|
|||||||
class:compact={compact}
|
class:compact={compact}
|
||||||
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
|
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
|
||||||
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
|
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
|
||||||
|
class:note-drag-above={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'top'}
|
||||||
|
class:note-drag-below={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'bottom'}
|
||||||
onclick={(e) => handleNoteClick(e, note)}
|
onclick={(e) => handleNoteClick(e, note)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'F2') {
|
||||||
|
e.preventDefault();
|
||||||
|
startRename(note);
|
||||||
|
}
|
||||||
|
}}
|
||||||
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
|
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
|
||||||
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
|
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
|
||||||
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
|
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
|
||||||
@@ -1099,11 +1292,16 @@
|
|||||||
e.dataTransfer!.effectAllowed = 'move';
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
if (canCustomReorder) {
|
||||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
noteDragFrom = noteIndex;
|
||||||
} else {
|
|
||||||
e.dataTransfer!.setData('text/plain', note.path);
|
e.dataTransfer!.setData('text/plain', note.path);
|
||||||
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
|
||||||
|
? selectedPaths
|
||||||
|
: [note.path];
|
||||||
|
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
|
||||||
e.dataTransfer!.effectAllowed = 'move';
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
}}
|
}}
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
@@ -1113,18 +1311,28 @@
|
|||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
||||||
qaDragOver = noteIndex;
|
qaDragOver = noteIndex;
|
||||||
|
} else if (canCustomReorder && noteDragFrom !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer!.dropEffect = 'move';
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
noteDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
||||||
|
noteDragOver = noteIndex;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ondragleave={() => {
|
ondragleave={() => {
|
||||||
if (qaDragOver === noteIndex) qaDragOver = null;
|
if (qaDragOver === noteIndex) qaDragOver = null;
|
||||||
|
if (noteDragOver === noteIndex) noteDragOver = null;
|
||||||
}}
|
}}
|
||||||
ondrop={(e) => {
|
ondrop={(e) => {
|
||||||
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
|
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleQaDrop(noteIndex);
|
handleQaDrop(noteIndex);
|
||||||
|
} else if (canCustomReorder && noteDragFrom !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCustomNoteDrop(noteIndex);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
|
ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }}
|
||||||
>
|
>
|
||||||
{#if compact}
|
{#if compact}
|
||||||
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
|
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
|
||||||
@@ -1175,16 +1383,40 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
{#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>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/snippet}
|
||||||
<div style="height: {bottomPad}px"></div>
|
|
||||||
|
{#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}
|
||||||
|
<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'}
|
||||||
@@ -1335,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>
|
||||||
@@ -1348,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">
|
||||||
@@ -1373,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">
|
||||||
@@ -1403,6 +1648,18 @@
|
|||||||
Title
|
Title
|
||||||
{#if $sortMode === 'title'}<span class="sort-check">✓</span>{/if}
|
{#if $sortMode === 'title'}<span class="sort-check">✓</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
|
<button class:active={$sortMode === 'custom'} onclick={() => setSortMode('custom')}>
|
||||||
|
<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" />
|
||||||
|
<circle cx="4" cy="6" r="1" />
|
||||||
|
<circle cx="4" cy="12" r="1" />
|
||||||
|
<circle cx="4" cy="18" r="1" />
|
||||||
|
</svg>
|
||||||
|
Custom
|
||||||
|
{#if $sortMode === 'custom'}<span class="sort-check">✓</span>{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1464,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 {
|
||||||
@@ -1488,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 {
|
||||||
@@ -1534,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);
|
||||||
@@ -1548,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));
|
||||||
@@ -1925,11 +2219,13 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item.qa-drag-above {
|
.note-item.qa-drag-above,
|
||||||
|
.note-item.note-drag-above {
|
||||||
border-top: 2px solid var(--accent);
|
border-top: 2px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item.qa-drag-below {
|
.note-item.qa-drag-below,
|
||||||
|
.note-item.note-drag-below {
|
||||||
border-bottom: 2px solid var(--accent);
|
border-bottom: 2px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1960,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;
|
||||||
@@ -2026,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;
|
||||||
@@ -2069,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);
|
||||||
|
|||||||