Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
|||||||
Your notes are stored as standard Markdown files on your local filesystem.
|
Your notes are stored as standard Markdown files on your local filesystem.
|
||||||
No cloud, no lock-in.
|
No cloud, no lock-in.
|
||||||
|
|
||||||
## Download (v1.3.2)
|
## Download (v1.3.4)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -42,7 +42,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";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -92,27 +92,37 @@ sudo eopkg it helixnotes
|
|||||||
|
|
||||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||||
|
|
||||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage)
|
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
|
||||||
|
|
||||||
#### .deb (manual)
|
#### .deb (manual)
|
||||||
|
|
||||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+)
|
[Download .deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
||||||
|
|
||||||
|
#### .rpm (manual)
|
||||||
|
|
||||||
|
[Download .rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64-setup.exe) (Windows 10/11)
|
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.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.2/HelixNotes_1.3.2_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
|
||||||
|
|
||||||
@@ -175,4 +185,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": 1782959384,
|
||||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -6,29 +6,33 @@
|
|||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs =
|
||||||
|
{
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
flake-utils,
|
flake-utils,
|
||||||
}:
|
}:
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||||
pname = cargoToml.package.name;
|
pname = cargoToml.package.name;
|
||||||
version = cargoToml.package.version;
|
version = cargoToml.package.version;
|
||||||
in {
|
in
|
||||||
|
{
|
||||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
|
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
@@ -44,7 +48,8 @@
|
|||||||
wrapGAppsHook3
|
wrapGAppsHook3
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs = with pkgs;
|
buildInputs =
|
||||||
|
with pkgs;
|
||||||
lib.optionals stdenv.hostPlatform.isLinux [
|
lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
webkitgtk_4_1
|
webkitgtk_4_1
|
||||||
libayatana-appindicator
|
libayatana-appindicator
|
||||||
@@ -71,6 +76,7 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
formatter = pkgs.alejandra;
|
formatter = pkgs.nixfmt;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.3.2",
|
"version": "1.3.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.2"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1860,6 +1860,7 @@ 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 0.36.2",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -1876,6 +1877,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",
|
||||||
@@ -5201,6 +5203,15 @@ 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.8.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.2"
|
version = "1.3.4"
|
||||||
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"
|
||||||
@@ -42,6 +42,12 @@ quick-xml = "0.36"
|
|||||||
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>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Hardened runtime entitlements for Developer ID distribution (outside Mac App Store).
|
||||||
|
Required for notarization. These do NOT enable App Sandbox.
|
||||||
|
Without these, the WebKit webview and bundled Rust libraries get killed by the
|
||||||
|
hardened runtime's library validation. -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Network access for auto-updater, AI features, WebDAV sync, image proxy -->
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#fff</color>
|
<color name="ic_launcher_background">#626ED4</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 709 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 984 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
|
||||||
<rect width="512" height="512" rx="112" ry="112" fill="#626ED4"/>
|
|
||||||
<line x1="128" y1="128" x2="384" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
|
||||||
<line x1="384" y1="128" x2="128" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
|
||||||
<circle cx="128" cy="128" r="36" fill="#ffffff"/>
|
|
||||||
<circle cx="384" cy="128" r="36" fill="#ffffff"/>
|
|
||||||
<circle cx="128" cy="384" r="36" fill="#ffffff"/>
|
|
||||||
<circle cx="384" cy="384" r="36" fill="#ffffff"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 633 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(85.333 85.333) scale(0.666667)">
|
||||||
|
<line x1="178.45584" y1="178.45584" x2="333.54416" y2="333.54416" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||||
|
<line x1="337.53708" y1="174.46292" x2="174.46294" y2="337.53708" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="153" cy="153" r="36" fill="#ffffff"/>
|
||||||
|
<circle cx="359" cy="153" r="36" fill="#ffffff"/>
|
||||||
|
<circle cx="153" cy="359" r="36" fill="#ffffff"/>
|
||||||
|
<circle cx="359" cy="359" r="36" fill="#ffffff"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 706 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"><rect
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
rx="112"
|
||||||
|
ry="112"
|
||||||
|
fill="#626ED4"
|
||||||
|
id="rect1" /><line
|
||||||
|
x1="178.45584"
|
||||||
|
y1="178.45584"
|
||||||
|
x2="333.54416"
|
||||||
|
y2="333.54416"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="25"
|
||||||
|
stroke-opacity="0.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
id="line1" /><line
|
||||||
|
x1="337.53708"
|
||||||
|
y1="174.46292"
|
||||||
|
x2="174.46294"
|
||||||
|
y2="337.53708"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="25"
|
||||||
|
stroke-opacity="0.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
id="line2" /><circle
|
||||||
|
cx="153"
|
||||||
|
cy="153"
|
||||||
|
r="36"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="circle2" /><circle
|
||||||
|
cx="359"
|
||||||
|
cy="153"
|
||||||
|
r="36"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="circle3" /><circle
|
||||||
|
cx="153"
|
||||||
|
cy="359"
|
||||||
|
r="36"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="circle4" /><circle
|
||||||
|
cx="359"
|
||||||
|
cy="359"
|
||||||
|
r="36"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="circle5" /></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 475 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"themes": [
|
||||||
|
{
|
||||||
|
"colors": {
|
||||||
|
"bg_active": "#373a52",
|
||||||
|
"bg_editor": "#1a1b26",
|
||||||
|
"bg_hover": "#2f3145",
|
||||||
|
"bg_primary": "#1a1b26",
|
||||||
|
"bg_secondary": "#1f2029",
|
||||||
|
"bg_tertiary": "#2a2b3d",
|
||||||
|
"border_color": "#2f3145",
|
||||||
|
"text_primary": "#c8cde4",
|
||||||
|
"text_secondary": "#6b7094"
|
||||||
|
},
|
||||||
|
"id": "custom-1782146306429",
|
||||||
|
"is_dark": true,
|
||||||
|
"name": "My theme"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "tauri-plugin-ios-vault-access"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["HelixNotes"]
|
||||||
|
description = "iOS Files folder access for HelixNotes"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
links = "tauri-plugin-ios-vault-access"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-plugin = { version = "2", features = ["build"] }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_plugin::Builder::new(&[]).ios_path("ios").build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// swift-tools-version:5.3
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "tauri-plugin-ios-vault-access",
|
||||||
|
platforms: [.iOS(.v14)],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "tauri-plugin-ios-vault-access",
|
||||||
|
type: .static,
|
||||||
|
targets: ["tauri-plugin-ios-vault-access"]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(name: "Tauri", path: "../.tauri/tauri-api")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "tauri-plugin-ios-vault-access",
|
||||||
|
dependencies: [.byName(name: "Tauri")],
|
||||||
|
path: "Sources"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftRs
|
||||||
|
import Tauri
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
private struct BookmarkRequest: Decodable {
|
||||||
|
let bookmarkId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
final class IosVaultAccessPlugin: Plugin, UIDocumentPickerDelegate {
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var pickerInvoke: Invoke?
|
||||||
|
private var activeBookmarkId: String?
|
||||||
|
private var activeURL: URL?
|
||||||
|
private var stagedBookmarkId: String?
|
||||||
|
private var stagedURL: URL?
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
rollbackStagedScope()
|
||||||
|
releaseCurrentScope()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func chooseFolder(_ invoke: Invoke) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard self.pickerInvoke == nil else {
|
||||||
|
invoke.reject("A folder picker is already open.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let viewController = self.manager.viewController else {
|
||||||
|
self.pickerInvoke = nil
|
||||||
|
invoke.reject("Files is unavailable right now. Try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.pickerInvoke = invoke
|
||||||
|
let picker = UIDocumentPickerViewController(
|
||||||
|
forOpeningContentTypes: [.folder],
|
||||||
|
asCopy: false
|
||||||
|
)
|
||||||
|
picker.delegate = self
|
||||||
|
picker.allowsMultipleSelection = false
|
||||||
|
viewController.present(picker, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func resolveFolder(_ invoke: Invoke) throws {
|
||||||
|
let request = try invoke.parseArgs(BookmarkRequest.self)
|
||||||
|
do {
|
||||||
|
invoke.resolve(try resolveBookmark(request.bookmarkId))
|
||||||
|
} catch {
|
||||||
|
invoke.reject(message(for: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func releaseActive(_ invoke: Invoke) {
|
||||||
|
rollbackStagedScope()
|
||||||
|
releaseCurrentScope()
|
||||||
|
invoke.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func commitStaged(_ invoke: Invoke) {
|
||||||
|
commitStagedScope()
|
||||||
|
invoke.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func rollbackStaged(_ invoke: Invoke) {
|
||||||
|
rollbackStagedScope()
|
||||||
|
invoke.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func forgetBookmark(_ invoke: Invoke) throws {
|
||||||
|
let request = try invoke.parseArgs(BookmarkRequest.self)
|
||||||
|
do {
|
||||||
|
let file = try bookmarkFile(for: request.bookmarkId, createDirectory: false)
|
||||||
|
lock.lock()
|
||||||
|
let isActive = activeBookmarkId == request.bookmarkId
|
||||||
|
let isStaged = stagedBookmarkId == request.bookmarkId
|
||||||
|
lock.unlock()
|
||||||
|
if fileManager.fileExists(atPath: file.path) {
|
||||||
|
try fileManager.removeItem(at: file)
|
||||||
|
}
|
||||||
|
if isActive {
|
||||||
|
releaseCurrentScope()
|
||||||
|
}
|
||||||
|
if isStaged {
|
||||||
|
rollbackStagedScope()
|
||||||
|
}
|
||||||
|
invoke.resolve()
|
||||||
|
} catch {
|
||||||
|
invoke.reject(message(for: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentPicker(
|
||||||
|
_ controller: UIDocumentPickerViewController,
|
||||||
|
didPickDocumentsAt urls: [URL]
|
||||||
|
) {
|
||||||
|
guard let invoke = takePickerInvoke() else { return }
|
||||||
|
guard let url = urls.first else {
|
||||||
|
invoke.resolve(cancelledResult())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
guard url.startAccessingSecurityScopedResource() else {
|
||||||
|
throw AccessError.denied
|
||||||
|
}
|
||||||
|
var adopted = false
|
||||||
|
defer {
|
||||||
|
if !adopted { url.stopAccessingSecurityScopedResource() }
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = try validateFolder(url)
|
||||||
|
let bookmarkId = existingBookmarkId(for: url) ?? UUID().uuidString.lowercased()
|
||||||
|
let bookmark = try url.bookmarkData(
|
||||||
|
options: .minimalBookmark,
|
||||||
|
includingResourceValuesForKeys: nil,
|
||||||
|
relativeTo: nil
|
||||||
|
)
|
||||||
|
try bookmark.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
|
||||||
|
stageScope(url: url, bookmarkId: bookmarkId)
|
||||||
|
adopted = true
|
||||||
|
invoke.resolve(result(bookmarkId: bookmarkId, url: url, name: name))
|
||||||
|
} catch {
|
||||||
|
invoke.reject(message(for: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||||
|
takePickerInvoke()?.resolve(cancelledResult())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveBookmark(_ bookmarkId: String) throws -> [String: Any] {
|
||||||
|
guard UUID(uuidString: bookmarkId) != nil else { throw AccessError.missing }
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
let currentURL = activeBookmarkId == bookmarkId ? activeURL : nil
|
||||||
|
lock.unlock()
|
||||||
|
if let currentURL {
|
||||||
|
return result(bookmarkId: bookmarkId, url: currentURL, name: try validateFolder(currentURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: bookmarkFile(for: bookmarkId, createDirectory: false))
|
||||||
|
var stale = false
|
||||||
|
let url = try URL(
|
||||||
|
resolvingBookmarkData: data,
|
||||||
|
options: [],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &stale
|
||||||
|
)
|
||||||
|
guard url.startAccessingSecurityScopedResource() else { throw AccessError.denied }
|
||||||
|
var adopted = false
|
||||||
|
defer {
|
||||||
|
if !adopted { url.stopAccessingSecurityScopedResource() }
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = try validateFolder(url)
|
||||||
|
if stale {
|
||||||
|
let refreshed = try url.bookmarkData(
|
||||||
|
options: .minimalBookmark,
|
||||||
|
includingResourceValuesForKeys: nil,
|
||||||
|
relativeTo: nil
|
||||||
|
)
|
||||||
|
try refreshed.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
|
||||||
|
}
|
||||||
|
stageScope(url: url, bookmarkId: bookmarkId)
|
||||||
|
adopted = true
|
||||||
|
return result(bookmarkId: bookmarkId, url: url, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateFolder(_ url: URL) throws -> String {
|
||||||
|
let values = try url.resourceValues(forKeys: [.isDirectoryKey, .nameKey])
|
||||||
|
guard values.isDirectory == true else { throw AccessError.notFolder }
|
||||||
|
guard try url.checkResourceIsReachable() else { throw AccessError.unavailable }
|
||||||
|
return values.name ?? url.lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stageScope(url: URL, bookmarkId: String) {
|
||||||
|
lock.lock()
|
||||||
|
let previous = stagedURL
|
||||||
|
stagedURL = url
|
||||||
|
stagedBookmarkId = bookmarkId
|
||||||
|
lock.unlock()
|
||||||
|
previous?.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commitStagedScope() {
|
||||||
|
lock.lock()
|
||||||
|
guard let next = stagedURL else {
|
||||||
|
lock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let previous = activeURL
|
||||||
|
activeURL = next
|
||||||
|
activeBookmarkId = stagedBookmarkId
|
||||||
|
stagedURL = nil
|
||||||
|
stagedBookmarkId = nil
|
||||||
|
lock.unlock()
|
||||||
|
previous?.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rollbackStagedScope() {
|
||||||
|
lock.lock()
|
||||||
|
let staged = stagedURL
|
||||||
|
stagedURL = nil
|
||||||
|
stagedBookmarkId = nil
|
||||||
|
lock.unlock()
|
||||||
|
staged?.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseCurrentScope() {
|
||||||
|
lock.lock()
|
||||||
|
let previous = activeURL
|
||||||
|
activeURL = nil
|
||||||
|
activeBookmarkId = nil
|
||||||
|
lock.unlock()
|
||||||
|
previous?.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func takePickerInvoke() -> Invoke? {
|
||||||
|
let invoke = pickerInvoke
|
||||||
|
pickerInvoke = nil
|
||||||
|
return invoke
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bookmarkDirectory(create: Bool) throws -> URL {
|
||||||
|
let base = try fileManager.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let directory = base.appendingPathComponent("VaultBookmarks", isDirectory: true)
|
||||||
|
if create {
|
||||||
|
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bookmarkFile(for id: String, createDirectory: Bool) throws -> URL {
|
||||||
|
guard UUID(uuidString: id) != nil else { throw AccessError.missing }
|
||||||
|
let directory = try bookmarkDirectory(create: createDirectory)
|
||||||
|
return directory.appendingPathComponent("\(id).bookmark")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func existingBookmarkId(for selectedURL: URL) -> String? {
|
||||||
|
guard
|
||||||
|
let directory = try? bookmarkDirectory(create: true),
|
||||||
|
let files = try? fileManager.contentsOfDirectory(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: nil
|
||||||
|
)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let selectedPath = selectedURL.standardizedFileURL.path
|
||||||
|
for file in files where file.pathExtension == "bookmark" {
|
||||||
|
guard let data = try? Data(contentsOf: file) else { continue }
|
||||||
|
var stale = false
|
||||||
|
guard
|
||||||
|
let resolved = try? URL(
|
||||||
|
resolvingBookmarkData: data,
|
||||||
|
options: [],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &stale
|
||||||
|
),
|
||||||
|
resolved.standardizedFileURL.path == selectedPath
|
||||||
|
else { continue }
|
||||||
|
let id = file.deletingPathExtension().lastPathComponent
|
||||||
|
if UUID(uuidString: id) != nil { return id }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func result(bookmarkId: String, url: URL, name: String) -> [String: Any] {
|
||||||
|
["bookmarkId": bookmarkId, "path": url.path, "name": name, "cancelled": false]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelledResult() -> [String: Any] {
|
||||||
|
["bookmarkId": "", "path": "", "name": "", "cancelled": true]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func message(for error: Error) -> String {
|
||||||
|
if let accessError = error as? AccessError { return accessError.localizedDescription }
|
||||||
|
return "The selected folder is unavailable. Open Files and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AccessError: LocalizedError {
|
||||||
|
case denied
|
||||||
|
case missing
|
||||||
|
case notFolder
|
||||||
|
case unavailable
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .denied:
|
||||||
|
return "HelixNotes no longer has access to this folder. Choose it again in Files."
|
||||||
|
case .missing:
|
||||||
|
return "Saved access to this folder is missing. Choose it again in Files."
|
||||||
|
case .notFolder:
|
||||||
|
return "The selected item is not a folder."
|
||||||
|
case .unavailable:
|
||||||
|
return "The selected folder is unavailable. Open Files and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_cdecl("init_plugin_ios_vault_access")
|
||||||
|
func initPlugin() -> Plugin {
|
||||||
|
IosVaultAccessPlugin()
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{
|
||||||
|
plugin::{Builder, PluginHandle, TauriPlugin},
|
||||||
|
Manager, Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FolderSelection {
|
||||||
|
pub bookmark_id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cancelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BookmarkRequest<'a> {
|
||||||
|
bookmark_id: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
tauri::ios_plugin_binding!(init_plugin_ios_vault_access);
|
||||||
|
|
||||||
|
pub struct IosVaultAccess<R: Runtime>(PluginHandle<R>);
|
||||||
|
|
||||||
|
impl<R: Runtime> IosVaultAccess<R> {
|
||||||
|
pub fn choose_folder(&self) -> Result<FolderSelection, String> {
|
||||||
|
self.0
|
||||||
|
.run_mobile_plugin("chooseFolder", ())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_folder(&self, bookmark_id: &str) -> Result<FolderSelection, String> {
|
||||||
|
self.0
|
||||||
|
.run_mobile_plugin("resolveFolder", BookmarkRequest { bookmark_id })
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_active(&self) -> Result<(), String> {
|
||||||
|
self.0
|
||||||
|
.run_mobile_plugin("releaseActive", ())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_staged(&self) -> Result<(), String> {
|
||||||
|
self.0
|
||||||
|
.run_mobile_plugin("commitStaged", ())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rollback_staged(&self) -> Result<(), String> {
|
||||||
|
self.0
|
||||||
|
.run_mobile_plugin("rollbackStaged", ())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forget_bookmark(&self, bookmark_id: &str) -> Result<(), String> {
|
||||||
|
self.0
|
||||||
|
.run_mobile_plugin("forgetBookmark", BookmarkRequest { bookmark_id })
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IosVaultAccessExt<R: Runtime> {
|
||||||
|
fn ios_vault_access(&self) -> &IosVaultAccess<R>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Runtime, T: Manager<R>> IosVaultAccessExt<R> for T {
|
||||||
|
fn ios_vault_access(&self) -> &IosVaultAccess<R> {
|
||||||
|
self.state::<IosVaultAccess<R>>().inner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
Builder::new("ios-vault-access")
|
||||||
|
.setup(|app, api| {
|
||||||
|
let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?;
|
||||||
|
app.manage(IosVaultAccess(handle));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -405,7 +405,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 +435,12 @@ 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",
|
||||||
|
|||||||
@@ -38,11 +38,45 @@ 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_page_load(|webview, payload| {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if webview.label() == "main"
|
||||||
|
&& matches!(
|
||||||
|
payload.event(),
|
||||||
|
tauri::webview::PageLoadEvent::Finished
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let window = webview.window();
|
||||||
|
let window_on_main = window.clone();
|
||||||
|
let _ = window.run_on_main_thread(move || {
|
||||||
|
fix_macos_traffic_lights(&window_on_main);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#[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()
|
||||||
@@ -98,10 +132,16 @@ 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_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_font_family,
|
commands::set_font_family,
|
||||||
commands::set_line_height,
|
commands::set_line_height,
|
||||||
@@ -369,3 +409,51 @@ 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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -312,8 +312,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))
|
||||||
|
|||||||
@@ -50,16 +50,58 @@ 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, 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,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub accent_color: Option<String>,
|
pub accent_color: Option<String>,
|
||||||
@@ -151,7 +193,7 @@ pub struct AppConfig {
|
|||||||
pub enable_wiki_links: bool,
|
pub enable_wiki_links: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub restore_last_session: bool,
|
pub restore_last_session: bool,
|
||||||
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured.
|
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sync_provider: Option<String>,
|
pub sync_provider: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -168,12 +210,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
|
||||||
}
|
}
|
||||||
@@ -211,6 +259,7 @@ impl Default for AppConfig {
|
|||||||
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(),
|
||||||
accent_color: None,
|
accent_color: None,
|
||||||
font_size: None,
|
font_size: None,
|
||||||
@@ -265,6 +314,7 @@ impl Default for AppConfig {
|
|||||||
sync_on_change: false,
|
sync_on_change: false,
|
||||||
sync_interval_minutes: 0,
|
sync_interval_minutes: 0,
|
||||||
last_sync_time: None,
|
last_sync_time: None,
|
||||||
|
custom_themes: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +324,8 @@ pub struct VaultState {
|
|||||||
pub last_open_note: Option<String>,
|
pub last_open_note: Option<String>,
|
||||||
pub sidebar_width: f64,
|
pub sidebar_width: f64,
|
||||||
pub notelist_width: f64,
|
pub notelist_width: f64,
|
||||||
|
#[serde(default = "default_outline_width")]
|
||||||
|
pub outline_width: f64,
|
||||||
pub sidebar_collapsed: bool,
|
pub sidebar_collapsed: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notelist_collapsed: bool,
|
pub notelist_collapsed: bool,
|
||||||
@@ -309,6 +361,7 @@ impl Default for VaultState {
|
|||||||
last_open_note: None,
|
last_open_note: None,
|
||||||
sidebar_width: 220.0,
|
sidebar_width: 220.0,
|
||||||
notelist_width: 280.0,
|
notelist_width: 280.0,
|
||||||
|
outline_width: default_outline_width(),
|
||||||
sidebar_collapsed: false,
|
sidebar_collapsed: false,
|
||||||
notelist_collapsed: false,
|
notelist_collapsed: false,
|
||||||
collapsed_notebooks: Vec::new(),
|
collapsed_notebooks: Vec::new(),
|
||||||
@@ -345,6 +398,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)]
|
||||||
|
|||||||
@@ -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>>() {
|
||||||
@@ -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").
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -52,11 +52,10 @@ pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||||
let mut entries = Vec::new();
|
|
||||||
let root = Path::new(vault_root);
|
let root = Path::new(vault_root);
|
||||||
|
|
||||||
let Ok(read_dir) = fs::read_dir(dir) else {
|
let Ok(read_dir) = fs::read_dir(dir) else {
|
||||||
return entries;
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
|
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
|
||||||
@@ -76,29 +75,29 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
|||||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store note_count for the parent directory (used when this is the top-level call)
|
// Scan sibling notebooks in parallel: each subtree is independent, so
|
||||||
// Each directory pushes its own entry with its own note_count
|
// overlapping the per-directory read_dir calls hides FUSE latency on Android.
|
||||||
for dir_entry in dirs {
|
// par_iter().collect() preserves the (already-sorted) order.
|
||||||
let path = dir_entry.path();
|
let paths: Vec<PathBuf> = dirs.iter().map(|e| e.path()).collect();
|
||||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
paths
|
||||||
|
.par_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||||
let relative = path
|
let relative = path
|
||||||
.strip_prefix(root)
|
.strip_prefix(root)
|
||||||
.unwrap_or(&path)
|
.unwrap_or(path)
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
|
||||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
NotebookEntry {
|
||||||
|
|
||||||
entries.push(NotebookEntry {
|
|
||||||
name,
|
name,
|
||||||
path: path.to_string_lossy().to_string(),
|
path: path.to_string_lossy().to_string(),
|
||||||
relative_path: relative,
|
relative_path: relative,
|
||||||
children,
|
children,
|
||||||
note_count: child_note_count,
|
note_count: child_note_count,
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
entries
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Combined scan: returns (children, note_count) in a single read_dir pass.
|
/// Combined scan: returns (children, note_count) in a single read_dir pass.
|
||||||
@@ -130,26 +129,26 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
|
|||||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect();
|
||||||
for dir_entry in subdirs {
|
let entries: Vec<NotebookEntry> = paths
|
||||||
let path = dir_entry.path();
|
.par_iter()
|
||||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
.map(|path| {
|
||||||
|
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||||
let relative = path
|
let relative = path
|
||||||
.strip_prefix(root)
|
.strip_prefix(root)
|
||||||
.unwrap_or(&path)
|
.unwrap_or(path)
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
|
||||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
NotebookEntry {
|
||||||
|
|
||||||
entries.push(NotebookEntry {
|
|
||||||
name,
|
name,
|
||||||
path: path.to_string_lossy().to_string(),
|
path: path.to_string_lossy().to_string(),
|
||||||
relative_path: relative,
|
relative_path: relative,
|
||||||
children,
|
children,
|
||||||
note_count: child_note_count,
|
note_count: child_note_count,
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
(entries, note_count)
|
(entries, note_count)
|
||||||
}
|
}
|
||||||
@@ -197,47 +196,36 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
|||||||
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
|
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
|
||||||
#[cfg(mobile)]
|
#[cfg(mobile)]
|
||||||
{
|
{
|
||||||
let mut notes = Vec::new();
|
// Collect candidate .md paths first, then read their metadata in parallel.
|
||||||
// Log what read_dir sees
|
// Overlapping the per-file reads hides FUSE latency on Android (matches the
|
||||||
if let Ok(rd) = fs::read_dir(root) {
|
// desktop path below). The previous version also did an extra read_dir purely
|
||||||
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
|
// for debug logging, which doubled the directory I/O on the sandboxed FS.
|
||||||
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root);
|
let paths: Vec<PathBuf> = if notebook_path.is_some() {
|
||||||
for (i, entry) in items.iter().enumerate().take(5) {
|
match fs::read_dir(root) {
|
||||||
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err");
|
Ok(rd) => rd
|
||||||
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft);
|
.filter_map(|e| e.ok())
|
||||||
}
|
.map(|e| e.path())
|
||||||
} else {
|
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||||
log::info!("scan_notes: read_dir FAILED for {:?}", root);
|
.collect(),
|
||||||
}
|
Err(_) => Vec::new(),
|
||||||
|
|
||||||
if notebook_path.is_some() {
|
|
||||||
if let Ok(rd) = fs::read_dir(root) {
|
|
||||||
for entry in rd.filter_map(|e| e.ok()) {
|
|
||||||
let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
|
|
||||||
if is_file && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
|
|
||||||
if let Ok(note) = read_note_entry_metadata_only(&entry.path(), vault_root) {
|
|
||||||
notes.push(note);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let hn_dir = helixnotes_dir(vault_path);
|
let hn_dir = helixnotes_dir(vault_path);
|
||||||
for entry in WalkDir::new(root)
|
WalkDir::new(root)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
// filter_entry skips descending into hidden dirs (unlike filter)
|
// filter_entry skips descending into hidden dirs (unlike filter)
|
||||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| {
|
.map(|e| e.path().to_path_buf())
|
||||||
e.file_type().is_file()
|
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||||
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
.collect()
|
||||||
})
|
};
|
||||||
{
|
|
||||||
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) {
|
let mut notes: Vec<NoteEntry> = paths
|
||||||
notes.push(note);
|
.par_iter()
|
||||||
}
|
.filter_map(|path| read_note_entry_metadata_only(path, vault_root).ok())
|
||||||
}
|
.collect();
|
||||||
}
|
|
||||||
log::info!("scan_notes: mobile scan found {} notes", notes.len());
|
log::info!("scan_notes: mobile scan found {} notes", notes.len());
|
||||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||||
return Ok(notes);
|
return Ok(notes);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -8,14 +8,57 @@ use crate::state::AppState;
|
|||||||
use crate::types::FileEvent;
|
use crate::types::FileEvent;
|
||||||
use crate::vault::operations::helixnotes_dir;
|
use crate::vault::operations::helixnotes_dir;
|
||||||
|
|
||||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
|
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum WatcherBackend {
|
||||||
|
Recommended,
|
||||||
|
Poll,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
|
||||||
|
if is_ios {
|
||||||
|
WatcherBackend::Poll
|
||||||
|
} else {
|
||||||
|
WatcherBackend::Recommended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
|
||||||
|
match backend {
|
||||||
|
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
|
||||||
|
WatcherBackend::Poll => IOS_POLL_INTERVAL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum VaultWatcher {
|
||||||
|
Recommended(RecommendedWatcher),
|
||||||
|
Poll(PollWatcher),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultWatcher {
|
||||||
|
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
|
||||||
|
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
let mut watcher = RecommendedWatcher::new(
|
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
|
||||||
tx,
|
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
|
||||||
Config::default().with_poll_interval(Duration::from_secs(1)),
|
let mut watcher = match backend {
|
||||||
)
|
WatcherBackend::Recommended => VaultWatcher::Recommended(
|
||||||
.map_err(|e| e.to_string())?;
|
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
|
||||||
|
),
|
||||||
|
WatcherBackend::Poll => {
|
||||||
|
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
watcher
|
watcher
|
||||||
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
||||||
@@ -61,6 +104,15 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
|||||||
};
|
};
|
||||||
let _ = app.emit("file-changed", &fe);
|
let _ = app.emit("file-changed", &fe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On mobile, throttle event emission to prevent IPC flooding
|
||||||
|
// on FUSE filesystems where Syncthing/other apps generate
|
||||||
|
// constant file activity that blocks the Tauri command channel.
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
while rx.try_recv().is_ok() {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("File watcher error: {}", e);
|
log::error!("File watcher error: {}", e);
|
||||||
@@ -71,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
|||||||
|
|
||||||
Ok(watcher)
|
Ok(watcher)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ios_uses_polling_backend() {
|
||||||
|
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_platforms_keep_recommended_backend() {
|
||||||
|
assert_eq!(
|
||||||
|
watcher_backend_for_target(false),
|
||||||
|
WatcherBackend::Recommended
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_backends_use_the_expected_intervals() {
|
||||||
|
assert_eq!(
|
||||||
|
poll_interval_for_backend(WatcherBackend::Poll),
|
||||||
|
Duration::from_secs(10)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
poll_interval_for_backend(WatcherBackend::Recommended),
|
||||||
|
Duration::from_secs(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "HelixNotes",
|
"productName": "HelixNotes",
|
||||||
"version": "1.3.2",
|
"version": "1.3.4",
|
||||||
"identifier": "com.helixnotes.app",
|
"identifier": "com.helixnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../build",
|
"frontendDist": "../build",
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
"appimage": {
|
"appimage": {
|
||||||
"bundleMediaFramework": false
|
"bundleMediaFramework": false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"entitlements": "entitlements.plist"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,21 @@
|
|||||||
--danger-hover: #d43d3d;
|
--danger-hover: #d43d3d;
|
||||||
--success: #40c057;
|
--success: #40c057;
|
||||||
--warning: #fab005;
|
--warning: #fab005;
|
||||||
|
/* Obsidian-style callout accent hues (RGB triplets; mixed with the background via opacity, so one set works on every theme) */
|
||||||
|
--callout-note: 68, 138, 255;
|
||||||
|
--callout-abstract: 0, 191, 188;
|
||||||
|
--callout-info: 0, 184, 212;
|
||||||
|
--callout-todo: 0, 184, 212;
|
||||||
|
--callout-tip: 0, 191, 188;
|
||||||
|
--callout-success: 8, 185, 78;
|
||||||
|
--callout-question: 236, 191, 0;
|
||||||
|
--callout-warning: 236, 117, 0;
|
||||||
|
--callout-failure: 233, 49, 71;
|
||||||
|
--callout-danger: 233, 49, 71;
|
||||||
|
--callout-bug: 233, 49, 71;
|
||||||
|
--callout-example: 120, 82, 238;
|
||||||
|
--callout-quote: 158, 158, 158;
|
||||||
|
--callout-custom: 124, 134, 156;
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
@@ -204,6 +219,20 @@
|
|||||||
--danger-hover: #eba0ac;
|
--danger-hover: #eba0ac;
|
||||||
--success: #a6e3a1;
|
--success: #a6e3a1;
|
||||||
--warning: #f9e2af;
|
--warning: #f9e2af;
|
||||||
|
--callout-note: 100, 160, 255;
|
||||||
|
--callout-abstract: 60, 209, 205;
|
||||||
|
--callout-info: 60, 200, 224;
|
||||||
|
--callout-todo: 60, 200, 224;
|
||||||
|
--callout-tip: 60, 209, 205;
|
||||||
|
--callout-success: 80, 205, 120;
|
||||||
|
--callout-question: 240, 205, 80;
|
||||||
|
--callout-warning: 245, 150, 60;
|
||||||
|
--callout-failure: 240, 95, 115;
|
||||||
|
--callout-danger: 240, 95, 115;
|
||||||
|
--callout-bug: 240, 95, 115;
|
||||||
|
--callout-example: 165, 135, 245;
|
||||||
|
--callout-quote: 150, 150, 162;
|
||||||
|
--callout-custom: 150, 160, 182;
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
@@ -1113,10 +1142,6 @@ body.resizing {
|
|||||||
/* ── Mobile (Android/iOS) ── */
|
/* ── Mobile (Android/iOS) ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body {
|
body {
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
padding-left: env(safe-area-inset-left);
|
|
||||||
padding-right: env(safe-area-inset-right);
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
CustomTheme,
|
||||||
NoteContent,
|
NoteContent,
|
||||||
NoteEntry,
|
NoteEntry,
|
||||||
NoteMeta,
|
NoteMeta,
|
||||||
@@ -14,14 +15,23 @@ import type {
|
|||||||
BackupEntry,
|
BackupEntry,
|
||||||
VersionEntry,
|
VersionEntry,
|
||||||
TaskItem,
|
TaskItem,
|
||||||
|
ExternalVaultResult,
|
||||||
} 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> {
|
||||||
@@ -36,6 +46,22 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import {
|
import {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
notelistWidth,
|
notelistWidth,
|
||||||
|
outlineWidth,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
notelistCollapsed,
|
notelistCollapsed,
|
||||||
collapsedNotebooks,
|
collapsedNotebooks,
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
rootNoteCount,
|
rootNoteCount,
|
||||||
viewMode,
|
viewMode,
|
||||||
sortMode,
|
sortMode,
|
||||||
|
groupNotesByDate,
|
||||||
tasksLayout,
|
tasksLayout,
|
||||||
tasksHideCompleted,
|
tasksHideCompleted,
|
||||||
tasksOnlyFlagged,
|
tasksOnlyFlagged,
|
||||||
@@ -51,16 +53,16 @@
|
|||||||
notebookOrder,
|
notebookOrder,
|
||||||
noteOrder,
|
noteOrder,
|
||||||
syncState,
|
syncState,
|
||||||
platformIsMobile
|
platformIsMobile,
|
||||||
|
activeVaultConfig,
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
import { keybindings, matchAction } from '$lib/keybindings';
|
import { keybindings, matchAction } from '$lib/keybindings';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
const isMobile = $derived($platformIsMobile);
|
const isMobile = $derived($platformIsMobile);
|
||||||
const isAndroid = /android/i.test(navigator.userAgent);
|
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
|
import { darkThemes, isAndroid } from '$lib/platform';
|
||||||
import { darkThemes } from '$lib/platform';
|
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@@ -120,16 +122,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncConfigured(): boolean {
|
function syncConfigured(): boolean {
|
||||||
return get(appConfig)?.sync_provider === 'webdav';
|
return activeVaultConfig(get(appConfig))?.sync_provider === 'webdav';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-sync on a timer (only when configured and an interval is set).
|
// Auto-sync on a timer (only when configured and an interval is set).
|
||||||
async function checkScheduledSync() {
|
async function checkScheduledSync() {
|
||||||
const config = get(appConfig);
|
const vc = activeVaultConfig(get(appConfig));
|
||||||
if (config?.sync_provider !== 'webdav') return;
|
if (vc?.sync_provider !== 'webdav') return;
|
||||||
const mins = config.sync_interval_minutes ?? 0;
|
const mins = vc.sync_interval_minutes ?? 0;
|
||||||
if (!mins || get(syncState).running) return;
|
if (!mins || get(syncState).running) return;
|
||||||
const last = config.last_sync_time ? new Date(config.last_sync_time).getTime() : 0;
|
const last = vc.last_sync_time ? new Date(vc.last_sync_time).getTime() : 0;
|
||||||
if (Date.now() - last >= mins * 60 * 1000) {
|
if (Date.now() - last >= mins * 60 * 1000) {
|
||||||
try { await syncNow(); } catch (_) {}
|
try { await syncNow(); } catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -202,6 +204,7 @@
|
|||||||
last_open_note: $activeNotePath,
|
last_open_note: $activeNotePath,
|
||||||
sidebar_width: $sidebarWidth,
|
sidebar_width: $sidebarWidth,
|
||||||
notelist_width: $notelistWidth,
|
notelist_width: $notelistWidth,
|
||||||
|
outline_width: $outlineWidth,
|
||||||
sidebar_collapsed: $sidebarCollapsed,
|
sidebar_collapsed: $sidebarCollapsed,
|
||||||
notelist_collapsed: $notelistCollapsed,
|
notelist_collapsed: $notelistCollapsed,
|
||||||
collapsed_notebooks: $collapsedNotebooks,
|
collapsed_notebooks: $collapsedNotebooks,
|
||||||
@@ -209,6 +212,7 @@
|
|||||||
notebook_order: $notebookOrder,
|
notebook_order: $notebookOrder,
|
||||||
note_order: $noteOrder,
|
note_order: $noteOrder,
|
||||||
sort_mode: $sortMode,
|
sort_mode: $sortMode,
|
||||||
|
group_notes_by_date: $groupNotesByDate,
|
||||||
last_view_mode: $viewMode,
|
last_view_mode: $viewMode,
|
||||||
last_notebook: $activeNotebook?.relative_path ?? null,
|
last_notebook: $activeNotebook?.relative_path ?? null,
|
||||||
last_tag: $activeTag,
|
last_tag: $activeTag,
|
||||||
@@ -248,11 +252,11 @@
|
|||||||
// 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +430,13 @@
|
|||||||
editor?.addLinkFromToolbar();
|
editor?.addLinkFromToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||||
|
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||||
|
e.preventDefault();
|
||||||
|
noteList?.trashActiveNote();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const action = matchAction(e, $keybindings);
|
const action = matchAction(e, $keybindings);
|
||||||
if (action) {
|
if (action) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -528,6 +539,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
$sidebarCollapsed;
|
$sidebarCollapsed;
|
||||||
$notelistCollapsed;
|
$notelistCollapsed;
|
||||||
|
$outlineWidth;
|
||||||
persistState();
|
persistState();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -544,6 +556,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$sortMode;
|
$sortMode;
|
||||||
|
$groupNotesByDate;
|
||||||
$tasksLayout;
|
$tasksLayout;
|
||||||
$tasksHideCompleted;
|
$tasksHideCompleted;
|
||||||
$tasksOnlyFlagged;
|
$tasksOnlyFlagged;
|
||||||
@@ -567,6 +580,7 @@
|
|||||||
const state = await loadVaultState();
|
const state = await loadVaultState();
|
||||||
$sidebarWidth = state.sidebar_width;
|
$sidebarWidth = state.sidebar_width;
|
||||||
$notelistWidth = state.notelist_width;
|
$notelistWidth = state.notelist_width;
|
||||||
|
if (typeof state.outline_width === 'number') $outlineWidth = state.outline_width;
|
||||||
$sidebarCollapsed = state.sidebar_collapsed;
|
$sidebarCollapsed = state.sidebar_collapsed;
|
||||||
$notelistCollapsed = state.notelist_collapsed ?? false;
|
$notelistCollapsed = state.notelist_collapsed ?? false;
|
||||||
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
||||||
@@ -574,6 +588,7 @@
|
|||||||
$notebookOrder = state.notebook_order ?? {};
|
$notebookOrder = state.notebook_order ?? {};
|
||||||
$noteOrder = state.note_order ?? {};
|
$noteOrder = state.note_order ?? {};
|
||||||
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
|
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
|
||||||
|
if (typeof state.group_notes_by_date === 'boolean') $groupNotesByDate = state.group_notes_by_date;
|
||||||
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
|
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
|
||||||
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
|
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
|
||||||
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
|
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
|
||||||
@@ -599,6 +614,29 @@
|
|||||||
// 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()]);
|
||||||
|
|
||||||
|
// Full-vault attachment scans cause navigation stalls on mobile storage.
|
||||||
|
// Mobile users can run the same cleanup explicitly from the Info panel.
|
||||||
|
if (!isMobile) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (get(editorDirty)) return; // skip if user is actively editing
|
||||||
|
try {
|
||||||
|
const orphans = await findOrphanedAttachments();
|
||||||
|
if (orphans.length > 0) {
|
||||||
|
if (get(editorDirty)) return; // re-check after async scan
|
||||||
|
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||||
|
if (moved > 0) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-secondary);color:var(--text-primary);padding:10px 18px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);border:1px solid var(--border-color);font-size:13px;z-index:10000;opacity:0;transition:opacity .3s;pointer-events:none';
|
||||||
|
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
requestAnimationFrame(() => (toast.style.opacity = '1'));
|
||||||
|
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
// Restore the last session (view + open note) if enabled.
|
// Restore the last session (view + open note) if enabled.
|
||||||
if ($appConfig?.restore_last_session) {
|
if ($appConfig?.restore_last_session) {
|
||||||
const vault = $appConfig?.active_vault;
|
const vault = $appConfig?.active_vault;
|
||||||
@@ -664,7 +702,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
||||||
}, 3000);
|
}, 10000);
|
||||||
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
||||||
debouncedRefresh();
|
debouncedRefresh();
|
||||||
});
|
});
|
||||||
@@ -694,15 +732,16 @@
|
|||||||
|
|
||||||
// ── WebDAV sync: global status + auto-sync triggers ──
|
// ── WebDAV sync: global status + auto-sync triggers ──
|
||||||
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
|
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
|
||||||
unlistenSync.push(await listen('sync-done', (event: any) => {
|
unlistenSync.push(await listen('sync-done', async () => {
|
||||||
syncState.set({ running: false, error: null });
|
syncState.set({ running: false, error: null });
|
||||||
const cur = get(appConfig);
|
try {
|
||||||
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time });
|
appConfig.set(await getAppConfig());
|
||||||
|
} catch {}
|
||||||
}));
|
}));
|
||||||
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
|
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
|
||||||
|
|
||||||
// Sync when the vault opens (if enabled)
|
// Sync when the vault opens (if enabled)
|
||||||
if (syncConfigured() && get(appConfig)?.sync_on_open) {
|
if (syncConfigured() && activeVaultConfig(get(appConfig))?.sync_on_open) {
|
||||||
try { await syncNow(); } catch (_) {}
|
try { await syncNow(); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,8 +751,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);
|
||||||
}
|
}
|
||||||
@@ -829,7 +868,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $appConfig?.sync_provider === 'webdav'}
|
{#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
|
||||||
<button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}>
|
<button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
|
||||||
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
|
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
|
||||||
@@ -856,6 +895,13 @@
|
|||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="mobile-header-btn" onclick={() => ($showInfo = true)} title="Info">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
|
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
@@ -872,7 +918,7 @@
|
|||||||
<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()} 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} />
|
||||||
@@ -933,7 +979,7 @@
|
|||||||
|
|
||||||
{#if !$notelistCollapsed}
|
{#if !$notelistCollapsed}
|
||||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizeHandle onResize={handleNotelistResize} />
|
<ResizeHandle onResize={handleNotelistResize} />
|
||||||
@@ -1114,7 +1160,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
bindingsEqual,
|
bindingsEqual,
|
||||||
type ActionDef,
|
type ActionDef,
|
||||||
} from '$lib/keybindings';
|
} from '$lib/keybindings';
|
||||||
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
// Customizable shortcuts, grouped for display.
|
// Customizable shortcuts, grouped for display.
|
||||||
const actionGroups: { title: string; actions: ActionDef[] }[] = [
|
const actionGroups: { title: string; actions: ActionDef[] }[] = [
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
$showInfo = false;
|
$showInfo = false;
|
||||||
activeTab = 'shortcuts';
|
activeTab = isMobile ? 'about' : 'shortcuts';
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLink(url: string) {
|
function openLink(url: string) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
activeNotebook,
|
activeNotebook,
|
||||||
activeTag,
|
activeTag,
|
||||||
sortMode,
|
sortMode,
|
||||||
|
groupNotesByDate,
|
||||||
editorDirty,
|
editorDirty,
|
||||||
quickAccessPaths,
|
quickAccessPaths,
|
||||||
appConfig,
|
appConfig,
|
||||||
@@ -37,15 +38,16 @@
|
|||||||
getAllTags,
|
getAllTags,
|
||||||
createDailyNote
|
createDailyNote
|
||||||
} 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 { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||||
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 = () => {}, 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;
|
||||||
onNoteCreated?: () => void;
|
onNoteCreated?: () => void;
|
||||||
@@ -55,8 +57,6 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
const isAndroid = /android/i.test(navigator.userAgent);
|
|
||||||
let multiSelectMode = $state(false);
|
let multiSelectMode = $state(false);
|
||||||
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
|
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
|
||||||
let trashBusy = $state<string | null>(null);
|
let trashBusy = $state<string | null>(null);
|
||||||
@@ -213,6 +213,24 @@
|
|||||||
const customOrderViews = new Set(['all', 'notebook', 'tag']);
|
const customOrderViews = new Set(['all', 'notebook', 'tag']);
|
||||||
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
|
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
|
||||||
|
|
||||||
|
// Relative-date grouping (All Notes + date sorts only). Pinned notes form their own leading group.
|
||||||
|
let groupingActive = $derived($groupNotesByDate && $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified'));
|
||||||
|
let noteGroups = $derived.by(() => {
|
||||||
|
if (!groupingActive) return [] as { label: string; notes: NoteEntry[] }[];
|
||||||
|
const pinned: NoteEntry[] = [];
|
||||||
|
const rest: NoteEntry[] = [];
|
||||||
|
for (const n of $sortedNotes) (n.meta.pinned ? pinned : rest).push(n);
|
||||||
|
const groups: { label: string; notes: NoteEntry[] }[] = [];
|
||||||
|
if (pinned.length) groups.push({ label: 'Pinned', notes: pinned });
|
||||||
|
let cur: { label: string; notes: NoteEntry[] } | null = null;
|
||||||
|
for (const n of rest) {
|
||||||
|
const label = dateBucketLabel($sortMode === 'created' ? n.meta.created : n.meta.modified);
|
||||||
|
if (!cur || cur.label !== label) { cur = { label, notes: [] }; groups.push(cur); }
|
||||||
|
cur.notes.push(n);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
// Virtual scroll
|
// Virtual scroll
|
||||||
let listContainer = $state<HTMLDivElement>(null!);
|
let listContainer = $state<HTMLDivElement>(null!);
|
||||||
let scrollTop = $state(0);
|
let scrollTop = $state(0);
|
||||||
@@ -391,13 +409,14 @@
|
|||||||
$activeNote = content;
|
$activeNote = content;
|
||||||
$activeNotePath = task.note_path;
|
$activeNotePath = task.note_path;
|
||||||
$editorDirty = false;
|
$editorDirty = false;
|
||||||
onNoteSelected(task.note_path, content.content);
|
onNoteSelected(task.note_path, content.content, task);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to open task note:', e);
|
console.error('Failed to open task note:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCreateNote() {
|
export async function handleCreateNote() {
|
||||||
|
if ($viewMode === 'quickaccess' || $viewMode === 'trash') return;
|
||||||
if ($viewMode === 'daily') {
|
if ($viewMode === 'daily') {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
@@ -423,14 +442,26 @@
|
|||||||
const entry = await createNote(nbRelative, 'Untitled');
|
const entry = await createNote(nbRelative, 'Untitled');
|
||||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
await refresh();
|
$notes = [entry, ...$notes];
|
||||||
await selectNote(entry);
|
onBeforeNoteSwitch();
|
||||||
|
$activeNote = { path: entry.path, meta: entry.meta, content: '\n', raw: '' };
|
||||||
|
$activeNotePath = entry.path;
|
||||||
|
$editorDirty = false;
|
||||||
|
onNoteSelected(entry.path, '\n');
|
||||||
onNoteCreated();
|
onNoteCreated();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to create note:', e);
|
console.error('Failed to create note:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleListDoubleClick(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
if ($viewMode === 'tasks' || $viewMode === 'trash' || $viewMode === 'quickaccess') return;
|
||||||
|
handleCreateNote();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRename(note: NoteEntry) {
|
async function handleRename(note: NoteEntry) {
|
||||||
if (!editValue.trim() || editValue.trim() === note.meta.title) {
|
if (!editValue.trim() || editValue.trim() === note.meta.title) {
|
||||||
editingNote = null;
|
editingNote = null;
|
||||||
@@ -474,6 +505,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move the currently open note to the trash (keyboard shortcut from the editor).
|
||||||
|
export async function trashActiveNote() {
|
||||||
|
if ($viewMode === 'trash' || !$activeNotePath) return;
|
||||||
|
const note = $notes.find(n => n.path === $activeNotePath);
|
||||||
|
if (note) await handleDelete(note);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRestore(note: NoteEntry) {
|
async function handleRestore(note: NoteEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
try {
|
try {
|
||||||
@@ -749,6 +787,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;
|
||||||
@@ -949,7 +1000,14 @@
|
|||||||
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
|
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if $viewMode !== 'trash'}
|
{#if $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified')}
|
||||||
|
<button class="icon-btn" class:active-toggle={$groupNotesByDate} onclick={() => { $groupNotesByDate = !$groupNotesByDate; }} title="Group by date">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /><line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if $viewMode !== 'trash' && $viewMode !== 'quickaccess'}
|
||||||
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
|
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
@@ -965,8 +1023,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>
|
||||||
@@ -1047,7 +1111,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="list-content" class:tasks-mode={$viewMode === 'tasks'} bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
|
||||||
{#if $viewMode === 'tasks'}
|
{#if $viewMode === 'tasks'}
|
||||||
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1089,6 +1154,8 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
<p>Trash is empty</p>
|
<p>Trash is empty</p>
|
||||||
|
{:else if $viewMode === 'quickaccess'}
|
||||||
|
<p>No starred notes</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p>No notes yet</p>
|
<p>No notes yet</p>
|
||||||
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
||||||
@@ -1127,9 +1194,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div style="height: {topPad}px"></div>
|
{#snippet noteRow(note: NoteEntry, noteIndex: number)}
|
||||||
{#each visibleNotes as note, i (note.path)}
|
|
||||||
{@const noteIndex = startIndex + i}
|
|
||||||
{#if editingNote === note.path}
|
{#if editingNote === note.path}
|
||||||
<div class="note-item active">
|
<div class="note-item active">
|
||||||
<input
|
<input
|
||||||
@@ -1281,10 +1346,34 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $viewMode === 'trash'}
|
||||||
|
<span class="trash-row-actions">
|
||||||
|
<span class="trash-row-btn" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handleRestore(note); }} title="Restore" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handleRestore(note); } }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="trash-row-btn danger" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handlePermanentDelete(note); }} title="Delete permanently" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handlePermanentDelete(note); } }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if groupingActive}
|
||||||
|
{#each noteGroups as group (group.label)}
|
||||||
|
<div class="note-list-section">{group.label}</div>
|
||||||
|
{#each group.notes as note (note.path)}
|
||||||
|
{@render noteRow(note, -1)}
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div style="height: {topPad}px"></div>
|
||||||
|
{#each visibleNotes as note, i (note.path)}
|
||||||
|
{@render noteRow(note, startIndex + i)}
|
||||||
{/each}
|
{/each}
|
||||||
<div style="height: {bottomPad}px"></div>
|
<div style="height: {bottomPad}px"></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1459,6 +1548,14 @@
|
|||||||
</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">
|
||||||
@@ -1581,17 +1678,19 @@
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-content::-webkit-scrollbar {
|
.note-list-section {
|
||||||
width: 8px;
|
padding: 14px 12px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
.note-list-section:first-child {
|
||||||
.list-content::-webkit-scrollbar-thumb {
|
padding-top: 6px;
|
||||||
background: var(--text-tertiary);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-content::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item {
|
.note-item {
|
||||||
@@ -1605,6 +1704,7 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
contain: content;
|
contain: content;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item:hover {
|
.note-item:hover {
|
||||||
@@ -1651,6 +1751,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selection-action {
|
.selection-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1665,6 +1768,37 @@
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trash-row-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: none;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.note-item:hover .trash-row-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.trash-row-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.trash-row-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.trash-row-btn.danger:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||||
|
}
|
||||||
|
.note-list.mobile .trash-row-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.selection-action.danger {
|
.selection-action.danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
||||||
@@ -2079,6 +2213,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;
|
||||||
|
|||||||
@@ -103,8 +103,11 @@
|
|||||||
if (!$editorDirty) {
|
if (!$editorDirty) {
|
||||||
try {
|
try {
|
||||||
const content = await readNote(notePath);
|
const content = await readNote(notePath);
|
||||||
|
// Ignore the file-watcher echo of our own save; only reload genuine external edits.
|
||||||
|
if (content.content.trim() !== (editor?.getCurrentBody() ?? '').trim()) {
|
||||||
$activeNote = content;
|
$activeNote = content;
|
||||||
editor?.loadNote(notePath, content.content);
|
editor?.loadNote(notePath, content.content);
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView } from '$lib/stores/app';
|
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView, notebooks, activeNotebook, activeTag, viewMode } from '$lib/stores/app';
|
||||||
import { searchNotes, readNote } from '$lib/api';
|
import { searchNotes, readNote } from '$lib/api';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import type { SearchResult } from '$lib/types';
|
import type { SearchResult, NotebookEntry } from '$lib/types';
|
||||||
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let results = $state<SearchResult[]>([]);
|
let results = $state<SearchResult[]>([]);
|
||||||
@@ -83,7 +84,16 @@
|
|||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
function findNotebookByPath(nbs: NotebookEntry[], target: string): NotebookEntry | null {
|
||||||
|
for (const nb of nbs) {
|
||||||
|
if (nb.path === target) return nb;
|
||||||
|
if (nb.children.length) {
|
||||||
|
const found = findNotebookByPath(nb.children, target);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function openResult(result: SearchResult) {
|
async function openResult(result: SearchResult) {
|
||||||
try {
|
try {
|
||||||
@@ -92,6 +102,19 @@
|
|||||||
$activeNotePath = result.path;
|
$activeNotePath = result.path;
|
||||||
$editorDirty = false;
|
$editorDirty = false;
|
||||||
$showSearch = false;
|
$showSearch = false;
|
||||||
|
// Reveal the note in the notes list: switch to its notebook (or All Notes).
|
||||||
|
const sep = result.path.includes('\\') ? '\\' : '/';
|
||||||
|
const parentDir = result.path.slice(0, result.path.lastIndexOf(sep));
|
||||||
|
const nb = parentDir ? findNotebookByPath($notebooks, parentDir) : null;
|
||||||
|
if (nb) {
|
||||||
|
$activeNotebook = nb;
|
||||||
|
$activeTag = null;
|
||||||
|
$viewMode = 'notebook';
|
||||||
|
} else {
|
||||||
|
$activeNotebook = null;
|
||||||
|
$activeTag = null;
|
||||||
|
$viewMode = 'all';
|
||||||
|
}
|
||||||
if (isMobile) $mobileView = 'editor';
|
if (isMobile) $mobileView = 'editor';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to open search result:', e);
|
console.error('Failed to open search result:', e);
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall } from '$lib/stores/app';
|
import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||||
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api';
|
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
|
||||||
import { darkThemes } from '$lib/platform';
|
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||||
import { openUrl } from '$lib/api';
|
import { openUrl } from '$lib/api';
|
||||||
import type { ImportResult, BackupEntry } from '$lib/types';
|
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors } from '$lib/types';
|
||||||
|
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||||
|
|
||||||
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
|
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
|
||||||
@@ -303,25 +302,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── WebDAV sync ──
|
// ── WebDAV sync ──
|
||||||
let syncProvider = $state<string | null>($appConfig?.sync_provider ?? null);
|
let syncProvider = $state<string | null>(activeVaultConfig($appConfig)?.sync_provider ?? null);
|
||||||
let syncUrl = $state($appConfig?.webdav_url ?? '');
|
let syncUrl = $state(activeVaultConfig($appConfig)?.webdav_url ?? '');
|
||||||
let syncUsername = $state($appConfig?.webdav_username ?? '');
|
let syncUsername = $state(activeVaultConfig($appConfig)?.webdav_username ?? '');
|
||||||
let syncPassword = $state($appConfig?.webdav_password ?? '');
|
let syncPassword = $state(activeVaultConfig($appConfig)?.webdav_password ?? '');
|
||||||
let syncShowPassword = $state(false);
|
let syncShowPassword = $state(false);
|
||||||
let syncTestLoading = $state(false);
|
let syncTestLoading = $state(false);
|
||||||
let syncTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
let syncTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
let syncRunning = $state(false);
|
let syncRunning = $state(false);
|
||||||
let syncMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
let syncMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
let syncOnOpen = $state($appConfig?.sync_on_open ?? false);
|
let syncOnOpen = $state(activeVaultConfig($appConfig)?.sync_on_open ?? false);
|
||||||
let syncOnChange = $state($appConfig?.sync_on_change ?? false);
|
let syncOnChange = $state(activeVaultConfig($appConfig)?.sync_on_change ?? false);
|
||||||
let syncIntervalMinutes = $state($appConfig?.sync_interval_minutes ?? 0);
|
let syncIntervalMinutes = $state(activeVaultConfig($appConfig)?.sync_interval_minutes ?? 0);
|
||||||
|
|
||||||
|
// Re-seed the sync form when the active vault changes, so Settings reflects the current vault.
|
||||||
|
let lastSyncVault: string | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const identity = $appConfig?.active_bookmark_id
|
||||||
|
? `bookmark:${$appConfig.active_bookmark_id}`
|
||||||
|
: $appConfig?.active_vault
|
||||||
|
? `path:${$appConfig.active_vault}`
|
||||||
|
: null;
|
||||||
|
if (identity === lastSyncVault) return;
|
||||||
|
lastSyncVault = identity;
|
||||||
|
const vc = activeVaultConfig($appConfig);
|
||||||
|
syncProvider = vc?.sync_provider ?? null;
|
||||||
|
syncUrl = vc?.webdav_url ?? '';
|
||||||
|
syncUsername = vc?.webdav_username ?? '';
|
||||||
|
syncPassword = vc?.webdav_password ?? '';
|
||||||
|
syncOnOpen = vc?.sync_on_open ?? false;
|
||||||
|
syncOnChange = vc?.sync_on_change ?? false;
|
||||||
|
syncIntervalMinutes = vc?.sync_interval_minutes ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
async function saveSyncSettings() {
|
async function saveSyncSettings() {
|
||||||
await setSyncSettings(syncProvider, syncUrl || null, syncUsername || null, syncPassword || null, syncOnOpen, syncOnChange, syncIntervalMinutes);
|
await setSyncSettings(syncProvider, syncUrl || null, syncUsername || null, syncPassword || null, syncOnOpen, syncOnChange, syncIntervalMinutes);
|
||||||
// Reflect into the global store so the top-bar button visibility and the auto-sync
|
// Reflect into the active vault's config in the store so the top-bar button and auto-sync
|
||||||
// triggers update live, without needing an app restart.
|
// triggers update live, without an app restart.
|
||||||
if ($appConfig) $appConfig = {
|
if ($appConfig) {
|
||||||
...$appConfig,
|
const cur = $appConfig;
|
||||||
|
const active = activeVaultConfig(cur);
|
||||||
|
$appConfig = {
|
||||||
|
...cur,
|
||||||
|
vaults: cur.vaults.map((vault) => vault === active ? {
|
||||||
|
...vault,
|
||||||
sync_provider: syncProvider,
|
sync_provider: syncProvider,
|
||||||
webdav_url: syncUrl || null,
|
webdav_url: syncUrl || null,
|
||||||
webdav_username: syncUsername || null,
|
webdav_username: syncUsername || null,
|
||||||
@@ -329,8 +353,10 @@
|
|||||||
sync_on_open: syncOnOpen,
|
sync_on_open: syncOnOpen,
|
||||||
sync_on_change: syncOnChange,
|
sync_on_change: syncOnChange,
|
||||||
sync_interval_minutes: syncIntervalMinutes,
|
sync_interval_minutes: syncIntervalMinutes,
|
||||||
|
} : vault),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTestSync() {
|
async function handleTestSync() {
|
||||||
syncTestLoading = true;
|
syncTestLoading = true;
|
||||||
@@ -358,7 +384,7 @@
|
|||||||
syncMessage = null;
|
syncMessage = null;
|
||||||
const unlisteners: Array<() => void> = [];
|
const unlisteners: Array<() => void> = [];
|
||||||
const cleanup = () => { unlisteners.forEach((u) => u()); };
|
const cleanup = () => { unlisteners.forEach((u) => u()); };
|
||||||
unlisteners.push(await listen<{ success: boolean; summary?: { uploaded?: number; downloaded?: number; deleted_local?: number; deleted_remote?: number; conflicts?: number }; last_sync_time?: string }>('sync-done', (event) => {
|
unlisteners.push(await listen<{ success: boolean; summary?: { uploaded?: number; downloaded?: number; deleted_local?: number; deleted_remote?: number; conflicts?: number }; last_sync_time?: string }>('sync-done', async (event) => {
|
||||||
const s = event.payload.summary ?? {};
|
const s = event.payload.summary ?? {};
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (s.uploaded) parts.push(`${s.uploaded} uploaded`);
|
if (s.uploaded) parts.push(`${s.uploaded} uploaded`);
|
||||||
@@ -368,7 +394,11 @@
|
|||||||
if (s.conflicts) parts.push(`${s.conflicts} conflict copies`);
|
if (s.conflicts) parts.push(`${s.conflicts} conflict copies`);
|
||||||
syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' };
|
syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' };
|
||||||
syncRunning = false;
|
syncRunning = false;
|
||||||
if ($appConfig && event.payload.last_sync_time) $appConfig = { ...$appConfig, last_sync_time: event.payload.last_sync_time };
|
if (event.payload.last_sync_time) {
|
||||||
|
try {
|
||||||
|
$appConfig = await getAppConfig();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
cleanup();
|
cleanup();
|
||||||
}));
|
}));
|
||||||
unlisteners.push(await listen<{ error?: string }>('sync-error', (event) => {
|
unlisteners.push(await listen<{ error?: string }>('sync-error', (event) => {
|
||||||
@@ -387,7 +417,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let isThemeDark = $derived(
|
let isThemeDark = $derived(
|
||||||
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||||
|
($theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false))
|
||||||
);
|
);
|
||||||
|
|
||||||
const themePresets = [
|
const themePresets = [
|
||||||
@@ -494,6 +525,7 @@
|
|||||||
let activeContentWidth = $state<number | null>($appConfig?.content_width ?? null);
|
let activeContentWidth = $state<number | null>($appConfig?.content_width ?? null);
|
||||||
let themeDropdownOpen = $state(false);
|
let themeDropdownOpen = $state(false);
|
||||||
let accentDropdownOpen = $state(false);
|
let accentDropdownOpen = $state(false);
|
||||||
|
let activeCustomTheme = $derived($customThemes.find(c => c.id === $theme) ?? null);
|
||||||
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
|
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
|
||||||
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
|
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
|
||||||
let isCustomAccent = $derived(activeAccent.startsWith('#'));
|
let isCustomAccent = $derived(activeAccent.startsWith('#'));
|
||||||
@@ -501,6 +533,170 @@
|
|||||||
isCustomAccent ? activeAccent : (isThemeDark ? activeAccentPreset.dark : activeAccentPreset.light)
|
isCustomAccent ? activeAccent : (isThemeDark ? activeAccentPreset.dark : activeAccentPreset.light)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Custom theme editor state
|
||||||
|
const DEFAULT_DARK_COLORS: CustomThemeColors = {
|
||||||
|
bg_primary: '#1a1b26', bg_secondary: '#1f2029', bg_tertiary: '#2a2b3d',
|
||||||
|
bg_hover: '#2f3145', bg_active: '#373a52', bg_editor: '#1a1b26',
|
||||||
|
text_primary: '#c8cde4', text_secondary: '#6b7094', border_color: '#2f3145',
|
||||||
|
};
|
||||||
|
const DEFAULT_LIGHT_COLORS: CustomThemeColors = {
|
||||||
|
bg_primary: '#ffffff', bg_secondary: '#f8f9fa', bg_tertiary: '#f0f2f5',
|
||||||
|
bg_hover: '#e8eaed', bg_active: '#dde1e7', bg_editor: '#ffffff',
|
||||||
|
text_primary: '#1a1a2e', text_secondary: '#6b7280', border_color: '#e2e5ea',
|
||||||
|
};
|
||||||
|
|
||||||
|
let customThemeEditing = $state<CustomTheme | null>(null);
|
||||||
|
let customThemeEditorOpen = $state(false);
|
||||||
|
let customThemeMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
let customThemeExportingId = $state<string | null>(null);
|
||||||
|
let customThemeImporting = $state(false);
|
||||||
|
|
||||||
|
function openNewCustomThemeEditor() {
|
||||||
|
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) || $theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false);
|
||||||
|
customThemeEditing = {
|
||||||
|
id: `custom-${Date.now()}`,
|
||||||
|
name: '',
|
||||||
|
is_dark: isDark,
|
||||||
|
colors: isDark ? { ...DEFAULT_DARK_COLORS } : { ...DEFAULT_LIGHT_COLORS },
|
||||||
|
};
|
||||||
|
customThemeEditorOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditCustomThemeEditor(ct: CustomTheme) {
|
||||||
|
customThemeEditing = { ...ct, colors: { ...ct.colors } };
|
||||||
|
customThemeEditorOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCustomThemeEditor() {
|
||||||
|
customThemeEditorOpen = false;
|
||||||
|
customThemeEditing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewCustomTheme() {
|
||||||
|
if (!customThemeEditing) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--bg-primary', customThemeEditing.colors.bg_primary);
|
||||||
|
root.style.setProperty('--bg-secondary', customThemeEditing.colors.bg_secondary);
|
||||||
|
root.style.setProperty('--bg-tertiary', customThemeEditing.colors.bg_tertiary);
|
||||||
|
root.style.setProperty('--bg-hover', customThemeEditing.colors.bg_hover);
|
||||||
|
root.style.setProperty('--bg-active', customThemeEditing.colors.bg_active);
|
||||||
|
root.style.setProperty('--bg-editor', customThemeEditing.colors.bg_editor);
|
||||||
|
root.style.setProperty('--text-primary', customThemeEditing.colors.text_primary);
|
||||||
|
root.style.setProperty('--text-secondary', customThemeEditing.colors.text_secondary);
|
||||||
|
root.style.setProperty('--border-color', customThemeEditing.colors.border_color);
|
||||||
|
root.classList.toggle('dark', customThemeEditing.is_dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCurrentTheme() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
|
||||||
|
root.classList.remove('dark');
|
||||||
|
root.removeAttribute('data-theme');
|
||||||
|
for (const v of varsToClear) root.style.removeProperty(v);
|
||||||
|
const namedThemes = ['solarized-light','solarized-dark','catppuccin','nord','tokyo-night','github-light','github-dark','dracula','blueberry','forest-green','gruvbox','midnight-tide','cherry-blossom','synthwave','ember','moonlit','light-coffee','dark-coffee','cotton-candy','crimson','cloud','peach','material-dark','material-light','monokai','rose-pine','everforest','horizon','cyberpunk','black','one-dark'];
|
||||||
|
if ($theme.startsWith('custom-')) {
|
||||||
|
const ct = $customThemes.find(c => c.id === $theme);
|
||||||
|
if (ct) {
|
||||||
|
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
||||||
|
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
||||||
|
root.style.setProperty('--bg-tertiary', ct.colors.bg_tertiary);
|
||||||
|
root.style.setProperty('--bg-hover', ct.colors.bg_hover);
|
||||||
|
root.style.setProperty('--bg-active', ct.colors.bg_active);
|
||||||
|
root.style.setProperty('--bg-editor', ct.colors.bg_editor);
|
||||||
|
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
||||||
|
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
||||||
|
root.style.setProperty('--border-color', ct.colors.border_color);
|
||||||
|
if (ct.is_dark) root.classList.add('dark');
|
||||||
|
}
|
||||||
|
} else if (namedThemes.includes($theme)) {
|
||||||
|
root.setAttribute('data-theme', $theme);
|
||||||
|
if (darkThemes.includes($theme)) root.classList.add('dark');
|
||||||
|
} else if ($theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
root.classList.add('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCustomThemeEditor() {
|
||||||
|
if (!customThemeEditing || !customThemeEditing.name.trim()) return;
|
||||||
|
const theme_to_save = { ...customThemeEditing, name: customThemeEditing.name.trim() };
|
||||||
|
try {
|
||||||
|
await saveCustomTheme(theme_to_save);
|
||||||
|
if ($appConfig) {
|
||||||
|
const idx = $appConfig.custom_themes.findIndex(t => t.id === theme_to_save.id);
|
||||||
|
if (idx >= 0) $appConfig.custom_themes[idx] = theme_to_save;
|
||||||
|
else $appConfig.custom_themes = [...$appConfig.custom_themes, theme_to_save];
|
||||||
|
$appConfig = { ...$appConfig };
|
||||||
|
}
|
||||||
|
closeCustomThemeEditor();
|
||||||
|
} catch (e) {
|
||||||
|
customThemeMessage = { type: 'error', text: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCustomThemeEditor() {
|
||||||
|
restoreCurrentTheme();
|
||||||
|
closeCustomThemeEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectCustomTheme(ct: CustomTheme) {
|
||||||
|
$theme = ct.id;
|
||||||
|
setTheme(ct.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCustomTheme(ct: CustomTheme) {
|
||||||
|
try {
|
||||||
|
await deleteCustomTheme(ct.id);
|
||||||
|
if ($appConfig) {
|
||||||
|
$appConfig.custom_themes = $appConfig.custom_themes.filter(t => t.id !== ct.id);
|
||||||
|
$appConfig = { ...$appConfig };
|
||||||
|
}
|
||||||
|
if ($theme === ct.id) {
|
||||||
|
$theme = 'system';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
customThemeMessage = { type: 'error', text: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportCustomTheme(ct: CustomTheme) {
|
||||||
|
customThemeExportingId = ct.id;
|
||||||
|
try {
|
||||||
|
const safeName = ct.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||||
|
const path = await saveDialog({ filters: [{ name: 'JSON', extensions: ['json'] }], defaultPath: `${safeName}.json` });
|
||||||
|
if (path) {
|
||||||
|
await exportCustomTheme(ct.id, path);
|
||||||
|
customThemeMessage = { type: 'success', text: `"${ct.name}" exported.` };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
customThemeMessage = { type: 'error', text: String(e) };
|
||||||
|
} finally {
|
||||||
|
customThemeExportingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportCustomThemes() {
|
||||||
|
customThemeImporting = true;
|
||||||
|
try {
|
||||||
|
const path = await openDialog({ filters: [{ name: 'JSON', extensions: ['json'] }], multiple: false });
|
||||||
|
if (path) {
|
||||||
|
const imported = await importCustomThemes(path as string);
|
||||||
|
if ($appConfig) {
|
||||||
|
for (const t of imported) {
|
||||||
|
const idx = $appConfig.custom_themes.findIndex(x => x.id === t.id);
|
||||||
|
if (idx >= 0) $appConfig.custom_themes[idx] = t;
|
||||||
|
else $appConfig.custom_themes.push(t);
|
||||||
|
}
|
||||||
|
$appConfig = { ...$appConfig };
|
||||||
|
}
|
||||||
|
customThemeMessage = { type: 'success', text: `Imported ${imported.length} theme${imported.length !== 1 ? 's' : ''}.` };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
customThemeMessage = { type: 'error', text: String(e) };
|
||||||
|
} finally {
|
||||||
|
customThemeImporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// General settings
|
// General settings
|
||||||
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
||||||
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
|
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
|
||||||
@@ -547,10 +743,16 @@
|
|||||||
importResult = null;
|
importResult = null;
|
||||||
importError = null;
|
importError = null;
|
||||||
|
|
||||||
const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; error?: string }>('import-done', (event) => {
|
const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; frontmatter_normalized?: number; syntax_converted?: number; attachments_moved?: number; error?: string }>('import-done', (event) => {
|
||||||
const data = event.payload;
|
const data = event.payload;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
importResult = { files_converted: data.files_converted ?? 0, links_converted: data.links_converted ?? 0 };
|
importResult = {
|
||||||
|
files_converted: data.files_converted ?? 0,
|
||||||
|
links_converted: data.links_converted ?? 0,
|
||||||
|
frontmatter_normalized: data.frontmatter_normalized ?? 0,
|
||||||
|
syntax_converted: data.syntax_converted ?? 0,
|
||||||
|
attachments_moved: data.attachments_moved ?? 0,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
importError = data.error ?? 'Import failed';
|
importError = data.error ?? 'Import failed';
|
||||||
}
|
}
|
||||||
@@ -863,12 +1065,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Sync
|
Sync
|
||||||
</button>
|
</button>
|
||||||
|
{#if !isAndroid}
|
||||||
<button class="tab-btn" class:active={activeTab === 'updates'} onclick={() => activeTab = 'updates'}>
|
<button class="tab-btn" class:active={activeTab === 'updates'} onclick={() => activeTab = 'updates'}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 3"/>
|
<path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 3"/>
|
||||||
</svg>
|
</svg>
|
||||||
Updates
|
Updates
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
@@ -1230,6 +1434,166 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Themes -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header-row">
|
||||||
|
<h3>Custom Themes</h3>
|
||||||
|
<button class="create-theme-btn" onclick={openNewCustomThemeEditor}>
|
||||||
|
<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="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
New Theme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $customThemes.length > 0}
|
||||||
|
<div class="custom-theme-list">
|
||||||
|
{#each $customThemes as ct}
|
||||||
|
<div class="custom-theme-row" class:active={$theme === ct.id}>
|
||||||
|
<button class="custom-theme-swatch-btn" onclick={() => selectCustomTheme(ct)} title="Apply {ct.name}">
|
||||||
|
<span class="custom-theme-swatch" style="background: {ct.colors.bg_primary}; border-color: {ct.colors.border_color}">
|
||||||
|
<span class="swatch-sidebar" style="background: {ct.colors.bg_secondary}"></span>
|
||||||
|
</span>
|
||||||
|
<span class="custom-theme-name">{ct.name}</span>
|
||||||
|
{#if $theme === ct.id}
|
||||||
|
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<div class="custom-theme-actions">
|
||||||
|
<button class="icon-btn" title="Edit" onclick={() => openEditCustomThemeEditor(ct)}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 00-3.986-3.987L3.842 16.174a2 2 0 00-.5.83l-1.321 4.352a.5.5 0 00.623.622l4.353-1.32a2 2 0 00.83-.497z"/></svg>
|
||||||
|
</button>
|
||||||
|
{#if !isMobile}
|
||||||
|
<button class="icon-btn" title="Export" disabled={customThemeExportingId === ct.id} onclick={() => handleExportCustomTheme(ct)}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="icon-btn danger" title="Delete" onclick={() => removeCustomTheme(ct)}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="setting-hint" style="margin-top: 8px;">No custom themes yet. Create one to define your own colors.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !isMobile}
|
||||||
|
<div class="custom-theme-io">
|
||||||
|
<button class="backup-link-btn" onclick={handleImportCustomThemes} disabled={customThemeImporting}>
|
||||||
|
<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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
{customThemeImporting ? 'Importing...' : 'Import theme'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if customThemeMessage}
|
||||||
|
<div class="import-result {customThemeMessage.type}" style="margin-top: 10px;">
|
||||||
|
{#if customThemeMessage.type === 'success'}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
{/if}
|
||||||
|
<span>{customThemeMessage.text}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Theme Editor Modal -->
|
||||||
|
{#if customThemeEditorOpen && customThemeEditing}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeEditor} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="custom-theme-modal" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="custom-theme-modal-header">
|
||||||
|
<h3>{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
|
||||||
|
<button class="close-btn" onclick={cancelCustomThemeEditor}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-theme-modal-body">
|
||||||
|
<div class="ct-field">
|
||||||
|
<label class="ct-label">Theme Name</label>
|
||||||
|
<input class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ct-field">
|
||||||
|
<label class="ct-label">Mode</label>
|
||||||
|
<div class="setting-options">
|
||||||
|
<button class="option-btn" class:active={!customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = false; previewCustomTheme(); } }}>Light</button>
|
||||||
|
<button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ct-colors-grid">
|
||||||
|
<div class="ct-color-group">
|
||||||
|
<span class="ct-group-label">Backgrounds</span>
|
||||||
|
{#each [
|
||||||
|
{ key: 'bg_primary', label: 'Primary' },
|
||||||
|
{ key: 'bg_secondary', label: 'Sidebar' },
|
||||||
|
{ key: 'bg_tertiary', label: 'Cards' },
|
||||||
|
{ key: 'bg_editor', label: 'Editor' },
|
||||||
|
{ key: 'bg_hover', label: 'Hover' },
|
||||||
|
{ key: 'bg_active', label: 'Active' },
|
||||||
|
] as field}
|
||||||
|
<label class="ct-color-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="ct-color-input"
|
||||||
|
value={customThemeEditing.colors[field.key as keyof CustomThemeColors]}
|
||||||
|
oninput={(e) => { if (customThemeEditing) { (customThemeEditing.colors as any)[field.key] = e.currentTarget.value; previewCustomTheme(); } }}
|
||||||
|
/>
|
||||||
|
<span class="ct-color-label">{field.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ct-color-group">
|
||||||
|
<span class="ct-group-label">Text & Borders</span>
|
||||||
|
{#each [
|
||||||
|
{ key: 'text_primary', label: 'Primary Text' },
|
||||||
|
{ key: 'text_secondary', label: 'Secondary Text' },
|
||||||
|
{ key: 'border_color', label: 'Borders' },
|
||||||
|
] as field}
|
||||||
|
<label class="ct-color-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="ct-color-input"
|
||||||
|
value={customThemeEditing.colors[field.key as keyof CustomThemeColors]}
|
||||||
|
oninput={(e) => { if (customThemeEditing) { (customThemeEditing.colors as any)[field.key] = e.currentTarget.value; previewCustomTheme(); } }}
|
||||||
|
/>
|
||||||
|
<span class="ct-color-label">{field.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ct-preview">
|
||||||
|
<span class="ct-preview-label">Preview</span>
|
||||||
|
<div class="ct-preview-window" style="--p-bg: {customThemeEditing.colors.bg_primary}; --p-sidebar: {customThemeEditing.colors.bg_secondary}; --p-card: {customThemeEditing.colors.bg_tertiary}; --p-text: {customThemeEditing.colors.text_primary}; --p-text2: {customThemeEditing.colors.text_secondary}; --p-border: {customThemeEditing.colors.border_color}; --p-hover: {customThemeEditing.colors.bg_hover};">
|
||||||
|
<div class="ct-preview-sidebar">
|
||||||
|
<div class="ct-preview-item active"></div>
|
||||||
|
<div class="ct-preview-item"></div>
|
||||||
|
<div class="ct-preview-item"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ct-preview-main">
|
||||||
|
<div class="ct-preview-title"></div>
|
||||||
|
<div class="ct-preview-line long"></div>
|
||||||
|
<div class="ct-preview-line medium"></div>
|
||||||
|
<div class="ct-preview-line short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-theme-modal-footer">
|
||||||
|
<button class="cancel-btn" onclick={cancelCustomThemeEditor}>Cancel</button>
|
||||||
|
<button class="save-btn" onclick={saveCustomThemeEditor} disabled={!customThemeEditing.name.trim()}>Save Theme</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Font Size</h3>
|
<h3>Font Size</h3>
|
||||||
<div class="font-size-options">
|
<div class="font-size-options">
|
||||||
@@ -1453,7 +1817,7 @@
|
|||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Import from Obsidian</h3>
|
<h3>Import from Obsidian</h3>
|
||||||
<p class="import-desc">Convert Obsidian wiki-links (<code>![[image.png]]</code>, <code>[[note]]</code>) to standard markdown links across all notes in the current vault. This expects that you have opened an Obsidian vault directory directly as your HelixNotes vault.</p>
|
<p class="import-desc">Fully converts an Obsidian vault to HelixNotes format: normalizes frontmatter (tags, IDs, dates, title headings), converts <code>[[wiki-links]]</code> to standard markdown links, converts <code>==highlights==</code> and <code>%%comments%%</code>, and relocates attachments to <code>.helixnotes/attachments/</code>. Open the Obsidian vault directory as your HelixNotes vault first.</p>
|
||||||
<div class="import-warn">
|
<div class="import-warn">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
|
||||||
<span>This modifies files in place. Make a backup before running!</span>
|
<span>This modifies files in place. Make a backup before running!</span>
|
||||||
@@ -1475,7 +1839,16 @@
|
|||||||
{#if importResult}
|
{#if importResult}
|
||||||
<div class="import-result success">
|
<div class="import-result success">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
||||||
<span>Converted {importResult.links_converted} links across {importResult.files_converted} files</span>
|
<div class="import-stats">
|
||||||
|
<span>Import complete</span>
|
||||||
|
<ul>
|
||||||
|
{#if importResult.frontmatter_normalized}<li>Normalized frontmatter in {importResult.frontmatter_normalized} notes</li>{/if}
|
||||||
|
{#if importResult.links_converted}<li>Converted {importResult.links_converted} wiki-links to markdown</li>{/if}
|
||||||
|
{#if importResult.syntax_converted}<li>Converted Obsidian syntax (highlights, comments) in {importResult.syntax_converted} notes</li>{/if}
|
||||||
|
{#if importResult.attachments_moved}<li>Moved {importResult.attachments_moved} attachments to .helixnotes/attachments/</li>{/if}
|
||||||
|
{#if !importResult.frontmatter_normalized && !importResult.links_converted && !importResult.syntax_converted && !importResult.attachments_moved}<li>No changes needed</li>{/if}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if importError}
|
{#if importError}
|
||||||
@@ -1760,8 +2133,8 @@
|
|||||||
<span>{syncMessage.text}</span>
|
<span>{syncMessage.text}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $appConfig?.last_sync_time}
|
{#if activeVaultConfig($appConfig)?.last_sync_time}
|
||||||
<p class="setting-hint">Last sync: {new Date($appConfig.last_sync_time).toLocaleString()}</p>
|
<p class="setting-hint">Last sync: {new Date(activeVaultConfig($appConfig)!.last_sync_time!).toLocaleString()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="setting-hint">If a note is edited on two devices, both are kept (one as a "(conflict)" copy).</p>
|
<p class="setting-hint">If a note is edited on two devices, both are kept (one as a "(conflict)" copy).</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1867,7 +2240,7 @@
|
|||||||
Download from Website
|
Download from Website
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<a class="update-install-btn" href="https://codeberg.org/ArkHost/HelixNotes/releases" target="_blank" rel="noopener">
|
<a class="update-install-btn" href="https://gitlab.com/ArkHost/HelixNotes/-/releases" target="_blank" rel="noopener">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -2449,7 +2822,7 @@
|
|||||||
|
|
||||||
.import-result {
|
.import-result {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -2462,6 +2835,12 @@
|
|||||||
color: #059669;
|
color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-stats ul {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.import-result.error {
|
.import-result.error {
|
||||||
background: color-mix(in srgb, #e11d48 10%, transparent);
|
background: color-mix(in srgb, #e11d48 10%, transparent);
|
||||||
color: #e11d48;
|
color: #e11d48;
|
||||||
@@ -2964,4 +3343,363 @@
|
|||||||
padding: 12px 6px;
|
padding: 12px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Custom Themes ── */
|
||||||
|
.section-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-row h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-theme-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--text-accent);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-theme-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 2px 4px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-row.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-swatch-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-swatch-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-swatch {
|
||||||
|
width: 32px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-sidebar {
|
||||||
|
width: 35%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-io {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Custom Theme Modal ── */
|
||||||
|
.custom-theme-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-modal {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: 480px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 20px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-modal-header h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-modal-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-theme-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-name-input {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-name-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-colors-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-color-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-group-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-color-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-color-input {
|
||||||
|
width: 30px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-color-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme preview */
|
||||||
|
.ct-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-preview-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-preview-window {
|
||||||
|
display: flex;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--p-border);
|
||||||
|
height: 80px;
|
||||||
|
background: var(--p-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-preview-sidebar {
|
||||||
|
width: 80px;
|
||||||
|
background: var(--p-sidebar);
|
||||||
|
border-right: 1px solid var(--p-border);
|
||||||
|
padding: 8px 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-preview-item {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--p-text2);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-preview-item.active {
|
||||||
|
background: var(--p-hover);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-preview-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-preview-title {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--p-text);
|
||||||
|
width: 60%;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-preview-line {
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--p-text2);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-preview-line.long { width: 90%; }
|
||||||
|
.ct-preview-line.medium { width: 70%; }
|
||||||
|
.ct-preview-line.short { width: 45%; }
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
padding: 7px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -26,13 +26,13 @@
|
|||||||
import { readFile } from '@tauri-apps/plugin-fs';
|
import { readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import type { NotebookEntry } from '$lib/types';
|
import type { NotebookEntry } from '$lib/types';
|
||||||
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
let { onViewChanged = () => {} }: {
|
let { onViewChanged = () => {} }: {
|
||||||
onViewChanged?: () => void;
|
onViewChanged?: () => void;
|
||||||
} = $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);
|
|
||||||
|
|
||||||
// Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible.
|
// Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible.
|
||||||
// When all are hidden we drop the empty <nav> and its divider so the tree sits flush.
|
// When all are hidden we drop the empty <nav> and its divider so the tree sits flush.
|
||||||
@@ -53,6 +53,15 @@
|
|||||||
let dropTargetPath = $state<string | null>(null);
|
let dropTargetPath = $state<string | null>(null);
|
||||||
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
|
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
|
||||||
let draggedNotebookPath = $state<string | null>(null);
|
let draggedNotebookPath = $state<string | null>(null);
|
||||||
|
let nbPointer: { src: string } | null = null;
|
||||||
|
|
||||||
|
// Notebook paths arrive from Rust with native separators (backslashes on Windows), so path math must be separator-agnostic.
|
||||||
|
const norm = (p: string) => (p ?? '').replace(/\\/g, '/');
|
||||||
|
const lastSep = (p: string) => Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||||
|
const parentOf = (p: string) => { const i = lastSep(p); return i < 0 ? '' : p.slice(0, i); };
|
||||||
|
const baseOf = (p: string) => p.slice(lastSep(p) + 1);
|
||||||
|
const joinPath = (parent: string, name: string) => parent + (parent.includes('\\') ? '\\' : '/') + name;
|
||||||
|
const isDescendant = (child: string, anc: string) => child.startsWith(anc + '/') || child.startsWith(anc + '\\');
|
||||||
|
|
||||||
// Apply manual ordering recursively when in 'manual' sort mode.
|
// Apply manual ordering recursively when in 'manual' sort mode.
|
||||||
// In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged.
|
// In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged.
|
||||||
@@ -72,12 +81,6 @@
|
|||||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let tagsCollapsed = $state(true);
|
let tagsCollapsed = $state(true);
|
||||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||||
function expandNotebook(nb: NotebookEntry) {
|
|
||||||
if ($collapsedNotebooks.includes(nb.path)) {
|
|
||||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCollapse(nb: NotebookEntry, e: Event) {
|
function toggleCollapse(nb: NotebookEntry, e: Event) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if ($collapsedNotebooks.includes(nb.path)) {
|
if ($collapsedNotebooks.includes(nb.path)) {
|
||||||
@@ -143,6 +146,10 @@
|
|||||||
const vault = $appConfig?.active_vault;
|
const vault = $appConfig?.active_vault;
|
||||||
if (!vault) return;
|
if (!vault) return;
|
||||||
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
|
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
|
||||||
|
if (isMobile) {
|
||||||
|
onViewChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectAllNotes();
|
selectAllNotes();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,22 +160,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectNotebook(nb: NotebookEntry) {
|
async function selectNotebook(nb: NotebookEntry) {
|
||||||
const hasVisibleChildren = nb.children.length > 0;
|
// Deselect if clicking the already-active notebook; expand/collapse is the chevron's job
|
||||||
const wasCollapsed = $collapsedNotebooks.includes(nb.path);
|
|
||||||
// Deselect if clicking the already-active notebook
|
|
||||||
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
|
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
|
||||||
if (hasVisibleChildren) {
|
if (isMobile) {
|
||||||
if (wasCollapsed) {
|
onViewChanged();
|
||||||
await expandNotebook(nb);
|
|
||||||
} else {
|
|
||||||
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectAllNotes();
|
selectAllNotes();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasVisibleChildren) await expandNotebook(nb);
|
|
||||||
$viewMode = 'notebook';
|
$viewMode = 'notebook';
|
||||||
$activeNotebook = nb;
|
$activeNotebook = nb;
|
||||||
$activeTag = null;
|
$activeTag = null;
|
||||||
@@ -360,30 +360,34 @@
|
|||||||
|
|
||||||
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropTargetPath = null;
|
|
||||||
const srcPath = e.dataTransfer?.getData('text/plain');
|
const srcPath = e.dataTransfer?.getData('text/plain');
|
||||||
if (!srcPath) return;
|
if (srcPath) await nestNotebook(srcPath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nestNotebook(srcPath: string, destPath: string) {
|
||||||
|
dropTargetPath = null;
|
||||||
|
dropPosition = null;
|
||||||
draggedNotebookPath = null;
|
draggedNotebookPath = null;
|
||||||
// Don't move onto self or descendant
|
// Don't move onto self or descendant
|
||||||
if (destPath === srcPath || destPath.startsWith(srcPath + '/')) return;
|
if (destPath === srcPath || isDescendant(destPath, srcPath)) return;
|
||||||
// Don't move if already in that parent
|
// Don't move if already in that parent
|
||||||
const parentDir = srcPath.substring(0, srcPath.lastIndexOf('/'));
|
const parentDir = parentOf(srcPath);
|
||||||
if (parentDir === destPath) return;
|
if (parentDir === destPath) return;
|
||||||
try {
|
try {
|
||||||
const oldName = srcPath.split('/').pop() || '';
|
const oldName = baseOf(srcPath);
|
||||||
const newBasePath = destPath + '/' + oldName;
|
const newBasePath = joinPath(destPath, oldName);
|
||||||
await moveNotebook(srcPath, destPath);
|
await moveNotebook(srcPath, destPath);
|
||||||
// Update collapsedNotebooks paths
|
// Update collapsedNotebooks paths
|
||||||
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
||||||
if (p === srcPath) return newBasePath;
|
if (p === srcPath) return newBasePath;
|
||||||
if (p.startsWith(srcPath + '/')) return newBasePath + p.slice(srcPath.length);
|
if (isDescendant(p, srcPath)) return newBasePath + p.slice(srcPath.length);
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
// Update active notebook/note if inside the moved notebook
|
// Update active notebook/note if inside the moved notebook
|
||||||
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
|
if ($activeNotebook?.path === srcPath || isDescendant($activeNotebook?.path ?? '', srcPath)) {
|
||||||
selectAllNotes();
|
selectAllNotes();
|
||||||
}
|
}
|
||||||
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
|
if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
|
||||||
const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length);
|
const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length);
|
||||||
$activeNotePath = newNotePath;
|
$activeNotePath = newNotePath;
|
||||||
$activeNote = await readNote(newNotePath);
|
$activeNote = await readNote(newNotePath);
|
||||||
@@ -395,10 +399,74 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nbStopDragListeners() {
|
||||||
|
window.removeEventListener('pointermove', nbPointerMove);
|
||||||
|
window.removeEventListener('pointerup', nbDragEnd);
|
||||||
|
window.removeEventListener('pointercancel', nbDragEnd);
|
||||||
|
window.removeEventListener('mouseup', nbDragEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nbHandleDown(e: PointerEvent, nb: NotebookEntry) {
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
nbStopDragListeners();
|
||||||
|
nbPointer = { src: nb.path };
|
||||||
|
draggedNotebookPath = nb.path;
|
||||||
|
// Capture helps touch hold the gesture; on Windows mouse it makes WebView2 swallow the release, so skip it there.
|
||||||
|
if (e.pointerType === 'touch') {
|
||||||
|
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch {}
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', nbPointerMove, { passive: false });
|
||||||
|
window.addEventListener('pointerup', nbDragEnd);
|
||||||
|
window.addEventListener('pointercancel', nbDragEnd);
|
||||||
|
window.addEventListener('mouseup', nbDragEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nbPointerMove(e: PointerEvent) {
|
||||||
|
if (!nbPointer) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
||||||
|
if (el?.closest('[data-nb-root]')) { dropTargetPath = '__root__'; dropPosition = null; return; }
|
||||||
|
const row = el?.closest('[data-nb-path]') as HTMLElement | null;
|
||||||
|
const path = row?.getAttribute('data-nb-path');
|
||||||
|
if (!path) { dropTargetPath = null; dropPosition = null; return; }
|
||||||
|
const src = nbPointer.src;
|
||||||
|
if (path === src || path.startsWith(src + '/')) { dropTargetPath = null; dropPosition = null; return; }
|
||||||
|
const rect = row!.getBoundingClientRect();
|
||||||
|
const relY = (e.clientY - rect.top) / rect.height;
|
||||||
|
const pos: 'above' | 'into' | 'below' = relY < 0.3 ? 'above' : relY > 0.7 ? 'below' : 'into';
|
||||||
|
if (pos === 'into' && src.substring(0, src.lastIndexOf('/')) === path) { dropTargetPath = null; dropPosition = null; return; }
|
||||||
|
dropTargetPath = path;
|
||||||
|
dropPosition = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nbDragEnd() {
|
||||||
|
if (!nbPointer) return;
|
||||||
|
nbStopDragListeners();
|
||||||
|
const src = nbPointer.src;
|
||||||
|
const target = dropTargetPath;
|
||||||
|
const pos = dropPosition;
|
||||||
|
nbPointer = null;
|
||||||
|
draggedNotebookPath = null;
|
||||||
|
dropTargetPath = null;
|
||||||
|
dropPosition = null;
|
||||||
|
if (!target) return;
|
||||||
|
if (target === '__root__') {
|
||||||
|
const vaultRoot = $appConfig?.active_vault;
|
||||||
|
if (vaultRoot) nestNotebook(src, vaultRoot);
|
||||||
|
} else if (pos === 'above' || pos === 'below') {
|
||||||
|
handleNotebookReorder(src, target, pos);
|
||||||
|
} else if (pos === 'into') {
|
||||||
|
nestNotebook(src, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function findSiblingsOfPath(tree: NotebookEntry[], parentPath: string, vaultRoot: string): NotebookEntry[] {
|
function findSiblingsOfPath(tree: NotebookEntry[], parentPath: string, vaultRoot: string): NotebookEntry[] {
|
||||||
if (parentPath === vaultRoot) return tree;
|
const target = norm(parentPath);
|
||||||
|
if (target === norm(vaultRoot)) return tree;
|
||||||
for (const nb of tree) {
|
for (const nb of tree) {
|
||||||
if (nb.path === parentPath) return nb.children;
|
if (norm(nb.path) === target) return nb.children;
|
||||||
if (nb.children.length > 0) {
|
if (nb.children.length > 0) {
|
||||||
const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot);
|
const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot);
|
||||||
if (found.length > 0) return found;
|
if (found.length > 0) return found;
|
||||||
@@ -412,41 +480,39 @@
|
|||||||
dropPosition = null;
|
dropPosition = null;
|
||||||
draggedNotebookPath = null;
|
draggedNotebookPath = null;
|
||||||
if (srcPath === targetPath) return;
|
if (srcPath === targetPath) return;
|
||||||
if (targetPath.startsWith(srcPath + '/')) return; // can't move into descendant
|
if (isDescendant(targetPath, srcPath)) return; // can't move into descendant
|
||||||
const vaultRoot = $appConfig?.active_vault;
|
const vaultRoot = $appConfig?.active_vault;
|
||||||
if (!vaultRoot) return;
|
if (!vaultRoot) return;
|
||||||
|
|
||||||
const srcParent = srcPath.substring(0, srcPath.lastIndexOf('/'));
|
const srcParent = parentOf(srcPath);
|
||||||
const targetParent = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
const targetParent = parentOf(targetPath);
|
||||||
let actualSrcPath = srcPath;
|
let actualSrcPath = srcPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: if different parents, move src into target's parent first
|
// Step 1: if different parents, move src into target's parent first
|
||||||
if (srcParent !== targetParent) {
|
if (srcParent !== targetParent) {
|
||||||
const oldName = srcPath.split('/').pop() || '';
|
const oldName = baseOf(srcPath);
|
||||||
const newPath = targetParent === vaultRoot
|
const newPath = joinPath(targetParent, oldName);
|
||||||
? vaultRoot + '/' + oldName
|
|
||||||
: targetParent + '/' + oldName;
|
|
||||||
await moveNotebook(srcPath, targetParent);
|
await moveNotebook(srcPath, targetParent);
|
||||||
// Migrate paths in collapsedNotebooks
|
// Migrate paths in collapsedNotebooks
|
||||||
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
||||||
if (p === srcPath) return newPath;
|
if (p === srcPath) return newPath;
|
||||||
if (p.startsWith(srcPath + '/')) return newPath + p.slice(srcPath.length);
|
if (isDescendant(p, srcPath)) return newPath + p.slice(srcPath.length);
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
// Migrate paths in notebookOrder
|
// Migrate paths in notebookOrder
|
||||||
const migrated: Record<string, number> = {};
|
const migrated: Record<string, number> = {};
|
||||||
for (const [p, v] of Object.entries($notebookOrder)) {
|
for (const [p, v] of Object.entries($notebookOrder)) {
|
||||||
if (p === srcPath) migrated[newPath] = v;
|
if (p === srcPath) migrated[newPath] = v;
|
||||||
else if (p.startsWith(srcPath + '/')) migrated[newPath + p.slice(srcPath.length)] = v;
|
else if (isDescendant(p, srcPath)) migrated[newPath + p.slice(srcPath.length)] = v;
|
||||||
else migrated[p] = v;
|
else migrated[p] = v;
|
||||||
}
|
}
|
||||||
$notebookOrder = migrated;
|
$notebookOrder = migrated;
|
||||||
// Update active notebook/note
|
// Update active notebook/note
|
||||||
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
|
if ($activeNotebook?.path === srcPath || isDescendant($activeNotebook?.path ?? '', srcPath)) {
|
||||||
selectAllNotes();
|
selectAllNotes();
|
||||||
}
|
}
|
||||||
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
|
if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
|
||||||
const newNotePath = newPath + $activeNotePath.slice(srcPath.length);
|
const newNotePath = newPath + $activeNotePath.slice(srcPath.length);
|
||||||
$activeNotePath = newNotePath;
|
$activeNotePath = newNotePath;
|
||||||
$activeNote = await readNote(newNotePath);
|
$activeNote = await readNote(newNotePath);
|
||||||
@@ -544,6 +610,14 @@
|
|||||||
contextMenu = { x, y, notebook: nb };
|
contextMenu = { x, y, notebook: nb };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNotebookMenu(e: Event, nb: NotebookEntry) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 280);
|
||||||
|
contextMenu = { x, y, notebook: nb };
|
||||||
|
}
|
||||||
|
|
||||||
function closeContextMenu() {
|
function closeContextMenu() {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
}
|
}
|
||||||
@@ -578,7 +652,7 @@
|
|||||||
|
|
||||||
<svelte:window onclick={handleWindowClick} />
|
<svelte:window onclick={handleWindowClick} />
|
||||||
|
|
||||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem} ondblclick={startNewNotebookFromSidebar}>
|
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
||||||
@@ -675,11 +749,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="section">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="section" ondblclick={startNewNotebookFromSidebar}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="section-header"
|
class="section-header"
|
||||||
class:drop-target={dropTargetPath === '__root__'}
|
class:drop-target={dropTargetPath === '__root__'}
|
||||||
|
data-nb-root
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
if (draggedNotebookPath) {
|
if (draggedNotebookPath) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -739,11 +815,12 @@
|
|||||||
class="notebook-item"
|
class="notebook-item"
|
||||||
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
|
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
|
||||||
onclick={selectUnfiled}
|
onclick={selectUnfiled}
|
||||||
|
style="padding-left: 4px"
|
||||||
>
|
>
|
||||||
<span style="width:13px;flex-shrink:0"></span>
|
<span style="width:14px;flex-shrink:0"></span>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
|
||||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||||
<polyline points="14 2 14 8 20 8" />
|
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
|
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -823,8 +900,11 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#if contextMenu}
|
||||||
|
{#if isMobile}
|
||||||
|
<button type="button" class="context-menu-backdrop" aria-label="Close notebook actions" onclick={closeContextMenu}></button>
|
||||||
|
{/if}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||||
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
|
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
|
||||||
New Sub-notebook
|
New Sub-notebook
|
||||||
@@ -851,8 +931,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if trashContextMenu}
|
{#if trashContextMenu}
|
||||||
|
{#if isMobile}
|
||||||
|
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
|
||||||
|
{/if}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="context-menu" style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
<div class="context-menu" class:mobile={isMobile} style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||||
<button class="danger" onclick={handleEmptyTrash}>
|
<button class="danger" onclick={handleEmptyTrash}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" />
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" />
|
||||||
@@ -866,7 +949,7 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
|
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="delete-confirm" onclick={(e) => e.stopPropagation()}>
|
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
|
||||||
<h4>Delete "{deleteConfirm.name}"?</h4>
|
<h4>Delete "{deleteConfirm.name}"?</h4>
|
||||||
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
||||||
<div class="delete-confirm-actions">
|
<div class="delete-confirm-actions">
|
||||||
@@ -882,7 +965,7 @@
|
|||||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||||
{@const iconSrc = getNotebookIconSrc(nb)}
|
{@const iconSrc = getNotebookIconSrc(nb)}
|
||||||
{#if editingNotebook === nb.path}
|
{#if editingNotebook === nb.path}
|
||||||
<div class="notebook-item" style="padding-left: {8 + depth * 16}px">
|
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="rename-input"
|
class="rename-input"
|
||||||
@@ -896,14 +979,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="notebook-row" data-nb-path={nb.path}>
|
||||||
<button
|
<button
|
||||||
class="notebook-item"
|
class="notebook-item"
|
||||||
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
|
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
|
||||||
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
|
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
|
||||||
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
|
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
|
||||||
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
||||||
style="padding-left: {8 + depth * 16}px"
|
style="padding-left: {4 + depth * 16}px"
|
||||||
draggable="true"
|
data-nb-path={nb.path}
|
||||||
onclick={() => selectNotebook(nb)}
|
onclick={() => selectNotebook(nb)}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'F2') {
|
if (e.key === 'F2') {
|
||||||
@@ -912,8 +996,6 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
oncontextmenu={(e) => onContextMenu(e, nb)}
|
oncontextmenu={(e) => onContextMenu(e, nb)}
|
||||||
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
|
|
||||||
ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
|
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedNotebookPath) {
|
if (draggedNotebookPath) {
|
||||||
@@ -972,13 +1054,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if iconSrc}
|
{#if iconSrc}
|
||||||
<img class="notebook-icon" src={iconSrc} alt="" />
|
<img class="notebook-icon" src={iconSrc} alt="" />
|
||||||
|
{:else if isCollapsed || nb.children.length === 0}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
|
||||||
|
<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
|
||||||
|
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
|
||||||
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.6">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
|
||||||
<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="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
|
||||||
|
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
|
||||||
|
<path d="M10 9h8"/><path d="M10 13h8"/><path d="M10 17h5"/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
||||||
|
{#if $notebookSortMode === 'manual'}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
|
<span
|
||||||
|
class="nb-drag-handle"
|
||||||
|
title="Drag to reorder"
|
||||||
|
onpointerdown={(e) => nbHandleDown(e, nb)}
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if isMobile}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="notebook-actions-btn"
|
||||||
|
aria-label={`Actions for ${nb.name}`}
|
||||||
|
onclick={(e) => openNotebookMenu(e, nb)}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasChildren && !isCollapsed}
|
{#if hasChildren && !isCollapsed}
|
||||||
{#each nb.children as child (child.path)}
|
{#each nb.children as child (child.path)}
|
||||||
@@ -1070,7 +1181,7 @@
|
|||||||
.section {
|
.section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: 0 0 4px;
|
||||||
border-top: 1px solid var(--border-light);
|
border-top: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1221,12 +1332,16 @@
|
|||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notebook-row {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.notebook-item {
|
.notebook-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px 5px 4px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -1307,6 +1422,49 @@
|
|||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nb-drag-handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px;
|
||||||
|
margin-left: 2px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-drag-handle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-actions-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-actions-btn:active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.tag-list {
|
.tag-list {
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
@@ -1375,6 +1533,15 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 999;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.context-menu button {
|
.context-menu button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1529,7 +1696,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .notebook-item {
|
.sidebar.mobile .notebook-item {
|
||||||
padding: 12px 12px;
|
padding: 2px 56px 2px 12px;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -1570,34 +1737,40 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .context-menu {
|
.context-menu.mobile {
|
||||||
min-width: 200px;
|
left: calc(12px + env(safe-area-inset-left, 0px)) !important;
|
||||||
|
right: calc(12px + env(safe-area-inset-right, 0px));
|
||||||
|
top: auto !important;
|
||||||
|
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||||
|
min-width: 0;
|
||||||
|
max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||||
|
overflow-y: auto;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .delete-confirm {
|
.delete-confirm.mobile {
|
||||||
max-width: calc(100vw - 40px);
|
max-width: calc(100vw - 40px);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .delete-confirm h4 {
|
.delete-confirm.mobile h4 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .delete-confirm p {
|
.delete-confirm.mobile p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .delete-confirm-cancel,
|
.delete-confirm.mobile .delete-confirm-cancel,
|
||||||
.sidebar.mobile .delete-confirm-btn {
|
.delete-confirm.mobile .delete-confirm-btn {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
|
|||||||
@@ -16,11 +16,10 @@
|
|||||||
// Existing tags that match what's typed, prefix matches first, already-applied ones excluded.
|
// Existing tags that match what's typed, prefix matches first, already-applied ones excluded.
|
||||||
const suggestions = $derived.by(() => {
|
const suggestions = $derived.by(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return [];
|
|
||||||
const taken = new Set(existing);
|
const taken = new Set(existing);
|
||||||
const names = $tags
|
const names = $tags
|
||||||
.map(([name]) => name)
|
.map(([name]) => name)
|
||||||
.filter((name) => !taken.has(name) && name.toLowerCase().includes(q));
|
.filter((name) => !taken.has(name) && (!q || name.toLowerCase().includes(q)));
|
||||||
names.sort((a, b) => {
|
names.sort((a, b) => {
|
||||||
const ap = a.toLowerCase().startsWith(q) ? 0 : 1;
|
const ap = a.toLowerCase().startsWith(q) ? 0 : 1;
|
||||||
const bp = b.toLowerCase().startsWith(q) ? 0 : 1;
|
const bp = b.toLowerCase().startsWith(q) ? 0 : 1;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, syncState } from '$lib/stores/app';
|
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
|
||||||
import { syncNow } from '$lib/api';
|
import { syncNow } from '$lib/api';
|
||||||
|
|
||||||
let { onNewNote = () => {}, onDailyNote = () => {} }: {
|
let { onNewNote = () => {}, onDailyNote = () => {} }: {
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if $appConfig?.sync_provider === 'webdav'}
|
{#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
|
||||||
<button class="switch-vault-btn" class:active={$syncState.running} onclick={() => { if (!$syncState.running) syncNow().catch(() => {}); }} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing vault...' : 'Sync vault now')}>
|
<button class="switch-vault-btn" class:active={$syncState.running} onclick={() => { if (!$syncState.running) syncNow().catch(() => {}); }} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing vault...' : 'Sync vault now')}>
|
||||||
<svg width="14" height="14" 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="14" height="14" 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"/>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { documentDir } from '@tauri-apps/api/path';
|
import { documentDir } from '@tauri-apps/api/path';
|
||||||
import { openVault, removeVault, getAppConfig } from '$lib/api';
|
import { chooseExternalVault, getAppConfig, openVault, removeVault, restoreExternalVault } from '$lib/api';
|
||||||
import { appConfig, vaultReady } from '$lib/stores/app';
|
import { appConfig, vaultReady } from '$lib/stores/app';
|
||||||
import { isAndroid, isIOS, isMobile } from '$lib/platform';
|
import { isAndroid, isIOS, isMobile } from '$lib/platform';
|
||||||
import type { VaultConfig } from '$lib/types';
|
import type { VaultConfig } from '$lib/types';
|
||||||
|
|
||||||
|
let { initialError = '' }: { initialError?: string } = $props();
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
|
|
||||||
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
|
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
|
||||||
@@ -15,6 +17,10 @@
|
|||||||
let vaultName = $state('HelixNotes');
|
let vaultName = $state('HelixNotes');
|
||||||
let hasPermission = $state(!isAndroid);
|
let hasPermission = $state(!isAndroid);
|
||||||
let selectedLocation = $state('Documents');
|
let selectedLocation = $state('Documents');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (initialError && !error) error = initialError;
|
||||||
|
});
|
||||||
let customPath = $state('');
|
let customPath = $state('');
|
||||||
let iosBasePath = $state('');
|
let iosBasePath = $state('');
|
||||||
|
|
||||||
@@ -108,14 +114,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forgetVault(path: string) {
|
async function pickExternalFolder() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
try {
|
try {
|
||||||
await removeVault(path);
|
const selected = await chooseExternalVault();
|
||||||
|
if (!selected) return;
|
||||||
$appConfig = await getAppConfig();
|
$appConfig = await getAppConfig();
|
||||||
|
$vaultReady = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openRecentVault(vault: VaultConfig) {
|
||||||
|
if (loading) return;
|
||||||
|
if (!isIOS || !vault.bookmark_id) {
|
||||||
|
await openSelectedVault(vault.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await restoreExternalVault(vault.bookmark_id);
|
||||||
|
$appConfig = await getAppConfig();
|
||||||
|
$vaultReady = true;
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgetVault(vault: VaultConfig) {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const wasActive = isActiveVault(vault);
|
||||||
|
await removeVault(vault.path, vault.bookmark_id);
|
||||||
|
$appConfig = await getAppConfig();
|
||||||
|
if (wasActive) $vaultReady = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveVault(vault: VaultConfig): boolean {
|
||||||
|
if (vault.bookmark_id) {
|
||||||
|
return vault.bookmark_id === $appConfig?.active_bookmark_id;
|
||||||
|
}
|
||||||
|
return !$appConfig?.active_bookmark_id && vault.path === $appConfig?.active_vault;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@@ -146,7 +201,7 @@
|
|||||||
<p class="subtitle">Local markdown notes</p>
|
<p class="subtitle">Local markdown notes</p>
|
||||||
{#if isMobile}
|
{#if isMobile}
|
||||||
{#if isIOS}
|
{#if isIOS}
|
||||||
<p class="description">Your notes are saved in the app's folder and appear in the Files app under "On My iPhone → HelixNotes".</p>
|
<p class="description">Keep notes in HelixNotes, or use an existing folder from iCloud Drive or Files.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
|
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -197,6 +252,8 @@
|
|||||||
<button class="btn-primary" onclick={pickFolder} disabled={loading}>
|
<button class="btn-primary" onclick={pickFolder} disabled={loading}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
Opening...
|
Opening...
|
||||||
|
{:else if isIOS}
|
||||||
|
Use Local Folder
|
||||||
{:else if isMobile}
|
{:else if isMobile}
|
||||||
Get Started
|
Get Started
|
||||||
{:else}
|
{:else}
|
||||||
@@ -204,6 +261,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if isIOS}
|
||||||
|
<button class="btn-back" onclick={pickExternalFolder} disabled={loading}>
|
||||||
|
Choose Folder in Files
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if $appConfig?.active_vault}
|
{#if $appConfig?.active_vault}
|
||||||
<button class="btn-back" onclick={() => ($vaultReady = true)}>
|
<button class="btn-back" onclick={() => ($vaultReady = true)}>
|
||||||
Back
|
Back
|
||||||
@@ -215,11 +278,11 @@
|
|||||||
<span class="recent-label">Recent</span>
|
<span class="recent-label">Recent</span>
|
||||||
{#each recentVaults as vault}
|
{#each recentVaults as vault}
|
||||||
<div class="vault-row">
|
<div class="vault-row">
|
||||||
<button class="vault-item" class:current={vault.path === $appConfig?.active_vault} onclick={() => openSelectedVault(vault.path)}>
|
<button class="vault-item" class:current={isActiveVault(vault)} onclick={() => openRecentVault(vault)} disabled={loading}>
|
||||||
<span class="vault-name">{vault.name}{#if vault.path === $appConfig?.active_vault}<span class="vault-current-badge">Current</span>{/if}</span>
|
<span class="vault-name">{vault.name}{#if isActiveVault(vault)}<span class="vault-current-badge">Current</span>{/if}</span>
|
||||||
<span class="vault-path">{vault.path}</span>
|
<span class="vault-path">{vault.bookmark_id ? `Files · ${vault.path}` : vault.path}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault.path)}>
|
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault)} disabled={loading}>
|
||||||
<svg width="12" height="12" viewBox="0 0 10 10">
|
<svg width="12" height="12" viewBox="0 0 10 10">
|
||||||
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Obsidian-compatible callouts.
|
||||||
|
//
|
||||||
|
// Callouts are written as blockquotes whose first line is `[!type]`:
|
||||||
|
//
|
||||||
|
// > [!warning] Optional title
|
||||||
|
// > Body markdown...
|
||||||
|
//
|
||||||
|
// A trailing `+` / `-` on the type makes the callout foldable
|
||||||
|
// (`+` = expanded, `-` = collapsed). Types are case-insensitive and have
|
||||||
|
// aliases. Callouts round-trip to this exact syntax so notes stay portable
|
||||||
|
// (no lock-in) and stay compatible with Obsidian.
|
||||||
|
|
||||||
|
// alias (lowercase) -> canonical group used for icon + colour
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
note: 'note',
|
||||||
|
abstract: 'abstract', summary: 'abstract', tldr: 'abstract',
|
||||||
|
info: 'info',
|
||||||
|
todo: 'todo',
|
||||||
|
tip: 'tip', hint: 'tip', important: 'tip',
|
||||||
|
success: 'success', check: 'success', done: 'success',
|
||||||
|
question: 'question', help: 'question', faq: 'question',
|
||||||
|
warning: 'warning', caution: 'warning', attention: 'warning',
|
||||||
|
failure: 'failure', fail: 'failure', missing: 'failure',
|
||||||
|
danger: 'danger', error: 'danger',
|
||||||
|
bug: 'bug',
|
||||||
|
example: 'example',
|
||||||
|
quote: 'quote', cite: 'quote',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a (possibly aliased / cased) type to its canonical styling group.
|
||||||
|
* Unknown / user-defined types resolve to 'custom' (neutral accent + tag icon)
|
||||||
|
* so a `[!decision]` reads as intentional rather than masquerading as a note.
|
||||||
|
*/
|
||||||
|
export function calloutGroup(type: string): string {
|
||||||
|
return ALIASES[(type || '').trim().toLowerCase()] || 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default header label shown when no custom title is set (matches Obsidian). */
|
||||||
|
export function calloutLabel(type: string): string {
|
||||||
|
const t = (type || 'note').trim();
|
||||||
|
return t ? t.charAt(0).toUpperCase() + t.slice(1) : 'Note';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner SVG markup per canonical group (24x24, stroke = currentColor).
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
note: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||||
|
abstract: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 12h6"/><path d="M9 16h6"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="11" x2="12" y2="16"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
||||||
|
todo: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
||||||
|
tip: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5Z"/>',
|
||||||
|
success: '<polyline points="20 6 9 17 4 12"/>',
|
||||||
|
question: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
failure: '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
|
||||||
|
danger: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||||
|
bug: '<rect x="8" y="6" width="8" height="14" rx="4"/><path d="m19 8-3 2"/><path d="m5 8 3 2"/><path d="m19 16-3-2"/><path d="m5 16 3-2"/><path d="M21 13h-5"/><path d="M3 13h5"/><path d="m15 4-1.5 2.5"/><path d="m9 4 1.5 2.5"/>',
|
||||||
|
example: '<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"/>',
|
||||||
|
quote: '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 0-1 1.03V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>',
|
||||||
|
custom: '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full SVG icon string for a callout type, at the given pixel size. */
|
||||||
|
export function calloutIcon(type: string, size = 18): string {
|
||||||
|
const inner = ICONS[calloutGroup(type)] || ICONS.note;
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordered list of types offered in the type picker. */
|
||||||
|
export const CALLOUT_MENU: { type: string; label: string }[] = [
|
||||||
|
{ type: 'note', label: 'Note' },
|
||||||
|
{ type: 'abstract', label: 'Abstract' },
|
||||||
|
{ type: 'info', label: 'Info' },
|
||||||
|
{ type: 'todo', label: 'Todo' },
|
||||||
|
{ type: 'tip', label: 'Tip' },
|
||||||
|
{ type: 'success', label: 'Success' },
|
||||||
|
{ type: 'question', label: 'Question' },
|
||||||
|
{ type: 'warning', label: 'Warning' },
|
||||||
|
{ type: 'failure', label: 'Failure' },
|
||||||
|
{ type: 'danger', label: 'Danger' },
|
||||||
|
{ type: 'bug', label: 'Bug' },
|
||||||
|
{ type: 'example', label: 'Example' },
|
||||||
|
{ type: 'quote', label: 'Quote' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// First line of a callout: [!type], optional +/- fold marker, optional title.
|
||||||
|
const CALLOUT_RE = /^\s*\[!([\w-]+)\]([-+]?)[ \t]*(.*)$/;
|
||||||
|
|
||||||
|
const BLOCK_SELECTOR =
|
||||||
|
'p,ul,ol,blockquote,pre,h1,h2,h3,h4,h5,h6,table,hr,div[data-callout],div[data-secret-block],div[data-math-block],div[data-pdf-src],div[data-page-break]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert callout blockquotes produced by markdown-it into
|
||||||
|
* `<div data-callout=...>` blocks that the Callout node parses. Operates in
|
||||||
|
* place on a detached container element. Processes depth-first so nested
|
||||||
|
* callouts (`> > [!todo]`) are converted before their parents.
|
||||||
|
*/
|
||||||
|
export function transformCalloutBlockquotes(root: Element): void {
|
||||||
|
const walk = (el: Element) => {
|
||||||
|
for (const child of Array.from(el.children)) walk(child);
|
||||||
|
if (el.tagName === 'BLOCKQUOTE') convertBlockquote(el);
|
||||||
|
};
|
||||||
|
walk(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertBlockquote(bq: Element): void {
|
||||||
|
const first = bq.firstElementChild;
|
||||||
|
if (!first || first.tagName !== 'P') return;
|
||||||
|
|
||||||
|
// markdown-it merges the title line and any immediately-following body lines
|
||||||
|
// into one paragraph (separated by "\n"). Split off the first line.
|
||||||
|
const innerHtml = (first as HTMLElement).innerHTML;
|
||||||
|
const nlIdx = innerHtml.indexOf('\n');
|
||||||
|
const firstLineHtml = nlIdx === -1 ? innerHtml : innerHtml.slice(0, nlIdx);
|
||||||
|
const restHtml = nlIdx === -1 ? '' : innerHtml.slice(nlIdx + 1);
|
||||||
|
|
||||||
|
const probe = document.createElement('div');
|
||||||
|
probe.innerHTML = firstLineHtml;
|
||||||
|
const m = (probe.textContent || '').match(CALLOUT_RE);
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
const type = m[1].toLowerCase();
|
||||||
|
const foldChar = m[2];
|
||||||
|
const title = (m[3] || '').trim();
|
||||||
|
const foldable = foldChar === '+' || foldChar === '-';
|
||||||
|
const folded = foldChar === '-';
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('data-callout', type);
|
||||||
|
div.setAttribute('data-callout-foldable', foldable ? 'true' : 'false');
|
||||||
|
div.setAttribute('data-callout-folded', folded ? 'true' : 'false');
|
||||||
|
if (title) div.setAttribute('data-callout-title', title);
|
||||||
|
|
||||||
|
if (restHtml.trim()) {
|
||||||
|
(first as HTMLElement).innerHTML = restHtml;
|
||||||
|
div.appendChild(first);
|
||||||
|
} else {
|
||||||
|
first.remove();
|
||||||
|
}
|
||||||
|
while (bq.firstChild) div.appendChild(bq.firstChild);
|
||||||
|
|
||||||
|
// content is `block+`; guarantee at least one block.
|
||||||
|
if (!div.querySelector(BLOCK_SELECTOR)) div.appendChild(document.createElement('p'));
|
||||||
|
|
||||||
|
bq.replaceWith(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a Callout ProseMirror node back to `> [!type] ...` markdown.
|
||||||
|
* `serializeChild` serializes a child block (the editor's own serializer),
|
||||||
|
* which makes nesting fall out automatically (each `>` line gains another `>`).
|
||||||
|
*/
|
||||||
|
export function serializeCallout(node: any, serializeChild: (child: any) => string): string {
|
||||||
|
const type = node.attrs.type || 'note';
|
||||||
|
const foldable = !!node.attrs.foldable;
|
||||||
|
const folded = !!node.attrs.folded;
|
||||||
|
const title = (node.attrs.title || '').trim();
|
||||||
|
const suffix = foldable ? (folded ? '-' : '+') : '';
|
||||||
|
const header = `[!${type}]${suffix}${title ? ' ' + title : ''}`;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
node.forEach((child: any) => parts.push(serializeChild(child).replace(/\n+$/, '')));
|
||||||
|
while (parts.length && parts[parts.length - 1] === '') parts.pop();
|
||||||
|
|
||||||
|
const out = [`> ${header}`];
|
||||||
|
const inner = parts.join('\n\n');
|
||||||
|
if (inner.length) {
|
||||||
|
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
|
||||||
|
}
|
||||||
|
return out.join('\n') + '\n';
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
// Centralized platform detection. iOS WebViews report iPhone/iPad/iPod in the user
|
// Platform is the compile-time build target, injected by the backend into
|
||||||
// agent (not the literal "ios"), so they must be matched explicitly.
|
// window.__HELIX_PLATFORM__ before app scripts run (see src-tauri/src/lib.rs). The UA is only a
|
||||||
|
// fallback when that global is absent (SSR/prerender, plain-browser dev): some desktop WebKitGTK
|
||||||
|
// builds report a mobile-looking user-agent. (#63)
|
||||||
|
type HelixPlatform = { mobile: boolean; android: boolean; ios: boolean };
|
||||||
|
|
||||||
|
const injected: HelixPlatform | undefined =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? (window as unknown as { __HELIX_PLATFORM__?: HelixPlatform }).__HELIX_PLATFORM__
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||||
|
|
||||||
export const isAndroid = /android/i.test(ua);
|
export const isAndroid = injected ? injected.android : /android/i.test(ua);
|
||||||
export const isIOS = /iphone|ipad|ipod/i.test(ua);
|
export const isIOS = injected ? injected.ios : /iphone|ipad|ipod/i.test(ua);
|
||||||
export const isMobile = isAndroid || isIOS;
|
export const isMobile = injected ? injected.mobile : isAndroid || isIOS;
|
||||||
|
|
||||||
// Themes that use the dark color scheme. Used by applyTheme() to toggle the
|
// Themes that use the dark color scheme. Used by applyTheme() to toggle the
|
||||||
// `dark` class on the root element. Keep this in sync when adding new themes.
|
// `dark` class on the root element. Keep this in sync when adding new themes.
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { writable, derived, get } from "svelte/store";
|
import { writable, derived, get } from "svelte/store";
|
||||||
|
import { isMobile } from "$lib/platform";
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
CustomTheme,
|
||||||
NoteEntry,
|
NoteEntry,
|
||||||
NoteContent,
|
NoteContent,
|
||||||
NotebookEntry,
|
NotebookEntry,
|
||||||
|
VaultConfig,
|
||||||
VaultState,
|
VaultState,
|
||||||
ViewMode,
|
ViewMode,
|
||||||
SortMode,
|
SortMode,
|
||||||
@@ -11,11 +14,22 @@ import type {
|
|||||||
|
|
||||||
// App state
|
// App state
|
||||||
export const appConfig = writable<AppConfig | null>(null);
|
export const appConfig = writable<AppConfig | null>(null);
|
||||||
|
|
||||||
|
// The active vault's config entry (where per-vault WebDAV sync settings live).
|
||||||
|
export function activeVaultConfig(c: AppConfig | null): VaultConfig | null {
|
||||||
|
if (!c?.active_vault) return null;
|
||||||
|
if (c.active_bookmark_id) {
|
||||||
|
return c.vaults.find((v) => v.bookmark_id === c.active_bookmark_id) ?? null;
|
||||||
|
}
|
||||||
|
return c.vaults.find((v) => !v.bookmark_id && v.path === c.active_vault) ?? null;
|
||||||
|
}
|
||||||
export const vaultReady = writable(false);
|
export const vaultReady = writable(false);
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
export const viewMode = writable<ViewMode>("all");
|
export const viewMode = writable<ViewMode>("all");
|
||||||
export const sortMode = writable<SortMode>("modified");
|
export const sortMode = writable<SortMode>("modified");
|
||||||
|
// Group the notes list under relative-date headers (All Notes, date sorts only). Persisted per vault.
|
||||||
|
export const groupNotesByDate = writable<boolean>(false);
|
||||||
export const tasksLayout = writable<"list" | "calendar">("list");
|
export const tasksLayout = writable<"list" | "calendar">("list");
|
||||||
export const tasksHideCompleted = writable<boolean>(true);
|
export const tasksHideCompleted = writable<boolean>(true);
|
||||||
export const tasksOnlyFlagged = writable<boolean>(false);
|
export const tasksOnlyFlagged = writable<boolean>(false);
|
||||||
@@ -24,6 +38,7 @@ export const sidebarCollapsed = writable(false);
|
|||||||
export const notelistCollapsed = writable(false);
|
export const notelistCollapsed = writable(false);
|
||||||
export const sidebarWidth = writable(220);
|
export const sidebarWidth = writable(220);
|
||||||
export const notelistWidth = writable(280);
|
export const notelistWidth = writable(280);
|
||||||
|
export const outlineWidth = writable(220);
|
||||||
export const searchQuery = writable("");
|
export const searchQuery = writable("");
|
||||||
export const showCommandPalette = writable(false);
|
export const showCommandPalette = writable(false);
|
||||||
export const showSearch = writable(false);
|
export const showSearch = writable(false);
|
||||||
@@ -72,6 +87,7 @@ export const readOnly = writable(false);
|
|||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
export const theme = writable<string>("system");
|
export const theme = writable<string>("system");
|
||||||
|
export const customThemes = derived(appConfig, ($c): CustomTheme[] => $c?.custom_themes ?? []);
|
||||||
|
|
||||||
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
|
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
|
||||||
// whoever triggered it (manual button, settings, interval, on-change).
|
// whoever triggered it (manual button, settings, interval, on-change).
|
||||||
@@ -87,11 +103,8 @@ export const updateAvailable = writable<{
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
export const updateObj = writable<any>(null);
|
export const updateObj = writable<any>(null);
|
||||||
export const installType = writable<string>("native");
|
export const installType = writable<string>("native");
|
||||||
// True only on the Android/iOS build. Defaults to a user-agent guess for the first paint, then
|
// True only on the Android/iOS build. Reconfirmed from the backend at startup (see +layout). (#63)
|
||||||
// gets the authoritative compile-time value from the backend at startup (see +layout). (#63)
|
export const platformIsMobile = writable<boolean>(isMobile);
|
||||||
export const platformIsMobile = writable<boolean>(
|
|
||||||
typeof navigator !== "undefined" && /android|iphone|ipad|ipod/i.test(navigator.userAgent),
|
|
||||||
);
|
|
||||||
export const androidApkUrl = writable<string | null>(null);
|
export const androidApkUrl = writable<string | null>(null);
|
||||||
|
|
||||||
// Install types that handle their own updates (in-app auto-updater, or a
|
// Install types that handle their own updates (in-app auto-updater, or a
|
||||||
@@ -227,6 +240,7 @@ export const vaultState = derived(
|
|||||||
activeNotePath,
|
activeNotePath,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
notelistWidth,
|
notelistWidth,
|
||||||
|
outlineWidth,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
notelistCollapsed,
|
notelistCollapsed,
|
||||||
collapsedNotebooks,
|
collapsedNotebooks,
|
||||||
@@ -238,6 +252,7 @@ export const vaultState = derived(
|
|||||||
$activeNotePath,
|
$activeNotePath,
|
||||||
$sidebarWidth,
|
$sidebarWidth,
|
||||||
$notelistWidth,
|
$notelistWidth,
|
||||||
|
$outlineWidth,
|
||||||
$sidebarCollapsed,
|
$sidebarCollapsed,
|
||||||
$notelistCollapsed,
|
$notelistCollapsed,
|
||||||
$collapsedNotebooks,
|
$collapsedNotebooks,
|
||||||
@@ -249,6 +264,7 @@ export const vaultState = derived(
|
|||||||
last_open_note: $activeNotePath,
|
last_open_note: $activeNotePath,
|
||||||
sidebar_width: $sidebarWidth,
|
sidebar_width: $sidebarWidth,
|
||||||
notelist_width: $notelistWidth,
|
notelist_width: $notelistWidth,
|
||||||
|
outline_width: $outlineWidth,
|
||||||
sidebar_collapsed: $sidebarCollapsed,
|
sidebar_collapsed: $sidebarCollapsed,
|
||||||
notelist_collapsed: $notelistCollapsed,
|
notelist_collapsed: $notelistCollapsed,
|
||||||
collapsed_notebooks: $collapsedNotebooks,
|
collapsed_notebooks: $collapsedNotebooks,
|
||||||
|
|||||||
@@ -44,11 +44,47 @@ export interface NoteContent {
|
|||||||
export interface VaultConfig {
|
export interface VaultConfig {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
bookmark_id?: string | null;
|
||||||
|
// Per-vault WebDAV sync (moved off the global AppConfig).
|
||||||
|
sync_provider?: string | null;
|
||||||
|
webdav_url?: string | null;
|
||||||
|
webdav_username?: string | null;
|
||||||
|
webdav_password?: string | null;
|
||||||
|
sync_on_open?: boolean;
|
||||||
|
sync_on_change?: boolean;
|
||||||
|
sync_interval_minutes?: number;
|
||||||
|
last_sync_time?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalVaultResult {
|
||||||
|
bookmarkId: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomThemeColors {
|
||||||
|
bg_primary: string;
|
||||||
|
bg_secondary: string;
|
||||||
|
bg_tertiary: string;
|
||||||
|
bg_hover: string;
|
||||||
|
bg_active: string;
|
||||||
|
bg_editor: string;
|
||||||
|
text_primary: string;
|
||||||
|
text_secondary: string;
|
||||||
|
border_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomTheme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_dark: boolean;
|
||||||
|
colors: CustomThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
vaults: VaultConfig[];
|
vaults: VaultConfig[];
|
||||||
active_vault: string | null;
|
active_vault: string | null;
|
||||||
|
active_bookmark_id?: string | null;
|
||||||
theme: string;
|
theme: string;
|
||||||
accent_color: string | null;
|
accent_color: string | null;
|
||||||
font_size: number | null;
|
font_size: number | null;
|
||||||
@@ -103,12 +139,14 @@ export interface AppConfig {
|
|||||||
sync_on_change: boolean;
|
sync_on_change: boolean;
|
||||||
sync_interval_minutes: number;
|
sync_interval_minutes: number;
|
||||||
last_sync_time: string | null;
|
last_sync_time: string | null;
|
||||||
|
custom_themes: CustomTheme[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VaultState {
|
export interface VaultState {
|
||||||
last_open_note: string | null;
|
last_open_note: string | null;
|
||||||
sidebar_width: number;
|
sidebar_width: number;
|
||||||
notelist_width: number;
|
notelist_width: number;
|
||||||
|
outline_width?: number;
|
||||||
sidebar_collapsed: boolean;
|
sidebar_collapsed: boolean;
|
||||||
notelist_collapsed: boolean;
|
notelist_collapsed: boolean;
|
||||||
collapsed_notebooks: string[];
|
collapsed_notebooks: string[];
|
||||||
@@ -116,6 +154,7 @@ export interface VaultState {
|
|||||||
notebook_order?: Record<string, number>;
|
notebook_order?: Record<string, number>;
|
||||||
note_order?: Record<string, number>;
|
note_order?: Record<string, number>;
|
||||||
sort_mode?: string;
|
sort_mode?: string;
|
||||||
|
group_notes_by_date?: boolean;
|
||||||
last_view_mode?: string;
|
last_view_mode?: string;
|
||||||
last_notebook?: string | null;
|
last_notebook?: string | null;
|
||||||
last_tag?: string | null;
|
last_tag?: string | null;
|
||||||
@@ -140,6 +179,9 @@ export interface FileEvent {
|
|||||||
export interface ImportResult {
|
export interface ImportResult {
|
||||||
files_converted: number;
|
files_converted: number;
|
||||||
links_converted: number;
|
links_converted: number;
|
||||||
|
frontmatter_normalized: number;
|
||||||
|
syntax_converted: number;
|
||||||
|
attachments_moved: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VaultStats {
|
export interface VaultStats {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
function pathParts(path: string): string[] {
|
||||||
|
return path.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathRoot(path: string): string {
|
||||||
|
const normalized = path.replace(/\\/g, '/');
|
||||||
|
const drive = normalized.match(/^([A-Za-z]:)\//);
|
||||||
|
if (drive) return drive[1].toLowerCase();
|
||||||
|
return normalized.startsWith('/') ? '/' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativePath(fromDirectory: string, targetPath: string): string {
|
||||||
|
const from = pathParts(fromDirectory);
|
||||||
|
const target = pathParts(targetPath);
|
||||||
|
if (pathRoot(fromDirectory) !== pathRoot(targetPath)) return targetPath.replace(/\\/g, '/');
|
||||||
|
const windowsPath = /^[A-Za-z]:[\\/]/.test(fromDirectory) || /^[A-Za-z]:[\\/]/.test(targetPath);
|
||||||
|
const equal = windowsPath
|
||||||
|
? (left: string, right: string) => left.toLowerCase() === right.toLowerCase()
|
||||||
|
: (left: string, right: string) => left === right;
|
||||||
|
|
||||||
|
let common = 0;
|
||||||
|
while (common < from.length && common < target.length && equal(from[common], target[common])) {
|
||||||
|
common++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...Array(from.length - common).fill('..'), ...target.slice(common)];
|
||||||
|
return result.join('/') || '.';
|
||||||
|
}
|
||||||
@@ -24,3 +24,20 @@ export function formatDate(dateStr: string): string {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Relative-date bucket label for grouping the notes list:
|
||||||
|
// Today / Yesterday / Previous 7 Days / Previous 30 Days / "June" (this year) / "June 2025".
|
||||||
|
// Buckets are contiguous, so a list already sorted by date groups by consecutive label.
|
||||||
|
export function dateBucketLabel(dateStr: string, now: Date = new Date()): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const dayMs = 86400000;
|
||||||
|
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||||
|
const today = startOfDay(now);
|
||||||
|
const day = startOfDay(d);
|
||||||
|
if (day >= today) return 'Today';
|
||||||
|
if (day >= today - dayMs) return 'Yesterday';
|
||||||
|
if (day >= today - 7 * dayMs) return 'Previous 7 Days';
|
||||||
|
if (day >= today - 30 * dayMs) return 'Previous 30 Days';
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: 'long' });
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|||||||