diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dc18883..32413c6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -589,6 +589,254 @@ fn extract_title_fast(raw: &str) -> Option { None } +// ── Tasks ── + +#[tauri::command] +pub fn get_tasks(state: State<'_, AppState>) -> Result, 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 = std::path::Path::new(vault_path); + let hn_dir = operations::helixnotes_dir(vault_path); + + let task_re = regex::Regex::new(r"^\s*[-*]\s\[([ xX])\]\s+(.+?)\s*$").unwrap(); + let due_re = regex::Regex::new(r"\bdue:(\d{4}-\d{2}-\d{2})\b").unwrap(); + let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(high|medium|med|low)\b").unwrap(); + + let mut tasks = Vec::new(); + for entry in walkdir::WalkDir::new(vault) + .into_iter() + .filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir)) + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if !path.is_file() { + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let raw = match std::fs::read_to_string(path) { + Ok(r) => r, + Err(_) => continue, + }; + let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename); + let note_path = path.to_string_lossy().to_string(); + + for (i, line) in body.lines().enumerate() { + let caps = match task_re.captures(line) { + Some(c) => c, + None => continue, + }; + let completed = !caps[1].trim().is_empty(); // 'x'/'X' completed, ' ' open + let content = caps[2].to_string(); + + let due = due_re.captures(&content).map(|c| c[1].to_string()); + let priority = prio_re.captures(&content).map(|c| { + let p = c[1].to_lowercase(); + if p == "medium" { "med".to_string() } else { p } + }); + // Strip metadata tokens for the display text, then collapse whitespace. + let mut text = due_re.replace_all(&content, "").to_string(); + text = prio_re.replace_all(&text, " ").to_string(); + let text = text.split_whitespace().collect::>().join(" "); + + tasks.push(crate::types::TaskItem { + note_path: note_path.clone(), + note_title: meta.title.clone(), + line: i, + raw_line: line.to_string(), + text, + completed, + due, + priority, + }); + } + } + Ok(tasks) +} + +fn toggle_checkbox_line(line: &str, done: bool) -> String { + let mut s = line.to_string(); + if done { + if let Some(pos) = s.find("[ ]") { + s.replace_range(pos..pos + 3, "[x]"); + } + } else { + for marker in ["[x]", "[X]"] { + if let Some(pos) = s.find(marker) { + s.replace_range(pos..pos + 3, "[ ]"); + break; + } + } + } + s +} + +#[tauri::command] +pub fn set_task_done( + state: State<'_, AppState>, + note_path: String, + line: usize, + raw_line: String, + done: bool, +) -> Result<(), String> { + let p = std::path::Path::new(¬e_path); + let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?; + let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename); + + let mut lines: Vec = body.lines().map(|l| l.to_string()).collect(); + // Verify the expected line; if the note drifted, fall back to the first exact match. + let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) { + line + } else { + lines + .iter() + .position(|l| *l == raw_line) + .ok_or("Task line not found (note changed)")? + }; + + let toggled = toggle_checkbox_line(&lines[idx], done); + if toggled == lines[idx] { + return Ok(()); // already in the desired state + } + lines[idx] = toggled; + let mut new_body = lines.join("\n"); + if body.ends_with('\n') && !new_body.ends_with('\n') { + new_body.push('\n'); + } + operations::save_note(¬e_path, &meta, &new_body)?; + + if let Ok(guard) = state.search_index.lock() { + if let Some(search) = guard.as_ref() { + let _ = search.index_note(¬e_path); + } + } + Ok(()) +} + +fn set_priority_on_line(line: &str, priority: Option<&str>) -> String { + let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(?:high|medium|med|low)\b").unwrap(); + let stripped = prio_re.replace_all(line, "").to_string(); + let stripped = stripped.trim_end(); + match priority { + Some(p) => format!("{} !{}", stripped, p), + None => stripped.to_string(), + } +} + +#[tauri::command] +pub fn set_task_priority( + state: State<'_, AppState>, + note_path: String, + line: usize, + raw_line: String, + priority: Option, +) -> Result<(), String> { + // Normalize/validate priority ("medium" -> "med"); None clears it. + let prio = match priority.as_deref() { + None | Some("") | Some("none") => None, + Some("high") => Some("high"), + Some("med") | Some("medium") => Some("med"), + Some("low") => Some("low"), + Some(_) => return Err("Invalid priority".to_string()), + }; + + let p = std::path::Path::new(¬e_path); + let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?; + let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename); + + let mut lines: Vec = body.lines().map(|l| l.to_string()).collect(); + let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) { + line + } else { + lines + .iter() + .position(|l| *l == raw_line) + .ok_or("Task line not found (note changed)")? + }; + + let updated = set_priority_on_line(&lines[idx], prio); + if updated == lines[idx] { + return Ok(()); + } + lines[idx] = updated; + let mut new_body = lines.join("\n"); + if body.ends_with('\n') && !new_body.ends_with('\n') { + new_body.push('\n'); + } + operations::save_note(¬e_path, &meta, &new_body)?; + + if let Ok(guard) = state.search_index.lock() { + if let Some(search) = guard.as_ref() { + let _ = search.index_note(¬e_path); + } + } + Ok(()) +} + +fn set_due_on_line(line: &str, due: Option<&str>) -> String { + let due_re = regex::Regex::new(r"(?:^|\s)due:\d{4}-\d{2}-\d{2}\b").unwrap(); + let stripped = due_re.replace_all(line, "").to_string(); + let stripped = stripped.trim_end(); + match due { + Some(d) => format!("{} due:{}", stripped, d), + None => stripped.to_string(), + } +} + +#[tauri::command] +pub fn set_task_due( + state: State<'_, AppState>, + note_path: String, + line: usize, + raw_line: String, + due: Option, +) -> Result<(), String> { + // Validate format (YYYY-MM-DD); None/empty clears the due date. + let date_re = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); + let due_val: Option<&str> = match due.as_deref() { + None | Some("") => None, + Some(d) if date_re.is_match(d) => Some(d), + Some(_) => return Err("Invalid due date".to_string()), + }; + + let p = std::path::Path::new(¬e_path); + let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?; + let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename); + + let mut lines: Vec = body.lines().map(|l| l.to_string()).collect(); + let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) { + line + } else { + lines + .iter() + .position(|l| *l == raw_line) + .ok_or("Task line not found (note changed)")? + }; + + let updated = set_due_on_line(&lines[idx], due_val); + if updated == lines[idx] { + return Ok(()); + } + lines[idx] = updated; + let mut new_body = lines.join("\n"); + if body.ends_with('\n') && !new_body.ends_with('\n') { + new_body.push('\n'); + } + operations::save_note(¬e_path, &meta, &new_body)?; + + if let Ok(guard) = state.search_index.lock() { + if let Some(search) = guard.as_ref() { + let _ = search.index_note(¬e_path); + } + } + Ok(()) +} + // ── Search ── #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3dfa52e..7ae937b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -122,6 +122,10 @@ pub fn run() { commands::get_all_tags, commands::get_all_note_titles, commands::get_graph_data, + commands::get_tasks, + commands::set_task_done, + commands::set_task_priority, + commands::set_task_due, commands::search_notes, commands::reindex, commands::get_trash, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 127bcc7..ee6d7e5 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -348,3 +348,15 @@ pub struct GraphEdge { pub target: usize, pub bidirectional: bool, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskItem { + pub note_path: String, + pub note_title: String, + pub line: usize, + pub raw_line: String, + pub text: String, + pub completed: bool, + pub due: Option, + pub priority: Option, +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 79e244a..09fb49e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -13,6 +13,7 @@ import type { ImportResult, BackupEntry, VersionEntry, + TaskItem, } from "./types"; export async function openVault(path: string): Promise { @@ -331,6 +332,39 @@ export async function setBackupSettings( }); } +// ── Tasks ── + +export async function getTasks(): Promise { + return invoke("get_tasks"); +} + +export async function setTaskDone( + notePath: string, + line: number, + rawLine: string, + done: boolean, +): Promise { + return invoke("set_task_done", { notePath, line, rawLine, done }); +} + +export async function setTaskPriority( + notePath: string, + line: number, + rawLine: string, + priority: string | null, +): Promise { + return invoke("set_task_priority", { notePath, line, rawLine, priority }); +} + +export async function setTaskDue( + notePath: string, + line: number, + rawLine: string, + due: string | null, +): Promise { + return invoke("set_task_due", { notePath, line, rawLine, due }); +} + // ── Sync (WebDAV) ── export async function setSyncSettings( diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index 149600f..aace96e 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -51,11 +51,11 @@ const isMac = navigator.platform.startsWith('Mac'); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); const isAndroid = /android/i.test(navigator.userAgent); - import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow } from '$lib/api'; + import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api'; import { debounce } from '$lib/utils/debounce'; import { openNoteWindow } from '$lib/utils/window'; import { get } from 'svelte/store'; - import type { VaultState, FileEvent, NotebookEntry } from '$lib/types'; + import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types'; function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null { for (const nb of list) { @@ -217,14 +217,19 @@ persistState(); } + // Tasks view: the editor pane shows a placeholder until a task is opened from the list. + let taskNoteOpened = $state(false); + function handleNoteSelected(path: string, content: string) { // Selecting a real vault note exits viewer mode $viewerNote = null; + taskNoteOpened = true; editor?.loadNote(path, content); if (isMobile) $mobileView = 'editor'; } function handleViewChanged() { + taskNoteOpened = false; noteList?.refresh(); if (isMobile) $mobileView = 'notelist'; } @@ -235,6 +240,63 @@ if (isMobile) $mobileView = 'editor'; } + // Toggle a task done from the Tasks view. If the task's note is open in the editor, + // flush first then reload after the file edit (so we never clobber unsaved edits). + async function toggleTask(task: TaskItem) { + const done = !task.completed; + const isActive = task.note_path === $activeNotePath; + if (isActive) await editor?.forceSave(); + try { + await setTaskDone(task.note_path, task.line, task.raw_line, done); + } catch (e) { + console.error('Failed to toggle task:', e); + } + if (isActive) { + try { + const content = await readNote(task.note_path); + $activeNote = content; + $editorDirty = false; + editor?.loadNote(task.note_path, content.content); + } catch (_) {} + } + } + + async function changeTaskPriority(task: TaskItem, priority: string | null) { + const isActive = task.note_path === $activeNotePath; + if (isActive) await editor?.forceSave(); + try { + await setTaskPriority(task.note_path, task.line, task.raw_line, priority); + } catch (e) { + console.error('Failed to set task priority:', e); + } + if (isActive) { + try { + const content = await readNote(task.note_path); + $activeNote = content; + $editorDirty = false; + editor?.loadNote(task.note_path, content.content); + } catch (_) {} + } + } + + async function changeTaskDue(task: TaskItem, due: string | null) { + const isActive = task.note_path === $activeNotePath; + if (isActive) await editor?.forceSave(); + try { + await setTaskDue(task.note_path, task.line, task.raw_line, due); + } catch (e) { + console.error('Failed to set task due date:', e); + } + if (isActive) { + try { + const content = await readNote(task.note_path); + $activeNote = content; + $editorDirty = false; + editor?.loadNote(task.note_path, content.content); + } catch (_) {} + } + } + async function handleDailyNote() { try { const entry = await createDailyNote(); @@ -757,7 +819,7 @@
- editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} /> + editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
@@ -872,6 +942,21 @@ height: 100%; overflow: hidden; min-width: 300px; + position: relative; + } + + .tasks-editor-placeholder { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: var(--bg-primary); + color: var(--text-tertiary); + font-size: 14px; } .focus-topbar { diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index a9db31a..495529b 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -37,13 +37,17 @@ import { formatRelativeTime, formatDate } from '$lib/utils/time'; import { openNoteWindow } from '$lib/utils/window'; import { revealItemInDir } from '@tauri-apps/plugin-opener'; - import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types'; + import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types'; + import TasksView from './TasksView.svelte'; - let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: { + let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: { onNoteSelected?: (path: string, content: string) => void; onNoteMoved?: () => void; onBeforeNoteSwitch?: () => void; onNoteCreated?: () => void; + onToggleTask?: (t: TaskItem) => Promise; + onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise; + onSetTaskDue?: (t: TaskItem, d: string | null) => Promise; } = $props(); const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; @@ -281,6 +285,7 @@ if ($viewMode === 'tag') return `#${$activeTag}`; if ($viewMode === 'quickaccess') return 'Quick Access'; if ($viewMode === 'daily') return 'Daily Notes'; + if ($viewMode === 'tasks') return 'Tasks'; if ($viewMode === 'trash') return 'Trash'; return 'Notes'; }); @@ -298,6 +303,7 @@ } export async function refresh(invalidate = false) { + if ($viewMode === 'tasks') { $notes = []; return; } // Tasks view fetches its own data if (invalidate) { noteCache.clear(); } @@ -366,6 +372,19 @@ } } + async function openTask(task: TaskItem) { + try { + const content = await readNote(task.note_path); + onBeforeNoteSwitch(); + $activeNote = content; + $activeNotePath = task.note_path; + $editorDirty = false; + onNoteSelected(task.note_path, content.content); + } catch (e) { + console.error('Failed to open task note:', e); + } + } + export async function handleCreateNote() { if ($viewMode === 'daily') { const today = new Date(); @@ -836,6 +855,7 @@
{viewTitle}
+ {#if $viewMode !== 'tasks'} {#if isAndroid} {/if} + {/if}
@@ -951,6 +972,9 @@ {/if}
+ {#if $viewMode === 'tasks'} + + {/if} {#if $viewMode === 'daily'}
@@ -985,7 +1009,7 @@
{/if} - {#if $sortedNotes.length === 0 && $viewMode !== 'daily' && (!($viewMode === 'trash') || trashNotebooks.length === 0)} + {#if $sortedNotes.length === 0 && $viewMode !== 'daily' && $viewMode !== 'tasks' && (!($viewMode === 'trash') || trashNotebooks.length === 0)}
{#if $viewMode === 'trash'}

Trash is empty

diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 1693427..84d6cf4 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -150,6 +150,13 @@ onViewChanged(); } + function selectTasks() { + $viewMode = 'tasks'; + $activeNotebook = null; + $activeTag = null; + onViewChanged(); + } + function selectTrash() { $viewMode = 'trash'; $activeNotebook = null; @@ -511,6 +518,18 @@ Quick Access + + + {/if} +
+ +
+ + +
+ {#if viewLayout === 'list'} +
+ {#each [['due', 'Due'], ['priority', 'Priority'], ['note', 'Note']] as [v, label]} + + {/each} +
+ {/if} +
+ + {#snippet taskRow(t: TaskItem, dragMode: boolean)} + +
{ if (!dragMode) return; draggedTask = t; if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', rowKey(t)); } }} + ondragend={() => { draggedTask = null; dragOverDate = null; }} + > + + + +
onOpenTask(t)}> +
+ + + {#if prioMenuFor === rowKey(t)} + + {/if} + + {t.text} + + + {#if dueMenuFor === rowKey(t)} +
+ e.stopPropagation()} + onchange={(e) => { e.stopPropagation(); chooseDue(t, (e.target as HTMLInputElement).value || null); }} + /> + {#if t.due}{/if} +
+ {/if} +
+
+
{t.note_title}
+
+
+ {/snippet} + + {#if loading} +
Loading...
+ {:else if viewLayout === 'calendar'} +
+
+
+ + + +
+
+ {#each calDayNames as name}
{name}
{/each} + {#each calDays() as cell} + {#if cell.day === 0} +
+ {:else} + {@const cnt = (tasksByDate.get(cell.date) ?? []).length} + + {/if} + {/each} +
+
+
+
+
{agendaTitle(selectedDate)}
+ {#if selectedDayTasks.length === 0} +
Nothing due.
+ {:else} + {#each selectedDayTasks as t (rowKey(t))}{@render taskRow(t, true)}{/each} + {/if} +
+ {#if undatedTasks.length} +
+
No due date ({undatedTasks.length})
+ {#each undatedTasks as t (rowKey(t))}{@render taskRow(t, true)}{/each} +
+ {/if} +
+
+ {:else if filtered.length === 0} +
No tasks. Add - [ ] something to any note.{#if openCount === 0 && tasks.length}{' '}All done!{/if}
+ {:else} +
+ {#each filtered as t (rowKey(t))}{@render taskRow(t, false)}{/each} +
+ {/if} +
+ + diff --git a/src/lib/types.ts b/src/lib/types.ts index 380a93d..bf44525 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -164,4 +164,16 @@ export type ViewMode = | "trash" | "search" | "quickaccess" - | "daily"; + | "daily" + | "tasks"; + +export interface TaskItem { + note_path: string; + note_title: string; + line: number; + raw_line: string; + text: string; + completed: boolean; + due: string | null; + priority: string | null; +}