mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 09:27:29 +02:00
Revert "chore(release): prepare v1.3.5"
This reverts commit ade146f9f3.
This commit is contained in:
@@ -13,6 +13,9 @@ bun.lockb
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Local tests
|
||||
/tests/
|
||||
|
||||
# Nix
|
||||
/result
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
||||
Your notes are stored as standard Markdown files on your local filesystem.
|
||||
No cloud, no lock-in.
|
||||
|
||||
## Download (v1.3.5)
|
||||
## Download (v1.3.4)
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -18,7 +18,7 @@ No cloud, no lock-in.
|
||||
|
||||
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.AppImage)
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
|
||||
|
||||
#### Distro-specific packages
|
||||
|
||||
@@ -108,16 +108,16 @@ sudo eopkg it helixnotes
|
||||
|
||||
#### Manual package downloads
|
||||
|
||||
- [.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.5/HelixNotes-1.3.5-1.x86_64.rpm)
|
||||
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Installer](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64-setup.exe) (Windows 10/11)
|
||||
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
|
||||
|
||||
### macOS
|
||||
|
||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_aarch64.dmg) (M-series Macs)
|
||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
|
||||
|
||||
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
|
||||
>
|
||||
@@ -127,7 +127,7 @@ sudo eopkg it helixnotes
|
||||
|
||||
### Android
|
||||
|
||||
[Download APK](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_android.apk)
|
||||
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+41
-45
@@ -2,75 +2,71 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.3.5",
|
||||
"version": "1.3.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"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: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:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^26.2.0",
|
||||
"svelte": "^5.56.9",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"svelte": "^5.49.2",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.6"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tiptap/core": "^3.30.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.30.1",
|
||||
"@tiptap/extension-color": "^3.30.1",
|
||||
"@tiptap/extension-details": "^3.30.1",
|
||||
"@tiptap/extension-highlight": "^3.30.1",
|
||||
"@tiptap/extension-image": "^3.30.1",
|
||||
"@tiptap/extension-link": "^3.30.1",
|
||||
"@tiptap/extension-placeholder": "^3.30.1",
|
||||
"@tiptap/extension-subscript": "^3.30.1",
|
||||
"@tiptap/extension-superscript": "^3.30.1",
|
||||
"@tiptap/extension-table": "^3.30.1",
|
||||
"@tiptap/extension-table-cell": "^3.30.1",
|
||||
"@tiptap/extension-table-header": "^3.30.1",
|
||||
"@tiptap/extension-table-row": "^3.30.1",
|
||||
"@tiptap/extension-task-item": "^3.30.1",
|
||||
"@tiptap/extension-task-list": "^3.30.1",
|
||||
"@tiptap/extension-text-align": "^3.30.1",
|
||||
"@tiptap/extension-text-style": "^3.30.1",
|
||||
"@tiptap/extension-typography": "^3.30.1",
|
||||
"@tiptap/extension-underline": "^3.30.1",
|
||||
"@tiptap/pm": "^3.30.1",
|
||||
"@tiptap/starter-kit": "^3.30.1",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
||||
"@tiptap/extension-color": "^3.19.0",
|
||||
"@tiptap/extension-details": "^3.19.0",
|
||||
"@tiptap/extension-highlight": "^3.19.0",
|
||||
"@tiptap/extension-image": "^3.19.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
"@tiptap/extension-subscript": "^3.19.0",
|
||||
"@tiptap/extension-superscript": "^3.19.0",
|
||||
"@tiptap/extension-table": "^3.19.0",
|
||||
"@tiptap/extension-table-cell": "^3.19.0",
|
||||
"@tiptap/extension-table-header": "^3.19.0",
|
||||
"@tiptap/extension-table-row": "^3.19.0",
|
||||
"@tiptap/extension-task-item": "^3.19.0",
|
||||
"@tiptap/extension-task-list": "^3.19.0",
|
||||
"@tiptap/extension-text-align": "^3.19.0",
|
||||
"@tiptap/extension-text-style": "^3.19.0",
|
||||
"@tiptap/extension-typography": "^3.19.0",
|
||||
"@tiptap/extension-underline": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"highlight.js": "^11.12.0",
|
||||
"katex": "^0.16.47",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"lowlight": "^3.3.0",
|
||||
"markdown-it": "^14.3.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-mark": "^4.0.0",
|
||||
"markdown-it-sub": "^2.0.0",
|
||||
"markdown-it-sup": "^2.0.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"mermaid": "^11.16.1"
|
||||
"mermaid": "^11.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1213
-1065
File diff suppressed because it is too large
Load Diff
Generated
+1434
-1070
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.3.5"
|
||||
version = "1.3.4"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
edition = "2021"
|
||||
rust-version = "1.88.0"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[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"] }
|
||||
futures = "0.3"
|
||||
rayon = "1"
|
||||
quick-xml = "0.41"
|
||||
quick-xml = "0.36"
|
||||
urlencoding = "2"
|
||||
sha2 = "0.10"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
|
||||
@@ -26,7 +26,30 @@
|
||||
"dialog:allow-confirm",
|
||||
"fs:default",
|
||||
"fs:allow-read",
|
||||
"fs:allow-write",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-remove",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-copy-file",
|
||||
"fs:allow-stat",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-watch",
|
||||
"opener:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [{ "path": "/**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [
|
||||
{ "url": "https://**" },
|
||||
{ "url": "http://**" },
|
||||
{ "url": "mailto:*" }
|
||||
]
|
||||
},
|
||||
"opener:allow-reveal-item-in-dir"
|
||||
]
|
||||
}
|
||||
|
||||
+10
-22
@@ -8,7 +8,6 @@ const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
||||
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn ai_request(
|
||||
app: AppHandle,
|
||||
provider: String,
|
||||
@@ -23,11 +22,7 @@ pub fn ai_request(
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
// 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() {
|
||||
"openai" => {
|
||||
stream_openai(
|
||||
@@ -103,10 +98,7 @@ pub fn ai_request(
|
||||
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
||||
fn normalize_openai_base(base: &str) -> String {
|
||||
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(
|
||||
@@ -276,7 +268,9 @@ async fn stream_openai(
|
||||
body["temperature"] = json!(0.7);
|
||||
}
|
||||
|
||||
let mut req = client.post(url).header("content-type", "application/json");
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
@@ -386,11 +380,7 @@ pub async fn test_connection(
|
||||
model: &str,
|
||||
base_url: Option<&str>,
|
||||
) -> 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 {
|
||||
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
||||
"ollama" => {
|
||||
@@ -446,11 +436,7 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let is_gpt5 = model.starts_with("gpt-5");
|
||||
let token_key = if is_gpt5 {
|
||||
"max_completion_tokens"
|
||||
} else {
|
||||
"max_tokens"
|
||||
};
|
||||
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
@@ -463,7 +449,9 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
|
||||
]
|
||||
});
|
||||
|
||||
let mut req = client.post(url).header("content-type", "application/json");
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
|
||||
+14
-47
@@ -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 path = entry.path();
|
||||
|
||||
if path.extension().is_some_and(|ext| ext == "zip") {
|
||||
if path.extension().map_or(false, |ext| ext == "zip") {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
@@ -184,28 +184,14 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
|
||||
let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
|
||||
let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
|
||||
if backup.parent() != Some(backup_dir.as_path())
|
||||
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
|
||||
|| !backup.is_file()
|
||||
{
|
||||
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> {
|
||||
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let backup = validated_backup_file(backup_dir, backup_path)?;
|
||||
let backup = Path::new(backup_path);
|
||||
|
||||
if !backup.exists() {
|
||||
return Err("Backup file does not exist".to_string());
|
||||
}
|
||||
|
||||
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
||||
let mut archive =
|
||||
@@ -259,9 +245,12 @@ pub fn restore_backup(
|
||||
}
|
||||
|
||||
/// Delete a single backup file
|
||||
pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
|
||||
let path = validated_backup_file(backup_dir, backup_path)?;
|
||||
fs::remove_file(path).map_err(|error| error.to_string())
|
||||
pub fn delete_backup(backup_path: &str) -> Result<(), String> {
|
||||
let path = Path::new(backup_path);
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove old backups keeping only the newest `max_count`
|
||||
@@ -272,31 +261,9 @@ pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), Stri
|
||||
if backups.len() as u32 > max_count {
|
||||
let to_remove = backups.split_off(max_count as usize);
|
||||
for entry in to_remove {
|
||||
delete_backup(backup_dir, &entry.path)?;
|
||||
delete_backup(&entry.path)?;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+140
-315
@@ -9,9 +9,7 @@ fn index_note_bg(state: &State<'_, AppState>, path: &str) {
|
||||
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||
if let Some(search) = search {
|
||||
let p = path.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let _ = search.index_note(&p);
|
||||
});
|
||||
std::thread::spawn(move || { let _ = search.index_note(&p); });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +17,7 @@ fn remove_note_bg(state: &State<'_, AppState>, path: &str) {
|
||||
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||
if let Some(search) = search {
|
||||
let p = path.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let _ = search.remove_note(&p);
|
||||
});
|
||||
std::thread::spawn(move || { let _ = search.remove_note(&p); });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +189,9 @@ pub async fn choose_external_vault(
|
||||
if bookmark_was_registered {
|
||||
let _ = app.ios_vault_access().rollback_staged();
|
||||
} else {
|
||||
let _ = app.ios_vault_access().forget_bookmark(&result.bookmark_id);
|
||||
let _ = app
|
||||
.ios_vault_access()
|
||||
.forget_bookmark(&result.bookmark_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
@@ -355,10 +353,7 @@ pub fn set_accent_color(state: State<'_, AppState>, color: String) -> Result<(),
|
||||
}
|
||||
|
||||
#[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())?;
|
||||
if let Some(pos) = config.custom_themes.iter().position(|t| t.id == theme.id) {
|
||||
config.custom_themes[pos] = theme;
|
||||
@@ -396,12 +391,10 @@ mod custom_theme_reference_tests {
|
||||
|
||||
#[test]
|
||||
fn deleting_custom_theme_resets_system_pair_references() {
|
||||
let mut config = AppConfig {
|
||||
theme: "custom-work".to_string(),
|
||||
system_light_theme: "custom-work".to_string(),
|
||||
system_dark_theme: "custom-work".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut config = AppConfig::default();
|
||||
config.theme = "custom-work".to_string();
|
||||
config.system_light_theme = "custom-work".to_string();
|
||||
config.system_dark_theme = "custom-work".to_string();
|
||||
|
||||
clear_custom_theme_references(&mut config, "custom-work");
|
||||
|
||||
@@ -412,16 +405,9 @@ mod custom_theme_reference_tests {
|
||||
}
|
||||
|
||||
#[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 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())?;
|
||||
let export = serde_json::json!({ "version": 1, "themes": [theme] });
|
||||
let data = serde_json::to_string_pretty(&export).map_err(|e| e.to_string())?;
|
||||
@@ -430,14 +416,10 @@ pub fn export_custom_theme(
|
||||
}
|
||||
|
||||
#[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 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> = 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())?;
|
||||
for theme in &themes {
|
||||
@@ -480,7 +462,11 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_ui_scale(app: AppHandle, state: State<'_, AppState>, scale: f64) -> Result<(), String> {
|
||||
pub fn set_ui_scale(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
scale: f64,
|
||||
) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.ui_scale = Some(scale);
|
||||
save_app_config(&config)?;
|
||||
@@ -527,14 +513,8 @@ pub fn create_notebook(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_notebook(
|
||||
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)
|
||||
pub fn rename_notebook(path: String, new_name: String) -> Result<String, String> {
|
||||
operations::rename_notebook(&path, &new_name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -551,7 +531,7 @@ pub fn move_notebook(
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let new_full_path = operations::move_notebook(vault_path, ¬ebook_path, &dest_parent)?;
|
||||
let new_full_path = operations::move_notebook(¬ebook_path, &dest_parent)?;
|
||||
|
||||
let new_relative = Path::new(&new_full_path)
|
||||
.strip_prefix(vault_path.as_str())
|
||||
@@ -614,11 +594,7 @@ pub fn move_notebook(
|
||||
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
||||
let vault_path = {
|
||||
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)
|
||||
}
|
||||
@@ -636,10 +612,8 @@ pub fn get_notes(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_note(state: State<'_, AppState>, path: String) -> Result<NoteContent, 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::read_note(vault_path, &path)
|
||||
pub fn read_note(path: String) -> Result<NoteContent, String> {
|
||||
operations::read_note(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -649,23 +623,24 @@ pub fn save_note(
|
||||
meta: NoteMeta,
|
||||
body: String,
|
||||
) -> Result<(), 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();
|
||||
// Snapshot current content before overwriting (if file exists)
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(vault_path) = &config.active_vault {
|
||||
if std::path::Path::new(&path).exists() {
|
||||
if let Ok(old_raw) = std::fs::read_to_string(&path) {
|
||||
let max_versions = config.max_versions_per_note;
|
||||
let old_raw = operations::read_vault_note(&vault_path, &path)?.raw;
|
||||
let note_id = meta.id.clone();
|
||||
let vp = vault_path.clone();
|
||||
// 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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
operations::save_note(&vault_path, &path, &meta, &body)?;
|
||||
operations::save_note(&path, &meta, &body)?;
|
||||
|
||||
// Re-index note so search picks up changes (background to avoid blocking on FUSE fsync)
|
||||
index_note_bg(&state, &path);
|
||||
@@ -679,11 +654,7 @@ pub fn duplicate_note(
|
||||
path: String,
|
||||
) -> Result<crate::types::NoteEntry, 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);
|
||||
|
||||
let entry = operations::duplicate_note(&path, &vault)?;
|
||||
@@ -708,14 +679,10 @@ pub fn create_note(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_daily_note(
|
||||
state: State<'_, AppState>,
|
||||
date: Option<String>,
|
||||
) -> Result<NoteEntry, 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 vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
let entry =
|
||||
operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
|
||||
let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
|
||||
|
||||
index_note_bg(&state, &entry.path);
|
||||
|
||||
@@ -723,17 +690,9 @@ pub fn create_daily_note(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_note(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
new_title: String,
|
||||
) -> Result<String, String> {
|
||||
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();
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?.clone();
|
||||
drop(config);
|
||||
operations::rename_note(&path, &new_title, &vault_path)
|
||||
}
|
||||
@@ -765,7 +724,7 @@ pub fn move_note(
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let new_full_path = operations::move_note(vault_path, ¬e_path, &dest_notebook)?;
|
||||
let new_full_path = operations::move_note(¬e_path, &dest_notebook)?;
|
||||
|
||||
// Update quick access if the moved note was in it
|
||||
if !old_relative.is_empty() {
|
||||
@@ -841,7 +800,10 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/").to_string())
|
||||
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||
|
||||
entries.push(NoteTitleEntry { title, path: rel });
|
||||
entries.push(NoteTitleEntry {
|
||||
title,
|
||||
path: rel,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
@@ -897,34 +859,23 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() { continue; }
|
||||
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
|
||||
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)
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
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();
|
||||
|
||||
// Fast title extraction: scan for "title: " line in frontmatter without full YAML parse
|
||||
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();
|
||||
@@ -936,10 +887,7 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
if let Ok(rel) = path.strip_prefix(vault) {
|
||||
let rel_no_ext = rel.with_extension("");
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -955,27 +903,16 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
let mut edges: Vec<crate::types::GraphEdge> = Vec::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| {
|
||||
if src == tgt {
|
||||
return;
|
||||
}
|
||||
if edge_map.contains_key(&(src, tgt)) {
|
||||
return;
|
||||
} // exact duplicate
|
||||
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>, edge_map: &mut HashMap<(usize, usize), usize>, 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)) {
|
||||
// Reverse direction already exists - mark it as bidirectional
|
||||
edges[rev_idx].bidirectional = true;
|
||||
} else {
|
||||
let idx = edges.len();
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1024,32 +961,25 @@ 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.
|
||||
/// Scans for `title: ...` line within `---` fences.
|
||||
fn extract_title_fast(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim_start();
|
||||
if !trimmed.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
if !trimmed.starts_with("---") { return None; }
|
||||
// Find the closing ---
|
||||
let after_open = &trimmed[3..];
|
||||
let end = after_open.find("\n---")?;
|
||||
let frontmatter = &after_open[..end];
|
||||
for line in frontmatter.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(title) = line.strip_prefix("title:") {
|
||||
let val = title.trim();
|
||||
if line.starts_with("title:") {
|
||||
let val = line[6..].trim();
|
||||
// Strip surrounding quotes
|
||||
if (val.starts_with('"') && val.ends_with('"'))
|
||||
|| (val.starts_with('\'') && val.ends_with('\''))
|
||||
{
|
||||
return Some(val[1..val.len() - 1].to_string());
|
||||
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) {
|
||||
return Some(val[1..val.len()-1].to_string());
|
||||
}
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
@@ -1092,11 +1022,7 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
|
||||
Ok(r) => r,
|
||||
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 note_path = path.to_string_lossy().to_string();
|
||||
for (i, line) in body.lines().enumerate() {
|
||||
@@ -1109,11 +1035,7 @@ 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 priority = prio_re.captures(&content).map(|c| {
|
||||
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();
|
||||
text = prio_re.replace_all(&text, " ").to_string();
|
||||
@@ -1135,21 +1057,6 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
|
||||
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 {
|
||||
let mut s = line.to_string();
|
||||
if done {
|
||||
@@ -1175,8 +1082,12 @@ pub fn set_task_done(
|
||||
raw_line: String,
|
||||
done: bool,
|
||||
) -> Result<(), String> {
|
||||
let (vault_path, meta, body) = read_task_note(&state, ¬e_path)?;
|
||||
let mut lines: Vec<String> = body.lines().map(|line| line.to_string()).collect();
|
||||
let p = std::path::Path::new(¬e_path);
|
||||
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
|
||||
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.
|
||||
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
||||
line
|
||||
@@ -1196,7 +1107,7 @@ pub fn set_task_done(
|
||||
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
||||
new_body.push('\n');
|
||||
}
|
||||
operations::save_note(&vault_path, ¬e_path, &meta, &new_body)?;
|
||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
||||
|
||||
index_note_bg(&state, ¬e_path);
|
||||
Ok(())
|
||||
@@ -1229,18 +1140,18 @@ pub fn set_task_priority(
|
||||
Some(_) => return Err("Invalid priority".to_string()),
|
||||
};
|
||||
|
||||
let (vault_path, meta, body) = read_task_note(&state, ¬e_path)?;
|
||||
let mut lines: Vec<String> = body.lines().map(|line| line.to_string()).collect();
|
||||
let idx = if lines
|
||||
.get(line)
|
||||
.map(|item| *item == raw_line)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let p = std::path::Path::new(¬e_path);
|
||||
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
|
||||
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();
|
||||
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
||||
line
|
||||
} else {
|
||||
lines
|
||||
.iter()
|
||||
.position(|item| *item == raw_line)
|
||||
.position(|l| *l == raw_line)
|
||||
.ok_or("Task line not found (note changed)")?
|
||||
};
|
||||
|
||||
@@ -1253,7 +1164,7 @@ pub fn set_task_priority(
|
||||
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
||||
new_body.push('\n');
|
||||
}
|
||||
operations::save_note(&vault_path, ¬e_path, &meta, &new_body)?;
|
||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
||||
|
||||
index_note_bg(&state, ¬e_path);
|
||||
Ok(())
|
||||
@@ -1285,18 +1196,18 @@ pub fn set_task_due(
|
||||
Some(_) => return Err("Invalid due date".to_string()),
|
||||
};
|
||||
|
||||
let (vault_path, meta, body) = read_task_note(&state, ¬e_path)?;
|
||||
let mut lines: Vec<String> = body.lines().map(|line| line.to_string()).collect();
|
||||
let idx = if lines
|
||||
.get(line)
|
||||
.map(|item| *item == raw_line)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let p = std::path::Path::new(¬e_path);
|
||||
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
|
||||
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();
|
||||
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
||||
line
|
||||
} else {
|
||||
lines
|
||||
.iter()
|
||||
.position(|item| *item == raw_line)
|
||||
.position(|l| *l == raw_line)
|
||||
.ok_or("Task line not found (note changed)")?
|
||||
};
|
||||
|
||||
@@ -1309,7 +1220,7 @@ pub fn set_task_due(
|
||||
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
||||
new_body.push('\n');
|
||||
}
|
||||
operations::save_note(&vault_path, ¬e_path, &meta, &new_body)?;
|
||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
||||
|
||||
index_note_bg(&state, ¬e_path);
|
||||
Ok(())
|
||||
@@ -1358,11 +1269,7 @@ pub fn restore_note(
|
||||
) -> Result<String, String> {
|
||||
let vault_path = {
|
||||
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())
|
||||
}
|
||||
@@ -1371,11 +1278,7 @@ pub fn restore_note(
|
||||
pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Result<String, String> {
|
||||
let vault_path = {
|
||||
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)
|
||||
}
|
||||
@@ -1384,11 +1287,7 @@ pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Resul
|
||||
pub fn permanent_delete(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
||||
let vault_path = {
|
||||
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)
|
||||
}
|
||||
@@ -1448,11 +1347,8 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
||||
// Encode RGBA data to PNG
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::Cursor::new(&mut buf),
|
||||
img.width as u32,
|
||||
img.height as u32,
|
||||
);
|
||||
let mut encoder =
|
||||
png::Encoder::new(std::io::Cursor::new(&mut buf), img.width as u32, img.height as u32);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder
|
||||
@@ -1476,8 +1372,8 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
||||
#[tauri::command]
|
||||
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 img =
|
||||
image::load_from_memory(&data).map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let img_data = arboard::ImageData {
|
||||
@@ -1485,10 +1381,9 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
|
||||
height: h as usize,
|
||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||
};
|
||||
let mut clipboard =
|
||||
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard
|
||||
.set_image(img_data)
|
||||
let mut clipboard = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard.set_image(img_data)
|
||||
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1503,8 +1398,8 @@ pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
|
||||
let img =
|
||||
image::load_from_memory(&data).map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let img_data = arboard::ImageData {
|
||||
@@ -1512,10 +1407,9 @@ pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
|
||||
height: h as usize,
|
||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||
};
|
||||
let mut clipboard =
|
||||
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard
|
||||
.set_image(img_data)
|
||||
let mut clipboard = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard.set_image(img_data)
|
||||
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1575,7 +1469,6 @@ pub fn set_notebook_icon(
|
||||
// ── General Settings ──
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_general_settings(
|
||||
state: State<'_, AppState>,
|
||||
compact_notes: bool,
|
||||
@@ -1800,11 +1693,7 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let p = entry.path();
|
||||
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);
|
||||
files.push((name, size));
|
||||
}
|
||||
@@ -1813,10 +1702,7 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
|
||||
return Ok(Vec::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();
|
||||
if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") {
|
||||
if let Ok(content) = std::fs::read_to_string(p) {
|
||||
@@ -1988,6 +1874,7 @@ pub fn write_bytes_to(destination: String, data: Vec<u8>) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// ── Backup ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2048,18 +1935,15 @@ pub fn list_backups(state: State<'_, AppState>) -> Result<Vec<BackupEntry>, Stri
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_backup(app: AppHandle, backup_path: String) -> Result<(), String> {
|
||||
let (vault_path, backup_dir) = {
|
||||
let vault_path = {
|
||||
let state = app.state::<AppState>();
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
(
|
||||
config.active_vault.clone().ok_or("No active vault")?,
|
||||
crate::backup::get_backup_dir(&config.backup_location)?,
|
||||
)
|
||||
config.active_vault.clone().ok_or("No active vault")?
|
||||
};
|
||||
|
||||
std::thread::spawn(move || {
|
||||
use tauri::Emitter;
|
||||
match crate::backup::restore_backup(&vault_path, &backup_dir, &backup_path) {
|
||||
match crate::backup::restore_backup(&vault_path, &backup_path) {
|
||||
Ok(()) => {
|
||||
let _ = app.emit(
|
||||
"restore-done",
|
||||
@@ -2083,10 +1967,8 @@ pub fn restore_backup(app: AppHandle, backup_path: String) -> Result<(), String>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_backup(state: State<'_, AppState>, backup_path: String) -> Result<(), String> {
|
||||
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)
|
||||
pub fn delete_backup(backup_path: String) -> Result<(), String> {
|
||||
crate::backup::delete_backup(&backup_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2129,8 +2011,9 @@ pub fn create_version(
|
||||
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 max_versions = config.max_versions_per_note;
|
||||
let raw = operations::read_vault_note(vault_path, &path)?.raw;
|
||||
crate::history::force_snapshot(vault_path, ¬e_id, &raw, max_versions)
|
||||
let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
crate::history::force_snapshot(vault_path, ¬e_id, &raw, max_versions);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2147,7 +2030,6 @@ pub fn get_note_version_content(
|
||||
// ── AI ──
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_ai_settings(
|
||||
state: State<'_, AppState>,
|
||||
provider: Option<String>,
|
||||
@@ -2168,8 +2050,7 @@ pub fn set_ai_settings(
|
||||
config.ollama_api_key = ollama_api_key.filter(|k| !k.is_empty());
|
||||
}
|
||||
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.ai_api_key = key,
|
||||
@@ -2192,9 +2073,7 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
||||
.unwrap_or_else(|| "anthropic".to_string());
|
||||
let key = match provider.as_str() {
|
||||
"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(),
|
||||
_ => config.ai_api_key.clone(),
|
||||
}
|
||||
@@ -2236,7 +2115,11 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
||||
|
||||
// ── Sync (WebDAV) ──
|
||||
|
||||
fn vault_matches_identity(vault: &VaultConfig, path: &str, bookmark_id: Option<&str>) -> bool {
|
||||
fn vault_matches_identity(
|
||||
vault: &VaultConfig,
|
||||
path: &str,
|
||||
bookmark_id: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(bookmark_id) = bookmark_id {
|
||||
vault.bookmark_id.as_deref() == Some(bookmark_id)
|
||||
} else {
|
||||
@@ -2265,9 +2148,9 @@ mod vault_identity_tests {
|
||||
|
||||
#[test]
|
||||
fn bookmark_identity_disambiguates_vaults_with_the_same_path() {
|
||||
let mut config = AppConfig {
|
||||
active_vault: Some("/same/path".to_string()),
|
||||
vaults: vec![
|
||||
let mut config = AppConfig::default();
|
||||
config.active_vault = Some("/same/path".to_string());
|
||||
config.vaults = vec![
|
||||
VaultConfig {
|
||||
path: "/same/path".to_string(),
|
||||
name: "Local".to_string(),
|
||||
@@ -2279,11 +2162,9 @@ mod vault_identity_tests {
|
||||
bookmark_id: Some("bookmark".to_string()),
|
||||
..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");
|
||||
|
||||
config.active_bookmark_id = None;
|
||||
@@ -2309,7 +2190,6 @@ fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, Str
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_sync_settings(
|
||||
state: State<'_, AppState>,
|
||||
provider: Option<String>,
|
||||
@@ -2382,7 +2262,9 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
||||
.clone()
|
||||
.ok_or_else(|| "No active vault".to_string())
|
||||
.and_then(|vault| {
|
||||
sync_config_from(&config).map(|cfg| (vault, config.active_bookmark_id.clone(), cfg))
|
||||
sync_config_from(&config).map(|cfg| {
|
||||
(vault, config.active_bookmark_id.clone(), cfg)
|
||||
})
|
||||
});
|
||||
match gathered {
|
||||
Ok(vault_config) => vault_config,
|
||||
@@ -2396,9 +2278,7 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
||||
std::thread::spawn(move || {
|
||||
use tauri::Emitter;
|
||||
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 {
|
||||
Ok(summary) => {
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
@@ -2416,10 +2296,7 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
||||
);
|
||||
}
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2443,9 +2320,7 @@ pub fn ai_ask(
|
||||
.unwrap_or_else(|| "anthropic".to_string());
|
||||
let key = match provider.as_str() {
|
||||
"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(),
|
||||
_ => config.ai_api_key.clone(),
|
||||
}
|
||||
@@ -2550,9 +2425,7 @@ fn migrate_global_sync_to_vault(config: &mut AppConfig) -> bool {
|
||||
if config.sync_provider.is_none() && config.webdav_url.is_none() {
|
||||
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_url = config.webdav_url.clone();
|
||||
let g_user = config.webdav_username.clone();
|
||||
@@ -2576,56 +2449,10 @@ fn migrate_global_sync_to_vault(config: &mut AppConfig) -> bool {
|
||||
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> {
|
||||
let path = app_config_path()?;
|
||||
let data = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
|
||||
write_private_file(&path, data.as_bytes())?;
|
||||
std::fs::write(path, data).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2662,9 +2489,7 @@ pub fn get_install_type() -> String {
|
||||
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
|
||||
"deb".to_string()
|
||||
} 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"].iter().any(|pkg| {
|
||||
std::process::Command::new("pacman")
|
||||
.args(["-Q", pkg])
|
||||
.output()
|
||||
|
||||
+22
-60
@@ -1,36 +1,22 @@
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
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>/
|
||||
fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(Path::new(vault_path)
|
||||
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
|
||||
Path::new(vault_path)
|
||||
.join(".helixnotes")
|
||||
.join("history")
|
||||
.join(safe_path_component(note_id, "note ID")?))
|
||||
.join(note_id)
|
||||
}
|
||||
|
||||
/// Save a version snapshot if enough time has passed since the last one.
|
||||
/// Minimum interval: 5 minutes between snapshots.
|
||||
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
||||
let Ok(dir) = history_dir(vault_path, note_id) else {
|
||||
log::warn!("Skipping history snapshot with an invalid note ID");
|
||||
return;
|
||||
};
|
||||
let dir = history_dir(vault_path, note_id);
|
||||
|
||||
// Check if we should create a snapshot (5 min cooldown)
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
@@ -82,30 +68,31 @@ pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_ve
|
||||
}
|
||||
|
||||
/// Force-create a version snapshot, bypassing the cooldown.
|
||||
pub fn force_snapshot(
|
||||
vault_path: &str,
|
||||
note_id: &str,
|
||||
raw_content: &str,
|
||||
max_versions: u32,
|
||||
) -> Result<(), String> {
|
||||
let dir = history_dir(vault_path, note_id)?;
|
||||
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
||||
let dir = history_dir(vault_path, note_id);
|
||||
|
||||
if let Err(e) = fs::create_dir_all(&dir) {
|
||||
eprintln!("Failed to create history dir: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
||||
let filename = format!("{}.md", timestamp);
|
||||
let path = dir.join(&filename);
|
||||
|
||||
fs::write(&path, raw_content).map_err(|error| error.to_string())?;
|
||||
if let Err(e) = fs::write(&path, raw_content) {
|
||||
eprintln!("Failed to write version snapshot: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if max_versions > 0 {
|
||||
prune_versions(&dir, max_versions)?;
|
||||
let _ = prune_versions(&dir, max_versions);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all version snapshots for a note, newest first.
|
||||
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() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -115,7 +102,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())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "md") {
|
||||
if path.extension().map_or(false, |ext| ext == "md") {
|
||||
let filename = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
@@ -147,18 +134,17 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
||||
|
||||
/// Get the raw content of a specific version.
|
||||
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
|
||||
let filename = if let Some(t_pos) = timestamp.find('T') {
|
||||
let date_part = ×tamp[..t_pos];
|
||||
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
|
||||
let time_dashes = time_part.replace(':', "-");
|
||||
format!("{date_part}T{time_dashes}.md")
|
||||
format!("{}.md", format!("{}T{}", date_part, time_dashes))
|
||||
} else {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -168,7 +154,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
|
||||
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
|
||||
.collect();
|
||||
|
||||
// Sort by name (timestamps sort lexicographically) - newest last
|
||||
@@ -183,27 +169,3 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
||||
|
||||
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);
|
||||
|
||||
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
||||
responder.respond(
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(400)
|
||||
.body(Vec::new())
|
||||
@@ -234,7 +234,7 @@ pub fn run() {
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
responder.respond(
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
@@ -255,7 +255,7 @@ pub fn run() {
|
||||
let status = resp.status().as_u16();
|
||||
match resp.bytes() {
|
||||
Ok(bytes) => {
|
||||
responder.respond(
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", &content_type)
|
||||
@@ -265,7 +265,7 @@ pub fn run() {
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
responder.respond(
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
@@ -275,7 +275,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
responder.respond(
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
@@ -350,9 +350,9 @@ pub fn run() {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
tauri::WindowEvent::Destroyed
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
// When main window is destroyed, close all note windows
|
||||
if window.label() == "main" => {
|
||||
if window.label() == "main" {
|
||||
let app = window.app_handle();
|
||||
for (label, win) in app.webview_windows() {
|
||||
if label.starts_with("note-") {
|
||||
@@ -360,6 +360,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,10 +26,7 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -238,9 +235,7 @@ impl SearchIndex {
|
||||
// before the writer is created, so both indexing and querying use it).
|
||||
index.tokenizers().register(
|
||||
"cjk",
|
||||
TextAnalyzer::builder(CjkTokenizer)
|
||||
.filter(LowerCaser)
|
||||
.build(),
|
||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
||||
);
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -295,7 +290,7 @@ impl SearchIndex {
|
||||
doc.add_text(self.path_field, &path_str);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -326,7 +321,7 @@ impl SearchIndex {
|
||||
doc.add_text(self.path_field, path);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
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);
|
||||
|
||||
writer.commit().map_err(|e| e.to_string())?;
|
||||
@@ -346,7 +341,7 @@ impl SearchIndex {
|
||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let fields = [self.title_field, self.body_field, self.tags_field];
|
||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
||||
// 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.
|
||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||
@@ -381,10 +376,9 @@ impl SearchIndex {
|
||||
));
|
||||
vec![(Occur::Should, exact)]
|
||||
} else {
|
||||
let prefix: Box<dyn Query> =
|
||||
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
|
||||
field, term,
|
||||
)]));
|
||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
||||
vec![Term::from_field_text(field, term)],
|
||||
));
|
||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
1,
|
||||
|
||||
+8
-25
@@ -81,10 +81,7 @@ fn sha256_hex(bytes: &[u8]) -> 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.
|
||||
@@ -268,11 +265,7 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
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())
|
||||
}
|
||||
@@ -286,11 +279,7 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
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
|
||||
.headers()
|
||||
@@ -464,10 +453,8 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if cap != Cap::None {
|
||||
if let Ok(decoded) = e.decode() {
|
||||
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
|
||||
buf.push_str(&text);
|
||||
}
|
||||
if let Ok(t) = e.unescape() {
|
||||
buf.push_str(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,8 +515,8 @@ fn apply_changes(
|
||||
|
||||
match (l, r) {
|
||||
(Some(lf), Some(re)) => {
|
||||
let local_changed = m.is_none_or(|me| me.local_hash != lf.hash);
|
||||
let remote_changed = m.is_none_or(|me| &me.remote_etag != re);
|
||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
||||
if !local_changed && !remote_changed {
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
@@ -682,11 +669,7 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
||||
|
||||
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||
/// 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;
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
@@ -549,7 +549,10 @@ mod startup_view_tests {
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value.as_object_mut().unwrap().remove("show_note_switcher");
|
||||
value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("show_note_switcher");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
@@ -108,7 +108,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
|
||||
"[{}]",
|
||||
meta.tags
|
||||
.iter()
|
||||
.map(|t| t.to_string())
|
||||
.map(|t| format!("{}", t))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
chars.next(); // skip '['
|
||||
let mut depth = 1;
|
||||
// Skip alt text
|
||||
for c in chars.by_ref() {
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '[' {
|
||||
depth += 1;
|
||||
}
|
||||
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
let mut depth = 1;
|
||||
for c in chars.by_ref() {
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
}
|
||||
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
if ch == '[' {
|
||||
let mut link_text = String::new();
|
||||
let mut depth = 1;
|
||||
for c in chars.by_ref() {
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '[' {
|
||||
depth += 1;
|
||||
}
|
||||
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
let mut depth = 1;
|
||||
for c in chars.by_ref() {
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
@@ -112,15 +112,13 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
||||
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
|
||||
);
|
||||
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;
|
||||
format!("", alt, link_target)
|
||||
} else {
|
||||
let display = if alt_param.is_empty() || is_dimension_spec(alt_param) {
|
||||
let display = if alt_param.is_empty() {
|
||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
||||
} else if is_dimension_spec(alt_param) {
|
||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
||||
} else {
|
||||
alt_param
|
||||
@@ -139,11 +137,7 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
||||
let (file_part, anchor) = split_anchor(note_ref);
|
||||
if file_part.is_empty() {
|
||||
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;
|
||||
return format!("[{}](#{})", display, a);
|
||||
}
|
||||
@@ -171,22 +165,8 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
||||
.to_string();
|
||||
content = after_links;
|
||||
|
||||
content = fix_md_image_refs(
|
||||
&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,
|
||||
);
|
||||
content = fix_md_image_refs(&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 {
|
||||
changed = true;
|
||||
@@ -287,7 +267,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
let tags = normalize_tags(&mapping);
|
||||
|
||||
let title = mapping
|
||||
.get(serde_yaml::Value::String("title".into()))
|
||||
.get(&serde_yaml::Value::String("title".into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
@@ -303,7 +283,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
});
|
||||
|
||||
let id = mapping
|
||||
.get(serde_yaml::Value::String("id".into()))
|
||||
.get(&serde_yaml::Value::String("id".into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -313,9 +293,9 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
.iter()
|
||||
.find_map(|key| {
|
||||
mapping
|
||||
.get(serde_yaml::Value::String((*key).into()))
|
||||
.get(&serde_yaml::Value::String((*key).into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(frontmatter::parse_date_flexible)
|
||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
||||
})
|
||||
.or_else(|| file_created(path))
|
||||
.unwrap_or_else(Utc::now);
|
||||
@@ -324,15 +304,15 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
.iter()
|
||||
.find_map(|key| {
|
||||
mapping
|
||||
.get(serde_yaml::Value::String((*key).into()))
|
||||
.get(&serde_yaml::Value::String((*key).into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(frontmatter::parse_date_flexible)
|
||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
||||
})
|
||||
.or_else(|| file_modified(path))
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
let pinned = mapping
|
||||
.get(serde_yaml::Value::String("pinned".into()))
|
||||
.get(&serde_yaml::Value::String("pinned".into()))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -352,7 +332,7 @@ fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
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) {
|
||||
let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
|
||||
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
|
||||
@@ -409,7 +389,9 @@ fn file_created(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||
.ok()
|
||||
.and_then(|m| m.created().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||
.and_then(|d| {
|
||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
||||
})
|
||||
}
|
||||
|
||||
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||
@@ -417,7 +399,9 @@ fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||
.and_then(|d| {
|
||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String {
|
||||
@@ -529,9 +513,7 @@ fn fix_md_link_refs(
|
||||
let display = &caps[1];
|
||||
let href = &caps[2];
|
||||
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('#')
|
||||
{
|
||||
return format!("[{}]({})", display, href);
|
||||
@@ -567,7 +549,9 @@ fn fix_md_link_refs(
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
|
||||
fn move_attachments(
|
||||
vault: &Path,
|
||||
) -> Result<HashMap<String, String>, String> {
|
||||
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
||||
let _ = std::fs::create_dir_all(&attachments_dir);
|
||||
|
||||
@@ -617,7 +601,10 @@ fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn rewrite_attachment_refs(vault: &Path, moved: &HashMap<String, String>) -> Result<(), String> {
|
||||
fn rewrite_attachment_refs(
|
||||
vault: &Path,
|
||||
moved: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
|
||||
|
||||
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
|
||||
@@ -699,7 +686,7 @@ fn cleanup_empty_dirs(root: &Path) {
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.collect();
|
||||
|
||||
dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
|
||||
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count()));
|
||||
|
||||
for dir in dirs {
|
||||
let dir_str = dir.to_string_lossy();
|
||||
@@ -732,8 +719,7 @@ fn is_dimension_spec(s: &str) -> bool {
|
||||
if s.is_empty() {
|
||||
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 {
|
||||
@@ -921,10 +907,7 @@ mod tests {
|
||||
extract_heading_title("# My Title\n\nBody"),
|
||||
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("## Subheading"), None);
|
||||
assert_eq!(extract_heading_title(""), None);
|
||||
@@ -964,7 +947,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_normalize_tags_strips_hash() {
|
||||
let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
||||
let mapping: serde_yaml::Mapping =
|
||||
serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
||||
let tags = normalize_tags(&mapping);
|
||||
assert_eq!(tags, vec!["hashed"]);
|
||||
}
|
||||
|
||||
@@ -17,107 +17,6 @@ pub fn helixnotes_dir(vault_path: &str) -> PathBuf {
|
||||
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> {
|
||||
let hn_dir = helixnotes_dir(vault_path);
|
||||
fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?;
|
||||
@@ -241,11 +140,7 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
paths
|
||||
.par_iter()
|
||||
.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
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
@@ -296,11 +191,7 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
|
||||
let entries: Vec<NotebookEntry> = paths
|
||||
.par_iter()
|
||||
.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
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
@@ -352,15 +243,9 @@ 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> {
|
||||
let scan_path = notebook_path.unwrap_or(vault_path);
|
||||
let root = Path::new(scan_path);
|
||||
ensure_vault_content_dir(vault_path, root)?;
|
||||
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() {
|
||||
return Err("Path does not exist".to_string());
|
||||
@@ -416,9 +301,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
||||
} else {
|
||||
WalkDir::new(root)
|
||||
.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())
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
@@ -430,7 +313,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())
|
||||
.collect();
|
||||
|
||||
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
Ok(notes)
|
||||
}
|
||||
}
|
||||
@@ -540,18 +423,8 @@ fn read_note_entry_from_str(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
|
||||
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;
|
||||
pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
||||
let p = Path::new(path);
|
||||
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
|
||||
let filename = p
|
||||
.file_name()
|
||||
@@ -581,15 +454,14 @@ fn read_note_content(validated: &Path, reported_path: &str) -> Result<NoteConten
|
||||
}
|
||||
|
||||
Ok(NoteContent {
|
||||
path: reported_path.to_string(),
|
||||
path: path.to_string(),
|
||||
meta,
|
||||
content,
|
||||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
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))?;
|
||||
pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
|
||||
let mut updated_meta = meta.clone();
|
||||
updated_meta.modified = Utc::now();
|
||||
|
||||
@@ -599,7 +471,7 @@ pub fn save_note(vault_path: &str, path: &str, meta: &NoteMeta, body: &str) -> R
|
||||
}
|
||||
|
||||
// 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() {
|
||||
frontmatter::update_note_raw(&updated_meta, body)
|
||||
} else {
|
||||
@@ -615,11 +487,14 @@ pub fn create_note(
|
||||
notebook_relative: Option<&str>,
|
||||
title: &str,
|
||||
) -> Result<NoteEntry, String> {
|
||||
let requested_dir = match notebook_relative {
|
||||
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
|
||||
let dir = match notebook_relative {
|
||||
Some(rel) => Path::new(vault_path).join(rel),
|
||||
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 mut file_path = dir.join(format!("{}.md", filename));
|
||||
@@ -660,8 +535,10 @@ pub fn create_note(
|
||||
}
|
||||
|
||||
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
||||
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||
let src = validated.as_path();
|
||||
let src = Path::new(path);
|
||||
if !src.is_file() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
let parent = src
|
||||
.parent()
|
||||
@@ -795,9 +672,7 @@ pub fn create_daily_note(
|
||||
"eu" => target_date.format("%d/%m/%Y").to_string(),
|
||||
_ => {
|
||||
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()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -844,12 +719,12 @@ pub fn create_notebook(
|
||||
parent_relative: Option<&str>,
|
||||
name: &str,
|
||||
) -> Result<NotebookEntry, String> {
|
||||
let requested_parent = match parent_relative {
|
||||
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
|
||||
let parent = match parent_relative {
|
||||
Some(rel) => Path::new(vault_path).join(rel),
|
||||
None => PathBuf::from(vault_path),
|
||||
};
|
||||
let parent = ensure_vault_content_dir(vault_path, &requested_parent)?;
|
||||
let dir_path = parent.join(safe_child_name(name)?);
|
||||
|
||||
let dir_path = parent.join(name);
|
||||
if dir_path.exists() {
|
||||
return Err("Notebook already exists".to_string());
|
||||
}
|
||||
@@ -873,8 +748,10 @@ pub fn create_notebook(
|
||||
}
|
||||
|
||||
pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
||||
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
|
||||
let src = validated.as_path();
|
||||
let src = Path::new(note_path);
|
||||
if !src.exists() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||
@@ -894,8 +771,10 @@ pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> {
|
||||
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
|
||||
let src = validated.as_path();
|
||||
let src = Path::new(notebook_path);
|
||||
if !src.exists() {
|
||||
return Err("Notebook does not exist".to_string());
|
||||
}
|
||||
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||
@@ -924,8 +803,10 @@ 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> {
|
||||
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||
let src = validated.as_path();
|
||||
let src = Path::new(path);
|
||||
if !src.exists() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
// Read old title before renaming
|
||||
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||
@@ -958,13 +839,7 @@ 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();
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -1023,17 +898,11 @@ fn update_wikilinks_after_rename(
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() { continue; }
|
||||
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
|
||||
if *path_str == *new_path {
|
||||
continue;
|
||||
}
|
||||
if *path_str == *new_path { continue; }
|
||||
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
@@ -1049,13 +918,22 @@ fn update_wikilinks_after_rename(
|
||||
// If another note shares the same title, these would be ambiguous.
|
||||
if old_title != new_title && title_is_unique {
|
||||
// 1. Short title ref: [[Old Title]] → [[New Title]]
|
||||
result = result.replace(&format!("[[{}]]", old_title), &format!("[[{}]]", new_title));
|
||||
result = result.replace(
|
||||
&format!("[[{}]]", old_title),
|
||||
&format!("[[{}]]", new_title),
|
||||
);
|
||||
|
||||
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
|
||||
result = result.replace(&format!("[[{}|", old_title), &format!("[[{}|", new_title));
|
||||
result = result.replace(
|
||||
&format!("[[{}|", old_title),
|
||||
&format!("[[{}|", new_title),
|
||||
);
|
||||
|
||||
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
|
||||
result = result.replace(&format!("|{}]]", old_title), &format!("|{}]]", new_title));
|
||||
result = result.replace(
|
||||
&format!("|{}]]", old_title),
|
||||
&format!("|{}]]", new_title),
|
||||
);
|
||||
}
|
||||
|
||||
// Path-based rules are always safe (paths are unique).
|
||||
@@ -1088,11 +966,13 @@ fn update_wikilinks_after_rename(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rename_notebook(vault_path: &str, path: &str, new_name: &str) -> Result<String, String> {
|
||||
let validated = ensure_notebook_path(vault_path, Path::new(path))?;
|
||||
let src = validated.as_path();
|
||||
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
|
||||
let src = Path::new(path);
|
||||
if !src.exists() {
|
||||
return Err("Notebook does not exist".to_string());
|
||||
}
|
||||
|
||||
let new_path = src.parent().unwrap().join(safe_child_name(new_name)?);
|
||||
let new_path = src.parent().unwrap().join(new_name);
|
||||
if new_path.exists() {
|
||||
return Err("A notebook with that name already exists".to_string());
|
||||
}
|
||||
@@ -1101,12 +981,13 @@ pub fn rename_notebook(vault_path: &str, path: &str, new_name: &str) -> Result<S
|
||||
Ok(new_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn move_note(vault_path: &str, note_path: &str, dest_notebook: &str) -> Result<String, String> {
|
||||
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
|
||||
let src = validated.as_path();
|
||||
pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String> {
|
||||
let src = Path::new(note_path);
|
||||
if !src.exists() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_notebook))?;
|
||||
let dest_dir = validated_dest.as_path();
|
||||
let dest_dir = Path::new(dest_notebook);
|
||||
if !dest_dir.is_dir() {
|
||||
return Err("Destination notebook does not exist".to_string());
|
||||
}
|
||||
@@ -1118,16 +999,13 @@ pub fn move_note(vault_path: &str, note_path: &str, dest_notebook: &str) -> Resu
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn move_notebook(
|
||||
vault_path: &str,
|
||||
notebook_path: &str,
|
||||
dest_parent: &str,
|
||||
) -> Result<String, String> {
|
||||
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
|
||||
let src = validated.as_path();
|
||||
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> {
|
||||
let src = Path::new(notebook_path);
|
||||
if !src.exists() || !src.is_dir() {
|
||||
return Err("Notebook does not exist".to_string());
|
||||
}
|
||||
|
||||
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_parent))?;
|
||||
let dest_parent_path = validated_dest.as_path();
|
||||
let dest_parent_path = Path::new(dest_parent);
|
||||
if !dest_parent_path.is_dir() {
|
||||
return Err("Destination does not exist".to_string());
|
||||
}
|
||||
@@ -1170,10 +1048,7 @@ fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
|
||||
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
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);
|
||||
@@ -1192,10 +1067,7 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||
.min_depth(1)
|
||||
.into_iter()
|
||||
.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();
|
||||
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
// Strip timestamp prefix to get original notebook name
|
||||
@@ -1208,7 +1080,7 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||
};
|
||||
let modified = fs::metadata(&path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(DateTime::<Utc>::from)
|
||||
.map(|t| DateTime::<Utc>::from(t))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
notebooks.push(TrashNotebookEntry {
|
||||
name,
|
||||
@@ -1219,8 +1091,8 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||
}
|
||||
}
|
||||
|
||||
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
|
||||
notebooks.sort_by_key(|notebook| std::cmp::Reverse(notebook.modified));
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||
Ok(TrashContents { notes, notebooks })
|
||||
}
|
||||
|
||||
@@ -1229,17 +1101,15 @@ pub fn restore_note(
|
||||
trash_path: &str,
|
||||
dest_notebook: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
|
||||
let src = validated.as_path();
|
||||
if !src.is_file() {
|
||||
let src = Path::new(trash_path);
|
||||
if !src.exists() {
|
||||
return Err("Trashed note does not exist".to_string());
|
||||
}
|
||||
|
||||
let requested_dest = match dest_notebook {
|
||||
let dest_dir = match dest_notebook {
|
||||
Some(nb) => PathBuf::from(nb),
|
||||
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)
|
||||
let filename = src.file_name().unwrap_or_default().to_string_lossy();
|
||||
@@ -1262,18 +1132,15 @@ pub fn restore_note(
|
||||
}
|
||||
|
||||
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
|
||||
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
|
||||
let src = validated.as_path();
|
||||
if !src.is_dir() {
|
||||
let src = Path::new(trash_path);
|
||||
if !src.exists() || !src.is_dir() {
|
||||
return Err("Trashed notebook does not exist".to_string());
|
||||
}
|
||||
|
||||
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// 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) {
|
||||
original
|
||||
} else {
|
||||
@@ -1289,7 +1156,7 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let dest = Path::new(vault_path).join(safe_relative_path(&relative)?);
|
||||
let dest = Path::new(vault_path).join(&relative);
|
||||
|
||||
// Recreate parent directories if needed
|
||||
if let Some(parent) = dest.parent() {
|
||||
@@ -1303,8 +1170,7 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
|
||||
}
|
||||
|
||||
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
|
||||
let validated = ensure_trash_entry(vault_path, Path::new(path))?;
|
||||
let p = validated.as_path();
|
||||
let p = Path::new(path);
|
||||
let parent = p.parent().map(|pp| pp.to_path_buf());
|
||||
if p.is_dir() {
|
||||
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
|
||||
@@ -1565,9 +1431,8 @@ pub fn sanitize_filename(name: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
|
||||
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
|
||||
scan_notebooks, set_notebook_icon,
|
||||
compare_natural_names, duplicate_note, get_note_switcher_titles, helixnotes_dir,
|
||||
load_notebook_icons, scan_notebooks, set_notebook_icon,
|
||||
};
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
@@ -1576,7 +1441,10 @@ mod tests {
|
||||
fn compares_numeric_segments_anywhere_in_names() {
|
||||
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
|
||||
names.sort_by(|left, right| compare_natural_names(left, right));
|
||||
assert_eq!(names, ["Class 2a", "Class 2b", "Class 10a", "Class 10b"]);
|
||||
assert_eq!(
|
||||
names,
|
||||
["Class 2a", "Class 2b", "Class 10a", "Class 10b"]
|
||||
);
|
||||
assert_eq!(
|
||||
compare_natural_names("Class 02", "Class 2b"),
|
||||
std::cmp::Ordering::Less
|
||||
@@ -1724,75 +1592,6 @@ mod tests {
|
||||
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]
|
||||
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
||||
let vault =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.3.5",
|
||||
"version": "1.3.4",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"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",
|
||||
"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",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**/*", "/**", "**/.helixnotes/**"]
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ export async function getNoteSwitcherTitles(
|
||||
return invoke("get_note_switcher_titles", { recentPaths });
|
||||
}
|
||||
|
||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number; bidirectional: boolean }[] }> {
|
||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
|
||||
return invoke("get_graph_data");
|
||||
}
|
||||
|
||||
|
||||
@@ -132,8 +132,6 @@
|
||||
let highlightDropdown = $state(false);
|
||||
let alignDropdown = $state(false);
|
||||
let insertDropdown = $state(false);
|
||||
let tablePickerOpen = $state(false);
|
||||
let tablePickerHover = $state({ rows: 0, cols: 0 });
|
||||
|
||||
function scrollEditorBodyToBottom(source: HTMLElement | null | undefined = editorElement) {
|
||||
const editorBody = source?.closest('.editor-body') as HTMLElement | null;
|
||||
@@ -308,22 +306,6 @@
|
||||
let editorState = $state(0);
|
||||
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
|
||||
let aiMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let aiLoading = $state(false);
|
||||
@@ -488,6 +470,8 @@
|
||||
});
|
||||
let textContextMenu = $state<{ x: number; y: number; submenuLeft: 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 imageViewer = $state<{ src: string; alt: string } | null>(null);
|
||||
let copyToast = $state<'copying' | 'done' | null>(null);
|
||||
@@ -6583,16 +6567,16 @@
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="fmt-dropdown-wrap">
|
||||
<button class="fmt-btn" class:active={isEditorActive('heading')} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; insertDropdown = false; }} title="Heading">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
{#if headingDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
||||
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
||||
<button class:active={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||
<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={(editorState, editor.isActive('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={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6600,36 +6584,36 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Bold / Italic / Underline / Strike -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()} title="Bold">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()} title="Italic">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Link -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('link')} onclick={addLinkFromToolbar} title="Link">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Lists -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('bulletList')} onclick={toggleBulletList} title="Bullet List">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('taskList')} onclick={toggleTaskList} title="Task List">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
@@ -6683,7 +6667,7 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Highlight -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('highlight')} onclick={() => editor?.chain().focus().toggleHighlight({ color: highlightColors[0].value }).run()} title="Highlight">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
@@ -6752,17 +6736,17 @@
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="fmt-dropdown-wrap">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
{#if headingDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
|
||||
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
|
||||
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
|
||||
<button class:active={isEditorActive('heading', { level: 4 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</button>
|
||||
<button class:active={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
|
||||
<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={(editorState, editor.isActive('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={(editorState, editor.isActive('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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6770,16 +6754,16 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Text formatting -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()} title={`Bold (${modKey}+B)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()} title={`Italic (${modKey}+I)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} title={`Underline (${modKey}+U)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} title={`Strikethrough (${modKey}+Shift+X)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
@@ -6787,14 +6771,14 @@
|
||||
<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">
|
||||
<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: {getEditorAttributes('textStyle').color || 'var(--accent)'}"></span>
|
||||
<span class="color-indicator" style="background: {editor.getAttributes('textStyle').color || 'var(--accent)'}"></span>
|
||||
</button>
|
||||
{#if colorDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
{#each textColors as color}
|
||||
<button class="color-swatch" title={color.name} onclick={() => setTextColor(color.value)} style="background: {color.value || 'var(--text-primary)'}">
|
||||
{#if (color.value === '' && !getEditorAttributes('textStyle').color) || getEditorAttributes('textStyle').color === color.value}
|
||||
{#if (color.value === '' && !editor.getAttributes('textStyle').color) || editor.getAttributes('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>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -6806,20 +6790,20 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Link -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('link')} onclick={addLinkFromToolbar} title={`Link (${modKey}+K)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Lists -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('bulletList')} onclick={toggleBulletList} title={`Bullet List (${modKey}+Shift+8)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('taskList')} onclick={toggleTaskList} title={`Task List (${modKey}+Shift+9)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
@@ -6836,25 +6820,25 @@
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Code & Code Block -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('code')} onclick={() => editor?.chain().focus().toggleCode().run()} title={`Inline Code (${modKey}+E)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('codeBlock')} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title={`Code Block (${modKey}+Alt+C)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
<!-- Blockquote -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('blockquote')} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title={`Quote (${modKey}+Shift+B)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
<!-- Collapsible Section -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('details')} onclick={() => insertDetails()} title={`Collapsible Section (${modKey}+.)`}>
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
<!-- Callout -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('callout')} onclick={() => insertCallout('note')} title="Callout">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
@@ -6895,22 +6879,22 @@
|
||||
|
||||
<!-- Highlight -->
|
||||
<div class="fmt-dropdown-wrap">
|
||||
<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)`}>
|
||||
<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)`}>
|
||||
<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: {getEditorAttributes('highlight').color || 'var(--accent)'}"></span>
|
||||
<span class="color-indicator" style="background: {editor.getAttributes('highlight').color || 'var(--accent)'}"></span>
|
||||
</button>
|
||||
{#if highlightDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
{#each highlightColors as color}
|
||||
<button class="color-swatch" title={color.name} onclick={() => setHighlightColor(color.value)} style="background: {color.swatch}">
|
||||
{#if isEditorActive('highlight', { color: color.value })}
|
||||
{#if editor.isActive('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>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="color-swatch" title="Remove highlight" onclick={() => setHighlightColor('')} style="background: var(--bg-tertiary);">
|
||||
{#if !isEditorActive('highlight')}
|
||||
{#if !editor.isActive('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>
|
||||
{: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>
|
||||
@@ -6921,10 +6905,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Subscript & Superscript -->
|
||||
<button class="fmt-btn" class:active={isEditorActive('subscript')} onclick={() => editor?.chain().focus().toggleSubscript().run()} title="Subscript">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
<button class="fmt-btn" class:active={isEditorActive('superscript')} onclick={() => editor?.chain().focus().toggleSuperscript().run()} title="Superscript">
|
||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('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>
|
||||
</button>
|
||||
|
||||
@@ -6933,11 +6917,11 @@
|
||||
<!-- Text Alignment -->
|
||||
<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">
|
||||
{#if isEditorActive({ textAlign: 'center' })}
|
||||
{#if (editorState, editor.isActive({ 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>
|
||||
{:else if isEditorActive({ textAlign: 'right' })}
|
||||
{:else if (editorState, editor.isActive({ 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>
|
||||
{:else if isEditorActive({ textAlign: 'justify' })}
|
||||
{:else if (editorState, editor.isActive({ 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>
|
||||
{: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>
|
||||
@@ -6946,19 +6930,19 @@
|
||||
{#if alignDropdown}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fmt-dropdown align-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<button class:active={isEditorActive({ textAlign: 'left' })} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
|
||||
<button class:active={(editorState, editor.isActive({ 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>
|
||||
Left
|
||||
</button>
|
||||
<button class:active={isEditorActive({ textAlign: 'center' })} onclick={() => { editor?.chain().focus().setTextAlign('center').run(); alignDropdown = false; }}>
|
||||
<button class:active={(editorState, editor.isActive({ 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>
|
||||
Center
|
||||
</button>
|
||||
<button class:active={isEditorActive({ textAlign: 'right' })} onclick={() => { editor?.chain().focus().setTextAlign('right').run(); alignDropdown = false; }}>
|
||||
<button class:active={(editorState, editor.isActive({ 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>
|
||||
Right
|
||||
</button>
|
||||
<button class:active={isEditorActive({ textAlign: 'justify' })} onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); alignDropdown = false; }}>
|
||||
<button class:active={(editorState, editor.isActive({ 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>
|
||||
Justify
|
||||
</button>
|
||||
@@ -7119,12 +7103,12 @@
|
||||
</button>
|
||||
{#if ctxHeadingSubmenu}
|
||||
<div class="text-ctx-submenu" class:flip-left={textContextMenu?.submenuLeft}>
|
||||
<button class:active={isEditorActive('heading', { level: 1 })} onclick={() => ctxSetHeading(1)}>Heading 1</button>
|
||||
<button class:active={isEditorActive('heading', { level: 2 })} onclick={() => ctxSetHeading(2)}>Heading 2</button>
|
||||
<button class:active={isEditorActive('heading', { level: 3 })} onclick={() => ctxSetHeading(3)}>Heading 3</button>
|
||||
<button class:active={isEditorActive('heading', { level: 4 })} onclick={() => ctxSetHeading(4)}>Heading 4</button>
|
||||
<button class:active={(editorState, editor?.isActive('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={(editorState, editor?.isActive('heading', { level: 3 }))} onclick={() => ctxSetHeading(3)}>Heading 3</button>
|
||||
<button class:active={(editorState, editor?.isActive('heading', { level: 4 }))} onclick={() => ctxSetHeading(4)}>Heading 4</button>
|
||||
<div class="text-ctx-sep"></div>
|
||||
<button class:active={isEditorActive('paragraph')} onclick={ctxSetParagraph}>Paragraph</button>
|
||||
<button class:active={(editorState, editor?.isActive('paragraph'))} onclick={ctxSetParagraph}>Paragraph</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
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']);
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
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));
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
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\}/);
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
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']
|
||||
);
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
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'
|
||||
);
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
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
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
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\}>/);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
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' });
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
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