Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
|
||||
[](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.
|
||||
No cloud, no lock-in.
|
||||
|
||||
## Download (v1.3.2)
|
||||
## Download (v1.3.4)
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -42,7 +42,7 @@ sudo eopkg it helixnotes
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
helix-notes = {
|
||||
url = "git+https://codeberg.org/ArkHost/HelixNotes";
|
||||
url = "git+https://gitlab.com/ArkHost/HelixNotes";
|
||||
# inputs.nixpkgs.follows = "nixpkgs";
|
||||
}
|
||||
};
|
||||
@@ -92,21 +92,23 @@ sudo eopkg it helixnotes
|
||||
|
||||
#### 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)
|
||||
|
||||
[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
|
||||
|
||||
[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
|
||||
|
||||
[Download .dmg (Apple Silicon)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_aarch64.dmg) (M-series Macs)
|
||||
|
||||
[Download .dmg (Intel)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (older Macs, or 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):
|
||||
>
|
||||
@@ -116,11 +118,11 @@ sudo eopkg it helixnotes
|
||||
|
||||
### 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
|
||||
|
||||
@@ -183,4 +185,4 @@ pnpm tauri build
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[AGPL-3.0](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777268161,
|
||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
||||
"lastModified": 1782959384,
|
||||
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
||||
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -6,29 +6,33 @@
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs {inherit system;};
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
in {
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
|
||||
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
||||
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
@@ -44,7 +48,8 @@
|
||||
wrapGAppsHook3
|
||||
];
|
||||
|
||||
buildInputs = with pkgs;
|
||||
buildInputs =
|
||||
with pkgs;
|
||||
lib.optionals stdenv.hostPlatform.isLinux [
|
||||
webkitgtk_4_1
|
||||
libayatana-appindicator
|
||||
@@ -71,6 +76,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
formatter = pkgs.alejandra;
|
||||
});
|
||||
formatter = pkgs.nixfmt;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.3.3"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1860,6 +1860,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"notify",
|
||||
"objc2-app-kit",
|
||||
"png 0.17.16",
|
||||
"quick-xml 0.36.2",
|
||||
"rayon",
|
||||
@@ -1876,6 +1877,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-ios-vault-access",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
@@ -5201,6 +5203,15 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-ios-vault-access"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.8.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.3.3"
|
||||
version = "1.3.4"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
@@ -42,6 +42,12 @@ quick-xml = "0.36"
|
||||
urlencoding = "2"
|
||||
sha2 = "0.10"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-app-kit = "0.3.2"
|
||||
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
tauri-plugin-ios-vault-access = { path = "plugins/ios-vault-access" }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
<color name="ic_launcher_background">#626ED4</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 709 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 984 B |
@@ -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,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!({
|
||||
"model": model,
|
||||
"max_tokens": 10,
|
||||
"max_tokens": 20,
|
||||
"messages": [
|
||||
{
|
||||
"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> {
|
||||
let client = Client::new();
|
||||
let is_gpt5 = model.starts_with("gpt-5");
|
||||
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 10,
|
||||
token_key: 20,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
|
||||
@@ -56,7 +56,27 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::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| {
|
||||
#[cfg(target_os = "ios")]
|
||||
app.handle().plugin(tauri_plugin_ios_vault_access::init())?;
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
@@ -112,6 +132,8 @@ pub fn run() {
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::open_vault,
|
||||
commands::choose_external_vault,
|
||||
commands::restore_external_vault,
|
||||
commands::remove_vault,
|
||||
commands::get_app_config,
|
||||
commands::set_theme,
|
||||
@@ -387,3 +409,51 @@ fn percent_decode(input: &str) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub vault_transition: tokio::sync::Mutex<()>,
|
||||
pub importing: AtomicBool,
|
||||
pub syncing: AtomicBool,
|
||||
pub pending_open_file: Mutex<Option<String>>,
|
||||
@@ -20,6 +21,7 @@ impl AppState {
|
||||
config: Mutex::new(config),
|
||||
search_index: Mutex::new(None),
|
||||
watcher: Mutex::new(None),
|
||||
vault_transition: tokio::sync::Mutex::new(()),
|
||||
importing: AtomicBool::new(false),
|
||||
syncing: AtomicBool::new(false),
|
||||
pending_open_file: Mutex::new(None),
|
||||
|
||||
@@ -312,8 +312,9 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = resp.status().as_u16();
|
||||
// 201 created; 405 already exists; 301 redirect-to-existing.
|
||||
if resp.status().is_success() || s == 405 || s == 301 {
|
||||
// 201 created; 405 already exists; 301 redirect-to-existing;
|
||||
// 403 some servers (openmediavault et al.) return 403 instead of 405.
|
||||
if resp.status().is_success() || s == 405 || s == 301 || s == 403 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
||||
|
||||
@@ -54,6 +54,8 @@ pub struct NoteContent {
|
||||
pub struct VaultConfig {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub bookmark_id: Option<String>,
|
||||
// Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration).
|
||||
#[serde(default)]
|
||||
pub sync_provider: Option<String>,
|
||||
@@ -98,6 +100,8 @@ pub struct CustomTheme {
|
||||
pub struct AppConfig {
|
||||
pub vaults: Vec<VaultConfig>,
|
||||
pub active_vault: Option<String>,
|
||||
#[serde(default)]
|
||||
pub active_bookmark_id: Option<String>,
|
||||
pub theme: String,
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
@@ -255,6 +259,7 @@ impl Default for AppConfig {
|
||||
Self {
|
||||
vaults: Vec::new(),
|
||||
active_vault: None,
|
||||
active_bookmark_id: None,
|
||||
theme: "system".to_string(),
|
||||
accent_color: None,
|
||||
font_size: None,
|
||||
@@ -393,6 +398,9 @@ pub struct FileEvent {
|
||||
pub struct ImportResult {
|
||||
pub files_converted: u64,
|
||||
pub links_converted: u64,
|
||||
pub frontmatter_normalized: u64,
|
||||
pub syntax_converted: u64,
|
||||
pub attachments_moved: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -15,7 +15,7 @@ struct RawFrontmatter {
|
||||
}
|
||||
|
||||
/// Try to parse a date string in multiple common formats.
|
||||
fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
pub fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
let s = s.trim();
|
||||
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
||||
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
|
||||
@@ -191,7 +191,7 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
|
||||
format!("---\n{}---\n{}", yaml_str, body_with_heading)
|
||||
}
|
||||
|
||||
fn filename_to_title(filename: &str) -> String {
|
||||
pub fn filename_to_title(filename: &str) -> String {
|
||||
let stem = filename.trim_end_matches(".md");
|
||||
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
||||
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod frontmatter;
|
||||
pub mod import;
|
||||
pub mod operations;
|
||||
pub mod watcher;
|
||||
|
||||
@@ -61,6 +61,15 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
||||
};
|
||||
let _ = app.emit("file-changed", &fe);
|
||||
}
|
||||
|
||||
// On mobile, throttle event emission to prevent IPC flooding
|
||||
// on FUSE filesystems where Syncthing/other apps generate
|
||||
// constant file activity that blocks the Tauri command channel.
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
while rx.try_recv().is_ok() {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("File watcher error: {}", e);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
@@ -55,6 +55,9 @@
|
||||
"appimage": {
|
||||
"bundleMediaFramework": false
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,10 +1142,6 @@ body.resizing {
|
||||
/* ── Mobile (Android/iOS) ── */
|
||||
@media (max-width: 768px) {
|
||||
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;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@@ -15,14 +15,23 @@ import type {
|
||||
BackupEntry,
|
||||
VersionEntry,
|
||||
TaskItem,
|
||||
ExternalVaultResult,
|
||||
} from "./types";
|
||||
|
||||
export async function openVault(path: string): Promise<void> {
|
||||
return invoke("open_vault", { path });
|
||||
}
|
||||
|
||||
export async function removeVault(path: string): Promise<void> {
|
||||
return invoke("remove_vault", { path });
|
||||
export async function chooseExternalVault(): Promise<ExternalVaultResult | null> {
|
||||
return invoke("choose_external_vault");
|
||||
}
|
||||
|
||||
export async function restoreExternalVault(bookmarkId: string): Promise<ExternalVaultResult> {
|
||||
return invoke("restore_external_vault", { bookmarkId });
|
||||
}
|
||||
|
||||
export async function removeVault(path: string, bookmarkId?: string | null): Promise<void> {
|
||||
return invoke("remove_vault", { path, bookmarkId: bookmarkId ?? null });
|
||||
}
|
||||
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
const isMobile = $derived($platformIsMobile);
|
||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
|
||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isAndroid } from '$lib/platform';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
@@ -252,11 +252,11 @@
|
||||
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
||||
let taskNoteOpened = $state(false);
|
||||
|
||||
function handleNoteSelected(path: string, content: string) {
|
||||
function handleNoteSelected(path: string, content: string, task?: TaskItem) {
|
||||
// Selecting a real vault note exits viewer mode
|
||||
$viewerNote = null;
|
||||
taskNoteOpened = true;
|
||||
editor?.loadNote(path, content);
|
||||
editor?.loadNote(path, content, task);
|
||||
if (isMobile) $mobileView = 'editor';
|
||||
}
|
||||
|
||||
@@ -614,6 +614,29 @@
|
||||
// Run sidebar and note list refresh in parallel
|
||||
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.
|
||||
if ($appConfig?.restore_last_session) {
|
||||
const vault = $appConfig?.active_vault;
|
||||
@@ -679,7 +702,7 @@
|
||||
}
|
||||
}
|
||||
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
||||
}, 3000);
|
||||
}, 10000);
|
||||
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
||||
debouncedRefresh();
|
||||
});
|
||||
@@ -709,11 +732,11 @@
|
||||
|
||||
// ── WebDAV sync: global status + auto-sync triggers ──
|
||||
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
|
||||
unlistenSync.push(await listen('sync-done', (event: any) => {
|
||||
unlistenSync.push(await listen('sync-done', async () => {
|
||||
syncState.set({ running: false, error: null });
|
||||
const cur = get(appConfig);
|
||||
const ts = event.payload?.last_sync_time;
|
||||
if (cur && ts) appConfig.set({ ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) });
|
||||
try {
|
||||
appConfig.set(await getAppConfig());
|
||||
} catch {}
|
||||
}));
|
||||
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
|
||||
|
||||
@@ -895,7 +918,7 @@
|
||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); 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 class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||
<Editor bind:this={editor} />
|
||||
@@ -956,7 +979,7 @@
|
||||
|
||||
{#if !$notelistCollapsed}
|
||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); 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>
|
||||
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
@@ -1137,7 +1160,13 @@
|
||||
.mobile-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
padding:
|
||||
env(safe-area-inset-top, 0px)
|
||||
env(safe-area-inset-right, 0px)
|
||||
env(safe-area-inset-bottom, 0px)
|
||||
env(safe-area-inset-left, 0px);
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -41,13 +41,14 @@
|
||||
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward, viewerNote, notebooks, outlineWidth } from '$lib/stores/app';
|
||||
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
|
||||
import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types';
|
||||
import type { VersionEntry, AiStreamEvent, NoteTitleEntry, TaskItem as TaskRecord } from '$lib/types';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets';
|
||||
import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText';
|
||||
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
||||
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
||||
import { relativePath } from '$lib/utils/paths';
|
||||
import GraphView from './GraphView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
import { isMobile, isAndroid } from '$lib/platform';
|
||||
@@ -85,6 +86,13 @@
|
||||
let titleWasStripped = false;
|
||||
let strippedTitle = '';
|
||||
let strippedHeadingPrefix = '';
|
||||
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let taskRevealElement: HTMLElement | null = null;
|
||||
let taskRevealRequest = 0;
|
||||
const taskRevealPluginKey = new PluginKey('taskReveal');
|
||||
type TaskRevealTarget = { sourceOrdinal: number; richOrdinal: number | null };
|
||||
const TASK_LINE_RE = /^\s*[-*]\s\[[ xX]\]\s+.+?\s*$/;
|
||||
const RICH_TASK_LINE_RE = /^\s*-\s\[(?:x| )\]\s+.+?\s*$/;
|
||||
|
||||
let headingDropdown = $state(false);
|
||||
let colorDropdown = $state(false);
|
||||
@@ -101,6 +109,112 @@
|
||||
});
|
||||
}
|
||||
|
||||
function clearTaskReveal() {
|
||||
if (taskRevealTimer) clearTimeout(taskRevealTimer);
|
||||
taskRevealTimer = null;
|
||||
taskRevealElement?.classList.remove('task-target-source');
|
||||
taskRevealElement = null;
|
||||
if (editor && !editor.isDestroyed) {
|
||||
editor.view.dispatch(editor.state.tr.setMeta(taskRevealPluginKey, DecorationSet.empty));
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTaskTarget(task: TaskRecord, content: string): TaskRevealTarget | null {
|
||||
const lines = content.split(/\r?\n/).map((line) => line.replace(/\r$/, ''));
|
||||
const rawLine = task.raw_line.replace(/\r$/, '');
|
||||
const exactLines = lines.flatMap((line, index) => line === rawLine ? [index] : []);
|
||||
const line = lines[task.line] === rawLine
|
||||
? task.line
|
||||
: exactLines.length === 1 ? exactLines[0] : null;
|
||||
if (line === null || !TASK_LINE_RE.test(lines[line])) return null;
|
||||
|
||||
let sourceOrdinal = -1;
|
||||
let richOrdinal = -1;
|
||||
for (let index = 0; index <= line; index++) {
|
||||
if (TASK_LINE_RE.test(lines[index])) sourceOrdinal++;
|
||||
if (RICH_TASK_LINE_RE.test(lines[index])) richOrdinal++;
|
||||
}
|
||||
return {
|
||||
sourceOrdinal,
|
||||
richOrdinal: RICH_TASK_LINE_RE.test(lines[line]) ? richOrdinal : null,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceTaskRange(ordinal: number): { start: number; end: number } | null {
|
||||
let offset = 0;
|
||||
let current = -1;
|
||||
for (const line of sourceContent.split('\n')) {
|
||||
if (TASK_LINE_RE.test(line.replace(/\r$/, ''))) {
|
||||
current++;
|
||||
if (current === ordinal) return { start: offset, end: offset + line.length };
|
||||
}
|
||||
offset += line.length + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function revealTaskTarget(target: TaskRevealTarget): boolean {
|
||||
clearTaskReveal();
|
||||
if ($sourceMode) {
|
||||
if (!sourceElement) return false;
|
||||
const range = sourceTaskRange(target.sourceOrdinal);
|
||||
if (!range) return true;
|
||||
sourceElement.classList.add('task-target-source');
|
||||
sourceElement.setSelectionRange(range.start, range.end);
|
||||
scrollSourceToOffset(range.start);
|
||||
requestAnimationFrame(() => scrollSourceToOffset(range.start));
|
||||
taskRevealElement = sourceElement;
|
||||
taskRevealTimer = setTimeout(() => {
|
||||
if (sourceElement?.selectionStart === range.start && sourceElement.selectionEnd === range.end) {
|
||||
sourceElement.setSelectionRange(range.end, range.end);
|
||||
}
|
||||
sourceElement?.classList.remove('task-target-source');
|
||||
taskRevealElement = null;
|
||||
taskRevealTimer = null;
|
||||
}, 1600);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!editor || target.richOrdinal === null) return !!editor;
|
||||
let current = -1;
|
||||
let targetPos: number | null = null;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (targetPos !== null || node.type.name !== 'taskItem') return targetPos === null;
|
||||
current++;
|
||||
if (current === target.richOrdinal) targetPos = pos;
|
||||
return targetPos === null;
|
||||
});
|
||||
if (targetPos === null) return true;
|
||||
const node = editor.state.doc.nodeAt(targetPos);
|
||||
if (!node) return true;
|
||||
const decorations = DecorationSet.create(editor.state.doc, [
|
||||
Decoration.node(targetPos, targetPos + node.nodeSize, { class: 'task-target-reveal' }),
|
||||
]);
|
||||
editor.view.dispatch(editor.state.tr.setMeta(taskRevealPluginKey, decorations));
|
||||
const element = editor.view.nodeDOM(targetPos);
|
||||
if (!(element instanceof HTMLElement)) return true;
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
element.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
|
||||
const decoratedEditor = editor;
|
||||
taskRevealTimer = setTimeout(() => {
|
||||
if (!decoratedEditor.isDestroyed) {
|
||||
decoratedEditor.view.dispatch(decoratedEditor.state.tr.setMeta(taskRevealPluginKey, DecorationSet.empty));
|
||||
}
|
||||
taskRevealTimer = null;
|
||||
}, 2200);
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleTaskReveal(path: string, target: TaskRevealTarget | null, request: number, attempts = 0) {
|
||||
if (!target) return;
|
||||
tick().then(() => requestAnimationFrame(() => {
|
||||
if (request !== taskRevealRequest || path !== loadedPath) return;
|
||||
if (!revealTaskTarget(target) && attempts < 3) {
|
||||
scheduleTaskReveal(path, target, request, attempts + 1);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSourceCtrlEnd(event: KeyboardEvent) {
|
||||
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || event.key !== 'End') return false;
|
||||
event.preventDefault();
|
||||
@@ -143,6 +257,7 @@
|
||||
}
|
||||
|
||||
let anyDropdownOpen = $derived(headingDropdown || colorDropdown || highlightDropdown || alignDropdown || insertDropdown || tablePickerOpen);
|
||||
|
||||
let editorState = $state(0);
|
||||
let editorStateRaf = 0; // RAF handle for batching toolbar updates
|
||||
|
||||
@@ -300,6 +415,17 @@
|
||||
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
|
||||
let copyToast = $state<'copying' | 'done' | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const open = headingDropdown || colorDropdown || highlightDropdown || alignDropdown || insertDropdown || tablePickerOpen;
|
||||
if (!open) return;
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll<HTMLElement>('.fmt-dropdown').forEach((el) => {
|
||||
const overflow = el.getBoundingClientRect().right - (window.innerWidth - 8);
|
||||
el.style.transform = overflow > 0 ? `translateX(${-overflow}px)` : '';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Math insert/edit modal (opened by /math slash command or double-click on existing math node)
|
||||
let mathModal = $state<{ kind: 'block' | 'inline'; editPos: number | null; tex: string } | null>(null);
|
||||
|
||||
@@ -537,6 +663,50 @@
|
||||
return resolved.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a note path (which may be vault-relative or absolute) to a full
|
||||
* filesystem path for file I/O operations (readNote, renameNote, etc.).
|
||||
*
|
||||
* Wikilink data-path attributes store vault-relative paths (e.g. "folder/note.md")
|
||||
* for cross-platform portability. This helper joins them with the active vault
|
||||
* root so the Rust backend receives the full path it expects.
|
||||
*
|
||||
* Legacy absolute paths (from older Windows builds) are passed through as-is
|
||||
* so they don't break before the migration pass rewrites them.
|
||||
*/
|
||||
function resolveNotePath(path: string): string {
|
||||
if (!path) return path;
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (!vaultRoot) return path;
|
||||
// Normalize separators for comparison
|
||||
const norm = path.replace(/\\/g, '/');
|
||||
const root = vaultRoot.replace(/\\/g, '/');
|
||||
// Already absolute (starts with vault root, or has a Windows drive letter, or starts with /)
|
||||
if (norm.startsWith(root + '/')) return path;
|
||||
if (/^[A-Za-z]:[\\/]/.test(path)) return path;
|
||||
if (path.startsWith('/')) return path;
|
||||
// Relative: join with vault root
|
||||
return vaultRoot + '/' + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a data-path value to a vault-relative path with forward slashes.
|
||||
* Handles legacy absolute paths (from older Windows builds) and Windows
|
||||
* backslashes so wikilinks survive cross-device sync.
|
||||
*/
|
||||
function normalizeWikiPath(path: string): string {
|
||||
if (!path) return '';
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
let norm = path.replace(/\\/g, '/');
|
||||
if (vaultRoot) {
|
||||
const root = vaultRoot.replace(/\\/g, '/');
|
||||
if (norm.startsWith(root + '/')) {
|
||||
norm = norm.slice(root.length + 1);
|
||||
}
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
const CustomImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
@@ -995,6 +1165,92 @@
|
||||
},
|
||||
});
|
||||
|
||||
const StrikeShortcut = Extension.create({
|
||||
name: 'strikeShortcut',
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-Shift-x': () => this.editor.commands.toggleStrike(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const CalloutTyping = Extension.create({
|
||||
name: 'calloutTyping',
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('calloutTyping'),
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
keydown(view, event) {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return false;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
if (!selection.empty) return false;
|
||||
const head = selection.$head;
|
||||
if (head.depth !== 1 || head.parent.type.name !== 'paragraph') return false;
|
||||
if (head.parentOffset !== head.parent.content.size) return false;
|
||||
const m = head.parent.textContent.match(/^>\s*\[!([\w-]+)\]([-+]?)[ \t]*(.*)$/);
|
||||
if (!m) return false;
|
||||
event.preventDefault();
|
||||
const type = m[1].toLowerCase();
|
||||
const foldable = m[2] === '+' || m[2] === '-';
|
||||
const folded = m[2] === '-';
|
||||
const title = m[3];
|
||||
const start = head.before(1);
|
||||
const end = start + head.parent.nodeSize;
|
||||
const callout = state.schema.nodes.callout.create(
|
||||
{ type, title, foldable, folded },
|
||||
[state.schema.nodes.paragraph.create()],
|
||||
);
|
||||
const tr = state.tr.replaceWith(start, end, callout);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(start + 2)));
|
||||
tr.scrollIntoView();
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
handlePaste(view, event) {
|
||||
const text = event.clipboardData?.getData('text/plain');
|
||||
if (!text || !/^>\s*\[!/m.test(text)) return false;
|
||||
const lines = text.split('\n');
|
||||
const idx = lines.findIndex((l) => /^>\s*\[!/.test(l));
|
||||
if (idx === -1) return false;
|
||||
const m = lines[idx].match(/^>\s*\[!([\w-]+)\]([-+]?)[ \t]*(.*)$/);
|
||||
if (!m) return false;
|
||||
const type = m[1].toLowerCase();
|
||||
const foldable = m[2] === '+' || m[2] === '-';
|
||||
const folded = m[2] === '-';
|
||||
const title = m[3];
|
||||
const bodyLines = lines.slice(idx + 1).map((l) => l.replace(/^>\s?/, ''));
|
||||
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === '') bodyLines.pop();
|
||||
const { state } = view;
|
||||
const schema = state.schema;
|
||||
const bodyContent = bodyLines.length > 0
|
||||
? bodyLines.map((l) => schema.nodes.paragraph.create(null, l ? [schema.text(l)] : []))
|
||||
: [schema.nodes.paragraph.create()];
|
||||
const callout = schema.nodes.callout.create({ type, title, foldable, folded }, bodyContent);
|
||||
const head = state.selection.$head;
|
||||
const tr = state.tr;
|
||||
let insertPos: number;
|
||||
if (head.depth >= 1 && head.parent.type.name === 'paragraph') {
|
||||
insertPos = head.before(head.depth);
|
||||
tr.replaceWith(insertPos, insertPos + head.parent.nodeSize, callout);
|
||||
} else {
|
||||
insertPos = state.selection.to;
|
||||
tr.insert(insertPos, callout);
|
||||
}
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos + 2)));
|
||||
tr.scrollIntoView();
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const CtrlEndScrollPastEnd = Extension.create({
|
||||
name: 'ctrlEndScrollPastEnd',
|
||||
addProseMirrorPlugins() {
|
||||
@@ -1126,15 +1382,25 @@
|
||||
async function svgToPngBlob(svgEl: SVGElement, scale = 2): Promise<Blob> {
|
||||
const clone = svgEl.cloneNode(true) as SVGElement;
|
||||
if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
if (!clone.getAttribute('xmlns:xlink')) clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||
|
||||
// Sanitize: remove external resource references that taint canvas on WebKitGTK ("The operation is insecure")
|
||||
clone.querySelectorAll('image').forEach((img) => {
|
||||
const href = img.getAttribute('href') || img.getAttribute('xlink:href') || '';
|
||||
if (href.startsWith('http') || href.startsWith('//') || href.startsWith('data:')) {
|
||||
img.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const svgString = new XMLSerializer().serializeToString(clone);
|
||||
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
try {
|
||||
// Use data URL instead of blob URL — blob: URLs taint canvas on some WebKitGTK versions
|
||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgString);
|
||||
|
||||
const img = new window.Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('SVG image load failed'));
|
||||
img.src = url;
|
||||
img.src = dataUrl;
|
||||
});
|
||||
const bbox = svgEl.getBoundingClientRect();
|
||||
const width = Math.max(Math.round(bbox.width || 800), 100);
|
||||
@@ -1151,9 +1417,6 @@
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((b) => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function flashToast(container: HTMLElement, msg: string) {
|
||||
@@ -1176,9 +1439,22 @@
|
||||
flashToast(container, 'Copied');
|
||||
} catch (e: any) {
|
||||
console.error('[Mermaid] copy failed', e);
|
||||
// SecurityError fallback: canvas tainted by WebKitGTK — copy SVG source as text instead
|
||||
if (e instanceof DOMException && (e.name === 'SecurityError' || e.code === 18)) {
|
||||
try {
|
||||
const clone = svgEl.cloneNode(true) as SVGElement;
|
||||
if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
const svgString = new XMLSerializer().serializeToString(clone);
|
||||
await navigator.clipboard.writeText(svgString);
|
||||
flashToast(container, 'Copied SVG source (PNG blocked by browser security)');
|
||||
} catch (e2: any) {
|
||||
flashToast(container, 'Copy failed: ' + (e2?.message || String(e2)));
|
||||
}
|
||||
} else {
|
||||
flashToast(container, 'Copy failed: ' + (e?.message || String(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDiagram(container: HTMLElement) {
|
||||
const svgEl = container.querySelector('svg') as SVGElement | null;
|
||||
@@ -1206,9 +1482,14 @@
|
||||
flashToast(container, 'Saved');
|
||||
} catch (e: any) {
|
||||
console.error('[Mermaid] save failed', e);
|
||||
// SecurityError fallback: canvas tainted by WebKitGTK — tell user to use SVG format
|
||||
if (e instanceof DOMException && (e.name === 'SecurityError' || e.code === 18)) {
|
||||
flashToast(container, 'PNG blocked by browser security — try SVG format');
|
||||
} else {
|
||||
flashToast(container, 'Save failed: ' + (e?.message || String(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addToolbar(container: HTMLElement, source: string) {
|
||||
const toolbar = document.createElement('div');
|
||||
@@ -1429,6 +1710,28 @@
|
||||
},
|
||||
});
|
||||
|
||||
const TaskRevealExtension = Extension.create({
|
||||
name: 'taskReveal',
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: taskRevealPluginKey,
|
||||
state: {
|
||||
init() { return DecorationSet.empty; },
|
||||
apply(tr, old) {
|
||||
const meta = tr.getMeta(taskRevealPluginKey);
|
||||
if (meta !== undefined) return meta;
|
||||
return old.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) { return this.getState(state); },
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// Color swatch decorations: render a small filled square before every hex/rgb/hsl color
|
||||
// literal (in normal text AND code blocks), VSCode-style. These are view-only widget
|
||||
// decorations, so they never touch the document/markdown - the note stores only the plain
|
||||
@@ -1807,7 +2110,8 @@
|
||||
|
||||
// Keep menu within viewport (account for virtual keyboard on mobile)
|
||||
if (x + 240 > window.innerWidth) x = window.innerWidth - 250;
|
||||
const visibleBottom = window.innerHeight - keyboardHeight;
|
||||
const vv1 = window.visualViewport;
|
||||
const visibleBottom = isMobile && vv1 ? vv1.height + (vv1.offsetTop || 0) : window.innerHeight;
|
||||
const menuHeight = 300;
|
||||
let y = coords.bottom + 4;
|
||||
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
|
||||
@@ -1959,7 +2263,8 @@
|
||||
const coords = editor.view.coordsAtPos(from);
|
||||
let x = coords.left;
|
||||
if (x + 200 > window.innerWidth) x = window.innerWidth - 210;
|
||||
const visibleBottom = window.innerHeight - keyboardHeight;
|
||||
const vv2 = window.visualViewport;
|
||||
const visibleBottom = isMobile && vv2 ? vv2.height + (vv2.offsetTop || 0) : window.innerHeight;
|
||||
const menuH = 180;
|
||||
let y = coords.bottom + 4;
|
||||
if (y + menuH > visibleBottom) y = coords.top - menuH - 4;
|
||||
@@ -2082,13 +2387,10 @@
|
||||
});
|
||||
|
||||
function wikiLinkFolderPath(entry: NoteTitleEntry): string {
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (!vaultRoot || !entry.path) return '';
|
||||
// Normalize Windows backslashes so the folder subtitle shows there too.
|
||||
if (!entry.path) return '';
|
||||
// entry.path is vault-relative (e.g. "folder/note.md"); normalize Windows backslashes.
|
||||
const path = entry.path.replace(/\\/g, '/');
|
||||
const root = vaultRoot.replace(/\\/g, '/');
|
||||
const rel = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
|
||||
const parts = rel.split('/');
|
||||
const parts = path.split('/');
|
||||
// Return parent folder(s), excluding the filename
|
||||
return parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
|
||||
}
|
||||
@@ -2110,9 +2412,9 @@
|
||||
}
|
||||
|
||||
function wikiLinkRelPath(entry: NoteTitleEntry): string | null {
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (!vaultRoot || !entry.path || !entry.path.startsWith(vaultRoot + '/')) return null;
|
||||
return entry.path.slice(vaultRoot.length + 1).replace(/\.md$/, '');
|
||||
if (!entry.path) return null;
|
||||
// entry.path is vault-relative (e.g. "folder/note.md"); strip .md for the ref.
|
||||
return entry.path.replace(/\\/g, '/').replace(/\.md$/, '');
|
||||
}
|
||||
|
||||
// Scan the document for wiki-link marks whose visible text has been changed by
|
||||
@@ -2143,7 +2445,8 @@
|
||||
|
||||
for (const item of toRename) {
|
||||
try {
|
||||
const newPath = await renameNote(item.mark.attrs.path, item.newTitle);
|
||||
const absPath = resolveNotePath(item.mark.attrs.path);
|
||||
const newPath = await renameNote(absPath, item.newTitle);
|
||||
// Update the mark attrs in the editor (title + path) so the next save
|
||||
// serialises [[NewTitle]] and doesn't re-trigger the rename check
|
||||
if (editor) {
|
||||
@@ -2223,6 +2526,21 @@
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
// Normalize legacy absolute paths in data-path to vault-relative.
|
||||
// Older Windows builds wrote absolute paths (C:\Users\...) into wikilink
|
||||
// data-path attributes, which break when the vault is synced to another device.
|
||||
const normalizePath = (rawPath: string | null): string | null => {
|
||||
if (!rawPath) return null;
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
let norm = rawPath.replace(/\\/g, '/');
|
||||
if (vaultRoot) {
|
||||
const root = vaultRoot.replace(/\\/g, '/');
|
||||
if (norm.startsWith(root + '/')) {
|
||||
norm = norm.slice(root.length + 1);
|
||||
}
|
||||
}
|
||||
return norm;
|
||||
};
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-wiki-link]',
|
||||
@@ -2231,7 +2549,7 @@
|
||||
const text = el.textContent || '';
|
||||
return {
|
||||
title,
|
||||
path: el.getAttribute('data-path') || null,
|
||||
path: normalizePath(el.getAttribute('data-path')),
|
||||
// Detect alias from the HTML: if the visible text differs from the
|
||||
// stored title the link was serialised as [[ref|display]]
|
||||
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
||||
@@ -2245,7 +2563,7 @@
|
||||
const text = el.textContent || '';
|
||||
return {
|
||||
title,
|
||||
path: el.getAttribute('data-path') || null,
|
||||
path: normalizePath(el.getAttribute('data-path')),
|
||||
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
||||
};
|
||||
},
|
||||
@@ -2431,7 +2749,8 @@
|
||||
const coords = editor.view.coordsAtPos(from);
|
||||
let x = coords.left;
|
||||
if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
|
||||
const visibleBottom = window.innerHeight - keyboardHeight;
|
||||
const vv = window.visualViewport;
|
||||
const visibleBottom = isMobile && vv ? vv.height + (vv.offsetTop || 0) : window.innerHeight;
|
||||
const menuHeight = 360;
|
||||
let y = coords.bottom + 4;
|
||||
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
|
||||
@@ -2441,14 +2760,16 @@
|
||||
}
|
||||
|
||||
async function navigateToWikiLink(path: string, title: string, clickEvent?: MouseEvent) {
|
||||
// Normalize legacy absolute paths in data-path to vault-relative (issue: cross-device sync)
|
||||
path = normalizeWikiPath(path);
|
||||
// title may contain #heading or ^block anchors - strip for note lookup
|
||||
const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
||||
if (!path) {
|
||||
// Try path-based resolution first (for disambiguated refs like "folder/note")
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (noteTitle.includes('/') && vaultRoot) {
|
||||
const fullPath = vaultRoot + '/' + noteTitle + '.md';
|
||||
const pathMatch = wikiLinkTitlesCache.find(e => e.path === fullPath);
|
||||
if (noteTitle.includes('/')) {
|
||||
// entry.path is vault-relative (e.g. "folder/note.md"); match against that
|
||||
const relPath = noteTitle + '.md';
|
||||
const pathMatch = wikiLinkTitlesCache.find(e => e.path === relPath);
|
||||
if (pathMatch) {
|
||||
path = pathMatch.path;
|
||||
} else {
|
||||
@@ -2483,7 +2804,7 @@
|
||||
// Create the note (use clean title, not the anchor ref)
|
||||
const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle;
|
||||
const notebookRel = $activeNotePath
|
||||
? $activeNotePath.substring(($appConfig?.active_vault?.length ?? 0) + 1).split('/').slice(0, -1).join('/')
|
||||
? $activeNotePath.replace(/\\/g, '/').replace(($appConfig?.active_vault ?? '').replace(/\\/g, '/') + '/', '').split('/').slice(0, -1).join('/')
|
||||
: null;
|
||||
try {
|
||||
// Save the current note before navigating away
|
||||
@@ -2504,9 +2825,10 @@
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = await readNote(path);
|
||||
const absPath = resolveNotePath(path);
|
||||
const content = await readNote(absPath);
|
||||
$activeNote = { ...content, content: content.content };
|
||||
$activeNotePath = path;
|
||||
$activeNotePath = absPath;
|
||||
} catch (e) {
|
||||
// Note at path no longer exists (deleted/moved). Refresh cache and
|
||||
// retry as unresolved so the user can recreate it from the link.
|
||||
@@ -2518,9 +2840,10 @@
|
||||
async function navigateToWikiLinkDirect(entry: NoteTitleEntry) {
|
||||
wikiLinkNavDisambig = null;
|
||||
try {
|
||||
const content = await readNote(entry.path);
|
||||
const absPath = resolveNotePath(entry.path);
|
||||
const content = await readNote(absPath);
|
||||
$activeNote = { ...content, content: content.content };
|
||||
$activeNotePath = entry.path;
|
||||
$activeNotePath = absPath;
|
||||
} catch (e) {
|
||||
console.error('Failed to navigate to wiki-link:', e);
|
||||
}
|
||||
@@ -2605,7 +2928,8 @@
|
||||
// Standard markdown: resolve relative paths against the note's directory
|
||||
const notePath = $activeNotePath;
|
||||
if (notePath) {
|
||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
||||
const normalizedNotePath = notePath.replace(/\\/g, '/');
|
||||
const noteDir = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
|
||||
return convertFileSrc(normalizePath(`${noteDir}/${decoded}`));
|
||||
}
|
||||
// Last fallback: vault root
|
||||
@@ -2876,7 +3200,10 @@
|
||||
$editorDirty = false;
|
||||
}
|
||||
|
||||
export function loadNote(path: string, content: string) {
|
||||
export function loadNote(path: string, content: string, taskTarget?: TaskRecord) {
|
||||
clearTaskReveal();
|
||||
const revealRequest = ++taskRevealRequest;
|
||||
const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null;
|
||||
loadedPath = path;
|
||||
lastSourceMode = $sourceMode;
|
||||
isLoadingNote = true;
|
||||
@@ -2924,6 +3251,8 @@
|
||||
pendingContent = content;
|
||||
isLoadingNote = false;
|
||||
}
|
||||
if (!isMobile && showOutline) scheduleOutline();
|
||||
scheduleTaskReveal(path, revealTarget, revealRequest);
|
||||
}
|
||||
|
||||
function stripTitleH1(md: string): string {
|
||||
@@ -2997,6 +3326,9 @@
|
||||
}
|
||||
entries.push({ text: serializeNode(node), isImage: isImageNode(node) });
|
||||
});
|
||||
while (entries.length > 0 && entries[entries.length - 1].text === '<!-- -->') {
|
||||
entries.pop();
|
||||
}
|
||||
// Join: skip extra \n separator before image nodes so they don't get unwanted blank lines
|
||||
let result = '';
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
@@ -3045,10 +3377,11 @@
|
||||
let changed = false;
|
||||
state.doc.descendants((node: any, pos: number) => {
|
||||
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
|
||||
if (node.attrs.backgroundColor || (node.attrs.colspan && node.attrs.colspan > 1) || (node.attrs.rowspan && node.attrs.rowspan > 1)) {
|
||||
if (node.attrs.backgroundColor || node.attrs.colwidth || (node.attrs.colspan && node.attrs.colspan > 1) || (node.attrs.rowspan && node.attrs.rowspan > 1)) {
|
||||
tr = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
backgroundColor: null,
|
||||
colwidth: null,
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
});
|
||||
@@ -3123,17 +3456,22 @@
|
||||
case 'pageBreak':
|
||||
return '<div style="page-break-after: always;"></div>\n';
|
||||
case 'table': {
|
||||
// Check if any cell has styling (background color) or if there are merged cells
|
||||
let hasStyling = false;
|
||||
const firstRow = node.firstChild;
|
||||
let hasHeaderRow = !!firstRow && firstRow.childCount > 0;
|
||||
firstRow?.forEach((cell: any) => {
|
||||
if (cell.type.name !== 'tableHeader') hasHeaderRow = false;
|
||||
});
|
||||
node.descendants((child: any) => {
|
||||
if (child.type.name === 'tableCell' || child.type.name === 'tableHeader') {
|
||||
if (child.attrs.backgroundColor) hasStyling = true;
|
||||
if (child.attrs.colwidth) hasStyling = true;
|
||||
if (child.attrs.colspan && child.attrs.colspan > 1) hasStyling = true;
|
||||
if (child.attrs.rowspan && child.attrs.rowspan > 1) hasStyling = true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (hasStyling) {
|
||||
if (hasStyling || !hasHeaderRow) {
|
||||
const tempDiv = document.createElement('div');
|
||||
const frag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
|
||||
tempDiv.appendChild(frag);
|
||||
@@ -3487,6 +3825,7 @@
|
||||
|
||||
export function openNoteSearch() {
|
||||
noteSearchOpen = true;
|
||||
tick().then(() => noteSearchInput?.focus());
|
||||
}
|
||||
|
||||
function closeNoteSearch() {
|
||||
@@ -3514,7 +3853,7 @@
|
||||
}
|
||||
}
|
||||
// Convert asset:// URLs back to relative paths for saving
|
||||
if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost')) return src;
|
||||
if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost') && !src.startsWith('https://asset.localhost')) return src;
|
||||
let absPath = '';
|
||||
try {
|
||||
const url = new URL(src);
|
||||
@@ -3524,18 +3863,18 @@
|
||||
}
|
||||
// Clean up any leading double/triple slashes (URL parsing artifact)
|
||||
absPath = absPath.replace(/^\/{2,}/, '/');
|
||||
// Make relative to note directory (matches how resolveImageSrc works)
|
||||
absPath = absPath.replace(/^\/([A-Za-z]:\/)/, '$1').replace(/\\/g, '/');
|
||||
const notePath = $activeNotePath;
|
||||
if (notePath) {
|
||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
||||
if (absPath.startsWith(noteDir + '/')) {
|
||||
return absPath.substring(noteDir.length + 1);
|
||||
}
|
||||
}
|
||||
// Fallback: make relative to vault root
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
const vaultRoot = $appConfig?.active_vault?.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
if (vaultRoot && absPath.startsWith(vaultRoot + '/')) {
|
||||
return absPath.substring(vaultRoot.length + 1);
|
||||
const vaultRelative = absPath.substring(vaultRoot.length + 1);
|
||||
if (vaultRelative.startsWith('.helixnotes/')) return vaultRelative;
|
||||
if (notePath) {
|
||||
const normalizedNotePath = notePath.replace(/\\/g, '/');
|
||||
const noteDir = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
|
||||
return relativePath(noteDir, absPath);
|
||||
}
|
||||
return vaultRelative;
|
||||
}
|
||||
return absPath;
|
||||
}
|
||||
@@ -3695,12 +4034,12 @@
|
||||
const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim();
|
||||
// Strip #heading and ^block anchors for title matching
|
||||
const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
||||
// Try to resolve: first by vault-relative path (for disambiguated links), then by title
|
||||
const vaultRoot = $appConfig?.active_vault ?? '';
|
||||
// Try to resolve: first by vault-relative path (for disambiguated links), then by title.
|
||||
// entry.path is vault-relative (e.g. "folder/note.md"); match against that.
|
||||
let match: NoteTitleEntry | undefined;
|
||||
if (titleForLookup.includes('/') && vaultRoot) {
|
||||
const fullPath = vaultRoot + '/' + titleForLookup + '.md';
|
||||
match = wikiLinkTitlesCache.find(e => e.path === fullPath);
|
||||
if (titleForLookup.includes('/')) {
|
||||
const relPath = titleForLookup + '.md';
|
||||
match = wikiLinkTitlesCache.find(e => e.path === relPath);
|
||||
}
|
||||
if (!match) {
|
||||
// Fallback: resolve by title (use the last segment if path-based)
|
||||
@@ -3806,10 +4145,10 @@
|
||||
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]+(.+)$/gm, '$1- <tiptask checked="false">$2</tiptask>');
|
||||
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1- <tiptask checked="false"> </tiptask>');
|
||||
|
||||
// Pre-process: strip list-separator comments before markdown-it.
|
||||
// markdown-it treats <!-- --> as an HTML block start, swallowing the
|
||||
// next line (e.g. an image) as raw HTML instead of parsing it.
|
||||
src = src.replace(/<!-- -->/g, '\n');
|
||||
// Pre-process: replace empty-paragraph markers with a div sentinel before markdown-it.
|
||||
// A bare <!-- --> starts an HTML block that swallows the next line; the blank lines
|
||||
// around the div keep it isolated. Post-render converts it back to <p></p>.
|
||||
src = src.replace(/<!-- -->/g, '\n\n<div data-empty-para></div>\n\n');
|
||||
|
||||
// Pre-process: preserve blank lines before image-only lines
|
||||
// markdown-it collapses blank lines into paragraph breaks, losing the empty paragraph.
|
||||
@@ -3826,8 +4165,8 @@
|
||||
html = html.replace(/<code([^>]*)>\n?/g, '<code$1>');
|
||||
html = html.replace(/\n<\/code>/g, '</code>');
|
||||
|
||||
// Post-process: convert list-separator comments back to empty paragraphs for TipTap
|
||||
html = html.replace(/<!-- -->/g, '<p></p>');
|
||||
// Post-process: convert empty-paragraph div sentinels back to empty paragraphs for TipTap
|
||||
html = html.replace(/<div data-empty-para><\/div>\n?/g, '<p></p>\n');
|
||||
|
||||
// Post-process: convert task list items to TipTap format
|
||||
// Convert opening <li> + <tiptask> into data-attributed <li>, handles both tight and loose (with <p>) lists
|
||||
@@ -4004,6 +4343,7 @@
|
||||
MathInline,
|
||||
PageBreak,
|
||||
Callout,
|
||||
CalloutTyping,
|
||||
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
|
||||
DetailsSummary,
|
||||
DetailsContent,
|
||||
@@ -4096,12 +4436,14 @@
|
||||
}),
|
||||
CtrlEndScrollPastEnd,
|
||||
HeadingShortcuts,
|
||||
StrikeShortcut,
|
||||
WrapSelectedText,
|
||||
SlashCommands,
|
||||
TaskMetaMenu,
|
||||
MoveLineShortcuts,
|
||||
TabIndent,
|
||||
NoteSearchExtension,
|
||||
TaskRevealExtension,
|
||||
ColorSwatch,
|
||||
TaskMetaDim,
|
||||
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
|
||||
@@ -4316,15 +4658,19 @@
|
||||
}
|
||||
|
||||
function linkModalSelectNote(entry: NoteTitleEntry) {
|
||||
// Build a relative .md path from the selected note and confirm immediately
|
||||
// Build a relative .md path from the selected note and confirm immediately.
|
||||
// entry.path is vault-relative (e.g. "folder/note.md"); currentNote is absolute.
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
const currentNote = $activeNotePath;
|
||||
if (vaultRoot && currentNote) {
|
||||
const noteDir = currentNote.substring(0, currentNote.lastIndexOf('/'));
|
||||
const targetRel = entry.path.startsWith(vaultRoot) ? entry.path.substring(vaultRoot.length + 1) : entry.path;
|
||||
const currentRel = noteDir.startsWith(vaultRoot) ? noteDir.substring(vaultRoot.length + 1) : noteDir;
|
||||
const targetParts = targetRel.split('/');
|
||||
const currentParts = currentRel ? currentRel.split('/') : [];
|
||||
// Strip vault root from currentNote to get its relative path
|
||||
const normCurrent = currentNote.replace(/\\/g, '/');
|
||||
const normRoot = vaultRoot.replace(/\\/g, '/');
|
||||
const currentRel = normCurrent.startsWith(normRoot + '/') ? normCurrent.slice(normRoot.length + 1) : normCurrent;
|
||||
const noteDir = currentRel.substring(0, currentRel.lastIndexOf('/'));
|
||||
// entry.path is already relative
|
||||
const targetParts = entry.path.split('/');
|
||||
const currentParts = noteDir ? noteDir.split('/') : [];
|
||||
let common = 0;
|
||||
while (common < targetParts.length && common < currentParts.length && targetParts[common] === currentParts[common]) common++;
|
||||
const ups = currentParts.length - common;
|
||||
@@ -4516,7 +4862,7 @@
|
||||
if (resolved.node(d).type.name === 'table') {
|
||||
resolved.node(d).descendants((n: any) => {
|
||||
if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') {
|
||||
if (n.attrs.backgroundColor || (n.attrs.colspan > 1) || (n.attrs.rowspan > 1)) hasStyling = true;
|
||||
if (n.attrs.backgroundColor || n.attrs.colwidth || (n.attrs.colspan > 1) || (n.attrs.rowspan > 1)) hasStyling = true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -4790,6 +5136,46 @@
|
||||
function openAiMenu() {
|
||||
if (!editor) return;
|
||||
closeTextContextMenu();
|
||||
|
||||
if ($sourceMode && sourceElement) {
|
||||
const start = sourceElement.selectionStart;
|
||||
const end = sourceElement.selectionEnd;
|
||||
const selected = sourceContent.slice(start, end);
|
||||
aiResult = null;
|
||||
aiError = null;
|
||||
aiLoading = false;
|
||||
aiShowCustom = false;
|
||||
aiTranslateMenu = false;
|
||||
aiCustomPrompt = '';
|
||||
aiMediaPlaceholders = new Map();
|
||||
aiOriginalMarkdown = '';
|
||||
aiEmptyNote = false;
|
||||
if (selected.trim()) {
|
||||
aiSelectionFrom = start;
|
||||
aiSelectionTo = end;
|
||||
aiSelectedText = selected;
|
||||
aiWholeNote = false;
|
||||
requestAnimationFrame(() => {
|
||||
sourceElement?.focus();
|
||||
sourceElement?.setSelectionRange(start, end);
|
||||
});
|
||||
} else {
|
||||
const fullMd = restoreTitleH1(sourceContent);
|
||||
if (!fullMd.trim()) {
|
||||
aiEmptyNote = true;
|
||||
aiWholeNote = true;
|
||||
aiSelectedText = '';
|
||||
} else {
|
||||
aiWholeNote = true;
|
||||
aiSelectedText = fullMd.trim();
|
||||
}
|
||||
aiSelectionFrom = 0;
|
||||
aiSelectionTo = 0;
|
||||
}
|
||||
aiMenu = isMobile ? { x: 0, y: 0 } : { x: Math.max(4, window.innerWidth / 2 - 110), y: 100 };
|
||||
return;
|
||||
}
|
||||
|
||||
editor.commands.focus();
|
||||
|
||||
const { from, to } = editor.state.selection;
|
||||
@@ -4940,7 +5326,7 @@
|
||||
}
|
||||
|
||||
async function aiApplyResult() {
|
||||
if (!editor || !aiResult) return;
|
||||
if (!aiResult) return;
|
||||
// Save a version snapshot before applying AI changes
|
||||
if ($activeNotePath && $activeNote && !aiEmptyNote) {
|
||||
try {
|
||||
@@ -4950,6 +5336,24 @@
|
||||
console.error('Failed to create version before AI apply:', e);
|
||||
}
|
||||
}
|
||||
if ($sourceMode) {
|
||||
if (aiEmptyNote) {
|
||||
const lines = aiResult.split('\n');
|
||||
let title = lines[0]?.replace(/^#+\s*/, '').trim() || 'Untitled';
|
||||
const body = lines.slice(1).join('\n').replace(/^\n+/, '');
|
||||
if ($activeNote) $activeNote.meta.title = title;
|
||||
sourceContent = body;
|
||||
} else if (aiWholeNote) {
|
||||
sourceContent = stripTitleH1(aiResult);
|
||||
} else {
|
||||
sourceContent = sourceContent.slice(0, aiSelectionFrom) + aiResult + sourceContent.slice(aiSelectionTo);
|
||||
}
|
||||
$editorDirty = true;
|
||||
autoSave();
|
||||
closeAiMenu();
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
if (aiEmptyNote) {
|
||||
// Parse title from first line, rest is content
|
||||
const lines = aiResult.split('\n');
|
||||
@@ -5391,6 +5795,7 @@
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTaskReveal();
|
||||
destroyEditor();
|
||||
unlistenFileChange?.();
|
||||
});
|
||||
@@ -6771,7 +7176,7 @@
|
||||
</div>
|
||||
<div class="table-ctx-sep"></div>
|
||||
{#if tableContextMenu.hasStyling}
|
||||
<button onclick={resetTableToMarkdown} title="Strip cell colors and merged cells, output as plain markdown">
|
||||
<button onclick={resetTableToMarkdown} title="Strip cell colors, merged cells, and custom widths, output as plain markdown">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7"/><polyline points="21 4 21 10 15 10"/></svg>
|
||||
Reset to Markdown
|
||||
</button>
|
||||
@@ -7834,19 +8239,6 @@
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.editor-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.editor-body::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.editor-body:has(.source-editor) {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -8144,7 +8536,7 @@
|
||||
}
|
||||
|
||||
:global(.tiptap-wrapper .tiptap p) {
|
||||
margin: 0 0 0.75em;
|
||||
margin: 0 0 0.4em;
|
||||
line-height: var(--editor-line-height, 1.65);
|
||||
}
|
||||
|
||||
@@ -8208,7 +8600,7 @@
|
||||
overflow-x: auto;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-size: 0.93em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -9160,6 +9552,32 @@
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li.task-target-reveal) {
|
||||
border-radius: 6px;
|
||||
animation: task-target-flash 2.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes task-target-flash {
|
||||
0%, 62% {
|
||||
background: color-mix(in srgb, var(--accent) 24%, var(--bg-editor));
|
||||
}
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.source-editor.task-target-source::selection {
|
||||
background: color-mix(in srgb, var(--accent) 36%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li.task-target-reveal) {
|
||||
animation: none;
|
||||
background: color-mix(in srgb, var(--accent) 24%, var(--bg-editor));
|
||||
}
|
||||
}
|
||||
|
||||
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -9411,7 +9829,7 @@
|
||||
|
||||
:global(.tiptap-wrapper .tiptap table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
table-layout: fixed;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
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) => {} }: {
|
||||
onNoteSelected?: (path: string, content: string) => void;
|
||||
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, task?: TaskItem) => void;
|
||||
onNoteMoved?: () => void;
|
||||
onBeforeNoteSwitch?: () => void;
|
||||
onNoteCreated?: () => void;
|
||||
@@ -409,13 +409,14 @@
|
||||
$activeNote = content;
|
||||
$activeNotePath = task.note_path;
|
||||
$editorDirty = false;
|
||||
onNoteSelected(task.note_path, content.content);
|
||||
onNoteSelected(task.note_path, content.content, task);
|
||||
} catch (e) {
|
||||
console.error('Failed to open task note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCreateNote() {
|
||||
if ($viewMode === 'quickaccess' || $viewMode === 'trash') return;
|
||||
if ($viewMode === 'daily') {
|
||||
const today = new Date();
|
||||
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
@@ -441,8 +442,12 @@
|
||||
const entry = await createNote(nbRelative, 'Untitled');
|
||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||
noteCache.clear();
|
||||
await refresh();
|
||||
await selectNote(entry);
|
||||
$notes = [entry, ...$notes];
|
||||
onBeforeNoteSwitch();
|
||||
$activeNote = { path: entry.path, meta: entry.meta, content: '\n', raw: '' };
|
||||
$activeNotePath = entry.path;
|
||||
$editorDirty = false;
|
||||
onNoteSelected(entry.path, '\n');
|
||||
onNoteCreated();
|
||||
} catch (e) {
|
||||
console.error('Failed to create note:', e);
|
||||
@@ -453,7 +458,7 @@
|
||||
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') return;
|
||||
if ($viewMode === 'tasks' || $viewMode === 'trash' || $viewMode === 'quickaccess') return;
|
||||
handleCreateNote();
|
||||
}
|
||||
|
||||
@@ -782,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 } {
|
||||
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
||||
@@ -989,7 +1007,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if $viewMode !== 'trash'}
|
||||
{#if $viewMode !== 'trash' && $viewMode !== 'quickaccess'}
|
||||
<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">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
@@ -1005,8 +1023,14 @@
|
||||
<span class="selection-count">{selectedPaths.size} selected</span>
|
||||
<div class="selection-actions">
|
||||
{#if $viewMode === 'trash'}
|
||||
<button class="selection-action" onclick={handleBatchRestore}>Restore</button>
|
||||
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
|
||||
<button class="selection-action" onclick={handleBatchRestore} title="Restore selected">
|
||||
<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}
|
||||
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
|
||||
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
|
||||
@@ -1088,7 +1112,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="list-content" bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
|
||||
<div class="list-content" class:tasks-mode={$viewMode === 'tasks'} bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
|
||||
{#if $viewMode === 'tasks'}
|
||||
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
||||
{/if}
|
||||
@@ -1130,6 +1154,8 @@
|
||||
<div class="empty-state">
|
||||
{#if $viewMode === 'trash'}
|
||||
<p>Trash is empty</p>
|
||||
{:else if $viewMode === 'quickaccess'}
|
||||
<p>No starred notes</p>
|
||||
{:else}
|
||||
<p>No notes yet</p>
|
||||
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
||||
@@ -1320,6 +1346,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/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>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -1512,6 +1548,14 @@
|
||||
</svg>
|
||||
Move to...
|
||||
</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}
|
||||
<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">
|
||||
@@ -1634,19 +1678,6 @@
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.list-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.list-content::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.list-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.note-list-section {
|
||||
padding: 14px 12px 4px;
|
||||
font-size: 0.7rem;
|
||||
@@ -1673,6 +1704,7 @@
|
||||
text-align: left;
|
||||
margin-bottom: 1px;
|
||||
contain: content;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
@@ -1719,6 +1751,9 @@
|
||||
}
|
||||
|
||||
.selection-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
@@ -1733,6 +1768,37 @@
|
||||
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 {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
||||
@@ -2147,6 +2213,11 @@
|
||||
padding: 4px 8px 180px;
|
||||
}
|
||||
|
||||
.note-list.mobile .list-content.tasks-mode {
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.note-list.mobile .note-item {
|
||||
padding: 14px 16px;
|
||||
min-height: 56px;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { 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('');
|
||||
@@ -84,6 +84,17 @@
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
const content = await readNote(result.path);
|
||||
@@ -91,6 +102,19 @@
|
||||
$activeNotePath = result.path;
|
||||
$editorDirty = 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';
|
||||
} catch (e) {
|
||||
console.error('Failed to open search result:', e);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } 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, isMobile, isAndroid } from '$lib/platform';
|
||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
@@ -318,9 +318,13 @@
|
||||
// Re-seed the sync form when the active vault changes, so Settings reflects the current vault.
|
||||
let lastSyncVault: string | null = null;
|
||||
$effect(() => {
|
||||
const path = $appConfig?.active_vault ?? null;
|
||||
if (path === lastSyncVault) return;
|
||||
lastSyncVault = path;
|
||||
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 ?? '';
|
||||
@@ -337,10 +341,11 @@
|
||||
// triggers update live, without an app restart.
|
||||
if ($appConfig) {
|
||||
const cur = $appConfig;
|
||||
const active = activeVaultConfig(cur);
|
||||
$appConfig = {
|
||||
...cur,
|
||||
vaults: cur.vaults.map((v) => v.path === cur.active_vault ? {
|
||||
...v,
|
||||
vaults: cur.vaults.map((vault) => vault === active ? {
|
||||
...vault,
|
||||
sync_provider: syncProvider,
|
||||
webdav_url: syncUrl || null,
|
||||
webdav_username: syncUsername || null,
|
||||
@@ -348,7 +353,7 @@
|
||||
sync_on_open: syncOnOpen,
|
||||
sync_on_change: syncOnChange,
|
||||
sync_interval_minutes: syncIntervalMinutes,
|
||||
} : v),
|
||||
} : vault),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -379,7 +384,7 @@
|
||||
syncMessage = null;
|
||||
const unlisteners: Array<() => void> = [];
|
||||
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 parts: string[] = [];
|
||||
if (s.uploaded) parts.push(`${s.uploaded} uploaded`);
|
||||
@@ -389,10 +394,10 @@
|
||||
if (s.conflicts) parts.push(`${s.conflicts} conflict copies`);
|
||||
syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' };
|
||||
syncRunning = false;
|
||||
if ($appConfig && event.payload.last_sync_time) {
|
||||
const cur = $appConfig;
|
||||
const ts = event.payload.last_sync_time;
|
||||
$appConfig = { ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) };
|
||||
if (event.payload.last_sync_time) {
|
||||
try {
|
||||
$appConfig = await getAppConfig();
|
||||
} catch {}
|
||||
}
|
||||
cleanup();
|
||||
}));
|
||||
@@ -583,7 +588,6 @@
|
||||
}
|
||||
|
||||
function restoreCurrentTheme() {
|
||||
// Re-apply the currently active theme to undo preview
|
||||
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');
|
||||
@@ -739,10 +743,16 @@
|
||||
importResult = 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;
|
||||
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 {
|
||||
importError = data.error ?? 'Import failed';
|
||||
}
|
||||
@@ -1807,7 +1817,7 @@
|
||||
<div class="tab-content">
|
||||
<div class="settings-section">
|
||||
<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">
|
||||
<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>
|
||||
@@ -1829,7 +1839,16 @@
|
||||
{#if importResult}
|
||||
<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>
|
||||
<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>
|
||||
{/if}
|
||||
{#if importError}
|
||||
@@ -2221,7 +2240,7 @@
|
||||
Download from Website
|
||||
</button>
|
||||
{: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">
|
||||
<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>
|
||||
@@ -2803,7 +2822,7 @@
|
||||
|
||||
.import-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 14px;
|
||||
@@ -2816,6 +2835,12 @@
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.import-stats ul {
|
||||
margin: 4px 0 0 0;
|
||||
padding-left: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.import-result.error {
|
||||
background: color-mix(in srgb, #e11d48 10%, transparent);
|
||||
color: #e11d48;
|
||||
|
||||
@@ -146,6 +146,10 @@
|
||||
const vault = $appConfig?.active_vault;
|
||||
if (!vault) return;
|
||||
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
|
||||
if (isMobile) {
|
||||
onViewChanged();
|
||||
return;
|
||||
}
|
||||
selectAllNotes();
|
||||
return;
|
||||
}
|
||||
@@ -158,6 +162,10 @@
|
||||
async function selectNotebook(nb: NotebookEntry) {
|
||||
// Deselect if clicking the already-active notebook; expand/collapse is the chevron's job
|
||||
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
|
||||
if (isMobile) {
|
||||
onViewChanged();
|
||||
return;
|
||||
}
|
||||
selectAllNotes();
|
||||
return;
|
||||
}
|
||||
@@ -799,11 +807,12 @@
|
||||
class="notebook-item"
|
||||
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
|
||||
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">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||
<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>
|
||||
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
|
||||
</button>
|
||||
@@ -942,7 +951,7 @@
|
||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||
{@const iconSrc = getNotebookIconSrc(nb)}
|
||||
{#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
|
||||
type="text"
|
||||
class="rename-input"
|
||||
@@ -962,7 +971,7 @@
|
||||
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
|
||||
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
|
||||
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
||||
style="padding-left: {8 + depth * 16}px"
|
||||
style="padding-left: {4 + depth * 16}px"
|
||||
data-nb-path={nb.path}
|
||||
onclick={() => selectNotebook(nb)}
|
||||
onkeydown={(e) => {
|
||||
@@ -1030,9 +1039,16 @@
|
||||
{/if}
|
||||
{#if iconSrc}
|
||||
<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}
|
||||
<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">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||
<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"/>
|
||||
<path d="M10 9h8"/><path d="M10 13h8"/><path d="M10 17h5"/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
||||
@@ -1139,7 +1155,7 @@
|
||||
.section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
padding: 0 0 4px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
@@ -1295,7 +1311,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
padding: 5px 8px 5px 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
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 { isAndroid, isIOS, isMobile } from '$lib/platform';
|
||||
import type { VaultConfig } from '$lib/types';
|
||||
|
||||
let { initialError = '' }: { initialError?: string } = $props();
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
|
||||
@@ -15,6 +17,10 @@
|
||||
let vaultName = $state('HelixNotes');
|
||||
let hasPermission = $state(!isAndroid);
|
||||
let selectedLocation = $state('Documents');
|
||||
|
||||
$effect(() => {
|
||||
if (initialError && !error) error = initialError;
|
||||
});
|
||||
let customPath = $state('');
|
||||
let iosBasePath = $state('');
|
||||
|
||||
@@ -108,14 +114,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetVault(path: string) {
|
||||
async function pickExternalFolder() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
await removeVault(path);
|
||||
const selected = await chooseExternalVault();
|
||||
if (!selected) return;
|
||||
$appConfig = await getAppConfig();
|
||||
$vaultReady = true;
|
||||
} catch (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>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -146,7 +201,7 @@
|
||||
<p class="subtitle">Local markdown notes</p>
|
||||
{#if isMobile}
|
||||
{#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}
|
||||
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
|
||||
{/if}
|
||||
@@ -197,6 +252,8 @@
|
||||
<button class="btn-primary" onclick={pickFolder} disabled={loading}>
|
||||
{#if loading}
|
||||
Opening...
|
||||
{:else if isIOS}
|
||||
Use Local Folder
|
||||
{:else if isMobile}
|
||||
Get Started
|
||||
{:else}
|
||||
@@ -204,6 +261,12 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isIOS}
|
||||
<button class="btn-back" onclick={pickExternalFolder} disabled={loading}>
|
||||
Choose Folder in Files
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.active_vault}
|
||||
<button class="btn-back" onclick={() => ($vaultReady = true)}>
|
||||
Back
|
||||
@@ -215,11 +278,11 @@
|
||||
<span class="recent-label">Recent</span>
|
||||
{#each recentVaults as vault}
|
||||
<div class="vault-row">
|
||||
<button class="vault-item" class:current={vault.path === $appConfig?.active_vault} onclick={() => openSelectedVault(vault.path)}>
|
||||
<span class="vault-name">{vault.name}{#if vault.path === $appConfig?.active_vault}<span class="vault-current-badge">Current</span>{/if}</span>
|
||||
<span class="vault-path">{vault.path}</span>
|
||||
<button class="vault-item" class:current={isActiveVault(vault)} onclick={() => openRecentVault(vault)} disabled={loading}>
|
||||
<span class="vault-name">{vault.name}{#if isActiveVault(vault)}<span class="vault-current-badge">Current</span>{/if}</span>
|
||||
<span class="vault-path">{vault.bookmark_id ? `Files · ${vault.path}` : vault.path}</span>
|
||||
</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">
|
||||
<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" />
|
||||
|
||||
@@ -163,7 +163,7 @@ export function serializeCallout(node: any, serializeChild: (child: any) => stri
|
||||
while (parts.length && parts[parts.length - 1] === '') parts.pop();
|
||||
|
||||
const out = [`> ${header}`];
|
||||
const inner = parts.join('\n');
|
||||
const inner = parts.join('\n\n');
|
||||
if (inner.length) {
|
||||
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ 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;
|
||||
return c.vaults.find((v) => v.path === c.active_vault) ?? 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);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface NoteContent {
|
||||
export interface VaultConfig {
|
||||
path: string;
|
||||
name: string;
|
||||
bookmark_id?: string | null;
|
||||
// Per-vault WebDAV sync (moved off the global AppConfig).
|
||||
sync_provider?: string | null;
|
||||
webdav_url?: string | null;
|
||||
@@ -55,6 +56,12 @@ export interface VaultConfig {
|
||||
last_sync_time?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalVaultResult {
|
||||
bookmarkId: string;
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CustomThemeColors {
|
||||
bg_primary: string;
|
||||
bg_secondary: string;
|
||||
@@ -77,6 +84,7 @@ export interface CustomTheme {
|
||||
export interface AppConfig {
|
||||
vaults: VaultConfig[];
|
||||
active_vault: string | null;
|
||||
active_bookmark_id?: string | null;
|
||||
theme: string;
|
||||
accent_color: string | null;
|
||||
font_size: number | null;
|
||||
@@ -171,6 +179,9 @@ export interface FileEvent {
|
||||
export interface ImportResult {
|
||||
files_converted: number;
|
||||
links_converted: number;
|
||||
frontmatter_normalized: number;
|
||||
syntax_converted: number;
|
||||
attachments_moved: number;
|
||||
}
|
||||
|
||||
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('/') || '.';
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||
import { getAppConfig, openVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes, isIOS } from '$lib/platform';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
||||
import AppLayout from '$lib/components/AppLayout.svelte';
|
||||
import NoteWindow from '$lib/components/NoteWindow.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
let noteWindowPath = $state<string | null>(null);
|
||||
let startupVaultError = $state('');
|
||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||
let removeFontSizeListener: (() => void) | null = null;
|
||||
|
||||
const defaultEditorFontSize = 14;
|
||||
const minEditorFontSize = 10;
|
||||
@@ -55,12 +58,17 @@
|
||||
};
|
||||
}
|
||||
|
||||
function setEditorFontSize(value: number) {
|
||||
function applyEditorFontSize(value: number) {
|
||||
const nextSize = Math.max(minEditorFontSize, Math.min(maxEditorFontSize, Math.round(value)));
|
||||
const restoreEditorViewport = preserveEditorViewportAfterLayout(getEditorScrollSurface());
|
||||
if ($appConfig) $appConfig.font_size = nextSize;
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${nextSize}px`);
|
||||
restoreEditorViewport();
|
||||
return nextSize;
|
||||
}
|
||||
|
||||
function setEditorFontSize(value: number) {
|
||||
const nextSize = applyEditorFontSize(value);
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
fontSizeSaveTimer = setTimeout(() => {
|
||||
setFontSize(nextSize).catch((e) => console.error('Failed to save font size:', e));
|
||||
@@ -116,6 +124,9 @@
|
||||
try {
|
||||
const config = await getAppConfig();
|
||||
$appConfig = config;
|
||||
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
|
||||
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
|
||||
});
|
||||
$theme = config.theme || 'system';
|
||||
|
||||
// Apply theme immediately to prevent flash
|
||||
@@ -132,9 +143,7 @@
|
||||
}
|
||||
|
||||
// Apply saved font settings
|
||||
if (config.font_size) {
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${config.font_size}px`);
|
||||
}
|
||||
if (config.font_size) applyEditorFontSize(config.font_size);
|
||||
if (config.line_height) {
|
||||
document.documentElement.style.setProperty('--editor-line-height', String(config.line_height));
|
||||
}
|
||||
@@ -192,26 +201,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Apply saved interface scale (desktop main window only; no-ops gracefully elsewhere)
|
||||
if (!noteWindowPath && config.ui_scale && config.ui_scale !== 1) {
|
||||
// Apply saved interface scale to every desktop window; no-ops gracefully elsewhere.
|
||||
if (config.ui_scale && config.ui_scale !== 1) {
|
||||
try {
|
||||
await getCurrentWebview().setZoom(config.ui_scale);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply interface scale:', e);
|
||||
}
|
||||
}
|
||||
if (!noteWindowPath) removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
||||
removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
||||
|
||||
// Auto-open last vault if available
|
||||
if (config.active_vault) {
|
||||
const activeVault = config.active_bookmark_id
|
||||
? config.vaults.find((vault) => vault.bookmark_id === config.active_bookmark_id)
|
||||
: config.vaults.find(
|
||||
(vault) => !vault.bookmark_id && vault.path === config.active_vault
|
||||
);
|
||||
try {
|
||||
// Note windows don't re-open the vault (already open in main process)
|
||||
if (!noteWindowPath) {
|
||||
if (isIOS && activeVault?.bookmark_id) {
|
||||
await restoreExternalVault(activeVault.bookmark_id);
|
||||
$appConfig = await getAppConfig();
|
||||
} else {
|
||||
await openVault(config.active_vault);
|
||||
}
|
||||
}
|
||||
$vaultReady = true;
|
||||
} catch {
|
||||
// Vault path might not exist anymore
|
||||
} catch (e) {
|
||||
if (isIOS && activeVault?.bookmark_id) {
|
||||
startupVaultError = String(e);
|
||||
$appConfig = {
|
||||
...config,
|
||||
active_vault: null,
|
||||
active_bookmark_id: null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -222,6 +248,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
removeEditorZoomShortcuts?.();
|
||||
removeFontSizeListener?.();
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
});
|
||||
</script>
|
||||
@@ -235,7 +262,7 @@
|
||||
{:else if $vaultReady}
|
||||
<AppLayout />
|
||||
{:else}
|
||||
<VaultPicker />
|
||||
<VaultPicker initialError={startupVaultError} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||