mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 09:27:29 +02:00
chore(release): prepare v1.3.5
This commit is contained in:
@@ -13,9 +13,6 @@ bun.lockb
|
|||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
|
||||||
# Local tests
|
|
||||||
/tests/
|
|
||||||
|
|
||||||
# Nix
|
# Nix
|
||||||
/result
|
/result
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
stages:
|
||||||
|
- verify
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
stage: verify
|
||||||
|
image: node:24-bookworm
|
||||||
|
before_script:
|
||||||
|
- corepack enable
|
||||||
|
- corepack prepare pnpm@11.18.0 --activate
|
||||||
|
- pnpm config set store-dir .pnpm-store
|
||||||
|
- pnpm install --frozen-lockfile
|
||||||
|
script:
|
||||||
|
- pnpm test
|
||||||
|
- pnpm check
|
||||||
|
- pnpm build
|
||||||
|
- pnpm audit --audit-level=high
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files:
|
||||||
|
- pnpm-lock.yaml
|
||||||
|
paths:
|
||||||
|
- .pnpm-store/
|
||||||
|
|
||||||
|
rust:
|
||||||
|
stage: verify
|
||||||
|
image: rust:1.97-bookworm
|
||||||
|
variables:
|
||||||
|
CARGO_HOME: "$CI_PROJECT_DIR/.cargo"
|
||||||
|
before_script:
|
||||||
|
- apt-get update
|
||||||
|
- apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
- rustup component add clippy rustfmt
|
||||||
|
script:
|
||||||
|
- cargo fmt --manifest-path src-tauri/Cargo.toml -- --check
|
||||||
|
- cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
|
||||||
|
- cargo test --manifest-path src-tauri/Cargo.toml
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files:
|
||||||
|
- src-tauri/Cargo.lock
|
||||||
|
paths:
|
||||||
|
- src-tauri/target/
|
||||||
|
- .cargo/registry/
|
||||||
|
- .cargo/git/
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# HelixNotes
|
# HelixNotes
|
||||||
|
|
||||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
|
||||||
[](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.4)
|
## Download (v1.3.5)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ No cloud, no lock-in.
|
|||||||
|
|
||||||
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
|
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
|
||||||
|
|
||||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
|
[Download AppImage](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.AppImage)
|
||||||
|
|
||||||
#### Distro-specific packages
|
#### Distro-specific packages
|
||||||
|
|
||||||
@@ -108,16 +108,16 @@ sudo eopkg it helixnotes
|
|||||||
|
|
||||||
#### Manual package downloads
|
#### Manual package downloads
|
||||||
|
|
||||||
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
- [.deb](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.deb) (Ubuntu 22.04+)
|
||||||
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
- [.rpm](https://download.helixnotes.com/releases/v1.3.5/HelixNotes-1.3.5-1.x86_64.rpm)
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
|
[Download Installer](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64-setup.exe) (Windows 10/11)
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
|
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_aarch64.dmg) (M-series Macs)
|
||||||
|
|
||||||
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
|
> **"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):
|
||||||
>
|
>
|
||||||
@@ -127,7 +127,7 @@ sudo eopkg it helixnotes
|
|||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
|
[Download APK](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_android.apk)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+45
-41
@@ -2,71 +2,75 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.3.4",
|
"version": "1.3.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"test": "node --test \"tests/*.test.mjs\"",
|
||||||
|
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build"
|
"tauri:build": "tauri build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.70.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tauri-apps/cli": "^2.10.0",
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"svelte": "^5.49.2",
|
"@types/node": "^26.2.0",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte": "^5.56.9",
|
||||||
"tailwindcss": "^4.1.18",
|
"svelte-check": "^4.7.6",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"@tiptap/core": "^3.19.0",
|
"@tiptap/core": "^3.30.1",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
"@tiptap/extension-code-block-lowlight": "^3.30.1",
|
||||||
"@tiptap/extension-color": "^3.19.0",
|
"@tiptap/extension-color": "^3.30.1",
|
||||||
"@tiptap/extension-details": "^3.19.0",
|
"@tiptap/extension-details": "^3.30.1",
|
||||||
"@tiptap/extension-highlight": "^3.19.0",
|
"@tiptap/extension-highlight": "^3.30.1",
|
||||||
"@tiptap/extension-image": "^3.19.0",
|
"@tiptap/extension-image": "^3.30.1",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.30.1",
|
||||||
"@tiptap/extension-placeholder": "^3.19.0",
|
"@tiptap/extension-placeholder": "^3.30.1",
|
||||||
"@tiptap/extension-subscript": "^3.19.0",
|
"@tiptap/extension-subscript": "^3.30.1",
|
||||||
"@tiptap/extension-superscript": "^3.19.0",
|
"@tiptap/extension-superscript": "^3.30.1",
|
||||||
"@tiptap/extension-table": "^3.19.0",
|
"@tiptap/extension-table": "^3.30.1",
|
||||||
"@tiptap/extension-table-cell": "^3.19.0",
|
"@tiptap/extension-table-cell": "^3.30.1",
|
||||||
"@tiptap/extension-table-header": "^3.19.0",
|
"@tiptap/extension-table-header": "^3.30.1",
|
||||||
"@tiptap/extension-table-row": "^3.19.0",
|
"@tiptap/extension-table-row": "^3.30.1",
|
||||||
"@tiptap/extension-task-item": "^3.19.0",
|
"@tiptap/extension-task-item": "^3.30.1",
|
||||||
"@tiptap/extension-task-list": "^3.19.0",
|
"@tiptap/extension-task-list": "^3.30.1",
|
||||||
"@tiptap/extension-text-align": "^3.19.0",
|
"@tiptap/extension-text-align": "^3.30.1",
|
||||||
"@tiptap/extension-text-style": "^3.19.0",
|
"@tiptap/extension-text-style": "^3.30.1",
|
||||||
"@tiptap/extension-typography": "^3.19.0",
|
"@tiptap/extension-typography": "^3.30.1",
|
||||||
"@tiptap/extension-underline": "^3.19.0",
|
"@tiptap/extension-underline": "^3.30.1",
|
||||||
"@tiptap/pm": "^3.19.0",
|
"@tiptap/pm": "^3.30.1",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.30.1",
|
||||||
"@types/katex": "^0.16.8",
|
"@types/katex": "^0.16.8",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.12.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.47",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.3.0",
|
||||||
"markdown-it-mark": "^4.0.0",
|
"markdown-it-mark": "^4.0.0",
|
||||||
"markdown-it-sub": "^2.0.0",
|
"markdown-it-sub": "^2.0.0",
|
||||||
"markdown-it-sup": "^2.0.0",
|
"markdown-it-sup": "^2.0.0",
|
||||||
"markdown-it-task-lists": "^2.1.1",
|
"markdown-it-task-lists": "^2.1.1",
|
||||||
"mermaid": "^11.14.0"
|
"mermaid": "^11.16.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1066
-1214
File diff suppressed because it is too large
Load Diff
Generated
+1063
-1427
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.4"
|
version = "1.3.5"
|
||||||
description = "Local markdown note-taking app"
|
description = "Local markdown note-taking app"
|
||||||
authors = ["HelixNotes"]
|
authors = ["HelixNotes"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.88.0"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "app_lib"
|
name = "app_lib"
|
||||||
@@ -38,7 +38,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
quick-xml = "0.36"
|
quick-xml = "0.41"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,30 +26,7 @@
|
|||||||
"dialog:allow-confirm",
|
"dialog:allow-confirm",
|
||||||
"fs:default",
|
"fs:default",
|
||||||
"fs:allow-read",
|
"fs:allow-read",
|
||||||
"fs:allow-write",
|
|
||||||
"fs:allow-exists",
|
|
||||||
"fs:allow-mkdir",
|
|
||||||
"fs:allow-remove",
|
|
||||||
"fs:allow-rename",
|
|
||||||
"fs:allow-copy-file",
|
|
||||||
"fs:allow-stat",
|
|
||||||
"fs:allow-read-dir",
|
|
||||||
"fs:allow-read-file",
|
"fs:allow-read-file",
|
||||||
"fs:allow-write-file",
|
|
||||||
"fs:allow-watch",
|
|
||||||
"opener:default",
|
|
||||||
{
|
|
||||||
"identifier": "opener:allow-open-path",
|
|
||||||
"allow": [{ "path": "/**" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "opener:allow-open-url",
|
|
||||||
"allow": [
|
|
||||||
{ "url": "https://**" },
|
|
||||||
{ "url": "http://**" },
|
|
||||||
{ "url": "mailto:*" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"opener:allow-reveal-item-in-dir"
|
"opener:allow-reveal-item-in-dir"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-16
@@ -8,6 +8,7 @@ const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
|||||||
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
||||||
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn ai_request(
|
pub fn ai_request(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
provider: String,
|
provider: String,
|
||||||
@@ -22,7 +23,11 @@ pub fn ai_request(
|
|||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
||||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
|
let key_opt = if api_key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(api_key.as_str())
|
||||||
|
};
|
||||||
let result = match provider.as_str() {
|
let result = match provider.as_str() {
|
||||||
"openai" => {
|
"openai" => {
|
||||||
stream_openai(
|
stream_openai(
|
||||||
@@ -98,7 +103,10 @@ pub fn ai_request(
|
|||||||
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
||||||
fn normalize_openai_base(base: &str) -> String {
|
fn normalize_openai_base(base: &str) -> String {
|
||||||
let b = base.trim().trim_end_matches('/');
|
let b = base.trim().trim_end_matches('/');
|
||||||
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
|
b.strip_suffix("/v1")
|
||||||
|
.unwrap_or(b)
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_anthropic(
|
async fn stream_anthropic(
|
||||||
@@ -268,9 +276,7 @@ async fn stream_openai(
|
|||||||
body["temperature"] = json!(0.7);
|
body["temperature"] = json!(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut req = client
|
let mut req = client.post(url).header("content-type", "application/json");
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/json");
|
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
req = req.header("Authorization", format!("Bearer {}", key));
|
req = req.header("Authorization", format!("Bearer {}", key));
|
||||||
@@ -380,7 +386,11 @@ pub async fn test_connection(
|
|||||||
model: &str,
|
model: &str,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
|
let key_opt = if api_key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(api_key)
|
||||||
|
};
|
||||||
match provider {
|
match provider {
|
||||||
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
||||||
"ollama" => {
|
"ollama" => {
|
||||||
@@ -434,14 +444,18 @@ 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 is_gpt5 = model.starts_with("gpt-5");
|
||||||
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
|
let token_key = if is_gpt5 {
|
||||||
|
"max_completion_tokens"
|
||||||
|
} else {
|
||||||
|
"max_tokens"
|
||||||
|
};
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
token_key: 20,
|
token_key: 20,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": "Hi"
|
"content": "Hi"
|
||||||
@@ -449,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut req = client
|
let mut req = client.post(url).header("content-type", "application/json");
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/json");
|
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
req = req.header("Authorization", format!("Bearer {}", key));
|
req = req.header("Authorization", format!("Bearer {}", key));
|
||||||
|
|||||||
+48
-15
@@ -134,7 +134,7 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
|||||||
let entry = entry.map_err(|e| e.to_string())?;
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
if path.extension().map_or(false, |ext| ext == "zip") {
|
if path.extension().is_some_and(|ext| ext == "zip") {
|
||||||
let filename = path
|
let filename = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore a backup by extracting the zip over the vault directory
|
fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
|
||||||
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> {
|
let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
|
||||||
let vault = Path::new(vault_path);
|
let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
|
||||||
let backup = Path::new(backup_path);
|
if backup.parent() != Some(backup_dir.as_path())
|
||||||
|
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
|
||||||
if !backup.exists() {
|
|| !backup.is_file()
|
||||||
return Err("Backup file does not exist".to_string());
|
{
|
||||||
|
return Err(
|
||||||
|
"Backup path must point to a ZIP file in the configured backup directory".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Ok(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a backup by extracting the zip over the vault directory
|
||||||
|
pub fn restore_backup(
|
||||||
|
vault_path: &str,
|
||||||
|
backup_dir: &Path,
|
||||||
|
backup_path: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let vault = Path::new(vault_path);
|
||||||
|
let backup = validated_backup_file(backup_dir, backup_path)?;
|
||||||
|
|
||||||
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
||||||
let mut archive =
|
let mut archive =
|
||||||
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a single backup file
|
/// Delete a single backup file
|
||||||
pub fn delete_backup(backup_path: &str) -> Result<(), String> {
|
pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
|
||||||
let path = Path::new(backup_path);
|
let path = validated_backup_file(backup_dir, backup_path)?;
|
||||||
if path.exists() {
|
fs::remove_file(path).map_err(|error| error.to_string())
|
||||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove old backups keeping only the newest `max_count`
|
/// Remove old backups keeping only the newest `max_count`
|
||||||
@@ -261,9 +272,31 @@ pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), Stri
|
|||||||
if backups.len() as u32 > max_count {
|
if backups.len() as u32 > max_count {
|
||||||
let to_remove = backups.split_off(max_count as usize);
|
let to_remove = backups.split_off(max_count as usize);
|
||||||
for entry in to_remove {
|
for entry in to_remove {
|
||||||
delete_backup(&entry.path)?;
|
delete_backup(backup_dir, &entry.path)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::delete_backup;
|
||||||
|
use std::fs;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_backup_rejects_files_outside_backup_directory() {
|
||||||
|
let root = std::env::temp_dir().join(format!("helixnotes-backup-test-{}", Uuid::new_v4()));
|
||||||
|
let backup_dir = root.join("backups");
|
||||||
|
let outside = root.join("outside.zip");
|
||||||
|
fs::create_dir_all(&backup_dir).unwrap();
|
||||||
|
fs::write(&outside, "must survive").unwrap();
|
||||||
|
|
||||||
|
let result = delete_backup(&backup_dir, &outside.to_string_lossy());
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(outside.exists());
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+359
-184
@@ -6,19 +6,23 @@ use std::path::Path;
|
|||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
fn index_note_bg(state: &State<'_, AppState>, path: &str) {
|
fn index_note_bg(state: &State<'_, AppState>, path: &str) {
|
||||||
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||||
if let Some(search) = search {
|
if let Some(search) = search {
|
||||||
let p = path.to_string();
|
let p = path.to_string();
|
||||||
std::thread::spawn(move || { let _ = search.index_note(&p); });
|
std::thread::spawn(move || {
|
||||||
}
|
let _ = search.index_note(&p);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_note_bg(state: &State<'_, AppState>, path: &str) {
|
fn remove_note_bg(state: &State<'_, AppState>, path: &str) {
|
||||||
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||||
if let Some(search) = search {
|
if let Some(search) = search {
|
||||||
let p = path.to_string();
|
let p = path.to_string();
|
||||||
std::thread::spawn(move || { let _ = search.remove_note(&p); });
|
std::thread::spawn(move || {
|
||||||
}
|
let _ = search.remove_note(&p);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_vault_runtime(state: &State<'_, AppState>) -> Result<(), String> {
|
fn clear_vault_runtime(state: &State<'_, AppState>) -> Result<(), String> {
|
||||||
@@ -189,9 +193,7 @@ pub async fn choose_external_vault(
|
|||||||
if bookmark_was_registered {
|
if bookmark_was_registered {
|
||||||
let _ = app.ios_vault_access().rollback_staged();
|
let _ = app.ios_vault_access().rollback_staged();
|
||||||
} else {
|
} else {
|
||||||
let _ = app
|
let _ = app.ios_vault_access().forget_bookmark(&result.bookmark_id);
|
||||||
.ios_vault_access()
|
|
||||||
.forget_bookmark(&result.bookmark_id);
|
|
||||||
}
|
}
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
@@ -353,7 +355,10 @@ pub fn set_accent_color(state: State<'_, AppState>, color: String) -> Result<(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_custom_theme(state: State<'_, AppState>, theme: crate::types::CustomTheme) -> Result<(), String> {
|
pub fn save_custom_theme(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
theme: crate::types::CustomTheme,
|
||||||
|
) -> Result<(), String> {
|
||||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
if let Some(pos) = config.custom_themes.iter().position(|t| t.id == theme.id) {
|
if let Some(pos) = config.custom_themes.iter().position(|t| t.id == theme.id) {
|
||||||
config.custom_themes[pos] = theme;
|
config.custom_themes[pos] = theme;
|
||||||
@@ -391,10 +396,12 @@ mod custom_theme_reference_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deleting_custom_theme_resets_system_pair_references() {
|
fn deleting_custom_theme_resets_system_pair_references() {
|
||||||
let mut config = AppConfig::default();
|
let mut config = AppConfig {
|
||||||
config.theme = "custom-work".to_string();
|
theme: "custom-work".to_string(),
|
||||||
config.system_light_theme = "custom-work".to_string();
|
system_light_theme: "custom-work".to_string(),
|
||||||
config.system_dark_theme = "custom-work".to_string();
|
system_dark_theme: "custom-work".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
clear_custom_theme_references(&mut config, "custom-work");
|
clear_custom_theme_references(&mut config, "custom-work");
|
||||||
|
|
||||||
@@ -405,9 +412,16 @@ mod custom_theme_reference_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn export_custom_theme(state: State<'_, AppState>, id: String, path: String) -> Result<(), String> {
|
pub fn export_custom_theme(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
let theme = config.custom_themes.iter().find(|t| t.id == id)
|
let theme = config
|
||||||
|
.custom_themes
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == id)
|
||||||
.ok_or_else(|| "Theme not found".to_string())?;
|
.ok_or_else(|| "Theme not found".to_string())?;
|
||||||
let export = serde_json::json!({ "version": 1, "themes": [theme] });
|
let export = serde_json::json!({ "version": 1, "themes": [theme] });
|
||||||
let data = serde_json::to_string_pretty(&export).map_err(|e| e.to_string())?;
|
let data = serde_json::to_string_pretty(&export).map_err(|e| e.to_string())?;
|
||||||
@@ -416,11 +430,15 @@ pub fn export_custom_theme(state: State<'_, AppState>, id: String, path: String)
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_custom_themes(state: State<'_, AppState>, path: String) -> Result<Vec<crate::types::CustomTheme>, String> {
|
pub fn import_custom_themes(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<Vec<crate::types::CustomTheme>, String> {
|
||||||
let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
|
let parsed: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
|
||||||
let themes: Vec<crate::types::CustomTheme> = serde_json::from_value(parsed["themes"].clone())
|
let themes: Vec<crate::types::CustomTheme> =
|
||||||
.map_err(|e| format!("Invalid theme file: {}", e))?;
|
serde_json::from_value(parsed["themes"].clone())
|
||||||
|
.map_err(|e| format!("Invalid theme file: {}", e))?;
|
||||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
for theme in &themes {
|
for theme in &themes {
|
||||||
if let Some(pos) = config.custom_themes.iter().position(|t| t.id == theme.id) {
|
if let Some(pos) = config.custom_themes.iter().position(|t| t.id == theme.id) {
|
||||||
@@ -462,11 +480,7 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_ui_scale(
|
pub fn set_ui_scale(app: AppHandle, state: State<'_, AppState>, scale: f64) -> Result<(), String> {
|
||||||
app: AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
scale: f64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.ui_scale = Some(scale);
|
config.ui_scale = Some(scale);
|
||||||
save_app_config(&config)?;
|
save_app_config(&config)?;
|
||||||
@@ -513,8 +527,14 @@ pub fn create_notebook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rename_notebook(path: String, new_name: String) -> Result<String, String> {
|
pub fn rename_notebook(
|
||||||
operations::rename_notebook(&path, &new_name)
|
state: State<'_, AppState>,
|
||||||
|
path: String,
|
||||||
|
new_name: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||||
|
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||||
|
operations::rename_notebook(vault_path, &path, &new_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -531,7 +551,7 @@ pub fn move_notebook(
|
|||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let new_full_path = operations::move_notebook(¬ebook_path, &dest_parent)?;
|
let new_full_path = operations::move_notebook(vault_path, ¬ebook_path, &dest_parent)?;
|
||||||
|
|
||||||
let new_relative = Path::new(&new_full_path)
|
let new_relative = Path::new(&new_full_path)
|
||||||
.strip_prefix(vault_path.as_str())
|
.strip_prefix(vault_path.as_str())
|
||||||
@@ -594,7 +614,11 @@ pub fn move_notebook(
|
|||||||
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
||||||
let vault_path = {
|
let vault_path = {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.active_vault.as_ref().ok_or("No active vault")?.clone()
|
config
|
||||||
|
.active_vault
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("No active vault")?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
operations::delete_notebook(&vault_path, &path)
|
operations::delete_notebook(&vault_path, &path)
|
||||||
}
|
}
|
||||||
@@ -612,8 +636,10 @@ pub fn get_notes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn read_note(path: String) -> Result<NoteContent, String> {
|
pub fn read_note(state: State<'_, AppState>, path: String) -> Result<NoteContent, String> {
|
||||||
operations::read_note(&path)
|
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||||
|
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||||
|
operations::read_note(vault_path, &path)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -623,27 +649,26 @@ pub fn save_note(
|
|||||||
meta: NoteMeta,
|
meta: NoteMeta,
|
||||||
body: String,
|
body: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Snapshot current content before overwriting (if file exists)
|
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let vault_path = config
|
||||||
if let Some(vault_path) = &config.active_vault {
|
.active_vault
|
||||||
if std::path::Path::new(&path).exists() {
|
.as_ref()
|
||||||
if let Ok(old_raw) = std::fs::read_to_string(&path) {
|
.ok_or("No active vault")?
|
||||||
let max_versions = config.max_versions_per_note;
|
.clone();
|
||||||
let note_id = meta.id.clone();
|
let max_versions = config.max_versions_per_note;
|
||||||
let vp = vault_path.clone();
|
let old_raw = operations::read_vault_note(&vault_path, &path)?.raw;
|
||||||
// Snapshot in background so save isn't slowed down
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
crate::history::maybe_snapshot(&vp, ¬e_id, &old_raw, max_versions);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(config);
|
drop(config);
|
||||||
|
|
||||||
operations::save_note(&path, &meta, &body)?;
|
let note_id = meta.id.clone();
|
||||||
|
let snapshot_vault = vault_path.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
crate::history::maybe_snapshot(&snapshot_vault, ¬e_id, &old_raw, max_versions);
|
||||||
|
});
|
||||||
|
|
||||||
// Re-index note so search picks up changes (background to avoid blocking on FUSE fsync)
|
operations::save_note(&vault_path, &path, &meta, &body)?;
|
||||||
index_note_bg(&state, &path);
|
|
||||||
|
// Re-index note so search picks up changes (background to avoid blocking on FUSE fsync)
|
||||||
|
index_note_bg(&state, &path);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -654,7 +679,11 @@ pub fn duplicate_note(
|
|||||||
path: String,
|
path: String,
|
||||||
) -> Result<crate::types::NoteEntry, String> {
|
) -> Result<crate::types::NoteEntry, String> {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
let vault = config.active_vault.as_ref().ok_or("No active vault")?.clone();
|
let vault = config
|
||||||
|
.active_vault
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("No active vault")?
|
||||||
|
.clone();
|
||||||
drop(config);
|
drop(config);
|
||||||
|
|
||||||
let entry = operations::duplicate_note(&path, &vault)?;
|
let entry = operations::duplicate_note(&path, &vault)?;
|
||||||
@@ -668,31 +697,43 @@ pub fn create_note(
|
|||||||
notebook_relative: Option<String>,
|
notebook_relative: Option<String>,
|
||||||
title: String,
|
title: String,
|
||||||
) -> Result<NoteEntry, String> {
|
) -> Result<NoteEntry, String> {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
|
||||||
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)?;
|
|
||||||
|
|
||||||
// Index new note (background to avoid blocking on FUSE fsync)
|
|
||||||
index_note_bg(&state, &entry.path);
|
|
||||||
|
|
||||||
Ok(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, String> {
|
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
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_note(vault_path, notebook_relative.as_deref(), &title)?;
|
||||||
|
|
||||||
index_note_bg(&state, &entry.path);
|
// Index new note (background to avoid blocking on FUSE fsync)
|
||||||
|
index_note_bg(&state, &entry.path);
|
||||||
|
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rename_note(state: State<'_, AppState>, path: String, new_title: String) -> Result<String, String> {
|
pub fn create_daily_note(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
date: Option<String>,
|
||||||
|
) -> Result<NoteEntry, String> {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?.clone();
|
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)?;
|
||||||
|
|
||||||
|
index_note_bg(&state, &entry.path);
|
||||||
|
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn rename_note(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
path: String,
|
||||||
|
new_title: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
let vault_path = config
|
||||||
|
.active_vault
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("No active vault")?
|
||||||
|
.clone();
|
||||||
drop(config);
|
drop(config);
|
||||||
operations::rename_note(&path, &new_title, &vault_path)
|
operations::rename_note(&path, &new_title, &vault_path)
|
||||||
}
|
}
|
||||||
@@ -703,8 +744,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 (background to avoid blocking on FUSE fsync)
|
// Remove from index (background to avoid blocking on FUSE fsync)
|
||||||
remove_note_bg(&state, &path);
|
remove_note_bg(&state, &path);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -724,7 +765,7 @@ pub fn move_note(
|
|||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let new_full_path = operations::move_note(¬e_path, &dest_notebook)?;
|
let new_full_path = operations::move_note(vault_path, ¬e_path, &dest_notebook)?;
|
||||||
|
|
||||||
// Update quick access if the moved note was in it
|
// Update quick access if the moved note was in it
|
||||||
if !old_relative.is_empty() {
|
if !old_relative.is_empty() {
|
||||||
@@ -800,10 +841,7 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
|||||||
.map(|r| r.to_string_lossy().replace('\\', "/").to_string())
|
.map(|r| r.to_string_lossy().replace('\\', "/").to_string())
|
||||||
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||||
|
|
||||||
entries.push(NoteTitleEntry {
|
entries.push(NoteTitleEntry { title, path: rel });
|
||||||
title,
|
|
||||||
path: rel,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
@@ -859,23 +897,34 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
|||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
{
|
{
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() { continue; }
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let path_str = path.to_string_lossy().to_string();
|
let path_str = path.to_string_lossy().to_string();
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Skip Syncthing conflict files
|
// Skip Syncthing conflict files
|
||||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
if name.contains(".sync-conflict-") { continue; }
|
if name.contains(".sync-conflict-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Deduplicate by canonical path (handles symlinks)
|
// Deduplicate by canonical path (handles symlinks)
|
||||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||||
let canonical_str = canonical.to_string_lossy().to_string();
|
let canonical_str = canonical.to_string_lossy().to_string();
|
||||||
if !seen_paths.insert(canonical_str) { continue; }
|
if !seen_paths.insert(canonical_str) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let raw = std::fs::read_to_string(path).unwrap_or_default();
|
let raw = std::fs::read_to_string(path).unwrap_or_default();
|
||||||
|
|
||||||
// Fast title extraction: scan for "title: " line in frontmatter without full YAML parse
|
// Fast title extraction: scan for "title: " line in frontmatter without full YAML parse
|
||||||
let title = extract_title_fast(&raw).unwrap_or_else(|| {
|
let title = extract_title_fast(&raw).unwrap_or_else(|| {
|
||||||
path.file_stem().unwrap_or_default().to_string_lossy().to_string()
|
path.file_stem()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
let idx = graph_nodes.len();
|
let idx = graph_nodes.len();
|
||||||
@@ -887,7 +936,10 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
|||||||
if let Ok(rel) = path.strip_prefix(vault) {
|
if let Ok(rel) = path.strip_prefix(vault) {
|
||||||
let rel_no_ext = rel.with_extension("");
|
let rel_no_ext = rel.with_extension("");
|
||||||
// Normalize Windows backslashes so [[folder/note]] links resolve cross-platform.
|
// Normalize Windows backslashes so [[folder/note]] links resolve cross-platform.
|
||||||
let rel_lower = rel_no_ext.to_string_lossy().replace('\\', "/").to_lowercase();
|
let rel_lower = rel_no_ext
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/")
|
||||||
|
.to_lowercase();
|
||||||
relpath_to_idx.entry(rel_lower).or_insert(idx);
|
relpath_to_idx.entry(rel_lower).or_insert(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,16 +955,27 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
|||||||
let mut edges: Vec<crate::types::GraphEdge> = Vec::new();
|
let mut edges: Vec<crate::types::GraphEdge> = Vec::new();
|
||||||
let mut edge_map: HashMap<(usize, usize), usize> = HashMap::new();
|
let mut edge_map: HashMap<(usize, usize), usize> = HashMap::new();
|
||||||
|
|
||||||
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>, edge_map: &mut HashMap<(usize, usize), usize>, src: usize, tgt: usize| {
|
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>,
|
||||||
if src == tgt { return; }
|
edge_map: &mut HashMap<(usize, usize), usize>,
|
||||||
if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate
|
src: usize,
|
||||||
|
tgt: usize| {
|
||||||
|
if src == tgt {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if edge_map.contains_key(&(src, tgt)) {
|
||||||
|
return;
|
||||||
|
} // exact duplicate
|
||||||
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
|
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
|
||||||
// Reverse direction already exists - mark it as bidirectional
|
// Reverse direction already exists - mark it as bidirectional
|
||||||
edges[rev_idx].bidirectional = true;
|
edges[rev_idx].bidirectional = true;
|
||||||
} else {
|
} else {
|
||||||
let idx = edges.len();
|
let idx = edges.len();
|
||||||
edge_map.insert((src, tgt), idx);
|
edge_map.insert((src, tgt), idx);
|
||||||
edges.push(crate::types::GraphEdge { source: src, target: tgt, bidirectional: false });
|
edges.push(crate::types::GraphEdge {
|
||||||
|
source: src,
|
||||||
|
target: tgt,
|
||||||
|
bidirectional: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -961,25 +1024,32 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(crate::types::GraphData { nodes: graph_nodes, edges })
|
Ok(crate::types::GraphData {
|
||||||
|
nodes: graph_nodes,
|
||||||
|
edges,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fast title extraction from frontmatter without full YAML parsing.
|
/// Fast title extraction from frontmatter without full YAML parsing.
|
||||||
/// Scans for `title: ...` line within `---` fences.
|
/// Scans for `title: ...` line within `---` fences.
|
||||||
fn extract_title_fast(raw: &str) -> Option<String> {
|
fn extract_title_fast(raw: &str) -> Option<String> {
|
||||||
let trimmed = raw.trim_start();
|
let trimmed = raw.trim_start();
|
||||||
if !trimmed.starts_with("---") { return None; }
|
if !trimmed.starts_with("---") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
// Find the closing ---
|
// Find the closing ---
|
||||||
let after_open = &trimmed[3..];
|
let after_open = &trimmed[3..];
|
||||||
let end = after_open.find("\n---")?;
|
let end = after_open.find("\n---")?;
|
||||||
let frontmatter = &after_open[..end];
|
let frontmatter = &after_open[..end];
|
||||||
for line in frontmatter.lines() {
|
for line in frontmatter.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.starts_with("title:") {
|
if let Some(title) = line.strip_prefix("title:") {
|
||||||
let val = line[6..].trim();
|
let val = title.trim();
|
||||||
// Strip surrounding quotes
|
// Strip surrounding quotes
|
||||||
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) {
|
if (val.starts_with('"') && val.ends_with('"'))
|
||||||
return Some(val[1..val.len()-1].to_string());
|
|| (val.starts_with('\'') && val.ends_with('\''))
|
||||||
|
{
|
||||||
|
return Some(val[1..val.len() - 1].to_string());
|
||||||
}
|
}
|
||||||
if !val.is_empty() {
|
if !val.is_empty() {
|
||||||
return Some(val.to_string());
|
return Some(val.to_string());
|
||||||
@@ -1022,7 +1092,11 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
|
|||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_) => return out,
|
Err(_) => return out,
|
||||||
};
|
};
|
||||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
|
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
|
||||||
let note_path = path.to_string_lossy().to_string();
|
let note_path = path.to_string_lossy().to_string();
|
||||||
for (i, line) in body.lines().enumerate() {
|
for (i, line) in body.lines().enumerate() {
|
||||||
@@ -1035,7 +1109,11 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
|
|||||||
let due = due_re.captures(&content).map(|c| c[1].to_string());
|
let due = due_re.captures(&content).map(|c| c[1].to_string());
|
||||||
let priority = prio_re.captures(&content).map(|c| {
|
let priority = prio_re.captures(&content).map(|c| {
|
||||||
let p = c[1].to_lowercase();
|
let p = c[1].to_lowercase();
|
||||||
if p == "medium" { "med".to_string() } else { p }
|
if p == "medium" {
|
||||||
|
"med".to_string()
|
||||||
|
} else {
|
||||||
|
p
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let mut text = due_re.replace_all(&content, "").to_string();
|
let mut text = due_re.replace_all(&content, "").to_string();
|
||||||
text = prio_re.replace_all(&text, " ").to_string();
|
text = prio_re.replace_all(&text, " ").to_string();
|
||||||
@@ -1057,6 +1135,21 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
|
|||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_task_note(
|
||||||
|
state: &State<'_, AppState>,
|
||||||
|
note_path: &str,
|
||||||
|
) -> Result<(String, NoteMeta, String), String> {
|
||||||
|
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||||
|
let vault_path = config
|
||||||
|
.active_vault
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("No active vault")?
|
||||||
|
.clone();
|
||||||
|
drop(config);
|
||||||
|
let note = operations::read_vault_note(&vault_path, note_path)?;
|
||||||
|
Ok((vault_path, note.meta, note.content))
|
||||||
|
}
|
||||||
|
|
||||||
fn toggle_checkbox_line(line: &str, done: bool) -> String {
|
fn toggle_checkbox_line(line: &str, done: bool) -> String {
|
||||||
let mut s = line.to_string();
|
let mut s = line.to_string();
|
||||||
if done {
|
if done {
|
||||||
@@ -1082,12 +1175,8 @@ pub fn set_task_done(
|
|||||||
raw_line: String,
|
raw_line: String,
|
||||||
done: bool,
|
done: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let p = std::path::Path::new(¬e_path);
|
let (vault_path, meta, body) = read_task_note(&state, ¬e_path)?;
|
||||||
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
|
let mut lines: Vec<String> = body.lines().map(|line| line.to_string()).collect();
|
||||||
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
|
||||||
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
|
|
||||||
|
|
||||||
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
|
|
||||||
// Verify the expected line; if the note drifted, fall back to the first exact match.
|
// Verify the expected line; if the note drifted, fall back to the first exact match.
|
||||||
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
||||||
line
|
line
|
||||||
@@ -1107,9 +1196,9 @@ pub fn set_task_done(
|
|||||||
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
||||||
new_body.push('\n');
|
new_body.push('\n');
|
||||||
}
|
}
|
||||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
operations::save_note(&vault_path, ¬e_path, &meta, &new_body)?;
|
||||||
|
|
||||||
index_note_bg(&state, ¬e_path);
|
index_note_bg(&state, ¬e_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,18 +1229,18 @@ pub fn set_task_priority(
|
|||||||
Some(_) => return Err("Invalid priority".to_string()),
|
Some(_) => return Err("Invalid priority".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let p = std::path::Path::new(¬e_path);
|
let (vault_path, meta, body) = read_task_note(&state, ¬e_path)?;
|
||||||
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
|
let mut lines: Vec<String> = body.lines().map(|line| line.to_string()).collect();
|
||||||
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
let idx = if lines
|
||||||
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
|
.get(line)
|
||||||
|
.map(|item| *item == raw_line)
|
||||||
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
|
.unwrap_or(false)
|
||||||
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
{
|
||||||
line
|
line
|
||||||
} else {
|
} else {
|
||||||
lines
|
lines
|
||||||
.iter()
|
.iter()
|
||||||
.position(|l| *l == raw_line)
|
.position(|item| *item == raw_line)
|
||||||
.ok_or("Task line not found (note changed)")?
|
.ok_or("Task line not found (note changed)")?
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1164,7 +1253,7 @@ pub fn set_task_priority(
|
|||||||
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
||||||
new_body.push('\n');
|
new_body.push('\n');
|
||||||
}
|
}
|
||||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
operations::save_note(&vault_path, ¬e_path, &meta, &new_body)?;
|
||||||
|
|
||||||
index_note_bg(&state, ¬e_path);
|
index_note_bg(&state, ¬e_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1196,18 +1285,18 @@ pub fn set_task_due(
|
|||||||
Some(_) => return Err("Invalid due date".to_string()),
|
Some(_) => return Err("Invalid due date".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let p = std::path::Path::new(¬e_path);
|
let (vault_path, meta, body) = read_task_note(&state, ¬e_path)?;
|
||||||
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
|
let mut lines: Vec<String> = body.lines().map(|line| line.to_string()).collect();
|
||||||
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
let idx = if lines
|
||||||
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
|
.get(line)
|
||||||
|
.map(|item| *item == raw_line)
|
||||||
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
|
.unwrap_or(false)
|
||||||
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
{
|
||||||
line
|
line
|
||||||
} else {
|
} else {
|
||||||
lines
|
lines
|
||||||
.iter()
|
.iter()
|
||||||
.position(|l| *l == raw_line)
|
.position(|item| *item == raw_line)
|
||||||
.ok_or("Task line not found (note changed)")?
|
.ok_or("Task line not found (note changed)")?
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1220,7 +1309,7 @@ pub fn set_task_due(
|
|||||||
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
||||||
new_body.push('\n');
|
new_body.push('\n');
|
||||||
}
|
}
|
||||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
operations::save_note(&vault_path, ¬e_path, &meta, &new_body)?;
|
||||||
|
|
||||||
index_note_bg(&state, ¬e_path);
|
index_note_bg(&state, ¬e_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1269,7 +1358,11 @@ pub fn restore_note(
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let vault_path = {
|
let vault_path = {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.active_vault.as_ref().ok_or("No active vault")?.clone()
|
config
|
||||||
|
.active_vault
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("No active vault")?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
operations::restore_note(&vault_path, &trash_path, dest_notebook.as_deref())
|
operations::restore_note(&vault_path, &trash_path, dest_notebook.as_deref())
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1371,11 @@ pub fn restore_note(
|
|||||||
pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Result<String, String> {
|
pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Result<String, String> {
|
||||||
let vault_path = {
|
let vault_path = {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.active_vault.as_ref().ok_or("No active vault")?.clone()
|
config
|
||||||
|
.active_vault
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("No active vault")?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
operations::restore_notebook(&vault_path, &trash_path)
|
operations::restore_notebook(&vault_path, &trash_path)
|
||||||
}
|
}
|
||||||
@@ -1287,7 +1384,11 @@ pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Resul
|
|||||||
pub fn permanent_delete(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
pub fn permanent_delete(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
||||||
let vault_path = {
|
let vault_path = {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.active_vault.as_ref().ok_or("No active vault")?.clone()
|
config
|
||||||
|
.active_vault
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("No active vault")?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
operations::permanent_delete(&vault_path, &path)
|
operations::permanent_delete(&vault_path, &path)
|
||||||
}
|
}
|
||||||
@@ -1347,8 +1448,11 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
|||||||
// Encode RGBA data to PNG
|
// Encode RGBA data to PNG
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
{
|
{
|
||||||
let mut encoder =
|
let mut encoder = png::Encoder::new(
|
||||||
png::Encoder::new(std::io::Cursor::new(&mut buf), img.width as u32, img.height as u32);
|
std::io::Cursor::new(&mut buf),
|
||||||
|
img.width as u32,
|
||||||
|
img.height as u32,
|
||||||
|
);
|
||||||
encoder.set_color(png::ColorType::Rgba);
|
encoder.set_color(png::ColorType::Rgba);
|
||||||
encoder.set_depth(png::BitDepth::Eight);
|
encoder.set_depth(png::BitDepth::Eight);
|
||||||
let mut writer = encoder
|
let mut writer = encoder
|
||||||
@@ -1372,8 +1476,8 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
|
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
|
||||||
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
|
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
|
||||||
let img = image::load_from_memory(&data)
|
let img =
|
||||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
image::load_from_memory(&data).map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||||
let rgba = img.to_rgba8();
|
let rgba = img.to_rgba8();
|
||||||
let (w, h) = rgba.dimensions();
|
let (w, h) = rgba.dimensions();
|
||||||
let img_data = arboard::ImageData {
|
let img_data = arboard::ImageData {
|
||||||
@@ -1381,9 +1485,10 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
|
|||||||
height: h as usize,
|
height: h as usize,
|
||||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||||
};
|
};
|
||||||
let mut clipboard = arboard::Clipboard::new()
|
let mut clipboard =
|
||||||
.map_err(|e| format!("Clipboard init failed: {}", e))?;
|
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||||
clipboard.set_image(img_data)
|
clipboard
|
||||||
|
.set_image(img_data)
|
||||||
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1398,8 +1503,8 @@ pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
|
|||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
|
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
|
||||||
let img = image::load_from_memory(&data)
|
let img =
|
||||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
image::load_from_memory(&data).map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||||
let rgba = img.to_rgba8();
|
let rgba = img.to_rgba8();
|
||||||
let (w, h) = rgba.dimensions();
|
let (w, h) = rgba.dimensions();
|
||||||
let img_data = arboard::ImageData {
|
let img_data = arboard::ImageData {
|
||||||
@@ -1407,9 +1512,10 @@ pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
|
|||||||
height: h as usize,
|
height: h as usize,
|
||||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||||
};
|
};
|
||||||
let mut clipboard = arboard::Clipboard::new()
|
let mut clipboard =
|
||||||
.map_err(|e| format!("Clipboard init failed: {}", e))?;
|
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||||
clipboard.set_image(img_data)
|
clipboard
|
||||||
|
.set_image(img_data)
|
||||||
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1469,6 +1575,7 @@ pub fn set_notebook_icon(
|
|||||||
// ── General Settings ──
|
// ── General Settings ──
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn set_general_settings(
|
pub fn set_general_settings(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
compact_notes: bool,
|
compact_notes: bool,
|
||||||
@@ -1693,7 +1800,11 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
|
|||||||
let entry = entry.map_err(|e| e.to_string())?;
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
let p = entry.path();
|
let p = entry.path();
|
||||||
if p.is_file() {
|
if p.is_file() {
|
||||||
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
let name = p
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
files.push((name, size));
|
files.push((name, size));
|
||||||
}
|
}
|
||||||
@@ -1702,7 +1813,10 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let mut haystack = String::new();
|
let mut haystack = String::new();
|
||||||
for entry in walkdir::WalkDir::new(vault).into_iter().filter_map(|e| e.ok()) {
|
for entry in walkdir::WalkDir::new(vault)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
{
|
||||||
let p = entry.path();
|
let p = entry.path();
|
||||||
if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") {
|
if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") {
|
||||||
if let Ok(content) = std::fs::read_to_string(p) {
|
if let Ok(content) = std::fs::read_to_string(p) {
|
||||||
@@ -1874,7 +1988,6 @@ pub fn write_bytes_to(destination: String, data: Vec<u8>) -> Result<(), String>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Backup ──
|
// ── Backup ──
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1935,15 +2048,18 @@ pub fn list_backups(state: State<'_, AppState>) -> Result<Vec<BackupEntry>, Stri
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn restore_backup(app: AppHandle, backup_path: String) -> Result<(), String> {
|
pub fn restore_backup(app: AppHandle, backup_path: String) -> Result<(), String> {
|
||||||
let vault_path = {
|
let (vault_path, backup_dir) = {
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.active_vault.clone().ok_or("No active vault")?
|
(
|
||||||
|
config.active_vault.clone().ok_or("No active vault")?,
|
||||||
|
crate::backup::get_backup_dir(&config.backup_location)?,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
match crate::backup::restore_backup(&vault_path, &backup_path) {
|
match crate::backup::restore_backup(&vault_path, &backup_dir, &backup_path) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"restore-done",
|
"restore-done",
|
||||||
@@ -1967,8 +2083,10 @@ pub fn restore_backup(app: AppHandle, backup_path: String) -> Result<(), String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_backup(backup_path: String) -> Result<(), String> {
|
pub fn delete_backup(state: State<'_, AppState>, backup_path: String) -> Result<(), String> {
|
||||||
crate::backup::delete_backup(&backup_path)
|
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||||
|
let backup_dir = crate::backup::get_backup_dir(&config.backup_location)?;
|
||||||
|
crate::backup::delete_backup(&backup_dir, &backup_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -2011,9 +2129,8 @@ pub fn create_version(
|
|||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
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 max_versions = config.max_versions_per_note;
|
let max_versions = config.max_versions_per_note;
|
||||||
let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
let raw = operations::read_vault_note(vault_path, &path)?.raw;
|
||||||
crate::history::force_snapshot(vault_path, ¬e_id, &raw, max_versions);
|
crate::history::force_snapshot(vault_path, ¬e_id, &raw, max_versions)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -2030,6 +2147,7 @@ pub fn get_note_version_content(
|
|||||||
// ── AI ──
|
// ── AI ──
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn set_ai_settings(
|
pub fn set_ai_settings(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
provider: Option<String>,
|
provider: Option<String>,
|
||||||
@@ -2050,7 +2168,8 @@ pub fn set_ai_settings(
|
|||||||
config.ollama_api_key = ollama_api_key.filter(|k| !k.is_empty());
|
config.ollama_api_key = ollama_api_key.filter(|k| !k.is_empty());
|
||||||
}
|
}
|
||||||
Some("openai_compatible") => {
|
Some("openai_compatible") => {
|
||||||
config.openai_compatible_base_url = openai_compatible_base_url.filter(|u| !u.trim().is_empty());
|
config.openai_compatible_base_url =
|
||||||
|
openai_compatible_base_url.filter(|u| !u.trim().is_empty());
|
||||||
config.openai_compatible_api_key = openai_compatible_api_key.filter(|k| !k.is_empty());
|
config.openai_compatible_api_key = openai_compatible_api_key.filter(|k| !k.is_empty());
|
||||||
}
|
}
|
||||||
_ => config.ai_api_key = key,
|
_ => config.ai_api_key = key,
|
||||||
@@ -2073,7 +2192,9 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
|||||||
.unwrap_or_else(|| "anthropic".to_string());
|
.unwrap_or_else(|| "anthropic".to_string());
|
||||||
let key = match provider.as_str() {
|
let key = match provider.as_str() {
|
||||||
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
|
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
|
||||||
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
|
"openai_compatible" => {
|
||||||
|
Some(config.openai_compatible_api_key.clone().unwrap_or_default())
|
||||||
|
}
|
||||||
"openai" => config.openai_api_key.clone(),
|
"openai" => config.openai_api_key.clone(),
|
||||||
_ => config.ai_api_key.clone(),
|
_ => config.ai_api_key.clone(),
|
||||||
}
|
}
|
||||||
@@ -2115,11 +2236,7 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
// ── Sync (WebDAV) ──
|
// ── Sync (WebDAV) ──
|
||||||
|
|
||||||
fn vault_matches_identity(
|
fn vault_matches_identity(vault: &VaultConfig, path: &str, bookmark_id: Option<&str>) -> bool {
|
||||||
vault: &VaultConfig,
|
|
||||||
path: &str,
|
|
||||||
bookmark_id: Option<&str>,
|
|
||||||
) -> bool {
|
|
||||||
if let Some(bookmark_id) = bookmark_id {
|
if let Some(bookmark_id) = bookmark_id {
|
||||||
vault.bookmark_id.as_deref() == Some(bookmark_id)
|
vault.bookmark_id.as_deref() == Some(bookmark_id)
|
||||||
} else {
|
} else {
|
||||||
@@ -2148,23 +2265,25 @@ mod vault_identity_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bookmark_identity_disambiguates_vaults_with_the_same_path() {
|
fn bookmark_identity_disambiguates_vaults_with_the_same_path() {
|
||||||
let mut config = AppConfig::default();
|
let mut config = AppConfig {
|
||||||
config.active_vault = Some("/same/path".to_string());
|
active_vault: Some("/same/path".to_string()),
|
||||||
config.vaults = vec![
|
vaults: vec![
|
||||||
VaultConfig {
|
VaultConfig {
|
||||||
path: "/same/path".to_string(),
|
path: "/same/path".to_string(),
|
||||||
name: "Local".to_string(),
|
name: "Local".to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
VaultConfig {
|
VaultConfig {
|
||||||
path: "/same/path".to_string(),
|
path: "/same/path".to_string(),
|
||||||
name: "Files".to_string(),
|
name: "Files".to_string(),
|
||||||
bookmark_id: Some("bookmark".to_string()),
|
bookmark_id: Some("bookmark".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
active_bookmark_id: Some("bookmark".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
config.active_bookmark_id = Some("bookmark".to_string());
|
|
||||||
assert_eq!(active_vault_config(&config).unwrap().name, "Files");
|
assert_eq!(active_vault_config(&config).unwrap().name, "Files");
|
||||||
|
|
||||||
config.active_bookmark_id = None;
|
config.active_bookmark_id = None;
|
||||||
@@ -2190,6 +2309,7 @@ fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn set_sync_settings(
|
pub fn set_sync_settings(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
provider: Option<String>,
|
provider: Option<String>,
|
||||||
@@ -2262,9 +2382,7 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
|||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| "No active vault".to_string())
|
.ok_or_else(|| "No active vault".to_string())
|
||||||
.and_then(|vault| {
|
.and_then(|vault| {
|
||||||
sync_config_from(&config).map(|cfg| {
|
sync_config_from(&config).map(|cfg| (vault, config.active_bookmark_id.clone(), cfg))
|
||||||
(vault, config.active_bookmark_id.clone(), cfg)
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
match gathered {
|
match gathered {
|
||||||
Ok(vault_config) => vault_config,
|
Ok(vault_config) => vault_config,
|
||||||
@@ -2278,7 +2396,9 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
let result = crate::sync::run_sync(app.clone(), vault.clone(), cfg);
|
let result = crate::sync::run_sync(app.clone(), vault.clone(), cfg);
|
||||||
app.state::<AppState>().syncing.store(false, Ordering::SeqCst);
|
app.state::<AppState>()
|
||||||
|
.syncing
|
||||||
|
.store(false, Ordering::SeqCst);
|
||||||
match result {
|
match result {
|
||||||
Ok(summary) => {
|
Ok(summary) => {
|
||||||
let ts = chrono::Utc::now().to_rfc3339();
|
let ts = chrono::Utc::now().to_rfc3339();
|
||||||
@@ -2296,7 +2416,10 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = app.emit("sync-error", serde_json::json!({ "success": false, "error": e }));
|
let _ = app.emit(
|
||||||
|
"sync-error",
|
||||||
|
serde_json::json!({ "success": false, "error": e }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2320,7 +2443,9 @@ pub fn ai_ask(
|
|||||||
.unwrap_or_else(|| "anthropic".to_string());
|
.unwrap_or_else(|| "anthropic".to_string());
|
||||||
let key = match provider.as_str() {
|
let key = match provider.as_str() {
|
||||||
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
|
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
|
||||||
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
|
"openai_compatible" => {
|
||||||
|
Some(config.openai_compatible_api_key.clone().unwrap_or_default())
|
||||||
|
}
|
||||||
"openai" => config.openai_api_key.clone(),
|
"openai" => config.openai_api_key.clone(),
|
||||||
_ => config.ai_api_key.clone(),
|
_ => config.ai_api_key.clone(),
|
||||||
}
|
}
|
||||||
@@ -2425,7 +2550,9 @@ fn migrate_global_sync_to_vault(config: &mut AppConfig) -> bool {
|
|||||||
if config.sync_provider.is_none() && config.webdav_url.is_none() {
|
if config.sync_provider.is_none() && config.webdav_url.is_none() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let Ok(active_index) = active_vault_index(config) else { return false; };
|
let Ok(active_index) = active_vault_index(config) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
let g_provider = config.sync_provider.clone();
|
let g_provider = config.sync_provider.clone();
|
||||||
let g_url = config.webdav_url.clone();
|
let g_url = config.webdav_url.clone();
|
||||||
let g_user = config.webdav_username.clone();
|
let g_user = config.webdav_username.clone();
|
||||||
@@ -2449,10 +2576,56 @@ fn migrate_global_sync_to_vault(config: &mut AppConfig) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_private_file(path: &std::path::Path, data: &[u8]) -> Result<(), String> {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let mut options = std::fs::OpenOptions::new();
|
||||||
|
options.create(true).truncate(true).write(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = options.open(path).map_err(|error| error.to_string())?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
}
|
||||||
|
file.write_all(data).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
mod config_permission_tests {
|
||||||
|
use super::write_private_file;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_files_are_owner_only() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"helixnotes-config-permissions-{}.json",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
std::fs::write(&path, "old").unwrap();
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||||
|
|
||||||
|
write_private_file(&path, b"secret").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"secret");
|
||||||
|
std::fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn save_app_config(config: &AppConfig) -> Result<(), String> {
|
fn save_app_config(config: &AppConfig) -> Result<(), String> {
|
||||||
let path = app_config_path()?;
|
let path = app_config_path()?;
|
||||||
let data = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
|
let data = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
|
||||||
std::fs::write(path, data).map_err(|e| e.to_string())?;
|
write_private_file(&path, data.as_bytes())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2489,13 +2662,15 @@ pub fn get_install_type() -> String {
|
|||||||
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
|
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
|
||||||
"deb".to_string()
|
"deb".to_string()
|
||||||
} else if std::path::Path::new("/var/lib/pacman/local").exists()
|
} else if std::path::Path::new("/var/lib/pacman/local").exists()
|
||||||
&& ["helixnotes", "helixnotes-bin", "helixnotes-appimage-bin"].iter().any(|pkg| {
|
&& ["helixnotes", "helixnotes-bin", "helixnotes-appimage-bin"]
|
||||||
std::process::Command::new("pacman")
|
.iter()
|
||||||
.args(["-Q", pkg])
|
.any(|pkg| {
|
||||||
.output()
|
std::process::Command::new("pacman")
|
||||||
.map(|o| o.status.success())
|
.args(["-Q", pkg])
|
||||||
.unwrap_or(false)
|
.output()
|
||||||
})
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
{
|
{
|
||||||
"aur".to_string()
|
"aur".to_string()
|
||||||
} else if std::env::var("APPIMAGE").is_ok() {
|
} else if std::env::var("APPIMAGE").is_ok() {
|
||||||
|
|||||||
+60
-22
@@ -1,22 +1,36 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
use crate::types::VersionEntry;
|
use crate::types::VersionEntry;
|
||||||
|
|
||||||
|
fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
|
||||||
|
let mut components = Path::new(value).components();
|
||||||
|
if value.is_empty()
|
||||||
|
|| !matches!(components.next(), Some(Component::Normal(_)))
|
||||||
|
|| components.next().is_some()
|
||||||
|
{
|
||||||
|
return Err(format!("Invalid {label}"));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
/// Directory: .helixnotes/history/<note-id>/
|
/// Directory: .helixnotes/history/<note-id>/
|
||||||
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
|
fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
|
||||||
Path::new(vault_path)
|
Ok(Path::new(vault_path)
|
||||||
.join(".helixnotes")
|
.join(".helixnotes")
|
||||||
.join("history")
|
.join("history")
|
||||||
.join(note_id)
|
.join(safe_path_component(note_id, "note ID")?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a version snapshot if enough time has passed since the last one.
|
/// Save a version snapshot if enough time has passed since the last one.
|
||||||
/// Minimum interval: 5 minutes between snapshots.
|
/// Minimum interval: 5 minutes between snapshots.
|
||||||
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
||||||
let dir = history_dir(vault_path, note_id);
|
let Ok(dir) = history_dir(vault_path, note_id) else {
|
||||||
|
log::warn!("Skipping history snapshot with an invalid note ID");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
// Check if we should create a snapshot (5 min cooldown)
|
// Check if we should create a snapshot (5 min cooldown)
|
||||||
if let Ok(entries) = fs::read_dir(&dir) {
|
if let Ok(entries) = fs::read_dir(&dir) {
|
||||||
@@ -68,31 +82,30 @@ pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_ve
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Force-create a version snapshot, bypassing the cooldown.
|
/// Force-create a version snapshot, bypassing the cooldown.
|
||||||
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
pub fn force_snapshot(
|
||||||
let dir = history_dir(vault_path, note_id);
|
vault_path: &str,
|
||||||
|
note_id: &str,
|
||||||
if let Err(e) = fs::create_dir_all(&dir) {
|
raw_content: &str,
|
||||||
eprintln!("Failed to create history dir: {}", e);
|
max_versions: u32,
|
||||||
return;
|
) -> Result<(), String> {
|
||||||
}
|
let dir = history_dir(vault_path, note_id)?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
||||||
let filename = format!("{}.md", timestamp);
|
let filename = format!("{}.md", timestamp);
|
||||||
let path = dir.join(&filename);
|
let path = dir.join(&filename);
|
||||||
|
|
||||||
if let Err(e) = fs::write(&path, raw_content) {
|
fs::write(&path, raw_content).map_err(|error| error.to_string())?;
|
||||||
eprintln!("Failed to write version snapshot: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if max_versions > 0 {
|
if max_versions > 0 {
|
||||||
let _ = prune_versions(&dir, max_versions);
|
prune_versions(&dir, max_versions)?;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all version snapshots for a note, newest first.
|
/// List all version snapshots for a note, newest first.
|
||||||
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
|
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
|
||||||
let dir = history_dir(vault_path, note_id);
|
let dir = history_dir(vault_path, note_id)?;
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -102,7 +115,7 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
|||||||
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||||
let entry = entry.map_err(|e| e.to_string())?;
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().map_or(false, |ext| ext == "md") {
|
if path.extension().is_some_and(|ext| ext == "md") {
|
||||||
let filename = path
|
let filename = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -134,17 +147,18 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
|||||||
|
|
||||||
/// Get the raw content of a specific version.
|
/// Get the raw content of a specific version.
|
||||||
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
|
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
|
||||||
|
safe_path_component(timestamp, "version timestamp")?;
|
||||||
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
|
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
|
||||||
let filename = if let Some(t_pos) = timestamp.find('T') {
|
let filename = if let Some(t_pos) = timestamp.find('T') {
|
||||||
let date_part = ×tamp[..t_pos];
|
let date_part = ×tamp[..t_pos];
|
||||||
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
|
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
|
||||||
let time_dashes = time_part.replace(':', "-");
|
let time_dashes = time_part.replace(':', "-");
|
||||||
format!("{}.md", format!("{}T{}", date_part, time_dashes))
|
format!("{date_part}T{time_dashes}.md")
|
||||||
} else {
|
} else {
|
||||||
format!("{}.md", timestamp)
|
format!("{}.md", timestamp)
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = history_dir(vault_path, note_id).join(&filename);
|
let path = history_dir(vault_path, note_id)?.join(&filename);
|
||||||
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
|
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +168,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
|||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.map(|e| e.path())
|
.map(|e| e.path())
|
||||||
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
|
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by name (timestamps sort lexicographically) - newest last
|
// Sort by name (timestamps sort lexicographically) - newest last
|
||||||
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{get_version, list_versions};
|
||||||
|
use std::fs;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_history_path_traversal() {
|
||||||
|
let vault =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-history-test-{}", Uuid::new_v4()));
|
||||||
|
let metadata = vault.join(".helixnotes");
|
||||||
|
let escaped_history = metadata.join("escaped");
|
||||||
|
fs::create_dir_all(&escaped_history).unwrap();
|
||||||
|
fs::write(escaped_history.join("2026-01-01T00-00-00.md"), "escaped").unwrap();
|
||||||
|
fs::create_dir_all(metadata.join("history").join("safe")).unwrap();
|
||||||
|
fs::write(metadata.join("secret.md"), "secret").unwrap();
|
||||||
|
|
||||||
|
assert!(list_versions(&vault.to_string_lossy(), "../escaped").is_err());
|
||||||
|
assert!(get_version(&vault.to_string_lossy(), "safe", "../../secret").is_err());
|
||||||
|
|
||||||
|
fs::remove_dir_all(vault).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ pub fn run() {
|
|||||||
let external_url = percent_decode(encoded);
|
let external_url = percent_decode(encoded);
|
||||||
|
|
||||||
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
||||||
let _ = responder.respond(
|
responder.respond(
|
||||||
tauri::http::Response::builder()
|
tauri::http::Response::builder()
|
||||||
.status(400)
|
.status(400)
|
||||||
.body(Vec::new())
|
.body(Vec::new())
|
||||||
@@ -234,7 +234,7 @@ pub fn run() {
|
|||||||
{
|
{
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = responder.respond(
|
responder.respond(
|
||||||
tauri::http::Response::builder()
|
tauri::http::Response::builder()
|
||||||
.status(502)
|
.status(502)
|
||||||
.body(Vec::new())
|
.body(Vec::new())
|
||||||
@@ -255,7 +255,7 @@ pub fn run() {
|
|||||||
let status = resp.status().as_u16();
|
let status = resp.status().as_u16();
|
||||||
match resp.bytes() {
|
match resp.bytes() {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let _ = responder.respond(
|
responder.respond(
|
||||||
tauri::http::Response::builder()
|
tauri::http::Response::builder()
|
||||||
.status(status)
|
.status(status)
|
||||||
.header("Content-Type", &content_type)
|
.header("Content-Type", &content_type)
|
||||||
@@ -265,7 +265,7 @@ pub fn run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = responder.respond(
|
responder.respond(
|
||||||
tauri::http::Response::builder()
|
tauri::http::Response::builder()
|
||||||
.status(502)
|
.status(502)
|
||||||
.body(Vec::new())
|
.body(Vec::new())
|
||||||
@@ -275,7 +275,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = responder.respond(
|
responder.respond(
|
||||||
tauri::http::Response::builder()
|
tauri::http::Response::builder()
|
||||||
.status(502)
|
.status(502)
|
||||||
.body(Vec::new())
|
.body(Vec::new())
|
||||||
@@ -350,9 +350,9 @@ pub fn run() {
|
|||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tauri::WindowEvent::Destroyed => {
|
tauri::WindowEvent::Destroyed
|
||||||
// When main window is destroyed, close all note windows
|
// When main window is destroyed, close all note windows
|
||||||
if window.label() == "main" {
|
if window.label() == "main" => {
|
||||||
let app = window.app_handle();
|
let app = window.app_handle();
|
||||||
for (label, win) in app.webview_windows() {
|
for (label, win) in app.webview_windows() {
|
||||||
if label.starts_with("note-") {
|
if label.starts_with("note-") {
|
||||||
@@ -360,7 +360,6 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
app_lib::run();
|
app_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(vault_path.as_bytes());
|
hasher.update(vault_path.as_bytes());
|
||||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
let key: String = hasher.finalize()[..8]
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect();
|
||||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +238,9 @@ impl SearchIndex {
|
|||||||
// before the writer is created, so both indexing and querying use it).
|
// before the writer is created, so both indexing and querying use it).
|
||||||
index.tokenizers().register(
|
index.tokenizers().register(
|
||||||
"cjk",
|
"cjk",
|
||||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
TextAnalyzer::builder(CjkTokenizer)
|
||||||
|
.filter(LowerCaser)
|
||||||
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(mobile)]
|
#[cfg(mobile)]
|
||||||
@@ -290,7 +295,7 @@ impl SearchIndex {
|
|||||||
doc.add_text(self.path_field, &path_str);
|
doc.add_text(self.path_field, &path_str);
|
||||||
doc.add_text(self.title_field, &meta.title);
|
doc.add_text(self.title_field, &meta.title);
|
||||||
doc.add_text(self.body_field, &content);
|
doc.add_text(self.body_field, &content);
|
||||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||||
let _ = writer.add_document(doc);
|
let _ = writer.add_document(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +326,7 @@ impl SearchIndex {
|
|||||||
doc.add_text(self.path_field, path);
|
doc.add_text(self.path_field, path);
|
||||||
doc.add_text(self.title_field, &meta.title);
|
doc.add_text(self.title_field, &meta.title);
|
||||||
doc.add_text(self.body_field, &content);
|
doc.add_text(self.body_field, &content);
|
||||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||||
let _ = writer.add_document(doc);
|
let _ = writer.add_document(doc);
|
||||||
|
|
||||||
writer.commit().map_err(|e| e.to_string())?;
|
writer.commit().map_err(|e| e.to_string())?;
|
||||||
@@ -341,7 +346,7 @@ impl SearchIndex {
|
|||||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||||
let searcher = reader.searcher();
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
let fields = [self.title_field, self.body_field, self.tags_field];
|
||||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||||
@@ -376,9 +381,10 @@ impl SearchIndex {
|
|||||||
));
|
));
|
||||||
vec![(Occur::Should, exact)]
|
vec![(Occur::Should, exact)]
|
||||||
} else {
|
} else {
|
||||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
let prefix: Box<dyn Query> =
|
||||||
vec![Term::from_field_text(field, term)],
|
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
|
||||||
));
|
field, term,
|
||||||
|
)]));
|
||||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||||
Term::from_field_text(field, term),
|
Term::from_field_text(field, term),
|
||||||
1,
|
1,
|
||||||
|
|||||||
+25
-8
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_etag(s: &str) -> String {
|
fn normalize_etag(s: &str) -> String {
|
||||||
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
s.trim()
|
||||||
|
.trim_start_matches("W/")
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percent-encode each path segment, keeping the `/` separators.
|
/// Percent-encode each path segment, keeping the `/` separators.
|
||||||
@@ -265,7 +268,11 @@ impl WebdavClient {
|
|||||||
.send()
|
.send()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
return Err(format!(
|
||||||
|
"GET {} failed: HTTP {}",
|
||||||
|
relpath,
|
||||||
|
resp.status().as_u16()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||||
}
|
}
|
||||||
@@ -279,7 +286,11 @@ impl WebdavClient {
|
|||||||
.send()
|
.send()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
return Err(format!(
|
||||||
|
"PUT {} failed: HTTP {}",
|
||||||
|
relpath,
|
||||||
|
resp.status().as_u16()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(resp
|
Ok(resp
|
||||||
.headers()
|
.headers()
|
||||||
@@ -453,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
|||||||
}
|
}
|
||||||
Ok(Event::Text(e)) => {
|
Ok(Event::Text(e)) => {
|
||||||
if cap != Cap::None {
|
if cap != Cap::None {
|
||||||
if let Ok(t) = e.unescape() {
|
if let Ok(decoded) = e.decode() {
|
||||||
buf.push_str(&t);
|
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
|
||||||
|
buf.push_str(&text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -515,8 +528,8 @@ fn apply_changes(
|
|||||||
|
|
||||||
match (l, r) {
|
match (l, r) {
|
||||||
(Some(lf), Some(re)) => {
|
(Some(lf), Some(re)) => {
|
||||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
let local_changed = m.is_none_or(|me| me.local_hash != lf.hash);
|
||||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
let remote_changed = m.is_none_or(|me| &me.remote_etag != re);
|
||||||
if !local_changed && !remote_changed {
|
if !local_changed && !remote_changed {
|
||||||
new_m.files.insert(
|
new_m.files.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
@@ -669,7 +682,11 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
|||||||
|
|
||||||
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||||
/// rebuilds the search index. Returns a summary of what changed.
|
/// rebuilds the search index. Returns a summary of what changed.
|
||||||
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
|
pub fn run_sync(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
vault: String,
|
||||||
|
cfg: WebdavConfig,
|
||||||
|
) -> Result<SyncSummary, String> {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
|
|||||||
@@ -549,10 +549,7 @@ mod startup_view_tests {
|
|||||||
assert!(!config.show_note_switcher);
|
assert!(!config.show_note_switcher);
|
||||||
|
|
||||||
let mut value = serde_json::to_value(config).unwrap();
|
let mut value = serde_json::to_value(config).unwrap();
|
||||||
value
|
value.as_object_mut().unwrap().remove("show_note_switcher");
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.remove("show_note_switcher");
|
|
||||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||||
|
|
||||||
assert!(!config.show_note_switcher);
|
assert!(!config.show_note_switcher);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
|
|||||||
"[{}]",
|
"[{}]",
|
||||||
meta.tags
|
meta.tags
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| format!("{}", t))
|
.map(|t| t.to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
)
|
)
|
||||||
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
chars.next(); // skip '['
|
chars.next(); // skip '['
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
// Skip alt text
|
// Skip alt text
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '[' {
|
if c == '[' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
if chars.peek() == Some(&'(') {
|
if chars.peek() == Some(&'(') {
|
||||||
chars.next();
|
chars.next();
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '(' {
|
if c == '(' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
if ch == '[' {
|
if ch == '[' {
|
||||||
let mut link_text = String::new();
|
let mut link_text = String::new();
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '[' {
|
if c == '[' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
|||||||
if chars.peek() == Some(&'(') {
|
if chars.peek() == Some(&'(') {
|
||||||
chars.next();
|
chars.next();
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
while let Some(c) = chars.next() {
|
for c in chars.by_ref() {
|
||||||
if c == '(' {
|
if c == '(' {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,13 +112,15 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
|||||||
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
|
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
|
||||||
);
|
);
|
||||||
if is_embeddable {
|
if is_embeddable {
|
||||||
let alt = if is_dimension_spec(alt_param) { "" } else { alt_param };
|
let alt = if is_dimension_spec(alt_param) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
alt_param
|
||||||
|
};
|
||||||
result.links_converted += 1;
|
result.links_converted += 1;
|
||||||
format!("", alt, link_target)
|
format!("", alt, link_target)
|
||||||
} else {
|
} else {
|
||||||
let display = if alt_param.is_empty() {
|
let display = if alt_param.is_empty() || is_dimension_spec(alt_param) {
|
||||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
|
||||||
} else if is_dimension_spec(alt_param) {
|
|
||||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
file_part.rsplit('/').next().unwrap_or(file_part)
|
||||||
} else {
|
} else {
|
||||||
alt_param
|
alt_param
|
||||||
@@ -137,7 +139,11 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
|||||||
let (file_part, anchor) = split_anchor(note_ref);
|
let (file_part, anchor) = split_anchor(note_ref);
|
||||||
if file_part.is_empty() {
|
if file_part.is_empty() {
|
||||||
if let Some(a) = anchor {
|
if let Some(a) = anchor {
|
||||||
let display = if display_param.is_empty() { a } else { display_param };
|
let display = if display_param.is_empty() {
|
||||||
|
a
|
||||||
|
} else {
|
||||||
|
display_param
|
||||||
|
};
|
||||||
result.links_converted += 1;
|
result.links_converted += 1;
|
||||||
return format!("[{}](#{})", display, a);
|
return format!("[{}](#{})", display, a);
|
||||||
}
|
}
|
||||||
@@ -165,8 +171,22 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
|||||||
.to_string();
|
.to_string();
|
||||||
content = after_links;
|
content = after_links;
|
||||||
|
|
||||||
content = fix_md_image_refs(&content, &md_img_re, vault, note_dir, &file_index, &mut result.links_converted);
|
content = fix_md_image_refs(
|
||||||
content = fix_md_link_refs(&content, &md_link_re, vault, note_dir, &file_index, &mut result.links_converted);
|
&content,
|
||||||
|
&md_img_re,
|
||||||
|
vault,
|
||||||
|
note_dir,
|
||||||
|
&file_index,
|
||||||
|
&mut result.links_converted,
|
||||||
|
);
|
||||||
|
content = fix_md_link_refs(
|
||||||
|
&content,
|
||||||
|
&md_link_re,
|
||||||
|
vault,
|
||||||
|
note_dir,
|
||||||
|
&file_index,
|
||||||
|
&mut result.links_converted,
|
||||||
|
);
|
||||||
|
|
||||||
if result.links_converted > links_before {
|
if result.links_converted > links_before {
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -267,7 +287,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
|||||||
let tags = normalize_tags(&mapping);
|
let tags = normalize_tags(&mapping);
|
||||||
|
|
||||||
let title = mapping
|
let title = mapping
|
||||||
.get(&serde_yaml::Value::String("title".into()))
|
.get(serde_yaml::Value::String("title".into()))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
@@ -283,7 +303,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let id = mapping
|
let id = mapping
|
||||||
.get(&serde_yaml::Value::String("id".into()))
|
.get(serde_yaml::Value::String("id".into()))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -293,9 +313,9 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|key| {
|
.find_map(|key| {
|
||||||
mapping
|
mapping
|
||||||
.get(&serde_yaml::Value::String((*key).into()))
|
.get(serde_yaml::Value::String((*key).into()))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
.and_then(frontmatter::parse_date_flexible)
|
||||||
})
|
})
|
||||||
.or_else(|| file_created(path))
|
.or_else(|| file_created(path))
|
||||||
.unwrap_or_else(Utc::now);
|
.unwrap_or_else(Utc::now);
|
||||||
@@ -304,15 +324,15 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|key| {
|
.find_map(|key| {
|
||||||
mapping
|
mapping
|
||||||
.get(&serde_yaml::Value::String((*key).into()))
|
.get(serde_yaml::Value::String((*key).into()))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
.and_then(frontmatter::parse_date_flexible)
|
||||||
})
|
})
|
||||||
.or_else(|| file_modified(path))
|
.or_else(|| file_modified(path))
|
||||||
.unwrap_or_else(Utc::now);
|
.unwrap_or_else(Utc::now);
|
||||||
|
|
||||||
let pinned = mapping
|
let pinned = mapping
|
||||||
.get(&serde_yaml::Value::String("pinned".into()))
|
.get(serde_yaml::Value::String("pinned".into()))
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
@@ -332,7 +352,7 @@ fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec<String> {
|
|||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
for key in &["tags", "tag"] {
|
for key in &["tags", "tag"] {
|
||||||
if let Some(val) = mapping.get(&serde_yaml::Value::String((*key).into())) {
|
if let Some(val) = mapping.get(serde_yaml::Value::String((*key).into())) {
|
||||||
for raw in yaml_value_to_strings(val) {
|
for raw in yaml_value_to_strings(val) {
|
||||||
let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
|
let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
|
||||||
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
|
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
|
||||||
@@ -389,9 +409,7 @@ fn file_created(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.created().ok())
|
.and_then(|m| m.created().ok())
|
||||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
.and_then(|d| {
|
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||||
@@ -399,9 +417,7 @@ fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.modified().ok())
|
.and_then(|m| m.modified().ok())
|
||||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
.and_then(|d| {
|
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String {
|
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String {
|
||||||
@@ -513,7 +529,9 @@ fn fix_md_link_refs(
|
|||||||
let display = &caps[1];
|
let display = &caps[1];
|
||||||
let href = &caps[2];
|
let href = &caps[2];
|
||||||
let decoded = percent_decode(href);
|
let decoded = percent_decode(href);
|
||||||
if decoded.starts_with("http") || decoded.starts_with('/') || decoded.starts_with("data:")
|
if decoded.starts_with("http")
|
||||||
|
|| decoded.starts_with('/')
|
||||||
|
|| decoded.starts_with("data:")
|
||||||
|| decoded.starts_with('#')
|
|| decoded.starts_with('#')
|
||||||
{
|
{
|
||||||
return format!("[{}]({})", display, href);
|
return format!("[{}]({})", display, href);
|
||||||
@@ -549,9 +567,7 @@ fn fix_md_link_refs(
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_attachments(
|
fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
|
||||||
vault: &Path,
|
|
||||||
) -> Result<HashMap<String, String>, String> {
|
|
||||||
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
||||||
let _ = std::fs::create_dir_all(&attachments_dir);
|
let _ = std::fs::create_dir_all(&attachments_dir);
|
||||||
|
|
||||||
@@ -601,10 +617,7 @@ fn move_attachments(
|
|||||||
Ok(moved)
|
Ok(moved)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rewrite_attachment_refs(
|
fn rewrite_attachment_refs(vault: &Path, moved: &HashMap<String, String>) -> Result<(), String> {
|
||||||
vault: &Path,
|
|
||||||
moved: &HashMap<String, String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
|
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
|
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
|
||||||
@@ -686,7 +699,7 @@ fn cleanup_empty_dirs(root: &Path) {
|
|||||||
.map(|e| e.path().to_path_buf())
|
.map(|e| e.path().to_path_buf())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count()));
|
dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
|
||||||
|
|
||||||
for dir in dirs {
|
for dir in dirs {
|
||||||
let dir_str = dir.to_string_lossy();
|
let dir_str = dir.to_string_lossy();
|
||||||
@@ -719,7 +732,8 @@ fn is_dimension_spec(s: &str) -> bool {
|
|||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
s.chars().all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
|
s.chars()
|
||||||
|
.all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_wiki_ref(file_index: &HashMap<String, String>, reference: &str) -> String {
|
fn resolve_wiki_ref(file_index: &HashMap<String, String>, reference: &str) -> String {
|
||||||
@@ -907,7 +921,10 @@ mod tests {
|
|||||||
extract_heading_title("# My Title\n\nBody"),
|
extract_heading_title("# My Title\n\nBody"),
|
||||||
Some("My Title".to_string())
|
Some("My Title".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(extract_heading_title("\n\n# Spaced Title"), Some("Spaced Title".to_string()));
|
assert_eq!(
|
||||||
|
extract_heading_title("\n\n# Spaced Title"),
|
||||||
|
Some("Spaced Title".to_string())
|
||||||
|
);
|
||||||
assert_eq!(extract_heading_title("Body without heading"), None);
|
assert_eq!(extract_heading_title("Body without heading"), None);
|
||||||
assert_eq!(extract_heading_title("## Subheading"), None);
|
assert_eq!(extract_heading_title("## Subheading"), None);
|
||||||
assert_eq!(extract_heading_title(""), None);
|
assert_eq!(extract_heading_title(""), None);
|
||||||
@@ -947,8 +964,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_tags_strips_hash() {
|
fn test_normalize_tags_strips_hash() {
|
||||||
let mapping: serde_yaml::Mapping =
|
let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
||||||
serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
|
||||||
let tags = normalize_tags(&mapping);
|
let tags = normalize_tags(&mapping);
|
||||||
assert_eq!(tags, vec!["hashed"]);
|
assert_eq!(tags, vec!["hashed"]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,107 @@ pub fn helixnotes_dir(vault_path: &str) -> PathBuf {
|
|||||||
Path::new(vault_path).join(".helixnotes")
|
Path::new(vault_path).join(".helixnotes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn canonicalize_path(path: &Path, label: &str) -> Result<PathBuf, String> {
|
||||||
|
fs::canonicalize(path).map_err(|error| format!("Invalid {label}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_vault_content_path(
|
||||||
|
vault_path: &str,
|
||||||
|
requested_path: &Path,
|
||||||
|
allow_root: bool,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let vault = canonicalize_path(Path::new(vault_path), "vault path")?;
|
||||||
|
let requested = canonicalize_path(requested_path, "vault item path")?;
|
||||||
|
let metadata = vault.join(".helixnotes");
|
||||||
|
|
||||||
|
if !requested.starts_with(&vault)
|
||||||
|
|| requested.starts_with(&metadata)
|
||||||
|
|| (!allow_root && requested == vault)
|
||||||
|
{
|
||||||
|
return Err("Path must stay inside the active vault".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(requested_path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_vault_content_dir(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let requested = ensure_vault_content_path(vault_path, requested_path, true)?;
|
||||||
|
if !requested.is_dir() {
|
||||||
|
return Err("Vault destination is not a directory".to_string());
|
||||||
|
}
|
||||||
|
Ok(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
|
||||||
|
if !requested.is_file()
|
||||||
|
|| requested
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
!= Some("md")
|
||||||
|
{
|
||||||
|
return Err("Note path must point to a Markdown file".to_string());
|
||||||
|
}
|
||||||
|
Ok(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_readable_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
if let Ok(note) = ensure_note_path(vault_path, requested_path) {
|
||||||
|
return Ok(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trashed_note = ensure_trash_entry(vault_path, requested_path)?;
|
||||||
|
if !trashed_note.is_file()
|
||||||
|
|| trashed_note
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
!= Some("md")
|
||||||
|
{
|
||||||
|
return Err("Note path must point to a Markdown file".to_string());
|
||||||
|
}
|
||||||
|
Ok(trashed_note)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_notebook_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
|
||||||
|
if !requested.is_dir() {
|
||||||
|
return Err("Notebook path must point to a directory".to_string());
|
||||||
|
}
|
||||||
|
Ok(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_trash_entry(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let trash = canonicalize_path(&helixnotes_dir(vault_path).join("trash"), "trash path")?;
|
||||||
|
let requested = canonicalize_path(requested_path, "trash item path")?;
|
||||||
|
if requested == trash || !requested.starts_with(&trash) {
|
||||||
|
return Err("Path must be an item inside the active vault trash".to_string());
|
||||||
|
}
|
||||||
|
Ok(requested_path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_relative_path(path: &str) -> Result<&Path, String> {
|
||||||
|
let relative = Path::new(path);
|
||||||
|
if relative.as_os_str().is_empty()
|
||||||
|
|| !relative
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
return Err("Path must be a safe vault-relative path".to_string());
|
||||||
|
}
|
||||||
|
Ok(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_child_name(name: &str) -> Result<&str, String> {
|
||||||
|
let mut components = Path::new(name).components();
|
||||||
|
if name.trim().is_empty()
|
||||||
|
|| !matches!(components.next(), Some(Component::Normal(_)))
|
||||||
|
|| components.next().is_some()
|
||||||
|
{
|
||||||
|
return Err("Name must not contain path separators".to_string());
|
||||||
|
}
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
|
pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
|
||||||
let hn_dir = helixnotes_dir(vault_path);
|
let hn_dir = helixnotes_dir(vault_path);
|
||||||
fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?;
|
fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?;
|
||||||
@@ -140,7 +241,11 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
|||||||
paths
|
paths
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.map(|path| {
|
.map(|path| {
|
||||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
let relative = path
|
let relative = path
|
||||||
.strip_prefix(root)
|
.strip_prefix(root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
@@ -191,7 +296,11 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
|
|||||||
let entries: Vec<NotebookEntry> = paths
|
let entries: Vec<NotebookEntry> = paths
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.map(|path| {
|
.map(|path| {
|
||||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
let relative = path
|
let relative = path
|
||||||
.strip_prefix(root)
|
.strip_prefix(root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
@@ -243,9 +352,15 @@ pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
|
|||||||
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
|
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
|
||||||
let scan_path = notebook_path.unwrap_or(vault_path);
|
let scan_path = notebook_path.unwrap_or(vault_path);
|
||||||
let root = Path::new(scan_path);
|
let root = Path::new(scan_path);
|
||||||
|
ensure_vault_content_dir(vault_path, root)?;
|
||||||
let vault_root = Path::new(vault_path);
|
let vault_root = Path::new(vault_path);
|
||||||
|
|
||||||
log::info!("scan_notes: vault={}, scan={}, exists={}", vault_path, scan_path, root.exists());
|
log::info!(
|
||||||
|
"scan_notes: vault={}, scan={}, exists={}",
|
||||||
|
vault_path,
|
||||||
|
scan_path,
|
||||||
|
root.exists()
|
||||||
|
);
|
||||||
|
|
||||||
if !root.exists() {
|
if !root.exists() {
|
||||||
return Err("Path does not exist".to_string());
|
return Err("Path does not exist".to_string());
|
||||||
@@ -301,7 +416,9 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
|||||||
} else {
|
} else {
|
||||||
WalkDir::new(root)
|
WalkDir::new(root)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
|
.filter_entry(|e| {
|
||||||
|
!is_hidden(e.path()) && !e.path().starts_with(helixnotes_dir(vault_path))
|
||||||
|
})
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.map(|e| e.path().to_path_buf())
|
.map(|e| e.path().to_path_buf())
|
||||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||||
@@ -313,7 +430,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
|||||||
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
|
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
|
||||||
Ok(notes)
|
Ok(notes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,8 +540,18 @@ fn read_note_entry_from_str(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
pub fn read_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
|
||||||
let p = Path::new(path);
|
let validated = ensure_readable_note_path(vault_path, Path::new(path))?;
|
||||||
|
read_note_content(&validated, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_vault_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
|
||||||
|
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||||
|
read_note_content(&validated, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_note_content(validated: &Path, reported_path: &str) -> Result<NoteContent, String> {
|
||||||
|
let p = validated;
|
||||||
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
|
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
|
||||||
let filename = p
|
let filename = p
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -454,14 +581,15 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(NoteContent {
|
Ok(NoteContent {
|
||||||
path: path.to_string(),
|
path: reported_path.to_string(),
|
||||||
meta,
|
meta,
|
||||||
content,
|
content,
|
||||||
raw,
|
raw,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
|
pub fn save_note(vault_path: &str, path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
|
||||||
|
let path = ensure_note_path(vault_path, Path::new(path))?;
|
||||||
let mut updated_meta = meta.clone();
|
let mut updated_meta = meta.clone();
|
||||||
updated_meta.modified = Utc::now();
|
updated_meta.modified = Utc::now();
|
||||||
|
|
||||||
@@ -471,7 +599,7 @@ pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read existing file to preserve unknown frontmatter fields
|
// Read existing file to preserve unknown frontmatter fields
|
||||||
let existing = fs::read_to_string(path).unwrap_or_default();
|
let existing = fs::read_to_string(&path).unwrap_or_default();
|
||||||
let raw = if existing.is_empty() {
|
let raw = if existing.is_empty() {
|
||||||
frontmatter::update_note_raw(&updated_meta, body)
|
frontmatter::update_note_raw(&updated_meta, body)
|
||||||
} else {
|
} else {
|
||||||
@@ -487,14 +615,11 @@ pub fn create_note(
|
|||||||
notebook_relative: Option<&str>,
|
notebook_relative: Option<&str>,
|
||||||
title: &str,
|
title: &str,
|
||||||
) -> Result<NoteEntry, String> {
|
) -> Result<NoteEntry, String> {
|
||||||
let dir = match notebook_relative {
|
let requested_dir = match notebook_relative {
|
||||||
Some(rel) => Path::new(vault_path).join(rel),
|
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
|
||||||
None => PathBuf::from(vault_path),
|
None => PathBuf::from(vault_path),
|
||||||
};
|
};
|
||||||
|
let dir = ensure_vault_content_dir(vault_path, &requested_dir)?;
|
||||||
if !dir.exists() {
|
|
||||||
return Err("Notebook directory does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let filename = sanitize_filename(title);
|
let filename = sanitize_filename(title);
|
||||||
let mut file_path = dir.join(format!("{}.md", filename));
|
let mut file_path = dir.join(format!("{}.md", filename));
|
||||||
@@ -535,10 +660,8 @@ pub fn create_note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
||||||
let src = Path::new(path);
|
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||||
if !src.is_file() {
|
let src = validated.as_path();
|
||||||
return Err("Note does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let parent = src
|
let parent = src
|
||||||
.parent()
|
.parent()
|
||||||
@@ -672,7 +795,9 @@ pub fn create_daily_note(
|
|||||||
"eu" => target_date.format("%d/%m/%Y").to_string(),
|
"eu" => target_date.format("%d/%m/%Y").to_string(),
|
||||||
_ => {
|
_ => {
|
||||||
let locale = get_system_locale();
|
let locale = get_system_locale();
|
||||||
target_date.format_localized("%B %d, %Y", locale).to_string()
|
target_date
|
||||||
|
.format_localized("%B %d, %Y", locale)
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -719,12 +844,12 @@ pub fn create_notebook(
|
|||||||
parent_relative: Option<&str>,
|
parent_relative: Option<&str>,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<NotebookEntry, String> {
|
) -> Result<NotebookEntry, String> {
|
||||||
let parent = match parent_relative {
|
let requested_parent = match parent_relative {
|
||||||
Some(rel) => Path::new(vault_path).join(rel),
|
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
|
||||||
None => PathBuf::from(vault_path),
|
None => PathBuf::from(vault_path),
|
||||||
};
|
};
|
||||||
|
let parent = ensure_vault_content_dir(vault_path, &requested_parent)?;
|
||||||
let dir_path = parent.join(name);
|
let dir_path = parent.join(safe_child_name(name)?);
|
||||||
if dir_path.exists() {
|
if dir_path.exists() {
|
||||||
return Err("Notebook already exists".to_string());
|
return Err("Notebook already exists".to_string());
|
||||||
}
|
}
|
||||||
@@ -748,10 +873,8 @@ pub fn create_notebook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
||||||
let src = Path::new(note_path);
|
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Note does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||||
@@ -771,10 +894,8 @@ pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> {
|
pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> {
|
||||||
let src = Path::new(notebook_path);
|
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Notebook does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||||
@@ -803,10 +924,8 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
|
pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
|
||||||
let src = Path::new(path);
|
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Note does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read old title before renaming
|
// Read old title before renaming
|
||||||
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||||
@@ -839,7 +958,13 @@ pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<Stri
|
|||||||
let new_path_str = new_path.to_string_lossy().to_string();
|
let new_path_str = new_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
// Update wikilinks in other notes that reference this note
|
// Update wikilinks in other notes that reference this note
|
||||||
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
|
update_wikilinks_after_rename(
|
||||||
|
vault_path,
|
||||||
|
&old_path_str,
|
||||||
|
&new_path_str,
|
||||||
|
&old_title,
|
||||||
|
new_title,
|
||||||
|
);
|
||||||
|
|
||||||
Ok(new_path_str)
|
Ok(new_path_str)
|
||||||
}
|
}
|
||||||
@@ -898,11 +1023,17 @@ fn update_wikilinks_after_rename(
|
|||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
{
|
{
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() { continue; }
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let path_str = path.to_string_lossy();
|
let path_str = path.to_string_lossy();
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Skip the renamed note itself
|
// Skip the renamed note itself
|
||||||
if *path_str == *new_path { continue; }
|
if *path_str == *new_path {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let content = match fs::read_to_string(path) {
|
let content = match fs::read_to_string(path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -918,22 +1049,13 @@ fn update_wikilinks_after_rename(
|
|||||||
// If another note shares the same title, these would be ambiguous.
|
// If another note shares the same title, these would be ambiguous.
|
||||||
if old_title != new_title && title_is_unique {
|
if old_title != new_title && title_is_unique {
|
||||||
// 1. Short title ref: [[Old Title]] → [[New Title]]
|
// 1. Short title ref: [[Old Title]] → [[New Title]]
|
||||||
result = result.replace(
|
result = result.replace(&format!("[[{}]]", old_title), &format!("[[{}]]", new_title));
|
||||||
&format!("[[{}]]", old_title),
|
|
||||||
&format!("[[{}]]", new_title),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
|
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
|
||||||
result = result.replace(
|
result = result.replace(&format!("[[{}|", old_title), &format!("[[{}|", new_title));
|
||||||
&format!("[[{}|", old_title),
|
|
||||||
&format!("[[{}|", new_title),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
|
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
|
||||||
result = result.replace(
|
result = result.replace(&format!("|{}]]", old_title), &format!("|{}]]", new_title));
|
||||||
&format!("|{}]]", old_title),
|
|
||||||
&format!("|{}]]", new_title),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path-based rules are always safe (paths are unique).
|
// Path-based rules are always safe (paths are unique).
|
||||||
@@ -966,13 +1088,11 @@ fn update_wikilinks_after_rename(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
|
pub fn rename_notebook(vault_path: &str, path: &str, new_name: &str) -> Result<String, String> {
|
||||||
let src = Path::new(path);
|
let validated = ensure_notebook_path(vault_path, Path::new(path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Notebook does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_path = src.parent().unwrap().join(new_name);
|
let new_path = src.parent().unwrap().join(safe_child_name(new_name)?);
|
||||||
if new_path.exists() {
|
if new_path.exists() {
|
||||||
return Err("A notebook with that name already exists".to_string());
|
return Err("A notebook with that name already exists".to_string());
|
||||||
}
|
}
|
||||||
@@ -981,13 +1101,12 @@ pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
|
|||||||
Ok(new_path.to_string_lossy().to_string())
|
Ok(new_path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String> {
|
pub fn move_note(vault_path: &str, note_path: &str, dest_notebook: &str) -> Result<String, String> {
|
||||||
let src = Path::new(note_path);
|
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
return Err("Note does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let dest_dir = Path::new(dest_notebook);
|
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_notebook))?;
|
||||||
|
let dest_dir = validated_dest.as_path();
|
||||||
if !dest_dir.is_dir() {
|
if !dest_dir.is_dir() {
|
||||||
return Err("Destination notebook does not exist".to_string());
|
return Err("Destination notebook does not exist".to_string());
|
||||||
}
|
}
|
||||||
@@ -999,13 +1118,16 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String>
|
|||||||
Ok(dest.to_string_lossy().to_string())
|
Ok(dest.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> {
|
pub fn move_notebook(
|
||||||
let src = Path::new(notebook_path);
|
vault_path: &str,
|
||||||
if !src.exists() || !src.is_dir() {
|
notebook_path: &str,
|
||||||
return Err("Notebook does not exist".to_string());
|
dest_parent: &str,
|
||||||
}
|
) -> Result<String, String> {
|
||||||
|
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
|
||||||
|
let src = validated.as_path();
|
||||||
|
|
||||||
let dest_parent_path = Path::new(dest_parent);
|
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_parent))?;
|
||||||
|
let dest_parent_path = validated_dest.as_path();
|
||||||
if !dest_parent_path.is_dir() {
|
if !dest_parent_path.is_dir() {
|
||||||
return Err("Destination does not exist".to_string());
|
return Err("Destination does not exist".to_string());
|
||||||
}
|
}
|
||||||
@@ -1048,7 +1170,10 @@ fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
|
|||||||
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||||
if !trash_dir.exists() {
|
if !trash_dir.exists() {
|
||||||
return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
|
return Ok(TrashContents {
|
||||||
|
notes: Vec::new(),
|
||||||
|
notebooks: Vec::new(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let vault_root = Path::new(vault_path);
|
let vault_root = Path::new(vault_path);
|
||||||
@@ -1067,7 +1192,10 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
|||||||
.min_depth(1)
|
.min_depth(1)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md"))
|
.filter(|e| {
|
||||||
|
e.path().is_file()
|
||||||
|
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
||||||
|
})
|
||||||
.count();
|
.count();
|
||||||
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
|
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
// Strip timestamp prefix to get original notebook name
|
// Strip timestamp prefix to get original notebook name
|
||||||
@@ -1080,7 +1208,7 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
|||||||
};
|
};
|
||||||
let modified = fs::metadata(&path)
|
let modified = fs::metadata(&path)
|
||||||
.and_then(|m| m.modified())
|
.and_then(|m| m.modified())
|
||||||
.map(|t| DateTime::<Utc>::from(t))
|
.map(DateTime::<Utc>::from)
|
||||||
.unwrap_or_else(|_| Utc::now());
|
.unwrap_or_else(|_| Utc::now());
|
||||||
notebooks.push(TrashNotebookEntry {
|
notebooks.push(TrashNotebookEntry {
|
||||||
name,
|
name,
|
||||||
@@ -1091,8 +1219,8 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
|
||||||
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
|
notebooks.sort_by_key(|notebook| std::cmp::Reverse(notebook.modified));
|
||||||
Ok(TrashContents { notes, notebooks })
|
Ok(TrashContents { notes, notebooks })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,15 +1229,17 @@ pub fn restore_note(
|
|||||||
trash_path: &str,
|
trash_path: &str,
|
||||||
dest_notebook: Option<&str>,
|
dest_notebook: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let src = Path::new(trash_path);
|
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
|
||||||
if !src.exists() {
|
let src = validated.as_path();
|
||||||
|
if !src.is_file() {
|
||||||
return Err("Trashed note does not exist".to_string());
|
return Err("Trashed note does not exist".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let dest_dir = match dest_notebook {
|
let requested_dest = match dest_notebook {
|
||||||
Some(nb) => PathBuf::from(nb),
|
Some(nb) => PathBuf::from(nb),
|
||||||
None => PathBuf::from(vault_path),
|
None => PathBuf::from(vault_path),
|
||||||
};
|
};
|
||||||
|
let dest_dir = ensure_vault_content_dir(vault_path, &requested_dest)?;
|
||||||
|
|
||||||
// Strip timestamp prefix from trash filename (17-char with millis or 14-char legacy)
|
// Strip timestamp prefix from trash filename (17-char with millis or 14-char legacy)
|
||||||
let filename = src.file_name().unwrap_or_default().to_string_lossy();
|
let filename = src.file_name().unwrap_or_default().to_string_lossy();
|
||||||
@@ -1132,15 +1262,18 @@ pub fn restore_note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
|
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
|
||||||
let src = Path::new(trash_path);
|
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
|
||||||
if !src.exists() || !src.is_dir() {
|
let src = validated.as_path();
|
||||||
|
if !src.is_dir() {
|
||||||
return Err("Trashed notebook does not exist".to_string());
|
return Err("Trashed notebook does not exist".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
|
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
|
||||||
|
|
||||||
// Try to read original path from sidecar .meta file
|
// Try to read original path from sidecar .meta file
|
||||||
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname));
|
let meta_path = src
|
||||||
|
.with_extension("")
|
||||||
|
.with_file_name(format!("{}.meta", dirname));
|
||||||
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
|
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
|
||||||
original
|
original
|
||||||
} else {
|
} else {
|
||||||
@@ -1156,7 +1289,7 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
|
|||||||
name.to_string()
|
name.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let dest = Path::new(vault_path).join(&relative);
|
let dest = Path::new(vault_path).join(safe_relative_path(&relative)?);
|
||||||
|
|
||||||
// Recreate parent directories if needed
|
// Recreate parent directories if needed
|
||||||
if let Some(parent) = dest.parent() {
|
if let Some(parent) = dest.parent() {
|
||||||
@@ -1170,7 +1303,8 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
|
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
|
||||||
let p = Path::new(path);
|
let validated = ensure_trash_entry(vault_path, Path::new(path))?;
|
||||||
|
let p = validated.as_path();
|
||||||
let parent = p.parent().map(|pp| pp.to_path_buf());
|
let parent = p.parent().map(|pp| pp.to_path_buf());
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
|
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
|
||||||
@@ -1431,8 +1565,9 @@ pub fn sanitize_filename(name: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
compare_natural_names, duplicate_note, get_note_switcher_titles, helixnotes_dir,
|
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
|
||||||
load_notebook_icons, scan_notebooks, set_notebook_icon,
|
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
|
||||||
|
scan_notebooks, set_notebook_icon,
|
||||||
};
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -1441,10 +1576,7 @@ mod tests {
|
|||||||
fn compares_numeric_segments_anywhere_in_names() {
|
fn compares_numeric_segments_anywhere_in_names() {
|
||||||
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
|
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
|
||||||
names.sort_by(|left, right| compare_natural_names(left, right));
|
names.sort_by(|left, right| compare_natural_names(left, right));
|
||||||
assert_eq!(
|
assert_eq!(names, ["Class 2a", "Class 2b", "Class 10a", "Class 10b"]);
|
||||||
names,
|
|
||||||
["Class 2a", "Class 2b", "Class 10a", "Class 10b"]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compare_natural_names("Class 02", "Class 2b"),
|
compare_natural_names("Class 02", "Class 2b"),
|
||||||
std::cmp::Ordering::Less
|
std::cmp::Ordering::Less
|
||||||
@@ -1592,6 +1724,75 @@ mod tests {
|
|||||||
fs::remove_file(outside).unwrap();
|
fs::remove_file(outside).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_markdown_notes_from_trash_without_allowing_external_files() {
|
||||||
|
let test_root =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||||
|
let vault = test_root.join("vault");
|
||||||
|
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
|
||||||
|
let trashed_note = trash.join("20240101000000000_Note.md");
|
||||||
|
let outside = test_root.join("outside.md");
|
||||||
|
fs::create_dir_all(&trash).unwrap();
|
||||||
|
fs::write(&trashed_note, "---\ntitle: Note\n---\n\ntrashed").unwrap();
|
||||||
|
fs::write(&outside, "outside").unwrap();
|
||||||
|
|
||||||
|
assert!(read_note(&vault.to_string_lossy(), &trashed_note.to_string_lossy()).is_ok());
|
||||||
|
assert!(read_note(&vault.to_string_lossy(), &outside.to_string_lossy()).is_err());
|
||||||
|
|
||||||
|
fs::remove_dir_all(test_root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_permanent_deletion_outside_trash() {
|
||||||
|
let test_root =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||||
|
let vault = test_root.join("vault");
|
||||||
|
let outside = test_root.join("outside.md");
|
||||||
|
fs::create_dir_all(helixnotes_dir(&vault.to_string_lossy()).join("trash")).unwrap();
|
||||||
|
fs::write(&outside, "must survive").unwrap();
|
||||||
|
|
||||||
|
let result = permanent_delete(&vault.to_string_lossy(), &outside.to_string_lossy());
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(fs::read_to_string(&outside).unwrap(), "must survive");
|
||||||
|
fs::remove_dir_all(test_root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_notebook_creation_outside_vault() {
|
||||||
|
let test_root =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||||
|
let vault = test_root.join("vault");
|
||||||
|
fs::create_dir_all(&vault).unwrap();
|
||||||
|
|
||||||
|
let result = create_notebook(&vault.to_string_lossy(), Some(".."), "escaped");
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(!test_root.join("escaped").exists());
|
||||||
|
fs::remove_dir_all(test_root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_traversal_in_restored_notebook_metadata() {
|
||||||
|
let test_root =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||||
|
let vault = test_root.join("vault");
|
||||||
|
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
|
||||||
|
let trashed_notebook = trash.join("20240101000000000_Notebook");
|
||||||
|
fs::create_dir_all(&trashed_notebook).unwrap();
|
||||||
|
fs::write(trash.join("20240101000000000_Notebook.meta"), "../escaped").unwrap();
|
||||||
|
|
||||||
|
let result = restore_notebook(
|
||||||
|
&vault.to_string_lossy(),
|
||||||
|
&trashed_notebook.to_string_lossy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(trashed_notebook.exists());
|
||||||
|
assert!(!test_root.join("escaped").exists());
|
||||||
|
fs::remove_dir_all(test_root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
||||||
let vault =
|
let vault =
|
||||||
@@ -1623,4 +1824,4 @@ mod tests {
|
|||||||
|
|
||||||
fs::remove_dir_all(vault).unwrap();
|
fs::remove_dir_all(vault).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "HelixNotes",
|
"productName": "HelixNotes",
|
||||||
"version": "1.3.4",
|
"version": "1.3.5",
|
||||||
"identifier": "com.helixnotes.app",
|
"identifier": "com.helixnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../build",
|
"frontendDist": "../build",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
|
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": ["**/*", "/**", "**/.helixnotes/**"]
|
"scope": ["**/*", "/**", "**/.helixnotes/**"]
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ export async function getNoteSwitcherTitles(
|
|||||||
return invoke("get_note_switcher_titles", { recentPaths });
|
return invoke("get_note_switcher_titles", { recentPaths });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
|
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number; bidirectional: boolean }[] }> {
|
||||||
return invoke("get_graph_data");
|
return invoke("get_graph_data");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,8 @@
|
|||||||
let highlightDropdown = $state(false);
|
let highlightDropdown = $state(false);
|
||||||
let alignDropdown = $state(false);
|
let alignDropdown = $state(false);
|
||||||
let insertDropdown = $state(false);
|
let insertDropdown = $state(false);
|
||||||
|
let tablePickerOpen = $state(false);
|
||||||
|
let tablePickerHover = $state({ rows: 0, cols: 0 });
|
||||||
|
|
||||||
function scrollEditorBodyToBottom(source: HTMLElement | null | undefined = editorElement) {
|
function scrollEditorBodyToBottom(source: HTMLElement | null | undefined = editorElement) {
|
||||||
const editorBody = source?.closest('.editor-body') as HTMLElement | null;
|
const editorBody = source?.closest('.editor-body') as HTMLElement | null;
|
||||||
@@ -306,6 +308,22 @@
|
|||||||
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
|
||||||
|
|
||||||
|
function isEditorActive(
|
||||||
|
nameOrAttributes: string | Record<string, unknown>,
|
||||||
|
attributes?: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
void editorState;
|
||||||
|
if (!editor) return false;
|
||||||
|
return typeof nameOrAttributes === 'string'
|
||||||
|
? editor.isActive(nameOrAttributes, attributes)
|
||||||
|
: editor.isActive(nameOrAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditorAttributes(name: string): Record<string, unknown> {
|
||||||
|
void editorState;
|
||||||
|
return editor?.getAttributes(name) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
// AI
|
// AI
|
||||||
let aiMenu = $state<{ x: number; y: number } | null>(null);
|
let aiMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let aiLoading = $state(false);
|
let aiLoading = $state(false);
|
||||||
@@ -470,8 +488,6 @@
|
|||||||
});
|
});
|
||||||
let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null);
|
let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null);
|
||||||
let tableContextMenu = $state<{ x: number; y: number; hasStyling: boolean } | null>(null);
|
let tableContextMenu = $state<{ x: number; y: number; hasStyling: boolean } | null>(null);
|
||||||
let tablePickerOpen = $state(false);
|
|
||||||
let tablePickerHover = $state({ rows: 0, cols: 0 });
|
|
||||||
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string; alt: string } | null>(null);
|
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string; alt: string } | null>(null);
|
||||||
let imageViewer = $state<{ src: string; alt: string } | null>(null);
|
let imageViewer = $state<{ src: string; alt: string } | null>(null);
|
||||||
let copyToast = $state<'copying' | 'done' | null>(null);
|
let copyToast = $state<'copying' | 'done' | null>(null);
|
||||||
@@ -6567,16 +6583,16 @@
|
|||||||
|
|
||||||
<!-- Heading dropdown -->
|
<!-- Heading dropdown -->
|
||||||
<div class="fmt-dropdown-wrap">
|
<div class="fmt-dropdown-wrap">
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('heading'))} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; insertDropdown = false; }} title="Heading">
|
<button class="fmt-btn" class:active={isEditorActive('heading')} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; insertDropdown = false; }} title="Heading">
|
||||||
<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="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></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="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if headingDropdown}
|
{#if headingDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||||
<button class:active={(editorState, editor.isActive('heading', { level: 1 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||||
<button class:active={(editorState, editor.isActive('heading', { level: 2 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
||||||
<button class:active={(editorState, editor.isActive('heading', { level: 3 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
||||||
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
<button class:active={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -6584,36 +6600,36 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Bold / Italic / Underline / Strike -->
|
<!-- Bold / Italic / Underline / Strike -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bold'))} onclick={() => editor?.chain().focus().toggleBold().run()} title="Bold">
|
<button class="fmt-btn" class:active={isEditorActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()} title="Bold">
|
||||||
<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="M6 12h9a4 4 0 010 8H7a1 1 0 01-1-1V5a1 1 0 011-1h7a4 4 0 010 8"/></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="M6 12h9a4 4 0 010 8H7a1 1 0 01-1-1V5a1 1 0 011-1h7a4 4 0 010 8"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('italic'))} onclick={() => editor?.chain().focus().toggleItalic().run()} title="Italic">
|
<button class="fmt-btn" class:active={isEditorActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()} title="Italic">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline">
|
<button class="fmt-btn" class:active={isEditorActive('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline">
|
||||||
<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="M6 4v6a6 6 0 0012 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></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="M6 4v6a6 6 0 0012 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough">
|
<button class="fmt-btn" class:active={isEditorActive('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough">
|
||||||
<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="M16 4H9a3 3 0 00-2.83 4"/><path d="M14 12a4 4 0 010 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></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="M16 4H9a3 3 0 00-2.83 4"/><path d="M14 12a4 4 0 010 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Link -->
|
<!-- Link -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title="Link">
|
<button class="fmt-btn" class:active={isEditorActive('link')} onclick={addLinkFromToolbar} title="Link">
|
||||||
<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 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></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 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Lists -->
|
<!-- Lists -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title="Bullet List">
|
<button class="fmt-btn" class:active={isEditorActive('bulletList')} onclick={toggleBulletList} title="Bullet List">
|
||||||
<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="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></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="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
|
<button class="fmt-btn" class:active={isEditorActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
|
||||||
<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="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></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="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title="Task List">
|
<button class="fmt-btn" class:active={isEditorActive('taskList')} onclick={toggleTaskList} title="Task List">
|
||||||
<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="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></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="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6667,7 +6683,7 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Highlight -->
|
<!-- Highlight -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={() => editor?.chain().focus().toggleHighlight({ color: highlightColors[0].value }).run()} title="Highlight">
|
<button class="fmt-btn" class:active={isEditorActive('highlight')} onclick={() => editor?.chain().focus().toggleHighlight({ color: highlightColors[0].value }).run()} title="Highlight">
|
||||||
<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="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 01-2.8 0l-5.2-5.2a2 2 0 010-2.8L14 4"/></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="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 01-2.8 0l-5.2-5.2a2 2 0 010-2.8L14 4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6736,17 +6752,17 @@
|
|||||||
|
|
||||||
<!-- Heading dropdown -->
|
<!-- Heading dropdown -->
|
||||||
<div class="fmt-dropdown-wrap">
|
<div class="fmt-dropdown-wrap">
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('heading'))} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Heading">
|
<button class="fmt-btn" class:active={isEditorActive('heading')} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Heading">
|
||||||
<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="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></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="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if headingDropdown}
|
{#if headingDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||||
<button class:active={(editorState, editor.isActive('heading', { level: 1 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||||
<button class:active={(editorState, editor.isActive('heading', { level: 2 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
||||||
<button class:active={(editorState, editor.isActive('heading', { level: 3 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
||||||
<button class:active={(editorState, editor.isActive('heading', { level: 4 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</button>
|
<button class:active={isEditorActive('heading', { level: 4 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</button>
|
||||||
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
<button class:active={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -6754,16 +6770,16 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Text formatting -->
|
<!-- Text formatting -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bold'))} onclick={() => editor?.chain().focus().toggleBold().run()} title={`Bold (${modKey}+B)`}>
|
<button class="fmt-btn" class:active={isEditorActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()} title={`Bold (${modKey}+B)`}>
|
||||||
<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="M6 12h9a4 4 0 010 8H7a1 1 0 01-1-1V5a1 1 0 011-1h7a4 4 0 010 8"/></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="M6 12h9a4 4 0 010 8H7a1 1 0 01-1-1V5a1 1 0 011-1h7a4 4 0 010 8"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('italic'))} onclick={() => editor?.chain().focus().toggleItalic().run()} title={`Italic (${modKey}+I)`}>
|
<button class="fmt-btn" class:active={isEditorActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()} title={`Italic (${modKey}+I)`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title={`Underline (${modKey}+U)`}>
|
<button class="fmt-btn" class:active={isEditorActive('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} title={`Underline (${modKey}+U)`}>
|
||||||
<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="M6 4v6a6 6 0 0012 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></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="M6 4v6a6 6 0 0012 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title={`Strikethrough (${modKey}+Shift+X)`}>
|
<button class="fmt-btn" class:active={isEditorActive('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} title={`Strikethrough (${modKey}+Shift+X)`}>
|
||||||
<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="M16 4H9a3 3 0 00-2.83 4"/><path d="M14 12a4 4 0 010 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></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="M16 4H9a3 3 0 00-2.83 4"/><path d="M14 12a4 4 0 010 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6771,14 +6787,14 @@
|
|||||||
<div class="fmt-dropdown-wrap">
|
<div class="fmt-dropdown-wrap">
|
||||||
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); colorDropdown = !colorDropdown; headingDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Text Color">
|
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); colorDropdown = !colorDropdown; headingDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Text Color">
|
||||||
<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="M4 20h16"/><path d="m6 16 6-12 6 12"/><path d="M8 12h8"/></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="M4 20h16"/><path d="m6 16 6-12 6 12"/><path d="M8 12h8"/></svg>
|
||||||
<span class="color-indicator" style="background: {editor.getAttributes('textStyle').color || 'var(--accent)'}"></span>
|
<span class="color-indicator" style="background: {getEditorAttributes('textStyle').color || 'var(--accent)'}"></span>
|
||||||
</button>
|
</button>
|
||||||
{#if colorDropdown}
|
{#if colorDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||||
{#each textColors as color}
|
{#each textColors as color}
|
||||||
<button class="color-swatch" title={color.name} onclick={() => setTextColor(color.value)} style="background: {color.value || 'var(--text-primary)'}">
|
<button class="color-swatch" title={color.name} onclick={() => setTextColor(color.value)} style="background: {color.value || 'var(--text-primary)'}">
|
||||||
{#if (color.value === '' && !editor.getAttributes('textStyle').color) || editor.getAttributes('textStyle').color === color.value}
|
{#if (color.value === '' && !getEditorAttributes('textStyle').color) || getEditorAttributes('textStyle').color === color.value}
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -6790,20 +6806,20 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Link -->
|
<!-- Link -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title={`Link (${modKey}+K)`}>
|
<button class="fmt-btn" class:active={isEditorActive('link')} onclick={addLinkFromToolbar} title={`Link (${modKey}+K)`}>
|
||||||
<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 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></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 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Lists -->
|
<!-- Lists -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title={`Bullet List (${modKey}+Shift+8)`}>
|
<button class="fmt-btn" class:active={isEditorActive('bulletList')} onclick={toggleBulletList} title={`Bullet List (${modKey}+Shift+8)`}>
|
||||||
<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="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></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="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
|
<button class="fmt-btn" class:active={isEditorActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
|
||||||
<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="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></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="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title={`Task List (${modKey}+Shift+9)`}>
|
<button class="fmt-btn" class:active={isEditorActive('taskList')} onclick={toggleTaskList} title={`Task List (${modKey}+Shift+9)`}>
|
||||||
<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="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></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="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6820,25 +6836,25 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Code & Code Block -->
|
<!-- Code & Code Block -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('code'))} onclick={() => editor?.chain().focus().toggleCode().run()} title={`Inline Code (${modKey}+E)`}>
|
<button class="fmt-btn" class:active={isEditorActive('code')} onclick={() => editor?.chain().focus().toggleCode().run()} title={`Inline Code (${modKey}+E)`}>
|
||||||
<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="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></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="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('codeBlock'))} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title={`Code Block (${modKey}+Alt+C)`}>
|
<button class="fmt-btn" class:active={isEditorActive('codeBlock')} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title={`Code Block (${modKey}+Alt+C)`}>
|
||||||
<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 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></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 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Blockquote -->
|
<!-- Blockquote -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('blockquote'))} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title={`Quote (${modKey}+Shift+B)`}>
|
<button class="fmt-btn" class:active={isEditorActive('blockquote')} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title={`Quote (${modKey}+Shift+B)`}>
|
||||||
<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="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></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="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Collapsible Section -->
|
<!-- Collapsible Section -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title={`Collapsible Section (${modKey}+.)`}>
|
<button class="fmt-btn" class:active={isEditorActive('details')} onclick={() => insertDetails()} title={`Collapsible Section (${modKey}+.)`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Callout -->
|
<!-- Callout -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('callout'))} onclick={() => insertCallout('note')} title="Callout">
|
<button class="fmt-btn" class:active={isEditorActive('callout')} onclick={() => insertCallout('note')} title="Callout">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="7" y1="5" x2="7" y2="19"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="7" y1="5" x2="7" y2="19"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6879,22 +6895,22 @@
|
|||||||
|
|
||||||
<!-- Highlight -->
|
<!-- Highlight -->
|
||||||
<div class="fmt-dropdown-wrap">
|
<div class="fmt-dropdown-wrap">
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title={`Highlight (${modKey}+Shift+H)`}>
|
<button class="fmt-btn" class:active={isEditorActive('highlight')} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title={`Highlight (${modKey}+Shift+H)`}>
|
||||||
<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="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 01-2.8 0l-5.2-5.2a2 2 0 010-2.8L14 4"/></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="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 01-2.8 0l-5.2-5.2a2 2 0 010-2.8L14 4"/></svg>
|
||||||
<span class="color-indicator" style="background: {editor.getAttributes('highlight').color || 'var(--accent)'}"></span>
|
<span class="color-indicator" style="background: {getEditorAttributes('highlight').color || 'var(--accent)'}"></span>
|
||||||
</button>
|
</button>
|
||||||
{#if highlightDropdown}
|
{#if highlightDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||||
{#each highlightColors as color}
|
{#each highlightColors as color}
|
||||||
<button class="color-swatch" title={color.name} onclick={() => setHighlightColor(color.value)} style="background: {color.swatch}">
|
<button class="color-swatch" title={color.name} onclick={() => setHighlightColor(color.value)} style="background: {color.swatch}">
|
||||||
{#if editor.isActive('highlight', { color: color.value })}
|
{#if isEditorActive('highlight', { color: color.value })}
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="color-swatch" title="Remove highlight" onclick={() => setHighlightColor('')} style="background: var(--bg-tertiary);">
|
<button class="color-swatch" title="Remove highlight" onclick={() => setHighlightColor('')} style="background: var(--bg-tertiary);">
|
||||||
{#if !editor.isActive('highlight')}
|
{#if !isEditorActive('highlight')}
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-primary)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-primary)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
@@ -6905,10 +6921,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subscript & Superscript -->
|
<!-- Subscript & Superscript -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('subscript'))} onclick={() => editor?.chain().focus().toggleSubscript().run()} title="Subscript">
|
<button class="fmt-btn" class:active={isEditorActive('subscript')} onclick={() => editor?.chain().focus().toggleSubscript().run()} title="Subscript">
|
||||||
<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="m4 5 8 8"/><path d="m12 5-8 8"/><path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 00-2.62-.44c-.42.24-.74.62-.9 1.07"/></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="m4 5 8 8"/><path d="m12 5-8 8"/><path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 00-2.62-.44c-.42.24-.74.62-.9 1.07"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('superscript'))} onclick={() => editor?.chain().focus().toggleSuperscript().run()} title="Superscript">
|
<button class="fmt-btn" class:active={isEditorActive('superscript')} onclick={() => editor?.chain().focus().toggleSuperscript().run()} title="Superscript">
|
||||||
<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="m4 19 8-8"/><path d="m12 19-8-8"/><path d="M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 00-2.617-.436c-.42.239-.738.614-.899 1.06"/></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="m4 19 8-8"/><path d="m12 19-8-8"/><path d="M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 00-2.617-.436c-.42.239-.738.614-.899 1.06"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6917,11 +6933,11 @@
|
|||||||
<!-- Text Alignment -->
|
<!-- Text Alignment -->
|
||||||
<div class="fmt-dropdown-wrap">
|
<div class="fmt-dropdown-wrap">
|
||||||
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); alignDropdown = !alignDropdown; headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; insertDropdown = false; }} title="Text Alignment">
|
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); alignDropdown = !alignDropdown; headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; insertDropdown = false; }} title="Text Alignment">
|
||||||
{#if (editorState, editor.isActive({ textAlign: 'center' }))}
|
{#if isEditorActive({ textAlign: 'center' })}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M17 12H7"/><path d="M19 19H5"/></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="M21 5H3"/><path d="M17 12H7"/><path d="M19 19H5"/></svg>
|
||||||
{:else if (editorState, editor.isActive({ textAlign: 'right' }))}
|
{:else if isEditorActive({ textAlign: 'right' })}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M21 12H9"/><path d="M21 19H7"/></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="M21 5H3"/><path d="M21 12H9"/><path d="M21 19H7"/></svg>
|
||||||
{:else if (editorState, editor.isActive({ textAlign: 'justify' }))}
|
{:else if isEditorActive({ textAlign: 'justify' })}
|
||||||
<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="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></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="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></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"><path d="M21 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></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="M21 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></svg>
|
||||||
@@ -6930,19 +6946,19 @@
|
|||||||
{#if alignDropdown}
|
{#if alignDropdown}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="fmt-dropdown align-dropdown" onclick={(e) => e.stopPropagation()}>
|
<div class="fmt-dropdown align-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||||
<button class:active={(editorState, editor.isActive({ textAlign: 'left' }))} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
|
<button class:active={isEditorActive({ textAlign: 'left' })} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
|
||||||
<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 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></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 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></svg>
|
||||||
Left
|
Left
|
||||||
</button>
|
</button>
|
||||||
<button class:active={(editorState, editor.isActive({ textAlign: 'center' }))} onclick={() => { editor?.chain().focus().setTextAlign('center').run(); alignDropdown = false; }}>
|
<button class:active={isEditorActive({ textAlign: 'center' })} onclick={() => { editor?.chain().focus().setTextAlign('center').run(); alignDropdown = false; }}>
|
||||||
<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 5H3"/><path d="M17 12H7"/><path d="M19 19H5"/></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 5H3"/><path d="M17 12H7"/><path d="M19 19H5"/></svg>
|
||||||
Center
|
Center
|
||||||
</button>
|
</button>
|
||||||
<button class:active={(editorState, editor.isActive({ textAlign: 'right' }))} onclick={() => { editor?.chain().focus().setTextAlign('right').run(); alignDropdown = false; }}>
|
<button class:active={isEditorActive({ textAlign: 'right' })} onclick={() => { editor?.chain().focus().setTextAlign('right').run(); alignDropdown = false; }}>
|
||||||
<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 5H3"/><path d="M21 12H9"/><path d="M21 19H7"/></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 5H3"/><path d="M21 12H9"/><path d="M21 19H7"/></svg>
|
||||||
Right
|
Right
|
||||||
</button>
|
</button>
|
||||||
<button class:active={(editorState, editor.isActive({ textAlign: 'justify' }))} onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); alignDropdown = false; }}>
|
<button class:active={isEditorActive({ textAlign: 'justify' })} onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); alignDropdown = false; }}>
|
||||||
<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="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></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="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg>
|
||||||
Justify
|
Justify
|
||||||
</button>
|
</button>
|
||||||
@@ -7103,12 +7119,12 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if ctxHeadingSubmenu}
|
{#if ctxHeadingSubmenu}
|
||||||
<div class="text-ctx-submenu" class:flip-left={textContextMenu?.submenuLeft}>
|
<div class="text-ctx-submenu" class:flip-left={textContextMenu?.submenuLeft}>
|
||||||
<button class:active={(editorState, editor?.isActive('heading', { level: 1 }))} onclick={() => ctxSetHeading(1)}>Heading 1</button>
|
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => ctxSetHeading(1)}>Heading 1</button>
|
||||||
<button class:active={(editorState, editor?.isActive('heading', { level: 2 }))} onclick={() => ctxSetHeading(2)}>Heading 2</button>
|
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => ctxSetHeading(2)}>Heading 2</button>
|
||||||
<button class:active={(editorState, editor?.isActive('heading', { level: 3 }))} onclick={() => ctxSetHeading(3)}>Heading 3</button>
|
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => ctxSetHeading(3)}>Heading 3</button>
|
||||||
<button class:active={(editorState, editor?.isActive('heading', { level: 4 }))} onclick={() => ctxSetHeading(4)}>Heading 4</button>
|
<button class:active={isEditorActive('heading', { level: 4 })} onclick={() => ctxSetHeading(4)}>Heading 4</button>
|
||||||
<div class="text-ctx-sep"></div>
|
<div class="text-ctx-sep"></div>
|
||||||
<button class:active={(editorState, editor?.isActive('paragraph'))} onclick={ctxSetParagraph}>Paragraph</button>
|
<button class:active={isEditorActive('paragraph')} onclick={ctxSetParagraph}>Paragraph</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { Schema } from '@tiptap/pm/model';
|
||||||
|
import { EditorState, TextSelection } from '@tiptap/pm/state';
|
||||||
|
import { applyClearFormatting } from '../src/lib/editor/clearFormatting.ts';
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'block+' },
|
||||||
|
paragraph: { content: 'text*', group: 'block' },
|
||||||
|
heading: { attrs: { level: { default: 1 } }, content: 'text*', group: 'block' },
|
||||||
|
codeBlock: { content: 'text*', group: 'block', code: true },
|
||||||
|
blockquote: { content: 'block+', group: 'block' },
|
||||||
|
bulletList: { content: 'listItem+', group: 'block' },
|
||||||
|
listItem: { content: 'paragraph block*' },
|
||||||
|
taskList: { content: 'taskItem+', group: 'block' },
|
||||||
|
taskItem: { attrs: { checked: { default: false } }, content: 'paragraph block*' },
|
||||||
|
text: { group: 'inline' },
|
||||||
|
},
|
||||||
|
marks: {
|
||||||
|
bold: {},
|
||||||
|
italic: {},
|
||||||
|
strike: {},
|
||||||
|
underline: {},
|
||||||
|
highlight: { attrs: { color: { default: null } } },
|
||||||
|
code: {},
|
||||||
|
link: { attrs: { href: {} } },
|
||||||
|
wikiLink: { attrs: { path: { default: null } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mark = (name, attrs = null) => schema.mark(name, attrs);
|
||||||
|
const text = (value, marks = []) => schema.text(value, marks);
|
||||||
|
const para = (...children) => schema.node('paragraph', null, children);
|
||||||
|
|
||||||
|
function clearedDoc(doc, from, to = from) {
|
||||||
|
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, from, to) });
|
||||||
|
const tr = state.tr;
|
||||||
|
applyClearFormatting(schema, tr);
|
||||||
|
return state.apply(tr).doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSummary(doc) {
|
||||||
|
const summary = [];
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.isText) summary.push(`${node.text}→${node.marks.map((m) => m.type.name).join('+') || 'none'}`);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('strips formatting marks from a selection but keeps link and wikiLink', () => {
|
||||||
|
// "bold"[1,5) "link"[5,9) "wiki"[9,13)
|
||||||
|
const doc = schema.node('doc', null, [
|
||||||
|
para(
|
||||||
|
text('bold', [mark('bold')]),
|
||||||
|
text('link', [mark('link', { href: 'https://example.com' })]),
|
||||||
|
text('wiki', [mark('wikiLink', { path: 'notes/wiki.md' })]),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = clearedDoc(doc, 1, 13);
|
||||||
|
const nodes = [];
|
||||||
|
result.firstChild.forEach((node) => nodes.push(node));
|
||||||
|
|
||||||
|
assert.deepEqual(nodes[0].marks, []);
|
||||||
|
assert.deepEqual(nodes[1].marks.map((m) => m.type.name), ['link']);
|
||||||
|
assert.equal(nodes[1].marks[0].attrs.href, 'https://example.com');
|
||||||
|
assert.deepEqual(nodes[2].marks.map((m) => m.type.name), ['wikiLink']);
|
||||||
|
assert.equal(nodes[2].marks[0].attrs.path, 'notes/wiki.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty selection clears the entire current line', () => {
|
||||||
|
// "one "[1,5) "two"[5,8) " three"[8,14), cursor inside "one" at 2
|
||||||
|
const doc = schema.node('doc', null, [
|
||||||
|
para(text('one '), text('two', [mark('bold')]), text(' three', [mark('italic')])),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = clearedDoc(doc, 2);
|
||||||
|
assert.deepEqual(markSummary(result), ['one two three→none']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cursor in a heading demotes it to a plain paragraph', () => {
|
||||||
|
const doc = schema.node('doc', null, [
|
||||||
|
schema.node('heading', { level: 2 }, [text('Title', [mark('bold')])]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = clearedDoc(doc, 3);
|
||||||
|
assert.equal(result.firstChild.type.name, 'paragraph');
|
||||||
|
assert.equal(result.firstChild.textContent, 'Title');
|
||||||
|
assert.deepEqual(markSummary(result), ['Title→none']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cursor in a code block converts it to a paragraph', () => {
|
||||||
|
const doc = schema.node('doc', null, [schema.node('codeBlock', null, [text('const x = 1;')])]);
|
||||||
|
|
||||||
|
const result = clearedDoc(doc, 4);
|
||||||
|
assert.equal(result.firstChild.type.name, 'paragraph');
|
||||||
|
assert.equal(result.firstChild.textContent, 'const x = 1;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cursor in a blockquote lifts the content out of the wrapper', () => {
|
||||||
|
const doc = schema.node('doc', null, [
|
||||||
|
schema.node('blockquote', null, [para(text('quoted', [mark('italic')]))]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = clearedDoc(doc, 3);
|
||||||
|
assert.equal(result.childCount, 1);
|
||||||
|
assert.equal(result.firstChild.type.name, 'paragraph');
|
||||||
|
assert.deepEqual(markSummary(result), ['quoted→none']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested blockquotes are fully unwrapped', () => {
|
||||||
|
const doc = schema.node('doc', null, [
|
||||||
|
schema.node('blockquote', null, [
|
||||||
|
schema.node('blockquote', null, [para(text('deep', [mark('bold')]))]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = clearedDoc(doc, 3);
|
||||||
|
assert.equal(result.childCount, 1);
|
||||||
|
assert.equal(result.firstChild.type.name, 'paragraph');
|
||||||
|
assert.deepEqual(markSummary(result), ['deep→none']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('task list structure and checked state survive clearing', () => {
|
||||||
|
const doc = schema.node('doc', null, [
|
||||||
|
schema.node('taskList', null, [
|
||||||
|
schema.node('taskItem', { checked: true }, [para(text('done task', [mark('strike')]))]),
|
||||||
|
schema.node('taskItem', { checked: false }, [para(text('open task'))]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
// cursor inside "done task": taskList at 0, taskItem at 1, paragraph at 2, text from 3
|
||||||
|
const result = clearedDoc(doc, 5);
|
||||||
|
|
||||||
|
const list = result.firstChild;
|
||||||
|
assert.equal(list.type.name, 'taskList');
|
||||||
|
assert.equal(list.childCount, 2);
|
||||||
|
assert.equal(list.firstChild.type.name, 'taskItem');
|
||||||
|
assert.equal(list.firstChild.attrs.checked, true);
|
||||||
|
assert.equal(list.lastChild.attrs.checked, false);
|
||||||
|
assert.deepEqual(markSummary(result), ['done task→none', 'open task→none']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial selection keeps formatting outside the selection', () => {
|
||||||
|
// "abcd" all bold; clear only [2,4) = "bc"
|
||||||
|
const doc = schema.node('doc', null, [para(text('abcd', [mark('bold')]))]);
|
||||||
|
|
||||||
|
const result = clearedDoc(doc, 2, 4);
|
||||||
|
assert.deepEqual(markSummary(result), ['a→bold', 'bc→none', 'd→bold']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-block selection demotes headings and strips marks in range', () => {
|
||||||
|
// heading "H1" [0,4), paragraph at 4 with "body" bold [5,9)
|
||||||
|
const doc = schema.node('doc', null, [
|
||||||
|
schema.node('heading', { level: 1 }, [text('H1')]),
|
||||||
|
para(text('body', [mark('bold')])),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = clearedDoc(doc, 2, 7);
|
||||||
|
assert.equal(result.firstChild.type.name, 'paragraph');
|
||||||
|
// marks removed only up to position 7: "bo" cleared, "dy" still bold
|
||||||
|
assert.deepEqual(markSummary(result), ['H1→none', 'bo→none', 'dy→bold']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { Schema } from '@tiptap/pm/model';
|
||||||
|
import { EditorState, Plugin, TextSelection } from '@tiptap/pm/state';
|
||||||
|
import { createCodeBlockInputScrollPlugin } from '../src/lib/editor/extensions/codeBlockInputScroll.ts';
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'block+' },
|
||||||
|
paragraph: { content: 'text*', group: 'block' },
|
||||||
|
codeBlock: {
|
||||||
|
attrs: { language: { default: null } },
|
||||||
|
content: 'text*',
|
||||||
|
group: 'block',
|
||||||
|
code: true,
|
||||||
|
},
|
||||||
|
text: { group: 'inline' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputRulesPlugin = new Plugin({ isInputRules: true });
|
||||||
|
|
||||||
|
function textBlock(type, text, attrs = null) {
|
||||||
|
return schema.node(type, attrs, text ? [schema.text(text)] : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWithSelection(type, text, options = {}) {
|
||||||
|
const { attrs = null, from = text.length + 1, to = from } = options;
|
||||||
|
const doc = schema.node('doc', null, [textBlock(type, text, attrs)]);
|
||||||
|
|
||||||
|
return EditorState.create({
|
||||||
|
doc,
|
||||||
|
selection: TextSelection.create(doc, from, to),
|
||||||
|
plugins: [inputRulesPlugin, createCodeBlockInputScrollPlugin()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToCodeBlock(state, language = null, { inputRule = true } = {}) {
|
||||||
|
const codeBlock = textBlock('codeBlock', '', { language });
|
||||||
|
const tr = state.tr.replaceWith(0, state.doc.content.size, codeBlock);
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, 1));
|
||||||
|
if (inputRule) {
|
||||||
|
tr.setMeta(inputRulesPlugin, {
|
||||||
|
transform: tr,
|
||||||
|
from: state.selection.from,
|
||||||
|
to: state.selection.to,
|
||||||
|
text: '\n',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.applyTransaction(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAppendedScroll(result) {
|
||||||
|
assert.equal(result.transactions.length, 2);
|
||||||
|
assert.equal(result.transactions[0].scrolledIntoView, false);
|
||||||
|
assert.equal(result.transactions[1].scrolledIntoView, true);
|
||||||
|
assert.equal(result.state.selection.$from.parent.type.name, 'codeBlock');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoAppendedScroll(result) {
|
||||||
|
assert.equal(result.transactions.length, 1);
|
||||||
|
assert.equal(result.transactions[0].scrolledIntoView, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('appends a scroll request for backtick and tilde input-rule transitions', () => {
|
||||||
|
assertAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', '```')));
|
||||||
|
assertAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', '~~~rust'), 'rust'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not append a scroll request for non-fence paragraph conversions', () => {
|
||||||
|
assertNoAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', 'ordinary text')));
|
||||||
|
assertNoAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', '```TypeScript')));
|
||||||
|
assertNoAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', '````')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not append a scroll request for toolbar conversions or note loads', () => {
|
||||||
|
assertNoAppendedScroll(
|
||||||
|
convertToCodeBlock(stateWithSelection('paragraph', '```'), null, { inputRule: false })
|
||||||
|
);
|
||||||
|
assertNoAppendedScroll(
|
||||||
|
convertToCodeBlock(stateWithSelection('paragraph', '~~~rust'), 'rust', { inputRule: false })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not append a scroll request for a non-empty fence selection', () => {
|
||||||
|
const state = stateWithSelection('paragraph', '```', { from: 1, to: 4 });
|
||||||
|
assertNoAppendedScroll(convertToCodeBlock(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not append a scroll request for a transaction already in a code block', () => {
|
||||||
|
const state = stateWithSelection('codeBlock', '```');
|
||||||
|
const tr = state.tr.insertText('x');
|
||||||
|
assertNoAppendedScroll(state.applyTransaction(tr));
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const editor = await readFile(
|
||||||
|
new URL('../src/lib/components/Editor.svelte', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
test('Note Info owns the only open-note trash action', () => {
|
||||||
|
assert.doesNotMatch(editor, /editor-trash-btn/);
|
||||||
|
assert.match(editor, /class="info-trash-btn"[\s\S]{0,300}onclick=\{moveOpenNoteToTrash\}/);
|
||||||
|
assert.match(editor, /class="info-trash-btn"[\s\S]{0,500}Move to Trash/);
|
||||||
|
assert.equal(editor.match(/onclick=\{moveOpenNoteToTrash\}/g)?.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Note Info hides the trash action when moving the note is unavailable', () => {
|
||||||
|
assert.match(
|
||||||
|
editor,
|
||||||
|
/\{#if onMoveToTrash && \$viewMode !== 'trash'\}[\s\S]{0,200}class="info-section info-actions"/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Note Info stays open when clicking another note', () => {
|
||||||
|
assert.doesNotMatch(editor, /onInfoClickAway/);
|
||||||
|
assert.match(editor, /class="info-close-btn" onclick=\{\(\) => showInfo = false\}/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { Schema } from '@tiptap/pm/model';
|
||||||
|
import { transformWithEsbuild } from 'vite';
|
||||||
|
|
||||||
|
const source = await readFile(
|
||||||
|
new URL('../src/lib/editor/mixedLists.ts', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const { code } = await transformWithEsbuild(source, 'mixedLists.ts', {
|
||||||
|
loader: 'ts',
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext'
|
||||||
|
});
|
||||||
|
const { convertListNode } = await import(
|
||||||
|
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'block+' },
|
||||||
|
paragraph: { content: 'text*', group: 'block' },
|
||||||
|
text: { group: 'inline' },
|
||||||
|
bulletList: { content: 'listItem+', group: 'block' },
|
||||||
|
listItem: { content: 'paragraph block*' },
|
||||||
|
taskList: { content: 'taskItem+', group: 'block' },
|
||||||
|
taskItem: {
|
||||||
|
attrs: { checked: { default: false } },
|
||||||
|
content: 'paragraph block*'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function paragraph(text) {
|
||||||
|
return schema.node('paragraph', null, [schema.text(text)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('converts every task item in a list to a bullet item', () => {
|
||||||
|
const sourceList = schema.node('taskList', null, [
|
||||||
|
schema.node('taskItem', { checked: true }, [paragraph('First')]),
|
||||||
|
schema.node('taskItem', { checked: false }, [paragraph('Second')])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const converted = convertListNode(schema, sourceList, 'bulletList');
|
||||||
|
|
||||||
|
assert.equal(converted.type.name, 'bulletList');
|
||||||
|
assert.deepEqual(
|
||||||
|
converted.content.content.map((item) => item.type.name),
|
||||||
|
['listItem', 'listItem']
|
||||||
|
);
|
||||||
|
assert.equal(converted.textContent, 'FirstSecond');
|
||||||
|
assert.equal(sourceList.type.name, 'taskList');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts bullet items to unchecked tasks without dropping nested blocks', () => {
|
||||||
|
const nestedBullets = schema.node('bulletList', null, [
|
||||||
|
schema.node('listItem', null, [paragraph('Nested note')])
|
||||||
|
]);
|
||||||
|
const sourceList = schema.node('bulletList', null, [
|
||||||
|
schema.node('listItem', null, [paragraph('Parent'), nestedBullets])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const converted = convertListNode(schema, sourceList, 'taskList');
|
||||||
|
const taskItem = converted.firstChild;
|
||||||
|
|
||||||
|
assert.equal(converted.type.name, 'taskList');
|
||||||
|
assert.equal(taskItem.type.name, 'taskItem');
|
||||||
|
assert.equal(taskItem.attrs.checked, false);
|
||||||
|
assert.equal(taskItem.lastChild.type.name, 'bulletList');
|
||||||
|
assert.equal(taskItem.textContent, 'ParentNested note');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves a list alone when it already has the requested type', () => {
|
||||||
|
const bullets = schema.node('bulletList', null, [
|
||||||
|
schema.node('listItem', null, [paragraph('Unchanged')])
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(convertListNode(schema, bullets, 'bulletList'), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { transformWithEsbuild } from 'vite';
|
||||||
|
|
||||||
|
const source = await readFile(
|
||||||
|
new URL('../src/lib/utils/note-drag.ts', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const { code } = await transformWithEsbuild(source, 'note-drag.ts', {
|
||||||
|
loader: 'ts',
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext'
|
||||||
|
});
|
||||||
|
const noteDrag = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
|
||||||
|
|
||||||
|
test('round-trips every selected note path in a drag payload', () => {
|
||||||
|
const paths = ['/vault/Alpha.md', '/vault/Projects/Beta.md'];
|
||||||
|
assert.deepEqual(noteDrag.decodeNoteDragPaths(noteDrag.encodeNoteDragPaths(paths)), paths);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps single-note and Windows path payloads compatible', () => {
|
||||||
|
assert.deepEqual(noteDrag.decodeNoteDragPaths('/vault/Alpha.md'), ['/vault/Alpha.md']);
|
||||||
|
assert.deepEqual(
|
||||||
|
noteDrag.decodeNoteDragPaths('C:\\Vault\\Alpha.md\r\nC:\\Vault\\Beta.md'),
|
||||||
|
['C:\\Vault\\Alpha.md', 'C:\\Vault\\Beta.md']
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { transformWithEsbuild } from 'vite';
|
||||||
|
|
||||||
|
const source = await readFile(
|
||||||
|
new URL('../src/lib/utils/paths.ts', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const { code } = await transformWithEsbuild(source, 'paths.ts', {
|
||||||
|
loader: 'ts',
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext'
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
assetSourceToMarkdown,
|
||||||
|
assetUrlToLocalPath,
|
||||||
|
normalizeLocalAssetPath,
|
||||||
|
resolvePathFromFile,
|
||||||
|
resolveVaultFilePath
|
||||||
|
} = await import(
|
||||||
|
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
test('resolves note links relative to Windows note paths', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolvePathFromFile('C:\\Vault\\Current.md', './Target.md'),
|
||||||
|
'C:/Vault/Target.md'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolvePathFromFile('C:\\Vault\\Folder\\Current.md', '../Target.md'),
|
||||||
|
'C:/Vault/Target.md'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolvePathFromFile('\\\\server\\share\\Folder\\Current.md', '../Target.md'),
|
||||||
|
'//server/share/Target.md'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves Linux relative note-link resolution', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolvePathFromFile('/vault/folder/Current.md', '../Target.md'),
|
||||||
|
'/vault/Target.md'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts Windows asset URLs back to portable attachment paths', () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeLocalAssetPath('/C:\\Users\\user\\Vault\\image.png'),
|
||||||
|
'C:/Users/user/Vault/image.png'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
assetSourceToMarkdown(
|
||||||
|
'http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5C.helixnotes%5Cattachments%5Cimage.png',
|
||||||
|
'C:\\Users\\user\\Vault\\Note.md',
|
||||||
|
'C:\\Users\\user\\Vault'
|
||||||
|
),
|
||||||
|
'.helixnotes/attachments/image.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves portable asset paths on Linux and macOS', () => {
|
||||||
|
assert.equal(
|
||||||
|
assetSourceToMarkdown(
|
||||||
|
'asset://localhost/%2Fhome%2Fuser%2FVault%2F.helixnotes%2Fattachments%2Fimage.png',
|
||||||
|
'/home/user/Vault/Note.md',
|
||||||
|
'/home/user/Vault'
|
||||||
|
),
|
||||||
|
'.helixnotes/attachments/image.png'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
assetSourceToMarkdown(
|
||||||
|
'asset://localhost/%2FUsers%2Fuser%2FVault%2Fassets%2Fimage.png',
|
||||||
|
'/Users/user/Vault/notes/Note.md',
|
||||||
|
'/Users/user/Vault'
|
||||||
|
),
|
||||||
|
'../assets/image.png'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
assetSourceToMarkdown('../assets/image.png', '/vault/notes/Note.md', '/vault'),
|
||||||
|
'../assets/image.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decodes local asset URLs without damaging platform roots', () => {
|
||||||
|
assert.equal(
|
||||||
|
assetUrlToLocalPath('http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5Cimage.png'),
|
||||||
|
'C:/Users/user/Vault/image.png'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
assetUrlToLocalPath('asset://localhost/%2Fhome%2Fuser%2FVault%2Fimage.png'),
|
||||||
|
'/home/user/Vault/image.png'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
assetUrlToLocalPath('http://asset.localhost/%5C%5Cserver%5Cshare%5CVault%5Cimage.png'),
|
||||||
|
'//server/share/Vault/image.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves vault files consistently across platforms', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveVaultFilePath('../assets/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
|
||||||
|
'C:/Vault/assets/image.png'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveVaultFilePath('.helixnotes/attachments/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
|
||||||
|
'C:/Vault/.helixnotes/attachments/image.png'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveVaultFilePath('../assets/image.png', '/vault/notes/Note.md', '/vault'),
|
||||||
|
'/vault/assets/image.png'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveVaultFilePath('../assets/image.png', '\\\\server\\share\\Vault\\notes\\Note.md', '\\\\server\\share\\Vault'),
|
||||||
|
'//server/share/Vault/assets/image.png'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { transformWithEsbuild } from 'vite';
|
||||||
|
|
||||||
|
const source = await readFile(
|
||||||
|
new URL('../src/lib/utils/note-switcher.ts', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const { code } = await transformWithEsbuild(source, 'note-switcher.ts', {
|
||||||
|
loader: 'ts',
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext'
|
||||||
|
});
|
||||||
|
const { buildNoteSwitcherRequestPaths, buildNoteSwitcherSections } = await import(
|
||||||
|
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const note = (path, title, relativePath) => ({ path, title, relativePath });
|
||||||
|
|
||||||
|
test('requests the current note first, then unique history from newest to oldest', () => {
|
||||||
|
const current = '/vault/Current.md';
|
||||||
|
const recent = '/vault/Recent.md';
|
||||||
|
const older = '/vault/Older.md';
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
buildNoteSwitcherRequestPaths(current, [older, recent, recent, current]),
|
||||||
|
[current, recent, older]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('puts the current note first, then unique history from newest to oldest up to the recent limit', () => {
|
||||||
|
const current = note('/vault/Current.md', 'Current', 'Current.md');
|
||||||
|
const alpha = note('/vault/Projects/Alpha.md', 'Alpha', 'Projects/Alpha.md');
|
||||||
|
const beta = note('/vault/Projects/Beta.md', 'Beta', 'Projects/Beta.md');
|
||||||
|
|
||||||
|
const sections = buildNoteSwitcherSections({
|
||||||
|
currentPath: current.path,
|
||||||
|
historyPaths: [beta.path, alpha.path, alpha.path, current.path],
|
||||||
|
knownNotes: [current, alpha, beta],
|
||||||
|
quickAccessNotes: [],
|
||||||
|
recentLimit: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(sections.recent, [
|
||||||
|
{ path: current.path, title: 'Current', folder: 'Unfiled', current: true },
|
||||||
|
{ path: alpha.path, title: 'Alpha', folder: 'Projects', current: false },
|
||||||
|
{ path: beta.path, title: 'Beta', folder: 'Projects', current: false }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes stale, unknown, and external paths that are absent from the known-note inventory', () => {
|
||||||
|
const current = note('/vault/Current.md', 'Current', 'Current.md');
|
||||||
|
const known = note('/vault/Notes/Known.md', 'Known', 'Notes/Known.md');
|
||||||
|
|
||||||
|
const sections = buildNoteSwitcherSections({
|
||||||
|
currentPath: current.path,
|
||||||
|
historyPaths: [
|
||||||
|
'/vault/Deleted.md',
|
||||||
|
known.path,
|
||||||
|
'/home/user/Downloads/External.md'
|
||||||
|
],
|
||||||
|
knownNotes: [current, known],
|
||||||
|
quickAccessNotes: [
|
||||||
|
note('/vault/Missing-Quick.md', 'Missing Quick', 'Missing-Quick.md'),
|
||||||
|
note('/home/user/Downloads/Pinned.md', 'External Quick', 'Pinned.md')
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(sections, {
|
||||||
|
recent: [
|
||||||
|
{ path: current.path, title: 'Current', folder: 'Unfiled', current: true },
|
||||||
|
{ path: known.path, title: 'Known', folder: 'Notes', current: false }
|
||||||
|
],
|
||||||
|
quickAccess: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps Quick Access order, removes Recent overlap, and derives root and nested folder labels', () => {
|
||||||
|
const root = note('/vault/Root.md', 'Root', 'Root.md');
|
||||||
|
const recentNested = note(
|
||||||
|
'/vault/Projects/Helix/Plan.md',
|
||||||
|
'Plan',
|
||||||
|
'Projects/Helix/Plan.md'
|
||||||
|
);
|
||||||
|
const quickRoot = note('/vault/Scratch.md', 'Scratch', 'Scratch.md');
|
||||||
|
const quickNested = note(
|
||||||
|
'/vault/Areas/Reading/Queue.md',
|
||||||
|
'Reading Queue',
|
||||||
|
'Areas/Reading/Queue.md'
|
||||||
|
);
|
||||||
|
|
||||||
|
const sections = buildNoteSwitcherSections({
|
||||||
|
currentPath: root.path,
|
||||||
|
historyPaths: [recentNested.path],
|
||||||
|
knownNotes: [root, recentNested, quickRoot, quickNested],
|
||||||
|
quickAccessNotes: [quickRoot, root, quickNested, recentNested]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(sections, {
|
||||||
|
recent: [
|
||||||
|
{ path: root.path, title: 'Root', folder: 'Unfiled', current: true },
|
||||||
|
{
|
||||||
|
path: recentNested.path,
|
||||||
|
title: 'Plan',
|
||||||
|
folder: 'Projects/Helix',
|
||||||
|
current: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
quickAccess: [
|
||||||
|
{ path: quickRoot.path, title: 'Scratch', folder: 'Unfiled', current: false },
|
||||||
|
{
|
||||||
|
path: quickNested.path,
|
||||||
|
title: 'Reading Queue',
|
||||||
|
folder: 'Areas/Reading',
|
||||||
|
current: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const source = await import(new URL('../src/lib/utils/notebook-icons.ts', import.meta.url));
|
||||||
|
|
||||||
|
const {
|
||||||
|
NOTEBOOK_ICON_OPTIONS,
|
||||||
|
decodeBuiltinNotebookIcon,
|
||||||
|
encodeBuiltinNotebookIcon,
|
||||||
|
normalizeNotebookIconKey
|
||||||
|
} = source;
|
||||||
|
|
||||||
|
test('built-in notebook icons have stable unique storage values', () => {
|
||||||
|
assert.equal(NOTEBOOK_ICON_OPTIONS.length, 16);
|
||||||
|
assert.equal(new Set(NOTEBOOK_ICON_OPTIONS.map(({ id }) => id)).size, NOTEBOOK_ICON_OPTIONS.length);
|
||||||
|
|
||||||
|
for (const { id } of NOTEBOOK_ICON_OPTIONS) {
|
||||||
|
const stored = encodeBuiltinNotebookIcon(id);
|
||||||
|
assert.equal(stored, `builtin:${id}`);
|
||||||
|
assert.equal(decodeBuiltinNotebookIcon(stored), id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom paths and unknown built-in values remain outside the icon codec', () => {
|
||||||
|
assert.equal(decodeBuiltinNotebookIcon('.helixnotes/attachments/notebook-icon.png'), null);
|
||||||
|
assert.equal(decodeBuiltinNotebookIcon('builtin:unknown'), null);
|
||||||
|
assert.equal(decodeBuiltinNotebookIcon(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes notebook icon keys to portable vault-relative paths', () => {
|
||||||
|
assert.equal(normalizeNotebookIconKey('Projects/Client'), 'Projects/Client');
|
||||||
|
assert.equal(normalizeNotebookIconKey(String.raw`Projects\Client`), 'Projects/Client');
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const sidebar = await readFile(
|
||||||
|
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
test('mobile notebook rows expose an accessible actions button', () => {
|
||||||
|
assert.match(sidebar, /\{#if isMobile\}[\s\S]{0,500}class="notebook-actions-btn"/);
|
||||||
|
assert.match(sidebar, /aria-label=\{`Actions for \$\{nb\.name\}`\}/);
|
||||||
|
assert.match(sidebar, /onclick=\{\(e\) => openNotebookMenu\(e, nb\)\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the notebook context menu receives its mobile styling outside the sidebar', () => {
|
||||||
|
assert.match(sidebar, /class="context-menu" class:mobile=\{isMobile\}/);
|
||||||
|
assert.match(sidebar, /\.context-menu\.mobile\s*\{/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile action sheets stay inside every safe area and prevent tap-through', () => {
|
||||||
|
assert.match(sidebar, /class="context-menu-backdrop"/);
|
||||||
|
assert.match(sidebar, /safe-area-inset-left/);
|
||||||
|
assert.match(sidebar, /safe-area-inset-right/);
|
||||||
|
assert.match(sidebar, /safe-area-inset-top/);
|
||||||
|
assert.match(sidebar, /safe-area-inset-bottom/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the actions button remains inside the notebook manual-sort drop target', () => {
|
||||||
|
assert.match(sidebar, /<div class="notebook-row" data-nb-path=\{nb\.path\}>/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { transformWithEsbuild } from 'vite';
|
||||||
|
|
||||||
|
const source = await readFile(
|
||||||
|
new URL('../src/lib/utils/startup-view.ts', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const { code } = await transformWithEsbuild(source, 'startup-view.ts', {
|
||||||
|
loader: 'ts',
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext'
|
||||||
|
});
|
||||||
|
const startup = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
|
||||||
|
|
||||||
|
test('normalizes every supported default view and falls back to All Notes', () => {
|
||||||
|
for (const view of ['all', 'quickaccess', 'tasks', 'daily']) {
|
||||||
|
assert.equal(startup.normalizeStartupView(view), view);
|
||||||
|
}
|
||||||
|
assert.equal(startup.normalizeStartupView('notebook'), 'all');
|
||||||
|
assert.equal(startup.normalizeStartupView(undefined), 'all');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the configured default when session restoration is disabled', () => {
|
||||||
|
assert.deepEqual(startup.resolveStartupTarget({
|
||||||
|
startupView: 'tasks',
|
||||||
|
restoreLastSession: false,
|
||||||
|
lastViewMode: 'daily',
|
||||||
|
lastNotebook: null,
|
||||||
|
lastTag: null
|
||||||
|
}), { mode: 'tasks' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restores a supported previous list when restoration is enabled', () => {
|
||||||
|
assert.deepEqual(startup.resolveStartupTarget({
|
||||||
|
startupView: 'daily',
|
||||||
|
restoreLastSession: true,
|
||||||
|
lastViewMode: 'quickaccess',
|
||||||
|
lastNotebook: null,
|
||||||
|
lastTag: null
|
||||||
|
}), { mode: 'quickaccess' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restores notebook and tag identifiers, otherwise uses the configured default', () => {
|
||||||
|
assert.deepEqual(startup.resolveStartupTarget({
|
||||||
|
startupView: 'all',
|
||||||
|
restoreLastSession: true,
|
||||||
|
lastViewMode: 'notebook',
|
||||||
|
lastNotebook: 'Projects',
|
||||||
|
lastTag: null
|
||||||
|
}), { mode: 'notebook', notebookPath: 'Projects' });
|
||||||
|
assert.deepEqual(startup.resolveStartupTarget({
|
||||||
|
startupView: 'tasks',
|
||||||
|
restoreLastSession: true,
|
||||||
|
lastViewMode: 'tag',
|
||||||
|
lastNotebook: null,
|
||||||
|
lastTag: null
|
||||||
|
}), { mode: 'tasks' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const appLayout = await readFile(
|
||||||
|
new URL('../src/lib/components/AppLayout.svelte', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
test('the theme shortcut recognizes a resolved custom dark theme', () => {
|
||||||
|
assert.match(
|
||||||
|
appLayout,
|
||||||
|
/\$customThemes\.find\(theme => theme\.id === \$resolvedTheme\)/
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
appLayout,
|
||||||
|
/darkThemes\.includes\(\$resolvedTheme\) \|\| \(customTheme\?\.is_dark \?\? false\)/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AppLayout leaves root theme application to the root layout', () => {
|
||||||
|
assert.doesNotMatch(appLayout, /function applyTheme\(/);
|
||||||
|
assert.doesNotMatch(appLayout, /applyTheme\(\$theme\)/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user