Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
# HelixNotes
|
||||||
|
|
||||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/releases)
|
||||||
[](https://helixnotes.com)
|
[](https://helixnotes.com)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
|||||||
Your notes are stored as standard Markdown files on your local filesystem.
|
Your notes are stored as standard Markdown files on your local filesystem.
|
||||||
No cloud, no lock-in.
|
No cloud, no lock-in.
|
||||||
|
|
||||||
## Download (v1.3.2)
|
## Download (v1.3.3)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ sudo eopkg it helixnotes
|
|||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
helix-notes = {
|
helix-notes = {
|
||||||
url = "git+https://codeberg.org/ArkHost/HelixNotes";
|
url = "git+https://gitlab.com/ArkHost/HelixNotes";
|
||||||
# inputs.nixpkgs.follows = "nixpkgs";
|
# inputs.nixpkgs.follows = "nixpkgs";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -92,21 +92,21 @@ sudo eopkg it helixnotes
|
|||||||
|
|
||||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||||
|
|
||||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage)
|
[Download AppImage](https://download.helixnotes.com/releases/v1.3.3/HelixNotes_1.3.3_amd64.AppImage)
|
||||||
|
|
||||||
#### .deb (manual)
|
#### .deb (manual)
|
||||||
|
|
||||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+)
|
[Download .deb](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_amd64.deb) (Ubuntu 22.04+)
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64-setup.exe) (Windows 10/11)
|
[Download Installer](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64-setup.exe) (Windows 10/11)
|
||||||
|
|
||||||
### macOS
|
### 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 (Apple Silicon)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_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 (Intel)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
|
||||||
|
|
||||||
> **"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):
|
> **"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 +116,11 @@ sudo eopkg it helixnotes
|
|||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_android.apk)
|
[Download APK](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_android.apk)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
All releases: [gitlab.com/ArkHost/HelixNotes/-/releases](https://gitlab.com/ArkHost/HelixNotes/-/releases)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -183,4 +183,4 @@ pnpm tauri build
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
[AGPL-3.0](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||||
|
|||||||
@@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777268161,
|
"lastModified": 1782959384,
|
||||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -6,29 +6,33 @@
|
|||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs =
|
||||||
|
{
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
flake-utils,
|
flake-utils,
|
||||||
}:
|
}:
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||||
pname = cargoToml.package.name;
|
pname = cargoToml.package.name;
|
||||||
version = cargoToml.package.version;
|
version = cargoToml.package.version;
|
||||||
in {
|
in
|
||||||
|
{
|
||||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
|
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
@@ -44,7 +48,8 @@
|
|||||||
wrapGAppsHook3
|
wrapGAppsHook3
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs = with pkgs;
|
buildInputs =
|
||||||
|
with pkgs;
|
||||||
lib.optionals stdenv.hostPlatform.isLinux [
|
lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
webkitgtk_4_1
|
webkitgtk_4_1
|
||||||
libayatana-appindicator
|
libayatana-appindicator
|
||||||
@@ -71,6 +76,7 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
formatter = pkgs.alejandra;
|
formatter = pkgs.nixfmt;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 3.0 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: 2.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: 4.0 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: 5.9 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: 8.0 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 5.4 KiB |
|
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 |
|
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 |
@@ -405,7 +405,7 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
|||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 10,
|
"max_tokens": 20,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
@@ -435,10 +435,12 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
|||||||
|
|
||||||
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
let is_gpt5 = model.starts_with("gpt-5");
|
||||||
|
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 10,
|
token_key: 20,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ use crate::vault::{operations, watcher};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
fn index_note_bg(state: &State<'_, AppState>, path: &str) {
|
||||||
|
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||||
|
if let Some(search) = search {
|
||||||
|
let p = path.to_string();
|
||||||
|
std::thread::spawn(move || { let _ = search.index_note(&p); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_note_bg(state: &State<'_, AppState>, path: &str) {
|
||||||
|
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||||
|
if let Some(search) = search {
|
||||||
|
let p = path.to_string();
|
||||||
|
std::thread::spawn(move || { let _ = search.remove_note(&p); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Vault Management ──
|
// ── Vault Management ──
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -338,12 +354,8 @@ pub fn save_note(
|
|||||||
|
|
||||||
operations::save_note(&path, &meta, &body)?;
|
operations::save_note(&path, &meta, &body)?;
|
||||||
|
|
||||||
// Re-index note so search picks up changes immediately
|
// Re-index note so search picks up changes (background to avoid blocking on FUSE fsync)
|
||||||
if let Ok(search_guard) = state.search_index.lock() {
|
index_note_bg(&state, &path);
|
||||||
if let Some(ref search) = *search_guard {
|
|
||||||
let _ = search.index_note(&path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -358,12 +370,8 @@ pub fn create_note(
|
|||||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||||
let entry = operations::create_note(vault_path, notebook_relative.as_deref(), &title)?;
|
let entry = operations::create_note(vault_path, notebook_relative.as_deref(), &title)?;
|
||||||
|
|
||||||
// Index new note
|
// Index new note (background to avoid blocking on FUSE fsync)
|
||||||
if let Ok(search_guard) = state.search_index.lock() {
|
index_note_bg(&state, &entry.path);
|
||||||
if let Some(ref search) = *search_guard {
|
|
||||||
let _ = search.index_note(&entry.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
@@ -374,11 +382,7 @@ pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Re
|
|||||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||||
let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
|
let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
|
||||||
|
|
||||||
if let Ok(search_guard) = state.search_index.lock() {
|
index_note_bg(&state, &entry.path);
|
||||||
if let Some(ref search) = *search_guard {
|
|
||||||
let _ = search.index_note(&entry.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
@@ -397,12 +401,8 @@ pub fn delete_note(state: State<'_, AppState>, path: String) -> Result<(), Strin
|
|||||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||||
operations::delete_note(vault_path, &path)?;
|
operations::delete_note(vault_path, &path)?;
|
||||||
|
|
||||||
// Remove from index
|
// Remove from index (background to avoid blocking on FUSE fsync)
|
||||||
if let Ok(search_guard) = state.search_index.lock() {
|
remove_note_bg(&state, &path);
|
||||||
if let Some(ref search) = *search_guard {
|
|
||||||
let _ = search.remove_note(&path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -473,7 +473,6 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
|||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let path_str = path.to_string_lossy();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -492,9 +491,16 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store vault-relative path with forward slashes for cross-platform portability.
|
||||||
|
// Absolute paths (e.g. C:\Users\...) break when the vault is synced to another device.
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(vault)
|
||||||
|
.map(|r| r.to_string_lossy().replace('\\', "/").to_string())
|
||||||
|
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||||
|
|
||||||
entries.push(NoteTitleEntry {
|
entries.push(NoteTitleEntry {
|
||||||
title,
|
title,
|
||||||
path: path_str.to_string(),
|
path: rel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,11 +787,7 @@ pub fn set_task_done(
|
|||||||
}
|
}
|
||||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
operations::save_note(¬e_path, &meta, &new_body)?;
|
||||||
|
|
||||||
if let Ok(guard) = state.search_index.lock() {
|
index_note_bg(&state, ¬e_path);
|
||||||
if let Some(search) = guard.as_ref() {
|
|
||||||
let _ = search.index_note(¬e_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,11 +844,7 @@ pub fn set_task_priority(
|
|||||||
}
|
}
|
||||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
operations::save_note(¬e_path, &meta, &new_body)?;
|
||||||
|
|
||||||
if let Ok(guard) = state.search_index.lock() {
|
index_note_bg(&state, ¬e_path);
|
||||||
if let Some(search) = guard.as_ref() {
|
|
||||||
let _ = search.index_note(¬e_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,11 +900,7 @@ pub fn set_task_due(
|
|||||||
}
|
}
|
||||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
operations::save_note(¬e_path, &meta, &new_body)?;
|
||||||
|
|
||||||
if let Ok(guard) = state.search_index.lock() {
|
index_note_bg(&state, ¬e_path);
|
||||||
if let Some(search) = guard.as_ref() {
|
|
||||||
let _ = search.index_note(¬e_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1288,6 +1282,9 @@ pub fn import_obsidian(app: AppHandle) -> Result<(), String> {
|
|||||||
"success": true,
|
"success": true,
|
||||||
"files_converted": result.files_converted,
|
"files_converted": result.files_converted,
|
||||||
"links_converted": result.links_converted,
|
"links_converted": result.links_converted,
|
||||||
|
"frontmatter_normalized": result.frontmatter_normalized,
|
||||||
|
"syntax_converted": result.syntax_converted,
|
||||||
|
"attachments_moved": result.attachments_moved,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1306,15 +1303,13 @@ pub fn import_obsidian(app: AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn do_import_obsidian(app: AppHandle, vault_path: &str) -> Result<ImportResult, String> {
|
fn do_import_obsidian(app: AppHandle, vault_path: &str) -> Result<ImportResult, String> {
|
||||||
// Pause file watcher during import
|
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
state
|
state
|
||||||
.importing
|
.importing
|
||||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
||||||
let result = do_import_obsidian_inner(vault_path);
|
let result = crate::vault::import::import(vault_path);
|
||||||
|
|
||||||
// Resume file watcher
|
|
||||||
state
|
state
|
||||||
.importing
|
.importing
|
||||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
@@ -1322,413 +1317,6 @@ fn do_import_obsidian(app: AppHandle, vault_path: &str) -> Result<ImportResult,
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn do_import_obsidian_inner(vault_path: &str) -> Result<ImportResult, String> {
|
|
||||||
let vault = std::path::Path::new(vault_path);
|
|
||||||
|
|
||||||
// Build file index: filename -> relative path from vault root
|
|
||||||
let mut file_index: std::collections::HashMap<String, String> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
for entry in walkdir::WalkDir::new(vault)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
{
|
|
||||||
let path = entry.path();
|
|
||||||
if !path.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
|
||||||
if let Ok(rel) = path.strip_prefix(vault) {
|
|
||||||
file_index.insert(name.to_string(), rel.to_string_lossy().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let wiki_embed =
|
|
||||||
regex::Regex::new(r"!\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]").map_err(|e| e.to_string())?;
|
|
||||||
let wiki_link =
|
|
||||||
regex::Regex::new(r"\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]").map_err(|e| e.to_string())?;
|
|
||||||
let md_img = regex::Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").map_err(|e| e.to_string())?;
|
|
||||||
let md_link = regex::Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let mut files_converted: u64 = 0;
|
|
||||||
let mut links_converted: u64 = 0;
|
|
||||||
|
|
||||||
// Walk all markdown files
|
|
||||||
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| {
|
|
||||||
let p = e.path();
|
|
||||||
p.is_file()
|
|
||||||
&& p.extension().and_then(|x| x.to_str()) == Some("md")
|
|
||||||
&& !p.to_string_lossy().contains("/.helixnotes/")
|
|
||||||
&& !p.to_string_lossy().contains("/.trash/")
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for entry in &md_files {
|
|
||||||
let path = entry.path();
|
|
||||||
let content = match std::fs::read_to_string(path) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let note_dir = path.parent().unwrap_or(std::path::Path::new(""));
|
|
||||||
|
|
||||||
let mut changed = false;
|
|
||||||
// Convert embeds first: ![[file.ext]] or ![[file.ext|alt]]
|
|
||||||
let after_embeds = wiki_embed
|
|
||||||
.replace_all(&content, |caps: ®ex::Captures| {
|
|
||||||
changed = true;
|
|
||||||
links_converted += 1;
|
|
||||||
let file_ref = &caps[1];
|
|
||||||
let alt = caps.get(2).map(|m| m.as_str()).unwrap_or("");
|
|
||||||
let resolved = resolve_wiki_ref(&file_index, file_ref);
|
|
||||||
// Convert vault-root-relative path to note-relative
|
|
||||||
let abs_target = vault.join(&resolved);
|
|
||||||
let rel = pathdiff(
|
|
||||||
abs_target.to_str().unwrap_or(""),
|
|
||||||
note_dir.to_str().unwrap_or(""),
|
|
||||||
)
|
|
||||||
.unwrap_or(resolved);
|
|
||||||
let encoded = rel.replace(' ', "%20");
|
|
||||||
// Use image syntax only for images/PDFs; regular link for other files
|
|
||||||
let ext = std::path::Path::new(file_ref)
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_lowercase();
|
|
||||||
let is_embeddable = matches!(
|
|
||||||
ext.as_str(),
|
|
||||||
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
|
|
||||||
);
|
|
||||||
if is_embeddable {
|
|
||||||
format!("", alt, encoded)
|
|
||||||
} else {
|
|
||||||
let display = if alt.is_empty() {
|
|
||||||
file_ref.rsplit('/').next().unwrap_or(file_ref)
|
|
||||||
} else {
|
|
||||||
alt
|
|
||||||
};
|
|
||||||
format!("[{}]({})", display, encoded)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Convert wiki links: [[note]] or [[note|display]]
|
|
||||||
let after_links = wiki_link
|
|
||||||
.replace_all(&after_embeds, |caps: ®ex::Captures| {
|
|
||||||
changed = true;
|
|
||||||
links_converted += 1;
|
|
||||||
let note_ref = &caps[1];
|
|
||||||
let display = caps.get(2).map(|m| m.as_str()).unwrap_or(note_ref);
|
|
||||||
let resolved = resolve_wiki_ref(&file_index, note_ref);
|
|
||||||
// Convert vault-root-relative path to note-relative
|
|
||||||
let abs_target = vault.join(&resolved);
|
|
||||||
let rel = pathdiff(
|
|
||||||
abs_target.to_str().unwrap_or(""),
|
|
||||||
note_dir.to_str().unwrap_or(""),
|
|
||||||
)
|
|
||||||
.unwrap_or(resolved);
|
|
||||||
let encoded = rel.replace(' ', "%20");
|
|
||||||
format!("[{}]({})", display, encoded)
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Fix bare-filename standard markdown references:  where filename has no path separator
|
|
||||||
|
|
||||||
let after_img_fix = md_img
|
|
||||||
.replace_all(&after_links, |caps: ®ex::Captures| {
|
|
||||||
let alt = &caps[1];
|
|
||||||
let src = &caps[2];
|
|
||||||
let decoded = percent_decode(src);
|
|
||||||
// Skip URLs and absolute paths
|
|
||||||
if decoded.starts_with("http") || decoded.starts_with('/') {
|
|
||||||
return format!("", alt, src);
|
|
||||||
}
|
|
||||||
// Try to resolve bare filename via file index
|
|
||||||
if !decoded.contains('/') {
|
|
||||||
if let Some(rel) = file_index.get(&decoded) {
|
|
||||||
changed = true;
|
|
||||||
links_converted += 1;
|
|
||||||
let abs_target = vault.join(rel);
|
|
||||||
let note_rel = pathdiff(
|
|
||||||
abs_target.to_str().unwrap_or(""),
|
|
||||||
note_dir.to_str().unwrap_or(""),
|
|
||||||
)
|
|
||||||
.unwrap_or(rel.clone());
|
|
||||||
let encoded = note_rel.replace(' ', "%20");
|
|
||||||
return format!("", alt, encoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fix vault-root-relative paths: if the path resolves from vault root, make it note-relative
|
|
||||||
if decoded.contains('/') {
|
|
||||||
let vault_abs = vault.join(&decoded);
|
|
||||||
if vault_abs.exists() {
|
|
||||||
let note_rel = pathdiff(
|
|
||||||
vault_abs.to_str().unwrap_or(""),
|
|
||||||
note_dir.to_str().unwrap_or(""),
|
|
||||||
)
|
|
||||||
.unwrap_or(decoded.clone());
|
|
||||||
if note_rel != decoded {
|
|
||||||
changed = true;
|
|
||||||
links_converted += 1;
|
|
||||||
let encoded = note_rel.replace(' ', "%20");
|
|
||||||
return format!("", alt, encoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
format!("", alt, src)
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let after_link_fix = md_link
|
|
||||||
.replace_all(&after_img_fix, |caps: ®ex::Captures| {
|
|
||||||
let display = &caps[1];
|
|
||||||
let href = &caps[2];
|
|
||||||
let decoded = percent_decode(href);
|
|
||||||
// Skip URLs and absolute paths
|
|
||||||
if decoded.starts_with("http") || decoded.starts_with('/') {
|
|
||||||
return format!("[{}]({})", display, href);
|
|
||||||
}
|
|
||||||
// Try to resolve bare filename via file index
|
|
||||||
if !decoded.contains('/') {
|
|
||||||
if let Some(rel) = file_index.get(&decoded) {
|
|
||||||
changed = true;
|
|
||||||
links_converted += 1;
|
|
||||||
let abs_target = vault.join(rel);
|
|
||||||
let note_rel = pathdiff(
|
|
||||||
abs_target.to_str().unwrap_or(""),
|
|
||||||
note_dir.to_str().unwrap_or(""),
|
|
||||||
)
|
|
||||||
.unwrap_or(rel.clone());
|
|
||||||
let encoded = note_rel.replace(' ', "%20");
|
|
||||||
return format!("[{}]({})", display, encoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fix vault-root-relative paths
|
|
||||||
if decoded.contains('/') {
|
|
||||||
let vault_abs = vault.join(&decoded);
|
|
||||||
if vault_abs.exists() {
|
|
||||||
let note_rel = pathdiff(
|
|
||||||
vault_abs.to_str().unwrap_or(""),
|
|
||||||
note_dir.to_str().unwrap_or(""),
|
|
||||||
)
|
|
||||||
.unwrap_or(decoded.clone());
|
|
||||||
if note_rel != decoded {
|
|
||||||
changed = true;
|
|
||||||
links_converted += 1;
|
|
||||||
let encoded = note_rel.replace(' ', "%20");
|
|
||||||
return format!("[{}]({})", display, encoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
format!("[{}]({})", display, href)
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if changed {
|
|
||||||
let _ = std::fs::write(path, after_link_fix);
|
|
||||||
files_converted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 2: Move all non-.md files into .helixnotes/attachments/ preserving structure ──
|
|
||||||
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
|
||||||
let _ = std::fs::create_dir_all(&attachments_dir);
|
|
||||||
|
|
||||||
let attachment_files: Vec<_> = walkdir::WalkDir::new(vault)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| {
|
|
||||||
let p = e.path();
|
|
||||||
if !p.is_file() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if p.extension().and_then(|x| x.to_str()) == Some("md") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let path_str = p.to_string_lossy();
|
|
||||||
!path_str.contains("/.helixnotes/")
|
|
||||||
&& !path_str.contains("/.obsidian/")
|
|
||||||
&& !path_str.contains("/.trash/")
|
|
||||||
&& !path_str.contains("/.stfolder")
|
|
||||||
&& !path_str.contains("/.claude/")
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Map: old vault-relative path -> new vault-relative path (preserving structure)
|
|
||||||
let mut moved_files: std::collections::HashMap<String, String> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
|
|
||||||
for entry in &attachment_files {
|
|
||||||
let src_path = entry.path();
|
|
||||||
let old_rel = match src_path.strip_prefix(vault) {
|
|
||||||
Ok(r) => r.to_string_lossy().to_string(),
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Preserve directory structure: ArkHost/Files/img.png -> .helixnotes/attachments/ArkHost/Files/img.png
|
|
||||||
let new_rel = format!(".helixnotes/attachments/{}", old_rel);
|
|
||||||
let dest_path = vault.join(&new_rel);
|
|
||||||
|
|
||||||
if let Some(parent) = dest_path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
if std::fs::rename(src_path, &dest_path).is_ok()
|
|
||||||
|| std::fs::copy(src_path, &dest_path)
|
|
||||||
.and_then(|_| std::fs::remove_file(src_path))
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
moved_files.insert(old_rel, new_rel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 3: Rewrite references in markdown files to point to new locations ──
|
|
||||||
// Use regex to only replace inside  and [...](...) patterns
|
|
||||||
if !moved_files.is_empty() {
|
|
||||||
let md_ref = regex::Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let md_files_pass2: Vec<_> = walkdir::WalkDir::new(vault)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| {
|
|
||||||
let p = e.path();
|
|
||||||
p.is_file()
|
|
||||||
&& p.extension().and_then(|x| x.to_str()) == Some("md")
|
|
||||||
&& !p.to_string_lossy().contains("/.helixnotes/")
|
|
||||||
&& !p.to_string_lossy().contains("/.trash/")
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for entry in &md_files_pass2 {
|
|
||||||
let path = entry.path();
|
|
||||||
let content = match std::fs::read_to_string(path) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let note_dir = path.parent().unwrap_or(std::path::Path::new(""));
|
|
||||||
let mut changed_p3 = false;
|
|
||||||
|
|
||||||
let new_content = md_ref
|
|
||||||
.replace_all(&content, |caps: ®ex::Captures| {
|
|
||||||
let prefix = &caps[1]; // ![alt] or [text]
|
|
||||||
let href = &caps[2];
|
|
||||||
let decoded_href = percent_decode(href);
|
|
||||||
|
|
||||||
// Skip URLs
|
|
||||||
if decoded_href.starts_with("http") || decoded_href.starts_with("data:") {
|
|
||||||
return format!("{}({})", prefix, href);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the href to a vault-relative path (files already moved, can't use .exists())
|
|
||||||
// Try note-dir-relative first, then vault-root-relative
|
|
||||||
let abs_from_note = note_dir.join(&decoded_href);
|
|
||||||
let rel_from_note = abs_from_note
|
|
||||||
.strip_prefix(vault)
|
|
||||||
.ok()
|
|
||||||
.map(|r| r.to_string_lossy().to_string());
|
|
||||||
let rel_from_vault = decoded_href.clone();
|
|
||||||
|
|
||||||
// Check note-relative resolution first, then vault-root
|
|
||||||
let old_vault_rel = rel_from_note
|
|
||||||
.as_ref()
|
|
||||||
.filter(|r| moved_files.contains_key(r.as_str()))
|
|
||||||
.cloned()
|
|
||||||
.or_else(|| {
|
|
||||||
if moved_files.contains_key(&rel_from_vault) {
|
|
||||||
Some(rel_from_vault)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(old_rel) = old_vault_rel {
|
|
||||||
if let Some(new_rel) = moved_files.get(&old_rel) {
|
|
||||||
changed_p3 = true;
|
|
||||||
let new_abs = vault.join(new_rel);
|
|
||||||
let new_note_rel = pathdiff(
|
|
||||||
new_abs.to_str().unwrap_or(""),
|
|
||||||
note_dir.to_str().unwrap_or(""),
|
|
||||||
)
|
|
||||||
.unwrap_or(new_rel.clone());
|
|
||||||
let encoded = new_note_rel.replace(' ', "%20");
|
|
||||||
return format!("{}({})", prefix, encoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
format!("{}({})", prefix, href)
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if changed_p3 {
|
|
||||||
let _ = std::fs::write(path, new_content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up empty directories left behind after moving files
|
|
||||||
cleanup_empty_dirs(vault);
|
|
||||||
|
|
||||||
Ok(ImportResult {
|
|
||||||
files_converted,
|
|
||||||
links_converted,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove empty directories recursively (bottom-up), skipping .helixnotes and .obsidian
|
|
||||||
fn cleanup_empty_dirs(root: &std::path::Path) {
|
|
||||||
let mut dirs: Vec<_> = walkdir::WalkDir::new(root)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| e.path().is_dir())
|
|
||||||
.map(|e| e.path().to_path_buf())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Sort by depth (deepest first) so we clean bottom-up
|
|
||||||
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count()));
|
|
||||||
|
|
||||||
for dir in dirs {
|
|
||||||
let dir_str = dir.to_string_lossy();
|
|
||||||
if dir_str.contains("/.helixnotes") || dir_str.contains("/.obsidian") || dir == root {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Ok(mut entries) = std::fs::read_dir(&dir) {
|
|
||||||
if entries.next().is_none() {
|
|
||||||
let _ = std::fs::remove_dir(&dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute a relative path from `base` to `target`
|
|
||||||
fn pathdiff(target: &str, base: &str) -> Result<String, ()> {
|
|
||||||
let target = std::path::Path::new(target);
|
|
||||||
let base = std::path::Path::new(base);
|
|
||||||
|
|
||||||
let target_components: Vec<_> = target.components().collect();
|
|
||||||
let base_components: Vec<_> = base.components().collect();
|
|
||||||
|
|
||||||
// Find common prefix length
|
|
||||||
let common = target_components
|
|
||||||
.iter()
|
|
||||||
.zip(base_components.iter())
|
|
||||||
.take_while(|(a, b)| a == b)
|
|
||||||
.count();
|
|
||||||
|
|
||||||
let mut result = std::path::PathBuf::new();
|
|
||||||
// Go up from base to common ancestor
|
|
||||||
for _ in common..base_components.len() {
|
|
||||||
result.push("..");
|
|
||||||
}
|
|
||||||
// Go down from common ancestor to target
|
|
||||||
for component in &target_components[common..] {
|
|
||||||
result.push(component);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result.to_string_lossy().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Orphaned attachment cleanup ──
|
// ── Orphaned attachment cleanup ──
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
@@ -1865,37 +1453,6 @@ fn percent_decode(s: &str) -> String {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_wiki_ref(
|
|
||||||
file_index: &std::collections::HashMap<String, String>,
|
|
||||||
reference: &str,
|
|
||||||
) -> String {
|
|
||||||
let reference = reference.trim();
|
|
||||||
// Strip Obsidian heading anchors (#) and block references (^) before lookup
|
|
||||||
let reference = reference.split('#').next().unwrap_or(reference).trim();
|
|
||||||
let reference = reference.split('^').next().unwrap_or(reference).trim();
|
|
||||||
// Direct match by filename
|
|
||||||
if let Some(rel) = file_index.get(reference) {
|
|
||||||
return rel.clone();
|
|
||||||
}
|
|
||||||
// Try with .md extension
|
|
||||||
let with_md = format!("{}.md", reference);
|
|
||||||
if let Some(rel) = file_index.get(&with_md) {
|
|
||||||
return rel.clone();
|
|
||||||
}
|
|
||||||
// Try matching just the filename part (e.g. "folder/note" -> "note.md")
|
|
||||||
if let Some(name) = reference.rsplit('/').next() {
|
|
||||||
if let Some(rel) = file_index.get(name) {
|
|
||||||
return rel.clone();
|
|
||||||
}
|
|
||||||
let name_md = format!("{}.md", name);
|
|
||||||
if let Some(rel) = file_index.get(&name_md) {
|
|
||||||
return rel.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: return as-is
|
|
||||||
reference.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Open file/URL with system default handler ──
|
// ── Open file/URL with system default handler ──
|
||||||
|
|
||||||
fn xdg_open(arg: &str) -> Result<(), String> {
|
fn xdg_open(arg: &str) -> Result<(), String> {
|
||||||
@@ -1935,6 +1492,10 @@ fn xdg_open(arg: &str) -> Result<(), String> {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
|
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
|
||||||
}
|
}
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
let _ = arg;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2424,6 +1985,7 @@ pub fn ai_ask(
|
|||||||
// path resolver, since dirs::config_dir() is not reliable in the app sandbox.
|
// path resolver, since dirs::config_dir() is not reliable in the app sandbox.
|
||||||
static MOBILE_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
static MOBILE_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
#[cfg(mobile)]
|
||||||
pub fn set_mobile_config_dir(path: std::path::PathBuf) {
|
pub fn set_mobile_config_dir(path: std::path::PathBuf) {
|
||||||
let _ = MOBILE_CONFIG_DIR.set(path);
|
let _ = MOBILE_CONFIG_DIR.set(path);
|
||||||
}
|
}
|
||||||
@@ -2509,6 +2071,14 @@ pub fn get_install_type() -> String {
|
|||||||
return forced.to_string();
|
return forced.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On mobile (iOS/Android) the concept of install type is irrelevant and the
|
||||||
|
// Linux detection below would attempt std::process::Command which is forbidden
|
||||||
|
// in the iOS sandbox. Return early.
|
||||||
|
if cfg!(mobile) {
|
||||||
|
return "mobile".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
"macos".to_string()
|
"macos".to_string()
|
||||||
} else if cfg!(target_os = "windows") {
|
} else if cfg!(target_os = "windows") {
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ pub fn run() {
|
|||||||
setup_tray(app)?;
|
setup_tray(app)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
fix_macos_traffic_lights(&window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check CLI args for a .md file path on initial launch
|
// Check CLI args for a .md file path on initial launch
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
@@ -387,3 +394,46 @@ fn percent_decode(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
String::from_utf8_lossy(&output).to_string()
|
String::from_utf8_lossy(&output).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn fix_macos_traffic_lights(window: &tauri::WebviewWindow) {
|
||||||
|
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
|
||||||
|
|
||||||
|
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(btn) => btn,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) {
|
||||||
|
Some(btn) => btn,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) {
|
||||||
|
Some(btn) => btn,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let superview = match close.superview() {
|
||||||
|
Some(sv) => sv,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sv_height = NSView::frame(&superview).size.height;
|
||||||
|
let btn_height = NSView::frame(&close).size.height;
|
||||||
|
if sv_height <= 0.0 || btn_height <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let centered_y = (sv_height - btn_height) / 2.0;
|
||||||
|
|
||||||
|
for btn in [&close, &miniaturize, &zoom] {
|
||||||
|
let mut frame = NSView::frame(btn);
|
||||||
|
frame.origin.y = centered_y;
|
||||||
|
btn.setFrameOrigin(frame.origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -312,8 +312,9 @@ impl WebdavClient {
|
|||||||
.send()
|
.send()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let s = resp.status().as_u16();
|
let s = resp.status().as_u16();
|
||||||
// 201 created; 405 already exists; 301 redirect-to-existing.
|
// 201 created; 405 already exists; 301 redirect-to-existing;
|
||||||
if resp.status().is_success() || s == 405 || s == 301 {
|
// 403 some servers (openmediavault et al.) return 403 instead of 405.
|
||||||
|
if resp.status().is_success() || s == 405 || s == 301 || s == 403 {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
||||||
|
|||||||
@@ -393,6 +393,9 @@ pub struct FileEvent {
|
|||||||
pub struct ImportResult {
|
pub struct ImportResult {
|
||||||
pub files_converted: u64,
|
pub files_converted: u64,
|
||||||
pub links_converted: u64,
|
pub links_converted: u64,
|
||||||
|
pub frontmatter_normalized: u64,
|
||||||
|
pub syntax_converted: u64,
|
||||||
|
pub attachments_moved: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct RawFrontmatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Try to parse a date string in multiple common formats.
|
/// Try to parse a date string in multiple common formats.
|
||||||
fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
|
pub fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
||||||
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
|
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
|
||||||
@@ -191,7 +191,7 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
|
|||||||
format!("---\n{}---\n{}", yaml_str, body_with_heading)
|
format!("---\n{}---\n{}", yaml_str, body_with_heading)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn filename_to_title(filename: &str) -> String {
|
pub fn filename_to_title(filename: &str) -> String {
|
||||||
let stem = filename.trim_end_matches(".md");
|
let stem = filename.trim_end_matches(".md");
|
||||||
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
||||||
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
|
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod frontmatter;
|
pub mod frontmatter;
|
||||||
|
pub mod import;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
pub mod watcher;
|
pub mod watcher;
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
|||||||
};
|
};
|
||||||
let _ = app.emit("file-changed", &fe);
|
let _ = app.emit("file-changed", &fe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On mobile, throttle event emission to prevent IPC flooding
|
||||||
|
// on FUSE filesystems where Syncthing/other apps generate
|
||||||
|
// constant file activity that blocks the Tauri command channel.
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
while rx.try_recv().is_ok() {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("File watcher error: {}", e);
|
log::error!("File watcher error: {}", e);
|
||||||
|
|||||||
@@ -55,6 +55,9 @@
|
|||||||
"appimage": {
|
"appimage": {
|
||||||
"bundleMediaFramework": false
|
"bundleMediaFramework": false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"entitlements": "entitlements.plist"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"hiddenTitle": true,
|
"hiddenTitle": true,
|
||||||
"trafficLightPosition": {
|
"trafficLightPosition": {
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 10
|
"y": 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1142,10 +1142,6 @@ body.resizing {
|
|||||||
/* ── Mobile (Android/iOS) ── */
|
/* ── Mobile (Android/iOS) ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body {
|
body {
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
padding-left: env(safe-area-inset-left);
|
|
||||||
padding-right: env(safe-area-inset-right);
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
const isMobile = $derived($platformIsMobile);
|
const isMobile = $derived($platformIsMobile);
|
||||||
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, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||||
import { darkThemes, isAndroid } from '$lib/platform';
|
import { darkThemes, isAndroid } from '$lib/platform';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
@@ -252,11 +252,11 @@
|
|||||||
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
||||||
let taskNoteOpened = $state(false);
|
let taskNoteOpened = $state(false);
|
||||||
|
|
||||||
function handleNoteSelected(path: string, content: string) {
|
function handleNoteSelected(path: string, content: string, task?: TaskItem) {
|
||||||
// Selecting a real vault note exits viewer mode
|
// Selecting a real vault note exits viewer mode
|
||||||
$viewerNote = null;
|
$viewerNote = null;
|
||||||
taskNoteOpened = true;
|
taskNoteOpened = true;
|
||||||
editor?.loadNote(path, content);
|
editor?.loadNote(path, content, task);
|
||||||
if (isMobile) $mobileView = 'editor';
|
if (isMobile) $mobileView = 'editor';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +614,26 @@
|
|||||||
// Run sidebar and note list refresh in parallel
|
// Run sidebar and note list refresh in parallel
|
||||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||||
|
|
||||||
|
// Auto-cleanup orphaned attachments in the background
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (get(editorDirty)) return; // skip if user is actively editing
|
||||||
|
try {
|
||||||
|
const orphans = await findOrphanedAttachments();
|
||||||
|
if (orphans.length > 0) {
|
||||||
|
if (get(editorDirty)) return; // re-check after async scan
|
||||||
|
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||||
|
if (moved > 0) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-secondary);color:var(--text-primary);padding:10px 18px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);border:1px solid var(--border-color);font-size:13px;z-index:10000;opacity:0;transition:opacity .3s;pointer-events:none';
|
||||||
|
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
requestAnimationFrame(() => (toast.style.opacity = '1'));
|
||||||
|
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
// Restore the last session (view + open note) if enabled.
|
// Restore the last session (view + open note) if enabled.
|
||||||
if ($appConfig?.restore_last_session) {
|
if ($appConfig?.restore_last_session) {
|
||||||
const vault = $appConfig?.active_vault;
|
const vault = $appConfig?.active_vault;
|
||||||
@@ -679,7 +699,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
|
||||||
}, 3000);
|
}, 10000);
|
||||||
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
|
||||||
debouncedRefresh();
|
debouncedRefresh();
|
||||||
});
|
});
|
||||||
@@ -895,7 +915,7 @@
|
|||||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
||||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||||
<Editor bind:this={editor} />
|
<Editor bind:this={editor} />
|
||||||
@@ -956,7 +976,7 @@
|
|||||||
|
|
||||||
{#if !$notelistCollapsed}
|
{#if !$notelistCollapsed}
|
||||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizeHandle onResize={handleNotelistResize} />
|
<ResizeHandle onResize={handleNotelistResize} />
|
||||||
@@ -1137,7 +1157,13 @@
|
|||||||
.mobile-shell {
|
.mobile-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100dvh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding:
|
||||||
|
env(safe-area-inset-top, 0px)
|
||||||
|
env(safe-area-inset-right, 0px)
|
||||||
|
env(safe-area-inset-bottom, 0px)
|
||||||
|
env(safe-area-inset-left, 0px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,14 @@
|
|||||||
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
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 { 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 { 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 { listen } from '@tauri-apps/api/event';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets';
|
import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets';
|
||||||
import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText';
|
import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText';
|
||||||
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
||||||
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
||||||
|
import { relativePath } from '$lib/utils/paths';
|
||||||
import GraphView from './GraphView.svelte';
|
import GraphView from './GraphView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
import { isMobile, isAndroid } from '$lib/platform';
|
import { isMobile, isAndroid } from '$lib/platform';
|
||||||
@@ -85,6 +86,13 @@
|
|||||||
let titleWasStripped = false;
|
let titleWasStripped = false;
|
||||||
let strippedTitle = '';
|
let strippedTitle = '';
|
||||||
let strippedHeadingPrefix = '';
|
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 headingDropdown = $state(false);
|
||||||
let colorDropdown = $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) {
|
function handleSourceCtrlEnd(event: KeyboardEvent) {
|
||||||
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || event.key !== 'End') return false;
|
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || event.key !== 'End') return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -143,6 +257,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let anyDropdownOpen = $derived(headingDropdown || colorDropdown || highlightDropdown || alignDropdown || insertDropdown || tablePickerOpen);
|
let anyDropdownOpen = $derived(headingDropdown || colorDropdown || highlightDropdown || alignDropdown || insertDropdown || tablePickerOpen);
|
||||||
|
|
||||||
let editorState = $state(0);
|
let editorState = $state(0);
|
||||||
let editorStateRaf = 0; // RAF handle for batching toolbar updates
|
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 imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
|
||||||
let copyToast = $state<'copying' | 'done' | 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)
|
// 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);
|
let mathModal = $state<{ kind: 'block' | 'inline'; editPos: number | null; tex: string } | null>(null);
|
||||||
|
|
||||||
@@ -537,6 +663,50 @@
|
|||||||
return resolved.join('/');
|
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({
|
const CustomImage = Image.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
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({
|
const CtrlEndScrollPastEnd = Extension.create({
|
||||||
name: 'ctrlEndScrollPastEnd',
|
name: 'ctrlEndScrollPastEnd',
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
@@ -1126,15 +1382,25 @@
|
|||||||
async function svgToPngBlob(svgEl: SVGElement, scale = 2): Promise<Blob> {
|
async function svgToPngBlob(svgEl: SVGElement, scale = 2): Promise<Blob> {
|
||||||
const clone = svgEl.cloneNode(true) as SVGElement;
|
const clone = svgEl.cloneNode(true) as SVGElement;
|
||||||
if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
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 svgString = new XMLSerializer().serializeToString(clone);
|
||||||
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
// Use data URL instead of blob URL — blob: URLs taint canvas on some WebKitGTK versions
|
||||||
const url = URL.createObjectURL(svgBlob);
|
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgString);
|
||||||
try {
|
|
||||||
const img = new window.Image();
|
const img = new window.Image();
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
img.onload = () => resolve();
|
img.onload = () => resolve();
|
||||||
img.onerror = () => reject(new Error('SVG image load failed'));
|
img.onerror = () => reject(new Error('SVG image load failed'));
|
||||||
img.src = url;
|
img.src = dataUrl;
|
||||||
});
|
});
|
||||||
const bbox = svgEl.getBoundingClientRect();
|
const bbox = svgEl.getBoundingClientRect();
|
||||||
const width = Math.max(Math.round(bbox.width || 800), 100);
|
const width = Math.max(Math.round(bbox.width || 800), 100);
|
||||||
@@ -1151,9 +1417,6 @@
|
|||||||
return await new Promise<Blob>((resolve, reject) => {
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
canvas.toBlob((b) => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
canvas.toBlob((b) => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function flashToast(container: HTMLElement, msg: string) {
|
function flashToast(container: HTMLElement, msg: string) {
|
||||||
@@ -1176,9 +1439,22 @@
|
|||||||
flashToast(container, 'Copied');
|
flashToast(container, 'Copied');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[Mermaid] copy failed', e);
|
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)));
|
flashToast(container, 'Copy failed: ' + (e?.message || String(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveDiagram(container: HTMLElement) {
|
async function saveDiagram(container: HTMLElement) {
|
||||||
const svgEl = container.querySelector('svg') as SVGElement | null;
|
const svgEl = container.querySelector('svg') as SVGElement | null;
|
||||||
@@ -1206,9 +1482,14 @@
|
|||||||
flashToast(container, 'Saved');
|
flashToast(container, 'Saved');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[Mermaid] save failed', e);
|
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)));
|
flashToast(container, 'Save failed: ' + (e?.message || String(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addToolbar(container: HTMLElement, source: string) {
|
function addToolbar(container: HTMLElement, source: string) {
|
||||||
const toolbar = document.createElement('div');
|
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
|
// 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
|
// 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
|
// 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)
|
// Keep menu within viewport (account for virtual keyboard on mobile)
|
||||||
if (x + 240 > window.innerWidth) x = window.innerWidth - 250;
|
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;
|
const menuHeight = 300;
|
||||||
let y = coords.bottom + 4;
|
let y = coords.bottom + 4;
|
||||||
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
|
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
|
||||||
@@ -1959,7 +2263,8 @@
|
|||||||
const coords = editor.view.coordsAtPos(from);
|
const coords = editor.view.coordsAtPos(from);
|
||||||
let x = coords.left;
|
let x = coords.left;
|
||||||
if (x + 200 > window.innerWidth) x = window.innerWidth - 210;
|
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;
|
const menuH = 180;
|
||||||
let y = coords.bottom + 4;
|
let y = coords.bottom + 4;
|
||||||
if (y + menuH > visibleBottom) y = coords.top - menuH - 4;
|
if (y + menuH > visibleBottom) y = coords.top - menuH - 4;
|
||||||
@@ -2082,13 +2387,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function wikiLinkFolderPath(entry: NoteTitleEntry): string {
|
function wikiLinkFolderPath(entry: NoteTitleEntry): string {
|
||||||
const vaultRoot = $appConfig?.active_vault;
|
if (!entry.path) return '';
|
||||||
if (!vaultRoot || !entry.path) return '';
|
// entry.path is vault-relative (e.g. "folder/note.md"); normalize Windows backslashes.
|
||||||
// Normalize Windows backslashes so the folder subtitle shows there too.
|
|
||||||
const path = entry.path.replace(/\\/g, '/');
|
const path = entry.path.replace(/\\/g, '/');
|
||||||
const root = vaultRoot.replace(/\\/g, '/');
|
const parts = path.split('/');
|
||||||
const rel = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
|
|
||||||
const parts = rel.split('/');
|
|
||||||
// Return parent folder(s), excluding the filename
|
// Return parent folder(s), excluding the filename
|
||||||
return parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
|
return parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
|
||||||
}
|
}
|
||||||
@@ -2110,9 +2412,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wikiLinkRelPath(entry: NoteTitleEntry): string | null {
|
function wikiLinkRelPath(entry: NoteTitleEntry): string | null {
|
||||||
const vaultRoot = $appConfig?.active_vault;
|
if (!entry.path) return null;
|
||||||
if (!vaultRoot || !entry.path || !entry.path.startsWith(vaultRoot + '/')) return null;
|
// entry.path is vault-relative (e.g. "folder/note.md"); strip .md for the ref.
|
||||||
return entry.path.slice(vaultRoot.length + 1).replace(/\.md$/, '');
|
return entry.path.replace(/\\/g, '/').replace(/\.md$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan the document for wiki-link marks whose visible text has been changed by
|
// Scan the document for wiki-link marks whose visible text has been changed by
|
||||||
@@ -2143,7 +2445,8 @@
|
|||||||
|
|
||||||
for (const item of toRename) {
|
for (const item of toRename) {
|
||||||
try {
|
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
|
// Update the mark attrs in the editor (title + path) so the next save
|
||||||
// serialises [[NewTitle]] and doesn't re-trigger the rename check
|
// serialises [[NewTitle]] and doesn't re-trigger the rename check
|
||||||
if (editor) {
|
if (editor) {
|
||||||
@@ -2223,6 +2526,21 @@
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
parseHTML() {
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'span[data-wiki-link]',
|
tag: 'span[data-wiki-link]',
|
||||||
@@ -2231,7 +2549,7 @@
|
|||||||
const text = el.textContent || '';
|
const text = el.textContent || '';
|
||||||
return {
|
return {
|
||||||
title,
|
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
|
// Detect alias from the HTML: if the visible text differs from the
|
||||||
// stored title the link was serialised as [[ref|display]]
|
// stored title the link was serialised as [[ref|display]]
|
||||||
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
||||||
@@ -2245,7 +2563,7 @@
|
|||||||
const text = el.textContent || '';
|
const text = el.textContent || '';
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
path: el.getAttribute('data-path') || null,
|
path: normalizePath(el.getAttribute('data-path')),
|
||||||
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -2431,7 +2749,8 @@
|
|||||||
const coords = editor.view.coordsAtPos(from);
|
const coords = editor.view.coordsAtPos(from);
|
||||||
let x = coords.left;
|
let x = coords.left;
|
||||||
if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
|
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;
|
const menuHeight = 360;
|
||||||
let y = coords.bottom + 4;
|
let y = coords.bottom + 4;
|
||||||
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
|
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
|
||||||
@@ -2441,14 +2760,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function navigateToWikiLink(path: string, title: string, clickEvent?: MouseEvent) {
|
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
|
// title may contain #heading or ^block anchors - strip for note lookup
|
||||||
const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
||||||
if (!path) {
|
if (!path) {
|
||||||
// Try path-based resolution first (for disambiguated refs like "folder/note")
|
// Try path-based resolution first (for disambiguated refs like "folder/note")
|
||||||
const vaultRoot = $appConfig?.active_vault;
|
if (noteTitle.includes('/')) {
|
||||||
if (noteTitle.includes('/') && vaultRoot) {
|
// entry.path is vault-relative (e.g. "folder/note.md"); match against that
|
||||||
const fullPath = vaultRoot + '/' + noteTitle + '.md';
|
const relPath = noteTitle + '.md';
|
||||||
const pathMatch = wikiLinkTitlesCache.find(e => e.path === fullPath);
|
const pathMatch = wikiLinkTitlesCache.find(e => e.path === relPath);
|
||||||
if (pathMatch) {
|
if (pathMatch) {
|
||||||
path = pathMatch.path;
|
path = pathMatch.path;
|
||||||
} else {
|
} else {
|
||||||
@@ -2483,7 +2804,7 @@
|
|||||||
// Create the note (use clean title, not the anchor ref)
|
// Create the note (use clean title, not the anchor ref)
|
||||||
const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle;
|
const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle;
|
||||||
const notebookRel = $activeNotePath
|
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;
|
: null;
|
||||||
try {
|
try {
|
||||||
// Save the current note before navigating away
|
// Save the current note before navigating away
|
||||||
@@ -2504,9 +2825,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const content = await readNote(path);
|
const absPath = resolveNotePath(path);
|
||||||
|
const content = await readNote(absPath);
|
||||||
$activeNote = { ...content, content: content.content };
|
$activeNote = { ...content, content: content.content };
|
||||||
$activeNotePath = path;
|
$activeNotePath = absPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Note at path no longer exists (deleted/moved). Refresh cache and
|
// Note at path no longer exists (deleted/moved). Refresh cache and
|
||||||
// retry as unresolved so the user can recreate it from the link.
|
// retry as unresolved so the user can recreate it from the link.
|
||||||
@@ -2518,9 +2840,10 @@
|
|||||||
async function navigateToWikiLinkDirect(entry: NoteTitleEntry) {
|
async function navigateToWikiLinkDirect(entry: NoteTitleEntry) {
|
||||||
wikiLinkNavDisambig = null;
|
wikiLinkNavDisambig = null;
|
||||||
try {
|
try {
|
||||||
const content = await readNote(entry.path);
|
const absPath = resolveNotePath(entry.path);
|
||||||
|
const content = await readNote(absPath);
|
||||||
$activeNote = { ...content, content: content.content };
|
$activeNote = { ...content, content: content.content };
|
||||||
$activeNotePath = entry.path;
|
$activeNotePath = absPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to navigate to wiki-link:', e);
|
console.error('Failed to navigate to wiki-link:', e);
|
||||||
}
|
}
|
||||||
@@ -2605,7 +2928,8 @@
|
|||||||
// Standard markdown: resolve relative paths against the note's directory
|
// Standard markdown: resolve relative paths against the note's directory
|
||||||
const notePath = $activeNotePath;
|
const notePath = $activeNotePath;
|
||||||
if (notePath) {
|
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}`));
|
return convertFileSrc(normalizePath(`${noteDir}/${decoded}`));
|
||||||
}
|
}
|
||||||
// Last fallback: vault root
|
// Last fallback: vault root
|
||||||
@@ -2876,7 +3200,10 @@
|
|||||||
$editorDirty = false;
|
$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;
|
loadedPath = path;
|
||||||
lastSourceMode = $sourceMode;
|
lastSourceMode = $sourceMode;
|
||||||
isLoadingNote = true;
|
isLoadingNote = true;
|
||||||
@@ -2924,6 +3251,8 @@
|
|||||||
pendingContent = content;
|
pendingContent = content;
|
||||||
isLoadingNote = false;
|
isLoadingNote = false;
|
||||||
}
|
}
|
||||||
|
if (!isMobile && showOutline) scheduleOutline();
|
||||||
|
scheduleTaskReveal(path, revealTarget, revealRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripTitleH1(md: string): string {
|
function stripTitleH1(md: string): string {
|
||||||
@@ -2997,6 +3326,9 @@
|
|||||||
}
|
}
|
||||||
entries.push({ text: serializeNode(node), isImage: isImageNode(node) });
|
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
|
// Join: skip extra \n separator before image nodes so they don't get unwanted blank lines
|
||||||
let result = '';
|
let result = '';
|
||||||
for (let i = 0; i < entries.length; i++) {
|
for (let i = 0; i < entries.length; i++) {
|
||||||
@@ -3045,10 +3377,11 @@
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
state.doc.descendants((node: any, pos: number) => {
|
state.doc.descendants((node: any, pos: number) => {
|
||||||
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
|
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, {
|
tr = tr.setNodeMarkup(pos, undefined, {
|
||||||
...node.attrs,
|
...node.attrs,
|
||||||
backgroundColor: null,
|
backgroundColor: null,
|
||||||
|
colwidth: null,
|
||||||
colspan: 1,
|
colspan: 1,
|
||||||
rowspan: 1,
|
rowspan: 1,
|
||||||
});
|
});
|
||||||
@@ -3123,17 +3456,22 @@
|
|||||||
case 'pageBreak':
|
case 'pageBreak':
|
||||||
return '<div style="page-break-after: always;"></div>\n';
|
return '<div style="page-break-after: always;"></div>\n';
|
||||||
case 'table': {
|
case 'table': {
|
||||||
// Check if any cell has styling (background color) or if there are merged cells
|
|
||||||
let hasStyling = false;
|
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) => {
|
node.descendants((child: any) => {
|
||||||
if (child.type.name === 'tableCell' || child.type.name === 'tableHeader') {
|
if (child.type.name === 'tableCell' || child.type.name === 'tableHeader') {
|
||||||
if (child.attrs.backgroundColor) hasStyling = true;
|
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.colspan && child.attrs.colspan > 1) hasStyling = true;
|
||||||
if (child.attrs.rowspan && child.attrs.rowspan > 1) hasStyling = true;
|
if (child.attrs.rowspan && child.attrs.rowspan > 1) hasStyling = true;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
if (hasStyling) {
|
if (hasStyling || !hasHeaderRow) {
|
||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
const frag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
|
const frag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
|
||||||
tempDiv.appendChild(frag);
|
tempDiv.appendChild(frag);
|
||||||
@@ -3487,6 +3825,7 @@
|
|||||||
|
|
||||||
export function openNoteSearch() {
|
export function openNoteSearch() {
|
||||||
noteSearchOpen = true;
|
noteSearchOpen = true;
|
||||||
|
tick().then(() => noteSearchInput?.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeNoteSearch() {
|
function closeNoteSearch() {
|
||||||
@@ -3514,7 +3853,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Convert asset:// URLs back to relative paths for saving
|
// 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 = '';
|
let absPath = '';
|
||||||
try {
|
try {
|
||||||
const url = new URL(src);
|
const url = new URL(src);
|
||||||
@@ -3524,18 +3863,18 @@
|
|||||||
}
|
}
|
||||||
// Clean up any leading double/triple slashes (URL parsing artifact)
|
// Clean up any leading double/triple slashes (URL parsing artifact)
|
||||||
absPath = absPath.replace(/^\/{2,}/, '/');
|
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;
|
const notePath = $activeNotePath;
|
||||||
if (notePath) {
|
const vaultRoot = $appConfig?.active_vault?.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
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;
|
|
||||||
if (vaultRoot && absPath.startsWith(vaultRoot + '/')) {
|
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;
|
return absPath;
|
||||||
}
|
}
|
||||||
@@ -3695,12 +4034,12 @@
|
|||||||
const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim();
|
const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim();
|
||||||
// Strip #heading and ^block anchors for title matching
|
// Strip #heading and ^block anchors for title matching
|
||||||
const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
||||||
// Try to resolve: first by vault-relative path (for disambiguated links), then by title
|
// Try to resolve: first by vault-relative path (for disambiguated links), then by title.
|
||||||
const vaultRoot = $appConfig?.active_vault ?? '';
|
// entry.path is vault-relative (e.g. "folder/note.md"); match against that.
|
||||||
let match: NoteTitleEntry | undefined;
|
let match: NoteTitleEntry | undefined;
|
||||||
if (titleForLookup.includes('/') && vaultRoot) {
|
if (titleForLookup.includes('/')) {
|
||||||
const fullPath = vaultRoot + '/' + titleForLookup + '.md';
|
const relPath = titleForLookup + '.md';
|
||||||
match = wikiLinkTitlesCache.find(e => e.path === fullPath);
|
match = wikiLinkTitlesCache.find(e => e.path === relPath);
|
||||||
}
|
}
|
||||||
if (!match) {
|
if (!match) {
|
||||||
// Fallback: resolve by title (use the last segment if path-based)
|
// 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">$2</tiptask>');
|
||||||
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1- <tiptask checked="false"> </tiptask>');
|
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1- <tiptask checked="false"> </tiptask>');
|
||||||
|
|
||||||
// Pre-process: strip list-separator comments before markdown-it.
|
// Pre-process: replace empty-paragraph markers with a div sentinel before markdown-it.
|
||||||
// markdown-it treats <!-- --> as an HTML block start, swallowing the
|
// A bare <!-- --> starts an HTML block that swallows the next line; the blank lines
|
||||||
// next line (e.g. an image) as raw HTML instead of parsing it.
|
// around the div keep it isolated. Post-render converts it back to <p></p>.
|
||||||
src = src.replace(/<!-- -->/g, '\n');
|
src = src.replace(/<!-- -->/g, '\n\n<div data-empty-para></div>\n\n');
|
||||||
|
|
||||||
// Pre-process: preserve blank lines before image-only lines
|
// Pre-process: preserve blank lines before image-only lines
|
||||||
// markdown-it collapses blank lines into paragraph breaks, losing the empty paragraph.
|
// 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(/<code([^>]*)>\n?/g, '<code$1>');
|
||||||
html = html.replace(/\n<\/code>/g, '</code>');
|
html = html.replace(/\n<\/code>/g, '</code>');
|
||||||
|
|
||||||
// Post-process: convert list-separator comments back to empty paragraphs for TipTap
|
// Post-process: convert empty-paragraph div sentinels back to empty paragraphs for TipTap
|
||||||
html = html.replace(/<!-- -->/g, '<p></p>');
|
html = html.replace(/<div data-empty-para><\/div>\n?/g, '<p></p>\n');
|
||||||
|
|
||||||
// Post-process: convert task list items to TipTap format
|
// 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
|
// Convert opening <li> + <tiptask> into data-attributed <li>, handles both tight and loose (with <p>) lists
|
||||||
@@ -4004,6 +4343,7 @@
|
|||||||
MathInline,
|
MathInline,
|
||||||
PageBreak,
|
PageBreak,
|
||||||
Callout,
|
Callout,
|
||||||
|
CalloutTyping,
|
||||||
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
|
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
|
||||||
DetailsSummary,
|
DetailsSummary,
|
||||||
DetailsContent,
|
DetailsContent,
|
||||||
@@ -4096,12 +4436,14 @@
|
|||||||
}),
|
}),
|
||||||
CtrlEndScrollPastEnd,
|
CtrlEndScrollPastEnd,
|
||||||
HeadingShortcuts,
|
HeadingShortcuts,
|
||||||
|
StrikeShortcut,
|
||||||
WrapSelectedText,
|
WrapSelectedText,
|
||||||
SlashCommands,
|
SlashCommands,
|
||||||
TaskMetaMenu,
|
TaskMetaMenu,
|
||||||
MoveLineShortcuts,
|
MoveLineShortcuts,
|
||||||
TabIndent,
|
TabIndent,
|
||||||
NoteSearchExtension,
|
NoteSearchExtension,
|
||||||
|
TaskRevealExtension,
|
||||||
ColorSwatch,
|
ColorSwatch,
|
||||||
TaskMetaDim,
|
TaskMetaDim,
|
||||||
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
|
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
|
||||||
@@ -4316,15 +4658,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function linkModalSelectNote(entry: NoteTitleEntry) {
|
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 vaultRoot = $appConfig?.active_vault;
|
||||||
const currentNote = $activeNotePath;
|
const currentNote = $activeNotePath;
|
||||||
if (vaultRoot && currentNote) {
|
if (vaultRoot && currentNote) {
|
||||||
const noteDir = currentNote.substring(0, currentNote.lastIndexOf('/'));
|
// Strip vault root from currentNote to get its relative path
|
||||||
const targetRel = entry.path.startsWith(vaultRoot) ? entry.path.substring(vaultRoot.length + 1) : entry.path;
|
const normCurrent = currentNote.replace(/\\/g, '/');
|
||||||
const currentRel = noteDir.startsWith(vaultRoot) ? noteDir.substring(vaultRoot.length + 1) : noteDir;
|
const normRoot = vaultRoot.replace(/\\/g, '/');
|
||||||
const targetParts = targetRel.split('/');
|
const currentRel = normCurrent.startsWith(normRoot + '/') ? normCurrent.slice(normRoot.length + 1) : normCurrent;
|
||||||
const currentParts = currentRel ? currentRel.split('/') : [];
|
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;
|
let common = 0;
|
||||||
while (common < targetParts.length && common < currentParts.length && targetParts[common] === currentParts[common]) common++;
|
while (common < targetParts.length && common < currentParts.length && targetParts[common] === currentParts[common]) common++;
|
||||||
const ups = currentParts.length - common;
|
const ups = currentParts.length - common;
|
||||||
@@ -4516,7 +4862,7 @@
|
|||||||
if (resolved.node(d).type.name === 'table') {
|
if (resolved.node(d).type.name === 'table') {
|
||||||
resolved.node(d).descendants((n: any) => {
|
resolved.node(d).descendants((n: any) => {
|
||||||
if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') {
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -4790,6 +5136,46 @@
|
|||||||
function openAiMenu() {
|
function openAiMenu() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
closeTextContextMenu();
|
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();
|
editor.commands.focus();
|
||||||
|
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
@@ -4940,7 +5326,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function aiApplyResult() {
|
async function aiApplyResult() {
|
||||||
if (!editor || !aiResult) return;
|
if (!aiResult) return;
|
||||||
// Save a version snapshot before applying AI changes
|
// Save a version snapshot before applying AI changes
|
||||||
if ($activeNotePath && $activeNote && !aiEmptyNote) {
|
if ($activeNotePath && $activeNote && !aiEmptyNote) {
|
||||||
try {
|
try {
|
||||||
@@ -4950,6 +5336,24 @@
|
|||||||
console.error('Failed to create version before AI apply:', e);
|
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) {
|
if (aiEmptyNote) {
|
||||||
// Parse title from first line, rest is content
|
// Parse title from first line, rest is content
|
||||||
const lines = aiResult.split('\n');
|
const lines = aiResult.split('\n');
|
||||||
@@ -5391,6 +5795,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
clearTaskReveal();
|
||||||
destroyEditor();
|
destroyEditor();
|
||||||
unlistenFileChange?.();
|
unlistenFileChange?.();
|
||||||
});
|
});
|
||||||
@@ -6771,7 +7176,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-ctx-sep"></div>
|
<div class="table-ctx-sep"></div>
|
||||||
{#if tableContextMenu.hasStyling}
|
{#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>
|
<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
|
Reset to Markdown
|
||||||
</button>
|
</button>
|
||||||
@@ -7834,19 +8239,6 @@
|
|||||||
margin-right: 8px;
|
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) {
|
.editor-body:has(.source-editor) {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -8144,7 +8536,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.tiptap-wrapper .tiptap p) {
|
:global(.tiptap-wrapper .tiptap p) {
|
||||||
margin: 0 0 0.75em;
|
margin: 0 0 0.4em;
|
||||||
line-height: var(--editor-line-height, 1.65);
|
line-height: var(--editor-line-height, 1.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8208,7 +8600,7 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 13px;
|
font-size: 0.93em;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9160,6 +9552,32 @@
|
|||||||
margin: 4px 0;
|
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) {
|
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -9411,7 +9829,7 @@
|
|||||||
|
|
||||||
:global(.tiptap-wrapper .tiptap table) {
|
:global(.tiptap-wrapper .tiptap table) {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: auto;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,8 @@
|
|||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
import { isMobile, isAndroid } from '$lib/platform';
|
import { isMobile, isAndroid } from '$lib/platform';
|
||||||
|
|
||||||
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||||
onNoteSelected?: (path: string, content: string) => void;
|
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
||||||
onNoteMoved?: () => void;
|
onNoteMoved?: () => void;
|
||||||
onBeforeNoteSwitch?: () => void;
|
onBeforeNoteSwitch?: () => void;
|
||||||
onNoteCreated?: () => void;
|
onNoteCreated?: () => void;
|
||||||
@@ -409,13 +409,14 @@
|
|||||||
$activeNote = content;
|
$activeNote = content;
|
||||||
$activeNotePath = task.note_path;
|
$activeNotePath = task.note_path;
|
||||||
$editorDirty = false;
|
$editorDirty = false;
|
||||||
onNoteSelected(task.note_path, content.content);
|
onNoteSelected(task.note_path, content.content, task);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to open task note:', e);
|
console.error('Failed to open task note:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCreateNote() {
|
export async function handleCreateNote() {
|
||||||
|
if ($viewMode === 'quickaccess' || $viewMode === 'trash') return;
|
||||||
if ($viewMode === 'daily') {
|
if ($viewMode === 'daily') {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
@@ -441,8 +442,12 @@
|
|||||||
const entry = await createNote(nbRelative, 'Untitled');
|
const entry = await createNote(nbRelative, 'Untitled');
|
||||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||||
noteCache.clear();
|
noteCache.clear();
|
||||||
await refresh();
|
$notes = [entry, ...$notes];
|
||||||
await selectNote(entry);
|
onBeforeNoteSwitch();
|
||||||
|
$activeNote = { path: entry.path, meta: entry.meta, content: '\n', raw: '' };
|
||||||
|
$activeNotePath = entry.path;
|
||||||
|
$editorDirty = false;
|
||||||
|
onNoteSelected(entry.path, '\n');
|
||||||
onNoteCreated();
|
onNoteCreated();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to create note:', e);
|
console.error('Failed to create note:', e);
|
||||||
@@ -453,7 +458,7 @@
|
|||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if ($viewMode === 'tasks' || $viewMode === 'trash') return;
|
if ($viewMode === 'tasks' || $viewMode === 'trash' || $viewMode === 'quickaccess') return;
|
||||||
handleCreateNote();
|
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 } {
|
function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 400): { x: number; y: number } {
|
||||||
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
||||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
||||||
@@ -989,7 +1007,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $viewMode !== 'trash'}
|
{#if $viewMode !== 'trash' && $viewMode !== 'quickaccess'}
|
||||||
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
|
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
@@ -1005,8 +1023,14 @@
|
|||||||
<span class="selection-count">{selectedPaths.size} selected</span>
|
<span class="selection-count">{selectedPaths.size} selected</span>
|
||||||
<div class="selection-actions">
|
<div class="selection-actions">
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
<button class="selection-action" onclick={handleBatchRestore}>Restore</button>
|
<button class="selection-action" onclick={handleBatchRestore} title="Restore selected">
|
||||||
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button class="selection-action danger" onclick={handleBatchPermanentDelete} title="Delete permanently">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
|
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
|
||||||
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
|
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
|
||||||
@@ -1130,6 +1154,8 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
<p>Trash is empty</p>
|
<p>Trash is empty</p>
|
||||||
|
{:else if $viewMode === 'quickaccess'}
|
||||||
|
<p>No starred notes</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p>No notes yet</p>
|
<p>No notes yet</p>
|
||||||
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
|
||||||
@@ -1320,6 +1346,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $viewMode === 'trash'}
|
||||||
|
<span class="trash-row-actions">
|
||||||
|
<span class="trash-row-btn" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handleRestore(note); }} title="Restore" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handleRestore(note); } }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="trash-row-btn danger" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handlePermanentDelete(note); }} title="Delete permanently" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handlePermanentDelete(note); } }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -1512,6 +1548,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Move to...
|
Move to...
|
||||||
</button>
|
</button>
|
||||||
|
{#if isFiled(contextMenu.note)}
|
||||||
|
<button onclick={() => unfileNote(contextMenu!.note)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><path d="M9 13h6"/>
|
||||||
|
</svg>
|
||||||
|
Unfile Note
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
|
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -1634,19 +1678,6 @@
|
|||||||
padding: 4px;
|
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 {
|
.note-list-section {
|
||||||
padding: 14px 12px 4px;
|
padding: 14px 12px 4px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -1673,6 +1704,7 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
contain: content;
|
contain: content;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item:hover {
|
.note-item:hover {
|
||||||
@@ -1719,6 +1751,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selection-action {
|
.selection-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1733,6 +1768,37 @@
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trash-row-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: none;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.note-item:hover .trash-row-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.trash-row-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.trash-row-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.trash-row-btn.danger:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||||
|
}
|
||||||
|
.note-list.mobile .trash-row-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.selection-action.danger {
|
.selection-action.danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView } from '$lib/stores/app';
|
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView, notebooks, activeNotebook, activeTag, viewMode } from '$lib/stores/app';
|
||||||
import { searchNotes, readNote } from '$lib/api';
|
import { searchNotes, readNote } from '$lib/api';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import type { SearchResult } from '$lib/types';
|
import type { SearchResult, NotebookEntry } from '$lib/types';
|
||||||
import { isMobile } from '$lib/platform';
|
import { isMobile } from '$lib/platform';
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
@@ -84,6 +84,17 @@
|
|||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
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) {
|
async function openResult(result: SearchResult) {
|
||||||
try {
|
try {
|
||||||
const content = await readNote(result.path);
|
const content = await readNote(result.path);
|
||||||
@@ -91,6 +102,19 @@
|
|||||||
$activeNotePath = result.path;
|
$activeNotePath = result.path;
|
||||||
$editorDirty = false;
|
$editorDirty = false;
|
||||||
$showSearch = false;
|
$showSearch = false;
|
||||||
|
// Reveal the note in the notes list: switch to its notebook (or All Notes).
|
||||||
|
const sep = result.path.includes('\\') ? '\\' : '/';
|
||||||
|
const parentDir = result.path.slice(0, result.path.lastIndexOf(sep));
|
||||||
|
const nb = parentDir ? findNotebookByPath($notebooks, parentDir) : null;
|
||||||
|
if (nb) {
|
||||||
|
$activeNotebook = nb;
|
||||||
|
$activeTag = null;
|
||||||
|
$viewMode = 'notebook';
|
||||||
|
} else {
|
||||||
|
$activeNotebook = null;
|
||||||
|
$activeTag = null;
|
||||||
|
$viewMode = 'all';
|
||||||
|
}
|
||||||
if (isMobile) $mobileView = 'editor';
|
if (isMobile) $mobileView = 'editor';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to open search result:', e);
|
console.error('Failed to open search result:', e);
|
||||||
|
|||||||
@@ -583,7 +583,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreCurrentTheme() {
|
function restoreCurrentTheme() {
|
||||||
// Re-apply the currently active theme to undo preview
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
|
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
@@ -739,10 +738,16 @@
|
|||||||
importResult = null;
|
importResult = null;
|
||||||
importError = null;
|
importError = null;
|
||||||
|
|
||||||
const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; error?: string }>('import-done', (event) => {
|
const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; frontmatter_normalized?: number; syntax_converted?: number; attachments_moved?: number; error?: string }>('import-done', (event) => {
|
||||||
const data = event.payload;
|
const data = event.payload;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
importResult = { files_converted: data.files_converted ?? 0, links_converted: data.links_converted ?? 0 };
|
importResult = {
|
||||||
|
files_converted: data.files_converted ?? 0,
|
||||||
|
links_converted: data.links_converted ?? 0,
|
||||||
|
frontmatter_normalized: data.frontmatter_normalized ?? 0,
|
||||||
|
syntax_converted: data.syntax_converted ?? 0,
|
||||||
|
attachments_moved: data.attachments_moved ?? 0,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
importError = data.error ?? 'Import failed';
|
importError = data.error ?? 'Import failed';
|
||||||
}
|
}
|
||||||
@@ -1807,7 +1812,7 @@
|
|||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Import from Obsidian</h3>
|
<h3>Import from Obsidian</h3>
|
||||||
<p class="import-desc">Convert Obsidian wiki-links (<code>![[image.png]]</code>, <code>[[note]]</code>) to standard markdown links across all notes in the current vault. This expects that you have opened an Obsidian vault directory directly as your HelixNotes vault.</p>
|
<p class="import-desc">Fully converts an Obsidian vault to HelixNotes format: normalizes frontmatter (tags, IDs, dates, title headings), converts <code>[[wiki-links]]</code> to standard markdown links, converts <code>==highlights==</code> and <code>%%comments%%</code>, and relocates attachments to <code>.helixnotes/attachments/</code>. Open the Obsidian vault directory as your HelixNotes vault first.</p>
|
||||||
<div class="import-warn">
|
<div class="import-warn">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
|
||||||
<span>This modifies files in place. Make a backup before running!</span>
|
<span>This modifies files in place. Make a backup before running!</span>
|
||||||
@@ -1829,7 +1834,16 @@
|
|||||||
{#if importResult}
|
{#if importResult}
|
||||||
<div class="import-result success">
|
<div class="import-result success">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
||||||
<span>Converted {importResult.links_converted} links across {importResult.files_converted} files</span>
|
<div class="import-stats">
|
||||||
|
<span>Import complete</span>
|
||||||
|
<ul>
|
||||||
|
{#if importResult.frontmatter_normalized}<li>Normalized frontmatter in {importResult.frontmatter_normalized} notes</li>{/if}
|
||||||
|
{#if importResult.links_converted}<li>Converted {importResult.links_converted} wiki-links to markdown</li>{/if}
|
||||||
|
{#if importResult.syntax_converted}<li>Converted Obsidian syntax (highlights, comments) in {importResult.syntax_converted} notes</li>{/if}
|
||||||
|
{#if importResult.attachments_moved}<li>Moved {importResult.attachments_moved} attachments to .helixnotes/attachments/</li>{/if}
|
||||||
|
{#if !importResult.frontmatter_normalized && !importResult.links_converted && !importResult.syntax_converted && !importResult.attachments_moved}<li>No changes needed</li>{/if}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if importError}
|
{#if importError}
|
||||||
@@ -2221,7 +2235,7 @@
|
|||||||
Download from Website
|
Download from Website
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<a class="update-install-btn" href="https://codeberg.org/ArkHost/HelixNotes/releases" target="_blank" rel="noopener">
|
<a class="update-install-btn" href="https://gitlab.com/ArkHost/HelixNotes/-/releases" target="_blank" rel="noopener">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -2803,7 +2817,7 @@
|
|||||||
|
|
||||||
.import-result {
|
.import-result {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -2816,6 +2830,12 @@
|
|||||||
color: #059669;
|
color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-stats ul {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.import-result.error {
|
.import-result.error {
|
||||||
background: color-mix(in srgb, #e11d48 10%, transparent);
|
background: color-mix(in srgb, #e11d48 10%, transparent);
|
||||||
color: #e11d48;
|
color: #e11d48;
|
||||||
|
|||||||
@@ -146,6 +146,10 @@
|
|||||||
const vault = $appConfig?.active_vault;
|
const vault = $appConfig?.active_vault;
|
||||||
if (!vault) return;
|
if (!vault) return;
|
||||||
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
|
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
|
||||||
|
if (isMobile) {
|
||||||
|
onViewChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectAllNotes();
|
selectAllNotes();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,6 +162,10 @@
|
|||||||
async function selectNotebook(nb: NotebookEntry) {
|
async function selectNotebook(nb: NotebookEntry) {
|
||||||
// Deselect if clicking the already-active notebook; expand/collapse is the chevron's job
|
// Deselect if clicking the already-active notebook; expand/collapse is the chevron's job
|
||||||
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
|
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
|
||||||
|
if (isMobile) {
|
||||||
|
onViewChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectAllNotes();
|
selectAllNotes();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -800,10 +808,10 @@
|
|||||||
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
|
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
|
||||||
onclick={selectUnfiled}
|
onclick={selectUnfiled}
|
||||||
>
|
>
|
||||||
<span style="width:13px;flex-shrink:0"></span>
|
<span style="width:14px;flex-shrink:0"></span>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
|
||||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||||
<polyline points="14 2 14 8 20 8" />
|
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
|
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -942,7 +950,7 @@
|
|||||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||||
{@const iconSrc = getNotebookIconSrc(nb)}
|
{@const iconSrc = getNotebookIconSrc(nb)}
|
||||||
{#if editingNotebook === nb.path}
|
{#if editingNotebook === nb.path}
|
||||||
<div class="notebook-item" style="padding-left: {8 + depth * 16}px">
|
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="rename-input"
|
class="rename-input"
|
||||||
@@ -962,7 +970,7 @@
|
|||||||
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
|
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
|
||||||
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
|
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
|
||||||
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
||||||
style="padding-left: {8 + depth * 16}px"
|
style="padding-left: {4 + depth * 16}px"
|
||||||
data-nb-path={nb.path}
|
data-nb-path={nb.path}
|
||||||
onclick={() => selectNotebook(nb)}
|
onclick={() => selectNotebook(nb)}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
@@ -1030,9 +1038,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if iconSrc}
|
{#if iconSrc}
|
||||||
<img class="notebook-icon" src={iconSrc} alt="" />
|
<img class="notebook-icon" src={iconSrc} alt="" />
|
||||||
|
{:else if isCollapsed || nb.children.length === 0}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
|
||||||
|
<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
|
||||||
|
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
|
||||||
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.6">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
|
||||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
|
||||||
|
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
|
||||||
|
<path d="M10 9h8"/><path d="M10 13h8"/><path d="M10 17h5"/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
||||||
@@ -1139,7 +1154,7 @@
|
|||||||
.section {
|
.section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: 0 0 4px;
|
||||||
border-top: 1px solid var(--border-light);
|
border-top: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,7 +1310,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px 5px 4px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function serializeCallout(node: any, serializeChild: (child: any) => stri
|
|||||||
while (parts.length && parts[parts.length - 1] === '') parts.pop();
|
while (parts.length && parts[parts.length - 1] === '') parts.pop();
|
||||||
|
|
||||||
const out = [`> ${header}`];
|
const out = [`> ${header}`];
|
||||||
const inner = parts.join('\n');
|
const inner = parts.join('\n\n');
|
||||||
if (inner.length) {
|
if (inner.length) {
|
||||||
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
|
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ export interface FileEvent {
|
|||||||
export interface ImportResult {
|
export interface ImportResult {
|
||||||
files_converted: number;
|
files_converted: number;
|
||||||
links_converted: number;
|
links_converted: number;
|
||||||
|
frontmatter_normalized: number;
|
||||||
|
syntax_converted: number;
|
||||||
|
attachments_moved: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VaultStats {
|
export interface VaultStats {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
function pathParts(path: string): string[] {
|
||||||
|
return path.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathRoot(path: string): string {
|
||||||
|
const normalized = path.replace(/\\/g, '/');
|
||||||
|
const drive = normalized.match(/^([A-Za-z]:)\//);
|
||||||
|
if (drive) return drive[1].toLowerCase();
|
||||||
|
return normalized.startsWith('/') ? '/' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativePath(fromDirectory: string, targetPath: string): string {
|
||||||
|
const from = pathParts(fromDirectory);
|
||||||
|
const target = pathParts(targetPath);
|
||||||
|
if (pathRoot(fromDirectory) !== pathRoot(targetPath)) return targetPath.replace(/\\/g, '/');
|
||||||
|
const windowsPath = /^[A-Za-z]:[\\/]/.test(fromDirectory) || /^[A-Za-z]:[\\/]/.test(targetPath);
|
||||||
|
const equal = windowsPath
|
||||||
|
? (left: string, right: string) => left.toLowerCase() === right.toLowerCase()
|
||||||
|
: (left: string, right: string) => left === right;
|
||||||
|
|
||||||
|
let common = 0;
|
||||||
|
while (common < from.length && common < target.length && equal(from[common], target[common])) {
|
||||||
|
common++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...Array(from.length - common).fill('..'), ...target.slice(common)];
|
||||||
|
return result.join('/') || '.';
|
||||||
|
}
|
||||||