Compare commits

...
27 Commits
Author SHA1 Message Date
Yuri Karamian 7624c42e02 v1.2.8 - fix url links, tab indent, todo state, graph rework, collapsible sections, nav history, page breaks, folder/tag display 2026-03-27 14:05:59 +01:00
Yuri Karamian d46012b650 update README.md v1.2.7 2026-03-19 21:34:52 +01:00
Yuri Karamian 5d11ae6ea0 update README.md 2026-03-19 20:45:53 +01:00
Yuri Karamian bc372993c4 v1.2.7 - prefix search, keyboard shortcut fixes, GNOME AppImage fix 2026-03-19 19:19:26 +01:00
Yuri Karamian 4897703111 update README.md 2026-03-19 01:39:20 +01:00
Yuri Karamian 1bc067d470 v1.2.6 - unfiled notes, keyboard shortcuts, Android fixes 2026-03-19 00:36:42 +01:00
Yuri Karamian 565688b284 Restore trashed notebooks to their original location instead of vault root 2026-03-17 22:48:24 +01:00
Yuri Karamian e87a2edc00 Add keyboard shortcuts for sidebar, theme, focus mode, read-only, and fullscreen toggles 2026-03-17 22:37:48 +01:00
Yuri Karamian 9f595212dd add unfiled notes 2026-03-17 21:45:51 +01:00
Yuri Karamian 79f16b454a update README.md 2026-03-12 01:06:53 +01:00
Yuri Karamian 7be1b56b7c v1.2.5 - calendar view for daily notes, trash notebook restore, attachment/link fixes 2026-03-12 00:12:45 +01:00
Yuri Karamian 0b17a7c458 update README.md 2026-03-09 18:03:50 +01:00
Yuri Karamian 6c2bc72fbb v1.2.4 - proxy external images through imgproxy protocol for WebKitGTK compat, hide copy/open buttons for external images 2026-03-09 15:58:58 +01:00
Yuri Karamian e67ec21319 update README.md 2026-03-08 19:13:01 +01:00
Yuri Karamian e87c8cbe1d update README.md 2026-03-06 21:49:44 +01:00
Yuri Karamian 4fac6d05a7 update README.md 2026-03-06 21:46:45 +01:00
Yuri Karamian fffea45c50 v1.2.3 2026-03-06 20:20:15 +01:00
Yuri Karamian eb88955e20 v1.2.2 2026-03-03 13:58:12 +01:00
Yuri Karamian 5180bd5a38 v1.2.2 - Localized daily note titles, Android light mode status bar fix, Print/Export to PDF 2026-03-03 13:28:04 +01:00
Yuri Karamian a6b9037ae0 Add fastlane metadata for F-Droid / IzzyOnDroid 2026-03-01 00:56:06 +01:00
Yuri Karamian 88251a503c remove fdroid template data 2026-03-01 00:29:34 +01:00
Yuri Karamian 283340a0f4 update README.md 2026-03-01 00:11:17 +01:00
Yuri Karamian 545d4aec3b update README.md 2026-03-01 00:07:54 +01:00
Yuri Karamian 9315151a64 update README.md 2026-02-28 23:53:21 +01:00
Yuri Karamian 4d93696028 v1.2.1 - Internal note links, mobile improvements, window state persistence 2026-02-28 23:20:19 +01:00
Yuri Karamian 6891a9cdc5 fix http images 2026-02-27 01:09:06 +01:00
Yuri Karamian 1e7fb248b2 v1.2.0 - Update README.md 2026-02-27 00:55:52 +01:00
31 changed files with 3221 additions and 613 deletions
+21 -26
View File
@@ -1,11 +1,16 @@
# HelixNotes # HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.7-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
A local markdown note-taking app built with Tauri, SvelteKit, and Rust. A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.1.9) ## Download (v1.2.7)
### Linux ### Linux
@@ -23,23 +28,23 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.1.9/HelixNotes_1.1.9_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.7/HelixNotes_1.2.7_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.9/HelixNotes_1.1.9_amd64.deb) Ubuntu 22.04+ [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.9/HelixNotes_1.1.9_x64-setup.exe) Windows 10/11 [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.9/HelixNotes_1.1.9_x64.dmg) — macOS (Intel, runs on Apple Silicon via Rosetta) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android ### Android
[Download APK](https://download.helixnotes.com/releases/v1.1.9/HelixNotes_1.1.9_android.apk) — Tested on Android 16 [Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_android.apk)
--- ---
@@ -47,26 +52,16 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
## Features ## Features
- **Markdown editor** with rich formatting toolbar, slash commands, source mode toggle, and code syntax highlighting - Markdown editor with toolbar, slash commands, source mode, code highlighting
- **Wiki-links** — link notes with `[[Note Title]]` syntax - `[[Wiki-links]]` and graph view
- **Graph view** — visualize connections between your notes - Full-text search (Tantivy)
- **Outline panel** — heading navigation for long notes - Outline panel, daily notes with calendar view, tags, drag-and-drop
- **Full-text search** powered by Tantivy - Math (KaTeX), PDF preview, Obsidian import
- **Math support** — KaTeX rendering for inline and block equations - AI writing tools (Ollama / Anthropic / OpenAI)
- **Daily notes** — one-click button to create or open today's note - Version history with diffs, automatic backups
- **AI writing tools** — improve, summarize, translate, and more (Ollama / Anthropic / OpenAI) - Multi-window, file associations, focus mode, view mode
- **Version history** — per-note snapshots with diff view - Themes, accent colors, fonts
- **Backups** — automatic zip-based vault backups - Local files, no cloud
- **PDF preview** — inline rendering of embedded PDFs
- **Tag management** — organize notes with tags, bulk edit from context menu
- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging
- **Obsidian import** — convert Obsidian wiki-links to standard markdown
- **Multi-window** — open notes in separate windows (right-click → "Open in New Window")
- **File associations** — open .md files from your file manager directly in HelixNotes
- **Themes** — light/dark mode with customizable accent colors, fonts, and line height
- **Focus mode** — distraction-free writing
- **View mode** — read-only toggle for distraction-free reading
- **Local** — everything stays on your machine
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html) Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
@@ -0,0 +1,3 @@
- Internal note links ([[wiki-links]] open the linked note)
- Mobile UI improvements
- Window state persistence (desktop)
@@ -0,0 +1,12 @@
Markdown note-taking app built with Tauri and Rust. Your notes are stored as standard Markdown files on your device.
Features:
- Markdown editor with toolbar, slash commands, and code highlighting
- [[Wiki-links]] and graph view
- Full-text search
- Outline panel, daily notes, tags
- Math (KaTeX) and PDF preview
- AI writing tools (Ollama, Anthropic, OpenAI)
- Version history with diffs
- Obsidian import
- Themes, accent colors, fonts
@@ -0,0 +1 @@
Local markdown note-taking app. No cloud, no lock-in.
@@ -0,0 +1 @@
HelixNotes
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.2.0", "version": "1.2.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+36 -2
View File
@@ -633,6 +633,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"pure-rust-locales",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link 0.2.1", "windows-link 0.2.1",
@@ -1849,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.2.0" version = "1.2.8"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1867,6 +1868,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sys-locale",
"tantivy", "tantivy",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -1876,6 +1878,7 @@ dependencies = [
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio", "tokio",
"uuid", "uuid",
"walkdir", "walkdir",
@@ -3141,7 +3144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.45.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3588,6 +3591,12 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "pure-rust-locales"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d"
[[package]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.27" version = "0.1.27"
@@ -3929,6 +3938,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"http", "http",
@@ -4769,6 +4779,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -5262,6 +5281,21 @@ dependencies = [
"zip 4.6.1", "zip 4.6.1",
] ]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.0",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.10.0" version = "2.10.0"
+5 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.0" version = "1.2.8"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
@@ -26,7 +26,8 @@ serde_yaml = "0.9"
log = "0.4" log = "0.4"
notify = "8" notify = "8"
walkdir = "2" walkdir = "2"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
sys-locale = "0.3"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
tantivy = "0.22" tantivy = "0.22"
gray_matter = "0.2" gray_matter = "0.2"
@@ -34,13 +35,14 @@ tokio = { version = "1", features = ["full"] }
dirs = "6" dirs = "6"
regex = "1" regex = "1"
zip = { version = "2", default-features = false, features = ["deflate"] } zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3" futures = "0.3"
rayon = "1" rayon = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
arboard = { version = "3", features = ["image-data", "wayland-data-control"] } arboard = { version = "3", features = ["image-data", "wayland-data-control"] }
png = "0.17" png = "0.17"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
+2
View File
@@ -12,6 +12,8 @@
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-set-decorations", "core:window:allow-set-decorations",
"core:window:allow-is-fullscreen",
"core:window:allow-set-fullscreen",
"core:window:allow-set-title", "core:window:allow-set-title",
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"dialog:default", "dialog:default",
+2 -1
View File
@@ -5,6 +5,7 @@
"platforms": ["linux", "macOS", "windows"], "platforms": ["linux", "macOS", "windows"],
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"updater:default" "updater:default",
"window-state:default"
] ]
} }
+129 -39
View File
@@ -112,6 +112,13 @@ pub fn get_notebooks(state: State<'_, AppState>) -> Result<Vec<NotebookEntry>, S
operations::scan_notebooks(vault_path) operations::scan_notebooks(vault_path)
} }
#[tauri::command]
pub fn count_root_notes(state: State<'_, AppState>) -> Result<usize, 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")?;
operations::count_root_notes(vault_path)
}
#[tauri::command] #[tauri::command]
pub fn create_notebook( pub fn create_notebook(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -201,9 +208,11 @@ pub fn move_notebook(
#[tauri::command] #[tauri::command]
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> { pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let vault_path = {
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let config = state.config.lock().map_err(|e| e.to_string())?;
operations::delete_notebook(vault_path, &path) config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::delete_notebook(&vault_path, &path)
} }
// ── Notes ── // ── Notes ──
@@ -248,6 +257,14 @@ pub fn save_note(
drop(config); drop(config);
operations::save_note(&path, &meta, &body)?; operations::save_note(&path, &meta, &body)?;
// Re-index note so search picks up changes immediately
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
let _ = search.index_note(&path);
}
}
Ok(()) Ok(())
} }
@@ -272,10 +289,10 @@ pub fn create_note(
} }
#[tauri::command] #[tauri::command]
pub fn create_daily_note(state: State<'_, AppState>) -> 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 config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_daily_note(vault_path)?; let entry = operations::create_daily_note(vault_path, date.as_deref())?;
if let Ok(search_guard) = state.search_index.lock() { if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard { if let Some(ref search) = *search_guard {
@@ -287,8 +304,11 @@ pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String
} }
#[tauri::command] #[tauri::command]
pub fn rename_note(path: String, new_title: String) -> Result<String, String> { pub fn rename_note(state: State<'_, AppState>, path: String, new_title: String) -> Result<String, String> {
operations::rename_note(&path, &new_title) let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?.clone();
drop(config);
operations::rename_note(&path, &new_title, &vault_path)
} }
#[tauri::command] #[tauri::command]
@@ -410,9 +430,14 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path); let vault = std::path::Path::new(vault_path);
// Pass 1: collect all notes with titles (fast title extraction, no YAML parsing) // Pass 1: collect ALL notes as nodes (no title deduplication — every note must appear)
let mut graph_nodes = Vec::new(); let mut graph_nodes = Vec::new();
let mut title_to_idx: HashMap<String, usize> = HashMap::new(); // title → list of node indices (one title can map to multiple notes in different folders)
let mut title_to_idxs: HashMap<String, Vec<usize>> = HashMap::new();
// vault-relative path without extension → idx (for [[subfolder/note]] style links)
let mut relpath_to_idx: HashMap<String, usize> = HashMap::new();
// absolute path → idx (for fast active-note lookup)
let mut path_to_idx: HashMap<String, usize> = HashMap::new();
let mut seen_paths: HashSet<String> = HashSet::new(); let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new(); let mut contents: Vec<String> = Vec::new();
@@ -442,12 +467,18 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
path.file_stem().unwrap_or_default().to_string_lossy().to_string() path.file_stem().unwrap_or_default().to_string_lossy().to_string()
}); });
// Deduplicate by title — skip if we already have a node with this title
let title_lower = title.to_lowercase();
if title_to_idx.contains_key(&title_lower) { continue; }
let idx = graph_nodes.len(); let idx = graph_nodes.len();
title_to_idx.insert(title_lower, idx); let title_lower = title.to_lowercase();
title_to_idxs.entry(title_lower).or_default().push(idx);
path_to_idx.insert(path_str.clone(), idx);
// Also index by vault-relative path without extension (e.g. "subfolder/note name")
if let Ok(rel) = path.strip_prefix(vault) {
let rel_no_ext = rel.with_extension("");
let rel_lower = rel_no_ext.to_string_lossy().to_lowercase();
relpath_to_idx.entry(rel_lower).or_insert(idx);
}
graph_nodes.push(crate::types::GraphNode { graph_nodes.push(crate::types::GraphNode {
title, title,
path: path_str, path: path_str,
@@ -456,8 +487,22 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
} }
// Pass 2: extract edges from wiki-links (inline scan, no regex) // Pass 2: extract edges from wiki-links (inline scan, no regex)
let mut edges = Vec::new(); // edge_map: (src, tgt) → index in edges vec, used to detect reverse links
let mut edge_set: HashSet<(usize, usize)> = HashSet::new(); 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
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 });
}
};
for (source_idx, body) in contents.iter().enumerate() { for (source_idx, body) in contents.iter().enumerate() {
let bytes = body.as_bytes(); let bytes = body.as_bytes();
@@ -478,11 +523,21 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let link = link.split('^').next().unwrap_or(link); let link = link.split('^').next().unwrap_or(link);
let link = link.trim().to_lowercase(); let link = link.trim().to_lowercase();
if let Some(&target_idx) = title_to_idx.get(&link) { // 1. Try vault-relative path match (e.g. "arkhost/note name")
if target_idx != source_idx { if let Some(&target_idx) = relpath_to_idx.get(&link) {
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) }; add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
if edge_set.insert(key) { } else if let Some(targets) = title_to_idxs.get(&link) {
edges.push(crate::types::GraphEdge { source: source_idx, target: target_idx }); // 2. Title match — connect to all notes with this title
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} else if link.contains('/') {
// 3. Path-based ref: try last segment as title fallback
if let Some(seg) = link.rsplit('/').next() {
if let Some(targets) = title_to_idxs.get(seg) {
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} }
} }
} }
@@ -551,10 +606,10 @@ pub fn reindex(state: State<'_, AppState>) -> Result<(), String> {
// ── Trash ── // ── Trash ──
#[tauri::command] #[tauri::command]
pub fn get_trash(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> { pub fn get_trash(state: State<'_, AppState>) -> Result<TrashContents, String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::get_trash_notes(vault_path) operations::get_trash_contents(vault_path)
} }
#[tauri::command] #[tauri::command]
@@ -563,14 +618,29 @@ pub fn restore_note(
trash_path: String, trash_path: String,
dest_notebook: Option<String>, dest_notebook: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let vault_path = {
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let config = state.config.lock().map_err(|e| e.to_string())?;
operations::restore_note(vault_path, &trash_path, dest_notebook.as_deref()) config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::restore_note(&vault_path, &trash_path, dest_notebook.as_deref())
} }
#[tauri::command] #[tauri::command]
pub fn permanent_delete(path: String) -> Result<(), String> { pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Result<String, String> {
operations::permanent_delete(&path) 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()
};
operations::restore_notebook(&vault_path, &trash_path)
}
#[tauri::command]
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()
};
operations::permanent_delete(&vault_path, &path)
} }
#[tauri::command] #[tauri::command]
@@ -1335,34 +1405,54 @@ fn resolve_wiki_ref(
reference.to_string() reference.to_string()
} }
// ── Open file with system default handler ── // ── Open file/URL with system default handler ──
#[tauri::command] fn xdg_open(arg: &str) -> Result<(), String> {
pub fn open_file(path: String) -> Result<(), String> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
std::process::Command::new("xdg-open") let mut cmd = std::process::Command::new("xdg-open");
.arg(&path) cmd.arg(arg);
.spawn() // Clear AppImage environment so child processes find host binaries
.map_err(|e| format!("Failed to open {}: {}", path, e))?; // (e.g. gio-launch-desktop on GNOME)
if std::env::var("APPIMAGE").is_ok() {
cmd.env_remove("LD_LIBRARY_PATH")
.env_remove("LD_PRELOAD")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
if let Ok(original_path) = std::env::var("PATH_ORIG") {
cmd.env("PATH", original_path);
}
}
cmd.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
std::process::Command::new("open") std::process::Command::new("open")
.arg(&path) .arg(arg)
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
std::process::Command::new("cmd") std::process::Command::new("cmd")
.args(["/C", "start", "", &path]) .args(["/C", "start", "", arg])
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
Ok(()) Ok(())
} }
#[tauri::command]
pub fn open_file(path: String) -> Result<(), String> {
xdg_open(&path)
}
#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
xdg_open(&url)
}
#[tauri::command] #[tauri::command]
pub fn copy_file_to(source: String, destination: String) -> Result<(), String> { pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?; std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?;
+98 -1
View File
@@ -98,6 +98,7 @@ pub fn run() {
commands::set_font_family, commands::set_font_family,
commands::set_line_height, commands::set_line_height,
commands::get_notebooks, commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook, commands::create_notebook,
commands::rename_notebook, commands::rename_notebook,
commands::delete_notebook, commands::delete_notebook,
@@ -117,6 +118,7 @@ pub fn run() {
commands::reindex, commands::reindex,
commands::get_trash, commands::get_trash,
commands::restore_note, commands::restore_note,
commands::restore_notebook,
commands::permanent_delete, commands::permanent_delete,
commands::empty_trash, commands::empty_trash,
commands::load_vault_state, commands::load_vault_state,
@@ -135,6 +137,7 @@ pub fn run() {
commands::get_vault_stats, commands::get_vault_stats,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
commands::open_url,
commands::copy_file_to, commands::copy_file_to,
commands::create_backup, commands::create_backup,
commands::list_backups, commands::list_backups,
@@ -149,7 +152,80 @@ pub fn run() {
commands::ai_ask, commands::ai_ask,
commands::get_install_type, commands::get_install_type,
commands::get_pending_open_file, commands::get_pending_open_file,
]); ])
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
let path = request.uri().path().to_string();
std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded);
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
let _ = responder.respond(
tauri::http::Response::builder()
.status(400)
.body(Vec::new())
.unwrap(),
);
return;
}
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
return;
}
};
match client.get(&external_url).send() {
Ok(resp) => {
let content_type = resp
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let status = resp.status().as_u16();
match resp.bytes() {
Ok(bytes) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(status)
.header("Content-Type", &content_type)
.header("Access-Control-Allow-Origin", "*")
.body(bytes.to_vec())
.unwrap(),
);
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
});
});
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
{ {
@@ -180,6 +256,7 @@ pub fn run() {
})); }));
builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
builder = builder.on_window_event(move |window, event| { builder = builder.on_window_event(move |window, event| {
match event { match event {
@@ -250,3 +327,23 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
fn percent_decode(input: &str) -> String {
let mut output = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(decoded) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
output.push(decoded);
i += 3;
continue;
}
}
output.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&output).to_string()
}
+32 -9
View File
@@ -9,9 +9,9 @@ use tantivy::collector::TopDocs;
use tantivy::directory::MmapDirectory; use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use tantivy::directory::RamDirectory; use tantivy::directory::RamDirectory;
use tantivy::query::QueryParser; use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query};
use tantivy::schema::*; use tantivy::schema::*;
use tantivy::{Index, IndexWriter, TantivyDocument}; use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use walkdir::WalkDir; use walkdir::WalkDir;
pub struct SearchIndex { pub struct SearchIndex {
@@ -152,14 +152,37 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?; let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher(); let searcher = reader.searcher();
let query_parser = QueryParser::for_index( let fields = vec![self.title_field, self.body_field, self.tags_field];
&self.index, let terms: Vec<String> = query_str
vec![self.title_field, self.body_field, self.tags_field], .split_whitespace()
); .map(|t| t.to_lowercase())
.collect();
let query = query_parser // For each search term, create prefix + fuzzy queries across all fields (OR),
.parse_query(query_str) // then AND all terms together
.map_err(|e| e.to_string())?; let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
.iter()
.map(|term| {
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
.iter()
.flat_map(|&field| {
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,
true,
));
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
})
.collect();
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
(Occur::Must, combined)
})
.collect();
let query = BooleanQuery::new(term_queries);
let top_docs = searcher let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit)) .search(&query, &TopDocs::with_limit(limit))
+15
View File
@@ -19,6 +19,20 @@ pub struct NoteEntry {
pub preview: String, pub preview: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashContents {
pub notes: Vec<NoteEntry>,
pub notebooks: Vec<TrashNotebookEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashNotebookEntry {
pub name: String,
pub path: String,
pub note_count: usize,
pub modified: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotebookEntry { pub struct NotebookEntry {
pub name: String, pub name: String,
@@ -271,4 +285,5 @@ pub struct GraphNode {
pub struct GraphEdge { pub struct GraphEdge {
pub source: usize, pub source: usize,
pub target: usize, pub target: usize,
pub bidirectional: bool,
} }
+323 -16
View File
@@ -1,6 +1,6 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState}; use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
use crate::vault::frontmatter; use crate::vault::frontmatter;
use chrono::{Local, Utc}; use chrono::{DateTime, Local, Locale, Utc};
use rayon::prelude::*; use rayon::prelude::*;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
@@ -63,7 +63,9 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
let mut dirs: Vec<_> = read_dir let mut dirs: Vec<_> = read_dir
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| { .filter(|e| {
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) && !is_hidden(&e.path()) e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
&& !is_hidden(&e.path())
&& e.file_name().to_string_lossy() != "Daily"
}) })
.collect(); .collect();
@@ -165,6 +167,22 @@ fn is_hidden(path: &Path) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
let root = Path::new(vault_path);
if !root.exists() {
return Err("Vault path does not exist".to_string());
}
let count = fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().map(|ft| ft.is_file()).unwrap_or(false)
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
.count();
Ok(count)
}
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> { pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
let scan_path = notebook_path.unwrap_or(vault_path); let scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path); let root = Path::new(scan_path);
@@ -276,7 +294,7 @@ fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteE
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (mut meta, _content) = frontmatter::parse_note(&raw, &filename); let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
// Always use filesystem timestamps on Android (faster than parsing date strings) // Always use filesystem timestamps on Android (faster than parsing date strings)
if let Ok(fs_meta) = fs::metadata(path) { if let Ok(fs_meta) = fs::metadata(path) {
@@ -294,11 +312,13 @@ fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteE
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let preview = frontmatter::extract_preview(&content, 120);
Ok(NoteEntry { Ok(NoteEntry {
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
relative_path: relative, relative_path: relative,
meta, meta,
preview: String::new(), preview,
}) })
} }
@@ -449,10 +469,66 @@ pub fn create_note(
}) })
} }
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> { fn get_system_locale() -> Locale {
let today = Local::now(); let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let date_str = today.format("%Y-%m-%d").to_string(); let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
let title = today.format("%B %d, %Y").to_string(); match lang {
"af" => Locale::af_ZA,
"ar" => Locale::ar_SA,
"be" => Locale::be_BY,
"bg" => Locale::bg_BG,
"ca" => Locale::ca_ES,
"cs" => Locale::cs_CZ,
"da" => Locale::da_DK,
"de" => Locale::de_DE,
"el" => Locale::el_GR,
"es" => Locale::es_ES,
"et" => Locale::et_EE,
"fi" => Locale::fi_FI,
"fr" => Locale::fr_FR,
"he" => Locale::he_IL,
"hi" => Locale::hi_IN,
"hr" => Locale::hr_HR,
"hu" => Locale::hu_HU,
"id" => Locale::id_ID,
"is" => Locale::is_IS,
"it" => Locale::it_IT,
"ja" => Locale::ja_JP,
"ka" => Locale::ka_GE,
"ko" => Locale::ko_KR,
"lt" => Locale::lt_LT,
"lv" => Locale::lv_LV,
"mk" => Locale::mk_MK,
"nb" | "no" => Locale::nb_NO,
"nl" => Locale::nl_NL,
"nn" => Locale::nn_NO,
"pl" => Locale::pl_PL,
"pt" => Locale::pt_BR,
"ro" => Locale::ro_RO,
"ru" => Locale::ru_RU,
"sk" => Locale::sk_SK,
"sl" => Locale::sl_SI,
"sq" => Locale::sq_AL,
"sr" => Locale::sr_RS,
"sv" => Locale::sv_SE,
"th" => Locale::th_TH,
"tr" => Locale::tr_TR,
"uk" => Locale::uk_UA,
"vi" => Locale::vi_VN,
"zh" => Locale::zh_CN,
_ => Locale::en_US,
}
}
pub fn create_daily_note(vault_path: &str, date: Option<&str>) -> Result<NoteEntry, String> {
let target_date = match date {
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
.map_err(|e| format!("Invalid date: {}", e))?,
None => Local::now().date_naive(),
};
let date_str = target_date.format("%Y-%m-%d").to_string();
let locale = get_system_locale();
let title = target_date.format_localized("%B %d, %Y", locale).to_string();
let dir = Path::new(vault_path).join("Daily"); let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?; fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -567,17 +643,26 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
let trash_name = format!("{}_{}", timestamp, dirname); let trash_name = format!("{}_{}", timestamp, dirname);
let dest = trash_dir.join(&trash_name); let dest = trash_dir.join(&trash_name);
// Save original relative path in a sidecar .meta file for restore
let relative = src
.strip_prefix(vault_path)
.unwrap_or(src)
.to_string_lossy()
.to_string();
let meta_path = trash_dir.join(format!("{}.meta", trash_name));
let _ = fs::write(&meta_path, &relative);
fs::rename(src, dest).map_err(|e| e.to_string())?; fs::rename(src, dest).map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> { pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
let src = Path::new(path); let src = Path::new(path);
if !src.exists() { if !src.exists() {
return Err("Note does not exist".to_string()); return Err("Note does not exist".to_string());
} }
// Update frontmatter // Read old title before renaming
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?; let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
let filename = src let filename = src
.file_name() .file_name()
@@ -585,6 +670,10 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (mut meta, content) = frontmatter::parse_note(&raw, &filename); let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
let old_title = meta.title.clone();
let old_path_str = src.to_string_lossy().to_string();
// Update frontmatter
meta.title = new_title.to_string(); meta.title = new_title.to_string();
meta.modified = Utc::now(); meta.modified = Utc::now();
if meta.id.is_empty() { if meta.id.is_empty() {
@@ -601,7 +690,134 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
fs::rename(src, &new_path).map_err(|e| e.to_string())?; fs::rename(src, &new_path).map_err(|e| e.to_string())?;
} }
Ok(new_path.to_string_lossy().to_string()) let new_path_str = new_path.to_string_lossy().to_string();
// Update wikilinks in other notes that reference this note
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
Ok(new_path_str)
}
/// Walk all .md files in the vault and update wikilink references after a note rename.
/// Updates both HTML data-attributes (data-path, data-title) and raw [[old_title]] references.
fn update_wikilinks_after_rename(
vault_path: &str,
old_path: &str,
new_path: &str,
old_title: &str,
new_title: &str,
) {
let vault = Path::new(vault_path);
let vault_prefix = format!("{}/", vault_path);
// Compute vault-relative path refs (without .md) for path-based wikilink refs
let old_rel_ref = old_path.strip_prefix(&vault_prefix)
.unwrap_or(old_path)
.strip_suffix(".md")
.unwrap_or(old_path);
let new_rel_ref = new_path.strip_prefix(&vault_prefix)
.unwrap_or(new_path)
.strip_suffix(".md")
.unwrap_or(new_path);
// Check if another note with the same old_title exists in the vault.
// If so, title-based replacements (rules 1-3) are ambiguous and must be skipped,
// because we can't tell which [[Old Title]] ref points to the renamed note
// vs. the other note with the same title.
let title_is_unique = !WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.any(|e| {
let p = e.path();
if !p.is_file() || p.to_string_lossy().as_ref() == new_path {
return false;
}
if p.extension().and_then(|ext| ext.to_str()) != Some("md") {
return false;
}
let p_str = p.to_string_lossy();
if p_str.contains("/.helixnotes/") || p_str.contains("/.trash/") {
return false;
}
// Check if this note's filename (without .md) matches the old title
p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case(old_title))
.unwrap_or(false)
});
for entry in WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
let path_str = path.to_string_lossy();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip the renamed note itself
if *path_str == *new_path { continue; }
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let mut result = content.clone();
// Notes are saved as markdown with [[ref]] or [[ref|display]] syntax.
// Update all wikilink forms that reference the renamed note:
// Title-based rules only apply when the old title is unique in the vault.
// 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),
);
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
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),
);
}
// Path-based rules are always safe (paths are unique).
if old_rel_ref != new_rel_ref {
// 4. Path-based ref: [[folder/Old Name]] → [[folder/New Name]]
result = result.replace(
&format!("[[{}]]", old_rel_ref),
&format!("[[{}]]", new_rel_ref),
);
// 5. Path-based ref with alias: [[folder/Old Name|display]] → [[folder/New Name|display]]
// Also update the alias if it matches the old title.
// e.g. [[folder/Old Name|Old Title]] → [[folder/New Name|New Title]]
if old_title != new_title {
result = result.replace(
&format!("[[{}|{}]]", old_rel_ref, old_title),
&format!("[[{}|{}]]", new_rel_ref, new_title),
);
}
// For aliases that DON'T match the old title, just update the ref part
result = result.replace(
&format!("[[{}|", old_rel_ref),
&format!("[[{}|", new_rel_ref),
);
}
if result != content {
let _ = fs::write(path, &result);
}
}
} }
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> { pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
@@ -669,14 +885,29 @@ pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, S
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
} }
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> { /// Remove a directory inside trash if it's empty (after restoring/deleting its last note).
fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
let trash_dir = helixnotes_dir(vault_path).join("trash");
if let Some(d) = dir {
if d != trash_dir && d.starts_with(&trash_dir) {
if let Ok(mut entries) = fs::read_dir(d) {
if entries.next().is_none() {
let _ = fs::remove_dir(d);
}
}
}
}
}
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() { if !trash_dir.exists() {
return Ok(Vec::new()); return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
} }
let vault_root = Path::new(vault_path); let vault_root = Path::new(vault_path);
let mut notes = Vec::new(); let mut notes = Vec::new();
let mut notebooks = Vec::new();
for entry in fs::read_dir(&trash_dir).map_err(|e| e.to_string())? { for entry in fs::read_dir(&trash_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?; let entry = entry.map_err(|e| e.to_string())?;
@@ -685,11 +916,39 @@ pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
if let Ok(note) = read_note_entry(&path, vault_root) { if let Ok(note) = read_note_entry(&path, vault_root) {
notes.push(note); notes.push(note);
} }
} else if path.is_dir() {
// Deleted notebook — count .md files inside
let note_count = WalkDir::new(&path)
.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"))
.count();
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
// Strip timestamp prefix to get original notebook name
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
dirname[18..].to_string()
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
dirname[15..].to_string()
} else {
dirname.to_string()
};
let modified = fs::metadata(&path)
.and_then(|m| m.modified())
.map(|t| DateTime::<Utc>::from(t))
.unwrap_or_else(|_| Utc::now());
notebooks.push(TrashNotebookEntry {
name,
path: path.to_string_lossy().to_string(),
note_count,
modified,
});
} }
} }
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
Ok(notes) notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(TrashContents { notes, notebooks })
} }
pub fn restore_note( pub fn restore_note(
@@ -717,18 +976,66 @@ pub fn restore_note(
&filename &filename
}; };
let parent = src.parent().map(|p| p.to_path_buf());
let dest = dest_dir.join(original_name); let dest = dest_dir.join(original_name);
fs::rename(src, &dest).map_err(|e| e.to_string())?; fs::rename(src, &dest).map_err(|e| e.to_string())?;
// Clean up empty parent directory (deleted notebook folder) in trash
cleanup_empty_trash_dir(vault_path, parent.as_deref());
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
} }
pub fn permanent_delete(path: &str) -> Result<(), String> { pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
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 relative = if let Ok(original) = fs::read_to_string(&meta_path) {
original
} else {
// Fallback for notebooks trashed before .meta files existed:
// strip timestamp prefix to get notebook name, restore to root
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
&dirname[18..]
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
&dirname[15..]
} else {
&dirname
};
name.to_string()
};
let dest = Path::new(vault_path).join(&relative);
// Recreate parent directories if needed
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::rename(src, &dest).map_err(|e| e.to_string())?;
// Clean up .meta file
let _ = fs::remove_file(&meta_path);
Ok(dest.to_string_lossy().to_string())
}
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
let p = Path::new(path); let p = Path::new(path);
let parent = p.parent().map(|pp| pp.to_path_buf());
if p.is_dir() { if p.is_dir() {
fs::remove_dir_all(p).map_err(|e| e.to_string())?; fs::remove_dir_all(p).map_err(|e| e.to_string())?;
} else { } else {
fs::remove_file(p).map_err(|e| e.to_string())?; fs::remove_file(p).map_err(|e| e.to_string())?;
} }
// Clean up empty parent directory (deleted notebook folder) in trash
cleanup_empty_trash_dir(vault_path, parent.as_deref());
Ok(()) Ok(())
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.2.0", "version": "1.2.8",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
@@ -24,7 +24,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost 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": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"] "scope": ["**/*", "/**", "**/.helixnotes/**"]
+91
View File
@@ -179,3 +179,94 @@ body.resizing {
display: none !important; display: none !important;
} }
} }
/* ── Print / Export PDF ── */
@media print {
/* Hide everything except editor content */
.titlebar,
.sidebar-panel,
.notelist-panel,
.resize-handle,
.toolbar-actions,
.editor-body-wrapper .note-search-bar,
.formatting-toolbar,
.editor-formatting-bar,
.history-panel,
.outline-panel,
.graph-panel,
.ai-panel,
.link-context-menu,
.image-toolbar,
.selection-bar {
display: none !important;
}
/* Force light theme colors for PDF */
:root, :root.dark {
--bg-primary: #ffffff !important;
--bg-secondary: #f8f9fa !important;
--bg-tertiary: #f0f1f3 !important;
--bg-editor: #ffffff !important;
--text-primary: #1a1a2e !important;
--text-secondary: #495057 !important;
--text-tertiary: #868e96 !important;
--text-accent: #5b6abf !important;
--accent: #5b6abf !important;
--border-color: #e2e5e9 !important;
--border-light: #f0f1f3 !important;
}
html, body {
height: auto;
overflow: visible;
background: white !important;
color: #1a1a2e !important;
font-size: 12pt;
padding: 0;
}
/* Override all scoped height/overflow constraints for print flow */
* {
overflow: visible !important;
height: auto !important;
max-height: none !important;
min-height: 0 !important;
}
.app-layout {
display: block !important;
}
.editor-panel {
width: 100% !important;
min-width: 0 !important;
}
.ProseMirror {
padding: 0 !important;
max-width: 100% !important;
background: white !important;
color: #1a1a2e !important;
}
/* Ensure images fit within page */
.ProseMirror img {
max-width: 100% !important;
page-break-inside: avoid;
}
/* Avoid breaking inside these elements */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
pre, blockquote, table, figure {
page-break-inside: avoid;
}
/* Code blocks with light background */
.ProseMirror pre, .ProseMirror code {
background: #f8f9fa !important;
color: #1a1a2e !important;
}
}
+16 -3
View File
@@ -7,6 +7,7 @@ import type {
NotebookEntry, NotebookEntry,
NoteTitleEntry, NoteTitleEntry,
SearchResult, SearchResult,
TrashContents,
VaultState, VaultState,
VaultStats, VaultStats,
ImportResult, ImportResult,
@@ -46,6 +47,10 @@ export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks"); return invoke("get_notebooks");
} }
export async function countRootNotes(): Promise<number> {
return invoke("count_root_notes");
}
export async function createNotebook( export async function createNotebook(
parentRelative: string | null, parentRelative: string | null,
name: string, name: string,
@@ -96,8 +101,8 @@ export async function createNote(
return invoke("create_note", { notebookRelative, title }); return invoke("create_note", { notebookRelative, title });
} }
export async function createDailyNote(): Promise<NoteEntry> { export async function createDailyNote(date?: string): Promise<NoteEntry> {
return invoke("create_daily_note"); return invoke("create_daily_note", { date: date ?? null });
} }
export async function renameNote( export async function renameNote(
@@ -141,7 +146,7 @@ export async function reindex(): Promise<void> {
return invoke("reindex"); return invoke("reindex");
} }
export async function getTrash(): Promise<NoteEntry[]> { export async function getTrash(): Promise<TrashContents> {
return invoke("get_trash"); return invoke("get_trash");
} }
@@ -152,6 +157,10 @@ export async function restoreNote(
return invoke("restore_note", { trashPath, destNotebook }); return invoke("restore_note", { trashPath, destNotebook });
} }
export async function restoreNotebook(trashPath: string): Promise<string> {
return invoke("restore_notebook", { trashPath });
}
export async function permanentDelete(path: string): Promise<void> { export async function permanentDelete(path: string): Promise<void> {
return invoke("permanent_delete", { path }); return invoke("permanent_delete", { path });
} }
@@ -258,6 +267,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path }); return invoke("open_file", { path });
} }
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
export async function copyFileTo( export async function copyFileTo(
source: string, source: string,
destination: string, destination: string,
+118 -60
View File
@@ -33,14 +33,17 @@
quickAccessPaths, quickAccessPaths,
tags, tags,
notes, notes,
viewMode,
activeTag,
updateAvailable as globalUpdateAvailable, updateAvailable as globalUpdateAvailable,
settingsTab settingsTab,
navHistory
} from '$lib/stores/app'; } from '$lib/stores/app';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@@ -57,9 +60,7 @@
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null; let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
let noteHistoryIndex = -1;
function parseFrequencyMs(freq: string): number { function parseFrequencyMs(freq: string): number {
switch (freq) { switch (freq) {
@@ -90,32 +91,19 @@
// Track note navigation in history stack // Track note navigation in history stack
$effect(() => { $effect(() => {
const path = $activeNotePath; const path = $activeNotePath;
if (navigatingFromHistory) { if (path) navHistory.push(path);
navigatingFromHistory = false;
return;
}
if (path) {
// Trim forward history and push
noteHistory = [...noteHistory.slice(0, noteHistoryIndex + 1), path];
noteHistoryIndex = noteHistory.length - 1;
}
}); });
function navigateHistory(direction: -1 | 1) { function navigateHistory(direction: -1 | 1) {
const newIndex = noteHistoryIndex + direction; const path = navHistory.go(direction);
if (newIndex < 0 || newIndex >= noteHistory.length) return; if (!path) return;
const path = noteHistory[newIndex];
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
readNote(path).then((content) => { readNote(path).then((content) => {
editor?.flushSave(); editor?.flushSave();
$activeNote = content; $activeNote = content;
$activeNotePath = path; $activeNotePath = path;
$editorDirty = false; $editorDirty = false;
editor?.loadNote(path, content.content); editor?.loadNote(path, content.content);
}).catch(() => { }).catch(() => {});
// Note may have been deleted, ignore
});
} }
async function handleOpenFile(filePath: string) { async function handleOpenFile(filePath: string) {
@@ -183,7 +171,8 @@
$activeNotePath = entry.path; $activeNotePath = entry.path;
$editorDirty = false; $editorDirty = false;
editor?.loadNote(entry.path, content.content); editor?.loadNote(entry.path, content.content);
noteList?.refresh(); $viewMode = 'daily';
noteList?.refresh(true);
sidebar?.refresh(); sidebar?.refresh();
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} catch (e) { } catch (e) {
@@ -192,34 +181,49 @@
} }
// Android back gesture / hardware back button support // Android back gesture / hardware back button support
let mobileNavFromPopstate = false; // We maintain a simple counter of how many views deep we are.
// sidebar=0, notelist=1, editor=2. Each forward nav pushes, back pops.
let historyDepth = 0;
let navFromPopstate = false;
if (isMobile) { if (isMobile) {
// Seed initial history state history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
history.replaceState({ mobileView: 'sidebar' }, '');
// When mobileView changes forward, push browser history so Android back gesture works
$effect(() => { $effect(() => {
const view = $mobileView; const view = $mobileView;
if (mobileNavFromPopstate) { if (navFromPopstate) {
mobileNavFromPopstate = false; navFromPopstate = false;
return; return;
} }
// Replace state to track current view const targetDepth = view === 'sidebar' ? 0 : view === 'notelist' ? 1 : 2;
history.pushState({ mobileView: view }, ''); if (targetDepth > historyDepth) {
// Forward navigation — push entries for each level skipped
for (let d = historyDepth + 1; d <= targetDepth; d++) {
const v = d === 1 ? 'notelist' : 'editor';
history.pushState({ mobileView: v, depth: d }, '');
}
historyDepth = targetDepth;
} else if (targetDepth < historyDepth) {
// Programmatic back (e.g. mobileBack button) — go back in history
const steps = historyDepth - targetDepth;
historyDepth = targetDepth;
navFromPopstate = true; // suppress the popstate that history.go triggers
history.go(-steps);
}
}); });
window.addEventListener('popstate', (e) => { window.addEventListener('popstate', (e) => {
const currentView = $mobileView; if (navFromPopstate) {
if (currentView === 'sidebar') { navFromPopstate = false;
// Already at root — let Android handle it (exit app)
return; return;
} }
mobileNavFromPopstate = true; const state = e.state;
if (currentView === 'editor') $mobileView = 'notelist'; const targetDepth = state?.depth ?? 0;
else if (currentView === 'notelist') $mobileView = 'sidebar'; historyDepth = targetDepth;
// Push state again so next back gesture also works navFromPopstate = true;
history.pushState({ mobileView: $mobileView }, ''); if (targetDepth === 0) $mobileView = 'sidebar';
else if (targetDepth === 1) $mobileView = 'notelist';
else $mobileView = 'editor';
}); });
} }
@@ -235,6 +239,7 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') { if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault(); e.preventDefault();
navigateHistory(-1); navigateHistory(-1);
@@ -245,19 +250,25 @@
navigateHistory(1); navigateHistory(1);
return; return;
} }
if (mod && !e.shiftKey && e.key === 'n') { if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
createAndFocusNote(); createAndFocusNote();
return;
} }
if (mod && e.shiftKey && e.key === 'N') { if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
} }
if (mod && e.shiftKey && e.key === 'F') { if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
$showSearch = true; $showSearch = true;
return; return;
} }
if (mod && !e.shiftKey && e.key === 'f') { if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
if ($activeNotePath) { if ($activeNotePath) {
editor?.openNoteSearch(); editor?.openNoteSearch();
@@ -266,28 +277,50 @@
} }
return; return;
} }
if (mod && e.key === 'p') { if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault(); e.preventDefault();
$showCommandPalette = true; $showCommandPalette = true;
return;
} }
if (mod && e.key === 's') { if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return;
} }
if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) { if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault(); e.preventDefault();
editor?.addLinkFromToolbar(); editor?.addLinkFromToolbar();
} }
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
if (mod && e.shiftKey && e.key === 'W') { if (mod && e.shiftKey && code === 'KeyW') {
e.preventDefault(); e.preventDefault();
if ($activeNotePath && $activeNote) { if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title); openNoteWindow($activeNotePath, $activeNote.meta.title);
} }
} }
if (mod && e.key === '\\') {
e.preventDefault();
$sidebarCollapsed = !$sidebarCollapsed;
return;
}
if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault();
$focusMode = !$focusMode;
return;
}
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
$readOnly = !$readOnly;
return;
}
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
if ($showSettings) $showSettings = false; if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false; else if ($showInfo) $showInfo = false;
@@ -387,9 +420,11 @@
debouncedRefresh(); debouncedRefresh();
}); });
} else { } else {
unlistenFileChange = await listen<FileEvent>('file-changed', async () => { const debouncedDesktopRefresh = debounce(async () => {
await sidebar?.refresh(); await Promise.all([sidebar?.refresh(), noteList?.refresh(true)]);
await noteList?.refresh(true); }, 300);
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
debouncedDesktopRefresh();
}); });
} }
@@ -445,7 +480,7 @@
{#if $mobileView === 'sidebar'} {#if $mobileView === 'sidebar'}
HelixNotes HelixNotes
{:else} {:else}
{$activeNotebook?.name || 'All Notes'} {#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{/if} {/if}
</span> </span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'} {#if $globalUpdateAvailable && $mobileView === 'sidebar'}
@@ -525,12 +560,7 @@
<line x1="21" y1="21" x2="16.65" y2="16.65" /> <line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg> </svg>
</button> </button>
<button class="mobile-header-btn" onclick={createAndFocusNote} title="New Note"> <button class="mobile-header-btn" onclick={handleDailyNote} title="Daily Note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button class="mobile-header-btn" onclick={handleDailyNote} title="Daily Note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" /> <line x1="16" y1="2" x2="16" y2="6" />
@@ -554,7 +584,12 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}> <div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} /> <Editor bind:this={editor} />
@@ -614,7 +649,7 @@
{/if} {/if}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
@@ -831,4 +866,27 @@
pointer-events: auto; pointer-events: auto;
} }
.mobile-fab {
position: absolute;
bottom: 24px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--accent);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
cursor: pointer;
z-index: 10;
}
.mobile-fab:active {
transform: scale(0.93);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
</style> </style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23 -8
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app'; import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api'; import { getVaultStats } from '$lib/api';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
@@ -116,22 +116,37 @@
</div> </div>
{:else} {:else}
<div class="shortcuts-section"> <div class="shortcuts-section">
<h4 class="shortcuts-group-title">Keyboard Shortcuts</h4> <h4 class="shortcuts-group-title">General</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<h4 class="shortcuts-group-title">Interface</h4>
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Navigation</h4>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<h4 class="shortcuts-group-title">Formatting</h4>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>U</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>U</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Exit focus mode</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Editor Commands</h4> <h4 class="shortcuts-group-title">Editor Commands</h4>
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
+561 -8
View File
@@ -24,26 +24,92 @@
saveNote, saveNote,
getTrash, getTrash,
restoreNote, restoreNote,
restoreNotebook,
permanentDelete, permanentDelete,
getQuickAccess, getQuickAccess,
addQuickAccess, addQuickAccess,
removeQuickAccess, removeQuickAccess,
reorderQuickAccess, reorderQuickAccess,
moveNote, moveNote,
getAllTags getAllTags,
createDailyNote
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time'; import { formatRelativeTime } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import type { NoteEntry, SortMode } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void; onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void;
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent);
let multiSelectMode = $state(false);
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
let trashBusy = $state<string | null>(null);
// Calendar state for daily notes view
let calMonth = $state(new Date().getMonth());
let calYear = $state(new Date().getFullYear());
let dailyDates = $state<Set<string>>(new Set());
const calMonthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const calDayNames = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
function calDays(): Array<{ day: number; date: string; current: boolean }> {
const first = new Date(calYear, calMonth, 1);
const lastDay = new Date(calYear, calMonth + 1, 0).getDate();
// Monday=0 offset
let startDow = first.getDay() - 1;
if (startDow < 0) startDow = 6;
const cells: Array<{ day: number; date: string; current: boolean }> = [];
for (let i = 0; i < startDow; i++) cells.push({ day: 0, date: '', current: false });
for (let d = 1; d <= lastDay; d++) {
const mm = String(calMonth + 1).padStart(2, '0');
const dd = String(d).padStart(2, '0');
const date = `${calYear}-${mm}-${dd}`;
const now = new Date();
const current = d === now.getDate() && calMonth === now.getMonth() && calYear === now.getFullYear();
cells.push({ day: d, date, current });
}
return cells;
}
function calPrev() {
if (calMonth === 0) { calMonth = 11; calYear--; }
else calMonth--;
}
function calNext() {
if (calMonth === 11) { calMonth = 0; calYear++; }
else calMonth++;
}
function calToday() {
const now = new Date();
calMonth = now.getMonth();
calYear = now.getFullYear();
}
async function handleDayClick(date: string) {
try {
const entry = await createDailyNote(date);
const content = await readNote(entry.path);
onBeforeNoteSwitch();
$activeNote = content;
$activeNotePath = entry.path;
onNoteSelected(entry.path, content.content);
if (!dailyDates.has(date)) {
dailyDates = new Set([...dailyDates, date]);
}
noteCache.clear();
await refresh();
} catch (e) {
console.error('Failed to open daily note:', e);
}
}
/** Derive tags from current $notes store (avoids re-scanning files on mobile) */ /** Derive tags from current $notes store (avoids re-scanning files on mobile) */
function deriveTagsFromNotes() { function deriveTagsFromNotes() {
@@ -80,6 +146,52 @@
let batchMovePicker = $state(false); let batchMovePicker = $state(false);
let batchTagEdit = $state(false); let batchTagEdit = $state(false);
// Mobile long-press selection
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressTriggered = false;
let touchStartPos = { x: 0, y: 0 };
const LONG_PRESS_MS = 500;
const LONG_PRESS_MOVE_THRESHOLD = 10;
function handleTouchStart(e: TouchEvent, note: NoteEntry) {
longPressTriggered = false;
const touch = e.touches[0];
touchStartPos = { x: touch.clientX, y: touch.clientY };
longPressTimer = setTimeout(() => {
longPressTriggered = true;
// Vibrate for haptic feedback if available
if (navigator.vibrate) navigator.vibrate(30);
const next = new Set(selectedPaths);
if (next.size === 0 && $activeNotePath && $activeNotePath !== note.path) {
next.add($activeNotePath);
}
if (next.has(note.path)) {
next.delete(note.path);
} else {
next.add(note.path);
}
selectedPaths = next;
}, LONG_PRESS_MS);
}
function handleTouchMove(e: TouchEvent) {
if (!longPressTimer) return;
const touch = e.touches[0];
const dx = touch.clientX - touchStartPos.x;
const dy = touch.clientY - touchStartPos.y;
if (Math.abs(dx) > LONG_PRESS_MOVE_THRESHOLD || Math.abs(dy) > LONG_PRESS_MOVE_THRESHOLD) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function handleTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Quick Access drag-to-reorder // Quick Access drag-to-reorder
let qaDragFrom = $state<number | null>(null); let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null); let qaDragOver = $state<number | null>(null);
@@ -108,6 +220,7 @@
selectedPaths = new Set(); selectedPaths = new Set();
lastClickedPath = null; lastClickedPath = null;
batchMovePicker = false; batchMovePicker = false;
multiSelectMode = false;
} }
// Clear selection and reset scroll when view/notebook changes // Clear selection and reset scroll when view/notebook changes
@@ -118,6 +231,15 @@
if (listContainer) listContainer.scrollTop = 0; if (listContainer) listContainer.scrollTop = 0;
}); });
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
$effect(() => {
$quickAccessPaths;
noteCache.delete('quickaccess');
if ($viewMode === 'quickaccess') {
refresh();
}
});
// Track container height via ResizeObserver // Track container height via ResizeObserver
$effect(() => { $effect(() => {
if (!listContainer) return; if (!listContainer) return;
@@ -156,6 +278,7 @@
if ($viewMode === 'notebook') return $activeNotebook?.name ?? 'Notebook'; if ($viewMode === 'notebook') return $activeNotebook?.name ?? 'Notebook';
if ($viewMode === 'tag') return `#${$activeTag}`; if ($viewMode === 'tag') return `#${$activeTag}`;
if ($viewMode === 'quickaccess') return 'Quick Access'; if ($viewMode === 'quickaccess') return 'Quick Access';
if ($viewMode === 'daily') return 'Daily Notes';
if ($viewMode === 'trash') return 'Trash'; if ($viewMode === 'trash') return 'Trash';
return 'Notes'; return 'Notes';
}); });
@@ -165,6 +288,7 @@
function cacheKey(): string { function cacheKey(): string {
if ($viewMode === 'trash') return 'trash'; if ($viewMode === 'trash') return 'trash';
if ($viewMode === 'daily') return 'daily';
if ($viewMode === 'quickaccess') return 'quickaccess'; if ($viewMode === 'quickaccess') return 'quickaccess';
if ($viewMode === 'tag' && $activeTag) return `tag:${$activeTag}`; if ($viewMode === 'tag' && $activeTag) return `tag:${$activeTag}`;
if ($viewMode === 'notebook' && $activeNotebook) return `nb:${$activeNotebook.path}`; if ($viewMode === 'notebook' && $activeNotebook) return `nb:${$activeNotebook.path}`;
@@ -185,7 +309,24 @@
try { try {
if ($viewMode === 'trash') { if ($viewMode === 'trash') {
$notes = await getTrash(); const trash = await getTrash();
$notes = trash.notes;
trashNotebooks = trash.notebooks;
} else if ($viewMode === 'daily') {
const vault = $appConfig?.active_vault;
if (vault) {
$notes = await getNotes(`${vault}/Daily`);
// Build set of dates from filenames (YYYY-MM-DD.md)
const dates = new Set<string>();
for (const n of $notes) {
const fname = n.path.split('/').pop() ?? '';
const m = fname.match(/^(\d{4}-\d{2}-\d{2})\.md$/);
if (m) dates.add(m[1]);
}
dailyDates = dates;
} else {
$notes = [];
}
} else if ($viewMode === 'quickaccess') { } else if ($viewMode === 'quickaccess') {
$notes = await getQuickAccess(); $notes = await getQuickAccess();
} else if ($viewMode === 'tag' && $activeTag) { } else if ($viewMode === 'tag' && $activeTag) {
@@ -224,12 +365,21 @@
} }
export async function handleCreateNote() { export async function handleCreateNote() {
if ($viewMode === 'daily') {
const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
await handleDayClick(date);
calMonth = today.getMonth();
calYear = today.getFullYear();
return;
}
const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null; const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null;
try { try {
const entry = await createNote(nbRelative, 'Untitled'); const entry = await createNote(nbRelative, 'Untitled');
noteCache.clear(); noteCache.clear();
await refresh(); await refresh();
await selectNote(entry); await selectNote(entry);
onNoteCreated();
} catch (e) { } catch (e) {
console.error('Failed to create note:', e); console.error('Failed to create note:', e);
} }
@@ -302,6 +452,32 @@
} }
} }
async function handleRestoreNotebook(nb: TrashNotebookEntry) {
trashBusy = nb.path;
try {
await restoreNotebook(nb.path);
noteCache.clear();
await refresh();
} catch (e) {
console.error('Failed to restore notebook:', e);
} finally {
trashBusy = null;
}
}
async function handleDeleteNotebook(nb: TrashNotebookEntry) {
trashBusy = nb.path;
try {
await permanentDelete(nb.path);
noteCache.clear();
trashNotebooks = trashNotebooks.filter(n => n.path !== nb.path);
} catch (e) {
console.error('Failed to delete notebook permanently:', e);
} finally {
trashBusy = null;
}
}
async function handleTogglePin(note: NoteEntry) { async function handleTogglePin(note: NoteEntry) {
contextMenu = null; contextMenu = null;
try { try {
@@ -504,6 +680,23 @@
} }
function handleNoteClick(e: MouseEvent, note: NoteEntry) { function handleNoteClick(e: MouseEvent, note: NoteEntry) {
// Mobile: if long-press just fired, ignore the click
if (isMobile && longPressTriggered) {
longPressTriggered = false;
e.preventDefault();
return;
}
// Mobile: if in selection mode (or Android multi-select mode), tap toggles selection
if (isMobile && (selectedPaths.size > 0 || multiSelectMode)) {
const next = new Set(selectedPaths);
if (next.has(note.path)) {
next.delete(note.path);
} else {
next.add(note.path);
}
selectedPaths = next;
return;
}
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
// Toggle individual selection // Toggle individual selection
const next = new Set(selectedPaths); const next = new Set(selectedPaths);
@@ -599,7 +792,11 @@
function openSortMenu(e: MouseEvent) { function openSortMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
sortMenu = { x: rect.left, y: rect.bottom + 4 }; const menuWidth = isMobile ? 220 : 180;
let x = rect.left;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (x < 4) x = 4;
sortMenu = { x, y: rect.bottom + 4 };
} }
function setSortMode(mode: SortMode) { function setSortMode(mode: SortMode) {
@@ -626,14 +823,21 @@
<div class="list-header"> <div class="list-header">
<span class="list-title">{viewTitle}</span> <span class="list-title">{viewTitle}</span>
<div class="list-actions"> <div class="list-actions">
{#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" />{#if multiSelectMode}<polyline points="6 6.5 7 7.5 9 5.5" stroke-width="2" /><polyline points="6 17.5 7 18.5 9 16.5" stroke-width="2" />{/if}<line x1="14" y1="6.5" x2="21" y2="6.5" /><line x1="14" y1="17.5" x2="21" y2="17.5" />
</svg>
</button>
{/if}
<button class="icon-btn" onclick={openSortMenu} title="Sort: {$sortMode}"> <button class="icon-btn" onclick={openSortMenu} title="Sort: {$sortMode}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" /> <line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
</svg> </svg>
</button> </button>
{#if $viewMode !== 'trash'} {#if $viewMode !== 'trash'}
<button class="icon-btn" onclick={handleCreateNote} title={`New note (${modKey}+N)`}> <button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
</button> </button>
@@ -650,6 +854,7 @@
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button> <button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
{:else} {:else}
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button> <button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
<button class="selection-action danger" onclick={handleBatchDelete}>Delete</button> <button class="selection-action danger" onclick={handleBatchDelete}>Delete</button>
{/if} {/if}
<button class="selection-close" onclick={clearSelection} title="Clear selection"> <button class="selection-close" onclick={clearSelection} title="Clear selection">
@@ -688,8 +893,86 @@
</div> </div>
{/if} {/if}
{#if batchTagEdit && !contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-header">
<span>Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="tag-edit-section">
<div class="tag-edit-input-row">
<input
type="text"
class="tag-edit-input"
placeholder="Add tag to all..."
bind:value={tagEditValue}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
/>
</div>
{#if tagEditTags.length > 0}
<div class="tag-edit-list">
{#each tagEditTags as tag}
<div class="tag-edit-item">
<span class="tag-edit-name">#{tag}</span>
<button class="tag-edit-remove" onclick={() => removeTagFromBatch(tag)} title="Remove from all">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/each}
</div>
{:else}
<div class="context-empty">No common tags</div>
{/if}
</div>
</div>
</div>
{/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}> <div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
{#if $sortedNotes.length === 0} {#if $viewMode === 'daily'}
<div class="cal">
<div class="cal-nav">
<button class="cal-nav-btn" onclick={calPrev} title="Previous month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="cal-title" onclick={calToday}>{calMonthNames[calMonth]} {calYear}</button>
<button class="cal-nav-btn" onclick={calNext} title="Next month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="cal-grid">
{#each calDayNames as name}
<div class="cal-head">{name}</div>
{/each}
{#each calDays() as cell}
{#if cell.day === 0}
<div class="cal-cell empty"></div>
{:else}
<button
class="cal-cell"
class:today={cell.current}
class:has-note={dailyDates.has(cell.date)}
class:active={$activeNotePath?.endsWith(`${cell.date}.md`) ?? false}
onclick={() => handleDayClick(cell.date)}
>
{cell.day}
</button>
{/if}
{/each}
</div>
</div>
{/if}
{#if $sortedNotes.length === 0 && $viewMode !== 'daily' && (!($viewMode === 'trash') || trashNotebooks.length === 0)}
<div class="empty-state"> <div class="empty-state">
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
<p>Trash is empty</p> <p>Trash is empty</p>
@@ -700,6 +983,37 @@
</div> </div>
{/if} {/if}
{#if $viewMode === 'trash' && trashNotebooks.length > 0}
{#each trashNotebooks as nb (nb.path)}
<div class="note-item trash-notebook" class:busy={trashBusy === nb.path}>
<div class="note-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
{nb.name}
</div>
{#if trashBusy === nb.path}
<div class="note-preview trash-busy">
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2a10 10 0 0 1 10 10"/></svg>
Working...
</div>
{:else}
<div class="note-preview">{nb.note_count} {nb.note_count === 1 ? 'note' : 'notes'}</div>
<div class="trash-notebook-actions">
<button class="trash-nb-btn restore" onclick={() => handleRestoreNotebook(nb)} title="Restore notebook">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 105.64-11.36L1 10"/></svg>
Restore
</button>
<button class="trash-nb-btn delete" onclick={() => handleDeleteNotebook(nb)} title="Delete permanently">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Delete
</button>
</div>
{/if}
</div>
{/each}
{/if}
<div style="height: {topPad}px"></div> <div style="height: {topPad}px"></div>
{#each visibleNotes as note, i (note.path)} {#each visibleNotes as note, i (note.path)}
{@const noteIndex = startIndex + i} {@const noteIndex = startIndex + i}
@@ -726,6 +1040,10 @@
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'} class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'} class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)} onclick={(e) => handleNoteClick(e, note)}
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
ontouchcancel={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
const pos = clampMenu(e.clientX, e.clientY); const pos = clampMenu(e.clientX, e.clientY);
@@ -995,6 +1313,14 @@
</svg> </svg>
Move to... Move to...
</button> </button>
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
Print / Export to PDF
</button>
{/if}
<button class="danger" onclick={() => handleDelete(contextMenu!.note)}> <button class="danger" onclick={() => handleDelete(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/> <path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
@@ -1066,6 +1392,7 @@
.list-actions { .list-actions {
display: flex; display: flex;
align-items: center;
gap: 2px; gap: 2px;
} }
@@ -1085,12 +1412,30 @@
color: var(--text-primary); color: var(--text-primary);
} }
.icon-btn.active-toggle {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.list-content { .list-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px; padding: 4px;
} }
.list-content::-webkit-scrollbar {
width: 8px;
}
.list-content::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.list-content::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.note-item { .note-item {
display: block; display: block;
width: 100%; width: 100%;
@@ -1566,6 +1911,10 @@
padding: 12px 16px; padding: 12px 16px;
} }
.note-list.mobile .list-actions {
gap: 10px;
}
.note-list.mobile .list-title { .note-list.mobile .list-title {
font-size: 16px; font-size: 16px;
} }
@@ -1630,6 +1979,9 @@
.note-list.mobile .selection-bar { .note-list.mobile .selection-bar {
padding: 8px 16px; padding: 8px 16px;
border-radius: 12px;
margin: 4px 8px;
border-bottom: none;
} }
.note-list.mobile .selection-count { .note-list.mobile .selection-count {
@@ -1640,6 +1992,7 @@
padding: 6px 14px; padding: 6px 14px;
font-size: 13px; font-size: 13px;
min-height: 36px; min-height: 36px;
border-radius: 10px;
} }
.note-list.mobile .context-menu { .note-list.mobile .context-menu {
@@ -1684,4 +2037,204 @@
font-size: 15px; font-size: 15px;
min-height: 44px; min-height: 44px;
} }
/* Mobile selection checkboxes */
.mobile-select-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 4px;
transition: all 0.15s ease;
}
.mobile-select-check.checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Mobile create note button (round, accent-colored) */
.mobile-create-btn {
background: var(--accent);
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.mobile-create-btn:active {
opacity: 0.8;
}
.trash-notebook .note-title {
display: flex;
align-items: center;
gap: 6px;
}
.trash-notebook-actions {
display: flex;
gap: 6px;
margin-top: 6px;
}
.trash-nb-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: none;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
background: var(--bg-hover);
color: var(--text-secondary);
}
.trash-nb-btn:hover {
background: var(--bg-active);
}
.trash-nb-btn.delete:hover {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #ef4444;
}
.trash-notebook.busy {
opacity: 0.6;
pointer-events: none;
}
.trash-busy {
display: flex;
align-items: center;
gap: 6px;
}
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Calendar */
.cal {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.cal-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.cal-nav-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
}
.cal-nav-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.cal-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
background: none;
border: none;
cursor: pointer;
padding: 2px 8px;
border-radius: 4px;
}
.cal-title:hover {
background: var(--bg-hover);
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px 2px;
padding: 0 2px;
}
.cal-head {
font-size: 0.65rem;
color: var(--text-secondary);
text-align: center;
padding: 2px 0;
font-weight: 500;
}
.cal-cell {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
margin: 0 auto;
}
.cal-cell.empty {
cursor: default;
}
.cal-cell:not(.empty):hover {
background: var(--bg-hover);
}
.cal-cell.today {
color: var(--accent);
font-weight: 700;
}
.cal-cell.has-note {
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--text-primary);
font-weight: 600;
}
.cal-cell.has-note:hover {
background: color-mix(in srgb, var(--accent) 25%, transparent);
}
.cal-cell.active {
background: var(--accent);
color: white;
font-weight: 600;
}
.cal-cell.active:hover {
background: var(--accent);
}
</style> </style>
+3 -2
View File
@@ -67,11 +67,12 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 's') { if (mod && !e.shiftKey && e.code === 'KeyS') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return;
} }
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && e.code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
+1 -1
View File
@@ -4,7 +4,7 @@
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry } from '$lib/types'; import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
+58 -4
View File
@@ -15,9 +15,10 @@
notebookIcons, notebookIcons,
appConfig, appConfig,
quickAccessPaths, quickAccessPaths,
collapsedNotebooks collapsedNotebooks,
rootNoteCount
} from '$lib/stores/app'; } from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes } from '$lib/api'; import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs'; import { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
@@ -49,20 +50,25 @@
} }
} }
export async function refresh() { export async function refresh() {
try { try {
if (isMobile) { if (isMobile) {
// On mobile, parallelize and skip getAllTags (derive from $notes instead) // On mobile, parallelize and skip getAllTags (derive from $notes instead)
const [nbs, icons, qaNotes] = await Promise.all([ const [nbs, icons, qaNotes, rootCount] = await Promise.all([
getNotebooks(), getNotebooks(),
getNotebookIcons(), getNotebookIcons(),
getQuickAccess(), getQuickAccess(),
countRootNotes(),
]); ]);
$notebooks = nbs; $notebooks = nbs;
$notebookIcons = icons; $notebookIcons = icons;
$quickAccessPaths = qaNotes.map(n => n.relative_path); $quickAccessPaths = qaNotes.map(n => n.relative_path);
$rootNoteCount = rootCount;
} else { } else {
$notebooks = await getNotebooks(); const [nbs, rootCount] = await Promise.all([getNotebooks(), countRootNotes()]);
$notebooks = nbs;
$rootNoteCount = rootCount;
$tags = await getAllTags(); $tags = await getAllTags();
$notebookIcons = await getNotebookIcons(); $notebookIcons = await getNotebookIcons();
const qaNotes = await getQuickAccess(); const qaNotes = await getQuickAccess();
@@ -80,6 +86,19 @@
onViewChanged(); onViewChanged();
} }
function selectUnfiled() {
const vault = $appConfig?.active_vault;
if (!vault) return;
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
selectAllNotes();
return;
}
$viewMode = 'notebook';
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
$activeTag = null;
onViewChanged();
}
function selectNotebook(nb: NotebookEntry) { function selectNotebook(nb: NotebookEntry) {
// Deselect if clicking the already-active notebook // Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) { if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
@@ -106,6 +125,13 @@
onViewChanged(); onViewChanged();
} }
function selectDaily() {
$viewMode = 'daily';
$activeNotebook = null;
$activeTag = null;
onViewChanged();
}
function selectTrash() { function selectTrash() {
$viewMode = 'trash'; $viewMode = 'trash';
$activeNotebook = null; $activeNotebook = null;
@@ -379,6 +405,20 @@
<span>Quick Access</span> <span>Quick Access</span>
</button> </button>
<button
class="nav-item"
class:active={$viewMode === 'daily'}
onclick={selectDaily}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>Daily Notes</span>
</button>
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'trash'} class:active={$viewMode === 'trash'}
@@ -445,6 +485,20 @@
{/if} {/if}
<div class="notebook-list"> <div class="notebook-list">
{#if $rootNoteCount > 0}
<button
class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled}
>
<span style="width:13px;flex-shrink:0"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
</button>
{/if}
{#each $notebooks as nb (nb.path)} {#each $notebooks as nb (nb.path)}
{@render notebookItem(nb, 0)} {@render notebookItem(nb, 0)}
{/each} {/each}
+1
View File
@@ -221,6 +221,7 @@
color: var(--text-primary); color: var(--text-primary);
} }
.switch-vault-btn.active { .switch-vault-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent); color: var(--accent);
+31 -1
View File
@@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import type { import type {
AppConfig, AppConfig,
NoteEntry, NoteEntry,
@@ -31,6 +31,7 @@ export const collapsedNotebooks = writable<string[]>([]);
// Data // Data
export const notebooks = writable<NotebookEntry[]>([]); export const notebooks = writable<NotebookEntry[]>([]);
export const rootNoteCount = writable<number>(0);
export const notes = writable<NoteEntry[]>([]); export const notes = writable<NoteEntry[]>([]);
export const tags = writable<[string, number][]>([]); export const tags = writable<[string, number][]>([]);
export const activeNote = writable<NoteContent | null>(null); export const activeNote = writable<NoteContent | null>(null);
@@ -105,6 +106,35 @@ export async function checkForUpdateMobile() {
} }
} }
// Note navigation history
interface NavHistoryState { stack: string[]; index: number; skipping: boolean; }
function createNavHistory() {
const store = writable<NavHistoryState>({ stack: [], index: -1, skipping: false });
return {
subscribe: store.subscribe,
push(path: string) {
store.update(s => {
if (s.skipping) return { ...s, skipping: false };
const trimmed = s.stack.slice(0, s.index + 1);
return { stack: [...trimmed, path], index: trimmed.length, skipping: false };
});
},
go(direction: -1 | 1): string | null {
let target: string | null = null;
store.update(s => {
const newIdx = s.index + direction;
if (newIdx < 0 || newIdx >= s.stack.length) return s;
target = s.stack[newIdx];
return { ...s, index: newIdx, skipping: true };
});
return target;
},
};
}
export const navHistory = createNavHistory();
export const canGoBack = derived(navHistory, $h => $h.index > 0);
export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length - 1);
// Derived // Derived
export const sortedNotes = derived( export const sortedNotes = derived(
[notes, sortMode, viewMode], [notes, sortMode, viewMode],
+14 -1
View File
@@ -14,6 +14,18 @@ export interface NoteEntry {
preview: string; preview: string;
} }
export interface TrashNotebookEntry {
name: string;
path: string;
note_count: number;
modified: string;
}
export interface TrashContents {
notes: NoteEntry[];
notebooks: TrashNotebookEntry[];
}
export interface NotebookEntry { export interface NotebookEntry {
name: string; name: string;
path: string; path: string;
@@ -131,4 +143,5 @@ export type ViewMode =
| "tag" | "tag"
| "trash" | "trash"
| "search" | "search"
| "quickaccess"; | "quickaccess"
| "daily";
+106 -25
View File
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import '../app.css'; import '../app.css';
import { theme, appConfig, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app'; import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { openFile, getInstallType } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
let { children } = $props(); let { children } = $props();
@@ -33,6 +32,53 @@
return resolved.join('/'); return resolved.join('/');
} }
const isAndroid = /android/i.test(navigator.userAgent);
function openLocalFile(path: string) {
if (isAndroid) {
const bridge = (window as any).Android;
if (bridge && typeof bridge.openFile === 'function') {
bridge.openFile(path);
}
} else {
openFile(path).catch((err) => console.error('Failed to open file:', err));
}
}
function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig);
const vaultRoot = config?.active_vault;
let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) {
// .helixnotes/ paths are always relative to vault root, not the note's directory
if (decoded.startsWith('.helixnotes/')) {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
} else {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
}
// Internal .md note link — navigate within the app
if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => {
activeNote.set({ ...content, content: content.content });
activeNotePath.set(absPath);
}).catch((err) => console.error('Failed to navigate to note:', err));
} else {
openLocalFile(absPath);
}
}
}
// Detect install type and check for updates on startup // Detect install type and check for updates on startup
onMount(() => { onMount(() => {
if (isMobile) { if (isMobile) {
@@ -47,37 +93,72 @@
// Intercept all link clicks in capture phase to prevent webview navigation // Intercept all link clicks in capture phase to prevent webview navigation
onMount(() => { onMount(() => {
function handleLinkClick(e: MouseEvent) { function handleLinkClick(e: MouseEvent) {
const target = (e.target as HTMLElement)?.closest('a'); const el = e.target as HTMLElement;
// PDF/attachment embeds: check for data-open-file on target or ancestors
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
if (fileTarget) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
openLocalFile(fileTarget.getAttribute('data-open-file')!);
return;
}
const target = el?.closest('a');
if (!target) return; if (!target) return;
const href = target.getAttribute('href'); const href = target.getAttribute('href');
if (!href) return; if (!href) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
resolveAndHandleLink(href);
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig);
const vaultRoot = config?.active_vault;
let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
openFile(absPath).catch((err) => console.error('Failed to open file:', err));
}
} }
document.addEventListener('click', handleLinkClick, true); document.addEventListener('click', handleLinkClick, true);
return () => document.removeEventListener('click', handleLinkClick, true);
// On Android WebView, tapping a link can trigger native navigation before
// the JS click event fires (causing 404s for relative .md links).
// We intercept touchend: if it's a tap (not a scroll) on an <a>, we
// preventDefault to block the native navigation and handle it ourselves.
let touchStartY = 0;
function handleTouchStart(e: TouchEvent) {
touchStartY = e.touches[0]?.clientY ?? 0;
}
function handleTouchEnd(e: TouchEvent) {
const touch = e.changedTouches[0];
if (!touch) return;
const endY = touch.clientY;
// If the finger moved > 10px, it's a scroll, not a tap
if (Math.abs(endY - touchStartY) > 10) return;
// Android WebView may report e.target as the editor root for elements
// inside contenteditable="false" blocks; use elementFromPoint instead
const el = (document.elementFromPoint(touch.clientX, touch.clientY) ?? e.target) as HTMLElement;
// PDF/attachment embeds: check for data-open-file on target or ancestors
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
if (fileTarget) {
e.preventDefault();
openLocalFile(fileTarget.getAttribute('data-open-file')!);
return;
}
const target = el?.closest('a');
if (!target) return;
const href = target.getAttribute('href');
if (!href) return;
e.preventDefault();
resolveAndHandleLink(href);
}
if (isMobile) {
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: true });
document.addEventListener('touchend', handleTouchEnd, true);
}
return () => {
document.removeEventListener('click', handleLinkClick, true);
if (isMobile) {
document.removeEventListener('touchstart', handleTouchStart, true);
document.removeEventListener('touchend', handleTouchEnd, true);
}
};
}); });
</script> </script>
+18 -18
View File
@@ -1,20 +1,20 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler",
} },
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
// //
// To make changes to top-level options such as include and exclude, we recommend extending // To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
} }