diff --git a/Journal.App/src-tauri/Cargo.lock b/Journal.App/src-tauri/Cargo.lock index bb657f8..e48c277 100644 --- a/Journal.App/src-tauri/Cargo.lock +++ b/Journal.App/src-tauri/Cargo.lock @@ -1777,6 +1777,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "tokio", ] [[package]] @@ -3863,6 +3864,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "windows-sys 0.61.2", ] diff --git a/Journal.App/src-tauri/Cargo.toml b/Journal.App/src-tauri/Cargo.toml index 7c025f7..f0753c2 100644 --- a/Journal.App/src-tauri/Cargo.toml +++ b/Journal.App/src-tauri/Cargo.toml @@ -22,4 +22,4 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" - +tokio = { version = "1", features = ["process", "io-util", "sync"] } diff --git a/Journal.App/src-tauri/src/lib.rs b/Journal.App/src-tauri/src/lib.rs index 92cbebc..1846b46 100644 --- a/Journal.App/src-tauri/src/lib.rs +++ b/Journal.App/src-tauri/src/lib.rs @@ -1,11 +1,13 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::env; -use std::io::{BufRead, BufReader, Write}; -use std::path::PathBuf; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::Mutex; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Stdio; use tauri::Manager; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::sync::Mutex; #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -23,6 +25,12 @@ struct CommandEnvelope { payload: Option, } +#[derive(Deserialize, Serialize, Default)] +struct AppSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + sidecar_root: Option, +} + struct ManagedSidecar { child: Child, stdin: ChildStdin, @@ -30,21 +38,18 @@ struct ManagedSidecar { } impl ManagedSidecar { - fn start() -> Result { - let sidecar_path = resolve_sidecar_path()?; - let root = project_root()?; - eprintln!( - "[sidecar] starting exe={} project_root={}", - sidecar_path.display(), - root.display() - ); - - let mut child = Command::new(sidecar_path) - .stdin(Stdio::piped()) + fn start(root: &Path) -> Result { + let sidecar_path = resolve_sidecar_path(root)?; + let mut cmd = Command::new(sidecar_path); + cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) - .current_dir(&root) - .env("JOURNAL_PROJECT_ROOT", &root) + .current_dir(root) + .env("JOURNAL_PROJECT_ROOT", root) + .kill_on_drop(true); + #[cfg(windows)] + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + let mut child = cmd .spawn() .map_err(|err| format!("Failed to start sidecar process: {err}"))?; @@ -67,29 +72,26 @@ impl ManagedSidecar { fn is_running(&mut self) -> bool { match self.child.try_wait() { Ok(None) => true, - Ok(Some(status)) => { - eprintln!("[sidecar] exited status={status}"); - false - } - Err(err) => { - eprintln!("[sidecar] try_wait_error={err}"); - false - } + Ok(Some(_)) => false, + Err(_) => false, } } - fn send_command_line(&mut self, input_line: &str) -> Result { + async fn send_command_line(&mut self, input_line: &str) -> Result { self.stdin .write_all(format!("{input_line}\n").as_bytes()) + .await .map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?; self.stdin .flush() + .await .map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?; let mut response_line = String::new(); let read = self .stdout .read_line(&mut response_line) + .await .map_err(|err| format!("Failed reading sidecar stdout: {err}"))?; if read == 0 { return Err("Sidecar stdout closed unexpectedly.".to_string()); @@ -105,43 +107,49 @@ impl ManagedSidecar { } impl Drop for ManagedSidecar { - fn drop(&mut self) { - if let Ok(None) = self.child.try_wait() { - if let Err(err) = self.child.kill() { - eprintln!("[sidecar] kill_on_drop_error={err}"); - return; - } - if let Err(err) = self.child.wait() { - eprintln!("[sidecar] wait_on_drop_error={err}"); - } else { - eprintln!("[sidecar] stopped_on_drop"); - } - } - } + fn drop(&mut self) {} } -#[derive(Default)] struct SidecarState { process: Mutex>, + root_override: Mutex>, + config_path: PathBuf, } -fn project_root() -> Result { +fn load_settings(path: &Path) -> AppSettings { + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> { + let json = serde_json::to_string_pretty(settings) + .map_err(|e| format!("Failed to serialize settings: {e}"))?; + fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}")) +} + +fn auto_detect_root() -> Result { let mut current = env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?; - loop { if current.join("Journal.Sidecar").exists() { return Ok(current); } - if !current.pop() { return Err("Unable to locate repository root containing Journal.Sidecar.".to_string()); } } } -fn resolve_sidecar_path() -> Result { - let root = project_root()?; +fn effective_root(root_override: &Option) -> Result { + if let Some(root) = root_override { + return Ok(root.clone()); + } + auto_detect_root() +} + +fn resolve_sidecar_path(root: &Path) -> Result { let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe"); if debug_path.exists() { return Ok(debug_path); @@ -156,11 +164,15 @@ fn resolve_sidecar_path() -> Result { Err("Journal.Sidecar.exe not found. Build Journal.Sidecar first.".to_string()) } -fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result { - let mut guard = state - .process - .lock() - .map_err(|_| "Failed to lock sidecar state.".to_string())?; +async fn send_with_managed_sidecar( + state: &SidecarState, + input_line: &str, +) -> Result { + let root = { + let root_override = state.root_override.lock().await; + effective_root(&root_override)? + }; + let mut guard = state.process.lock().await; for attempt in 1..=2 { let should_start = match guard.as_mut() { @@ -168,17 +180,16 @@ fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result true, }; if should_start { - *guard = Some(ManagedSidecar::start()?); + *guard = Some(ManagedSidecar::start(&root)?); } let Some(process) = guard.as_mut() else { return Err("Sidecar process unavailable.".to_string()); }; - match process.send_command_line(input_line) { + match process.send_command_line(input_line).await { Ok(line) => return Ok(line), Err(err) => { - eprintln!("[sidecar] send_error attempt={attempt} error={err}"); *guard = None; if attempt == 2 { return Err(err); @@ -190,26 +201,75 @@ fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result) -> Result { + let root_override = state.root_override.lock().await.clone(); + let root = effective_root(&root_override)?; + Ok(serde_json::json!({ + "root": root.to_string_lossy(), + "isCustom": root_override.is_some() + })) +} + +#[tauri::command] +async fn set_sidecar_root( + state: tauri::State<'_, SidecarState>, + path: String, +) -> Result { + let (new_override, root) = if path.trim().is_empty() { + let detected = auto_detect_root()?; + (None, detected) + } else { + let new_root = PathBuf::from(&path); + if !new_root.exists() { + return Err(format!( + "Directory '{}' does not exist.", + new_root.display() + )); + } + resolve_sidecar_path(&new_root)?; + (Some(new_root.clone()), new_root) }; - if guard.take().is_some() { - eprintln!("[sidecar] stop_requested"); + // Stop the current sidecar so it restarts with new root + { + let mut guard = state.process.lock().await; + guard.take(); } + + let is_custom = new_override.is_some(); + *state.root_override.lock().await = new_override.clone(); + + save_settings( + &state.config_path, + &AppSettings { + sidecar_root: new_override.map(|p| p.to_string_lossy().into_owned()), + }, + )?; + + Ok(serde_json::json!({ + "root": root.to_string_lossy(), + "isCustom": is_custom + })) } #[tauri::command] -fn shutdown(state: tauri::State<'_, SidecarState>, app_handle: tauri::AppHandle) { - eprintln!("[app] shutdown requested"); - stop_managed_sidecar(state.inner()); +async fn shutdown( + state: tauri::State<'_, SidecarState>, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + stop_managed_sidecar(state.inner()).await; app_handle.exit(0); + Ok(()) } #[tauri::command] -fn sidecar_command( +async fn sidecar_command( state: tauri::State<'_, SidecarState>, command: CommandEnvelope, ) -> Result { @@ -217,16 +277,9 @@ fn sidecar_command( return Err("Missing action".to_string()); } - eprintln!( - "[sidecar_command] action={} correlationId={:?} id={:?}", - command.action, command.correlation_id, command.id - ); - let input_line = serde_json::to_string(&command) .map_err(|err| format!("Serialize command failed: {err}"))?; - let response_line = send_with_managed_sidecar(state.inner(), &input_line)?; - - eprintln!("[sidecar_command] response={response_line}"); + let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?; serde_json::from_str::(&response_line) .map_err(|err| format!("Invalid sidecar JSON response: {err}")) } @@ -234,16 +287,36 @@ fn sidecar_command( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let app = tauri::Builder::default() - .manage(SidecarState::default()) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![sidecar_command, shutdown]) + .invoke_handler(tauri::generate_handler![ + sidecar_command, + shutdown, + get_sidecar_root, + set_sidecar_root, + ]) + .setup(|app| { + let config_dir = app.path().app_config_dir()?; + fs::create_dir_all(&config_dir).ok(); + let config_path = config_dir.join("settings.json"); + let settings = load_settings(&config_path); + let root_override = settings.sidecar_root.map(PathBuf::from); + + app.manage(SidecarState { + process: Mutex::new(None), + root_override: Mutex::new(root_override), + config_path, + }); + Ok(()) + }) .build(tauri::generate_context!()) .expect("error while building tauri application"); app.run(|app_handle, event| { if let tauri::RunEvent::ExitRequested { .. } = event { let state = app_handle.state::(); - stop_managed_sidecar(state.inner()); + if let Ok(mut guard) = state.process.try_lock() { + guard.take(); + }; } }); } diff --git a/Journal.App/src/lib/backend/client.ts b/Journal.App/src/lib/backend/client.ts index 7254252..251886b 100644 --- a/Journal.App/src/lib/backend/client.ts +++ b/Journal.App/src/lib/backend/client.ts @@ -10,27 +10,11 @@ export async function sendCommand(command: BackendCommand): Promise { ...command, correlationId: command.correlationId ?? newCorrelationId() }; - console.info("[backend] send", { - action: envelope.action, - correlationId: envelope.correlationId, - id: envelope.id - }); const response = await invoke>("sidecar_command", { command: envelope }); if (!response.ok) { - console.error("[backend] error", { - action: envelope.action, - correlationId: envelope.correlationId, - id: envelope.id, - error: response.error - }); throw new Error(response.error || "Backend command failed"); } - console.info("[backend] ok", { - action: envelope.action, - correlationId: envelope.correlationId, - id: envelope.id - }); return response.data; } diff --git a/Journal.App/src/lib/backend/lists.ts b/Journal.App/src/lib/backend/lists.ts new file mode 100644 index 0000000..74ec4d8 --- /dev/null +++ b/Journal.App/src/lib/backend/lists.ts @@ -0,0 +1,83 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +export type ListDocumentDto = { + id: string; + label: string; + content: string; + createdAt: string; + updatedAt: string; +}; + +export type CreateListPayload = { + label: string; + content?: string; +}; + +export type UpdateListPayload = { + label?: string; + content?: string; +}; + +type ListDocumentDtoRaw = { + id?: string; + label?: string; + content?: string; + createdAt?: string; + updatedAt?: string; + Id?: string; + Label?: string; + Content?: string; + CreatedAt?: string; + UpdatedAt?: string; +}; + +export function normalizeList(raw: ListDocumentDtoRaw): ListDocumentDto { + return { + id: pickCase(raw, "id", "Id", ""), + label: pickCase(raw, "label", "Label", ""), + content: pickCase(raw, "content", "Content", ""), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", "") + }; +} + +export async function listLists(): Promise { + const data = await sendCommand({ + action: "lists.list" + }); + return data.map(normalizeList).filter((item) => Boolean(item.id)); +} + +export async function getList(id: string): Promise { + const data = await sendCommand({ + action: "lists.get", + id + }); + if (!data) return null; + const normalized = normalizeList(data); + return normalized.id ? normalized : null; +} + +export async function createList(payload: CreateListPayload): Promise { + const data = await sendCommand({ + action: "lists.create", + payload + }); + return normalizeList(data); +} + +export function updateList(id: string, payload: UpdateListPayload): Promise { + return sendCommand({ + action: "lists.update", + id, + payload + }); +} + +export function deleteList(id: string): Promise { + return sendCommand({ + action: "lists.delete", + id + }); +} diff --git a/Journal.App/src/lib/backend/todos.ts b/Journal.App/src/lib/backend/todos.ts new file mode 100644 index 0000000..1802e9e --- /dev/null +++ b/Journal.App/src/lib/backend/todos.ts @@ -0,0 +1,144 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +export type TodoItemDto = { + id: string; + listId: string; + text: string; + done: boolean; + sortOrder: number; +}; + +export type TodoListDto = { + id: string; + label: string; + createdAt: string; + items: TodoItemDto[]; +}; + +export type CreateTodoListPayload = { + label: string; +}; + +export type UpdateTodoListPayload = { + label?: string; +}; + +export type CreateTodoItemPayload = { + listId: string; + text: string; + sortOrder?: number; +}; + +export type UpdateTodoItemPayload = { + text?: string; + done?: boolean; + sortOrder?: number; +}; + +type TodoItemDtoRaw = { + id?: string; + listId?: string; + text?: string; + done?: boolean; + sortOrder?: number; + Id?: string; + ListId?: string; + Text?: string; + Done?: boolean; + SortOrder?: number; +}; + +type TodoListDtoRaw = { + id?: string; + label?: string; + createdAt?: string; + items?: TodoItemDtoRaw[]; + Id?: string; + Label?: string; + CreatedAt?: string; + Items?: TodoItemDtoRaw[]; +}; + +function normalizeItem(raw: TodoItemDtoRaw): TodoItemDto { + return { + id: pickCase(raw, "id", "Id", ""), + listId: pickCase(raw, "listId", "ListId", ""), + text: pickCase(raw, "text", "Text", ""), + done: pickCase(raw, "done", "Done", false), + sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0) + }; +} + +function normalizeList(raw: TodoListDtoRaw): TodoListDto { + const rawItems = pickCase(raw, "items", "Items", [] as TodoItemDtoRaw[]); + return { + id: pickCase(raw, "id", "Id", ""), + label: pickCase(raw, "label", "Label", ""), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + items: rawItems.map(normalizeItem) + }; +} + +export async function listTodoLists(): Promise { + const data = await sendCommand({ + action: "todos.list" + }); + return data.map(normalizeList).filter((item) => Boolean(item.id)); +} + +export async function getTodoList(id: string): Promise { + const data = await sendCommand({ + action: "todos.get", + id + }); + if (!data) return null; + const normalized = normalizeList(data); + return normalized.id ? normalized : null; +} + +export async function createTodoList(payload: CreateTodoListPayload): Promise { + const data = await sendCommand({ + action: "todos.create", + payload + }); + return normalizeList(data); +} + +export function updateTodoList(id: string, payload: UpdateTodoListPayload): Promise { + return sendCommand({ + action: "todos.update", + id, + payload + }); +} + +export function deleteTodoList(id: string): Promise { + return sendCommand({ + action: "todos.delete", + id + }); +} + +export async function createTodoItem(payload: CreateTodoItemPayload): Promise { + const data = await sendCommand({ + action: "todos.items.create", + payload + }); + return normalizeItem(data); +} + +export function updateTodoItem(id: string, payload: UpdateTodoItemPayload): Promise { + return sendCommand({ + action: "todos.items.update", + id, + payload + }); +} + +export function deleteTodoItem(id: string): Promise { + return sendCommand({ + action: "todos.items.delete", + id + }); +} diff --git a/Journal.App/src/lib/components/EditorPanel.svelte b/Journal.App/src/lib/components/EditorPanel.svelte index 6a72bd5..76a6a68 100644 --- a/Journal.App/src/lib/components/EditorPanel.svelte +++ b/Journal.App/src/lib/components/EditorPanel.svelte @@ -1,31 +1,7 @@
-
-

{activeSection === "fragments" ? "Create Fragment" : activeSection === "todos" ? "To-Do List" : editorTitle}

- {#if activeSection !== "fragments" && activeSection !== "todos"} -
- {#if activeSection === "entries"} - - {/if} - - -
- {/if} -
- - {#if activeSection === "fragments"} -
- {#if fragmentMode === "view"} -
- {@html renderMarkdown(openDocumentContent)} -
- - -
-
- {:else} -
-

{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}

- -
- - {#if fragmentType === customTypeValue} - - {:else} - - {/if} -
-
- - -
- -
- - - {#if fragmentMode !== "create"} - - {/if} -
-
- {/if} -
+ {#if !openDocumentId} +
+ edit_note +

Select or create an item to get started

+
+ {:else if activeSection === "fragments"} + {:else if activeSection === "todos"} -
-
-
- - -
- -
    - {#each todoItems as todo} -
  • - - - {#if editingTodoId === todo.id} - { - if (event.key === "Enter") saveEditTodo(); - if (event.key === "Escape") cancelEditTodo(); - }} - /> -
    - - -
    - {:else} - {todo.text} -
    - - -
    - {/if} -
  • - {/each} -
-
-
+ {:else} -
- {#if !previewOnly} -
- - - - - - - -
- {/if} - -
- {#if previewOnly} -
- {@html renderedHtml} -
- {:else} - - {/if} -
-
+ {/if}
@@ -672,421 +56,22 @@ gap: 16px; } - .editor-header { + .editor-empty { + flex: 1; display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; + justify-content: center; gap: 12px; - } - - .editor-header h1 { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - } - - .editor-actions { - display: flex; - gap: 8px; - } - - .editor-actions button { - border-radius: 7px; - border: 1px solid var(--border-soft); - padding: 6px 11px; - font-size: 0.78rem; - font-weight: 500; - color: var(--text-muted); - cursor: pointer; - } - - .editor-actions button:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - .editor-actions button.primary { - border-color: var(--border-strong); - background: var(--surface-3); - color: var(--text-primary); - } - - .editor-actions button.primary:hover { - background: var(--bg-active); - } - - .editor-surface { - min-height: 0; - flex: 1; - border-radius: 10px; - border: 1px solid var(--border-soft); - background: var(--surface-1); - padding: 10px; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 8px; - } - - .editor-surface.preview-only { - grid-template-rows: minmax(0, 1fr); - } - - .fragment-surface { - min-height: 0; - flex: 1; - padding: 8px; - display: grid; - place-items: center; - } - - .fragment-form { - width: min(760px, 100%); - border: 1px solid var(--border-soft); - border-radius: 12px; - background: var(--surface-1); - box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25); - padding: 14px; - display: flex; - flex-direction: column; - gap: 10px; - } - - .fragment-view { - width: min(760px, 100%); - border: 1px solid var(--border-soft); - border-radius: 12px; - background: var(--surface-1); - box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25); - padding: 14px; - display: flex; - flex-direction: column; - gap: 14px; - color: var(--text-primary); - } - - .fragment-view :global(h1), - .fragment-view :global(h2), - .fragment-view :global(h3), - .fragment-view :global(h4), - .fragment-view :global(h5), - .fragment-view :global(h6) { - margin: 0 0 8px; - } - - .fragment-view :global(p), - .fragment-view :global(ul), - .fragment-view :global(ol), - .fragment-view :global(blockquote), - .fragment-view :global(pre) { - margin: 0 0 10px; - } - - .fragment-view :global(code) { - background: var(--surface-2); - border: 1px solid var(--border-soft); - border-radius: 4px; - padding: 1px 4px; - font-family: Consolas, "Courier New", monospace; - font-size: 0.82rem; - } - - .fragment-form h2 { - font-size: 0.94rem; - font-weight: 600; - color: var(--text-primary); - } - - .fragment-form input, - .fragment-form select, - .fragment-form textarea { - width: 100%; - border: 1px solid var(--border-soft); - border-radius: 8px; - background: var(--bg-app); - color: var(--text-primary); - padding: 9px 10px; - font-size: 0.86rem; - } - - .fragment-form textarea { - resize: vertical; - min-height: 160px; - } - - .fragment-form-row { - display: grid; - grid-template-columns: 180px minmax(0, 1fr); - gap: 8px; - } - - .fragment-submit { - width: fit-content; - border-radius: 8px; - border: 1px solid var(--border-strong); - background: var(--surface-3); - color: var(--text-primary); - padding: 8px 12px; - font-size: 0.82rem; - cursor: pointer; - } - - .fragment-submit:hover { - background: var(--bg-hover); - } - - .fragment-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - } - - .fragment-secondary, - .fragment-danger { - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--surface-1); - color: var(--text-muted); - padding: 8px 12px; - font-size: 0.82rem; - cursor: pointer; - } - - .fragment-secondary:hover, - .fragment-danger:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - .todo-surface { - min-height: 0; - flex: 1; - padding: 8px; - display: grid; - place-items: center; - } - - .todo-card { - width: min(760px, 100%); - border: 1px solid var(--border-soft); - border-radius: 12px; - background: var(--surface-1); - box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25); - padding: 14px; - display: flex; - flex-direction: column; - gap: 10px; - max-height: 100%; - overflow: auto; - } - - .todo-create { - display: flex; - gap: 8px; - } - - .todo-create input, - .todo-edit-input { - width: 100%; - border: 1px solid var(--border-soft); - border-radius: 8px; - background: var(--bg-app); - color: var(--text-primary); - padding: 8px 10px; - font-size: 0.86rem; - } - - .todo-add-btn { - border-radius: 8px; - border: 1px solid var(--border-strong); - background: var(--surface-3); - color: var(--text-primary); - padding: 8px 12px; - font-size: 0.82rem; - cursor: pointer; - } - - .todo-add-btn:hover { - background: var(--bg-hover); - } - - .todo-list { - list-style: none; - display: flex; - flex-direction: column; - gap: 8px; - } - - .todo-item { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - border: 1px solid var(--border-soft); - border-radius: 8px; - padding: 8px 10px; - background: var(--bg-app); - } - - .todo-check { - display: grid; - place-items: center; - } - - .todo-text { - font-size: 0.86rem; - color: var(--text-primary); - } - - .todo-text.is-done { color: var(--text-dim); - text-decoration: line-through; - } - .todo-actions { - display: flex; - gap: 6px; - } + .empty-icon { + font-size: 2.4rem; + opacity: 0.5; + } - .todo-btn { - border-radius: 7px; - border: 1px solid var(--border-soft); - background: var(--surface-1); - color: var(--text-muted); - padding: 6px 10px; - font-size: 0.78rem; - cursor: pointer; - } - - .todo-btn.save { - border-color: var(--border-strong); - background: var(--surface-3); - color: var(--text-primary); - } - - .todo-btn.danger:hover, - .todo-btn.ghost:hover, - .todo-btn.save:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - .editor-toolbar { - display: flex; - flex-wrap: wrap; - gap: 6px; - border: 1px solid var(--border-soft); - border-radius: 8px; - background: var(--bg-app); - padding: 6px; - } - - .editor-toolbar button { - border-radius: 6px; - border: 1px solid var(--border-soft); - background: var(--surface-2); - color: var(--text-muted); - padding: 5px 8px; - font-size: 0.74rem; - cursor: pointer; - } - - .toolbar-select { - border-radius: 6px; - border: 1px solid var(--border-soft); - background: var(--surface-2); - color: var(--text-muted); - padding: 5px 8px; - font-size: 0.74rem; - cursor: pointer; - } - - .editor-toolbar button:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - .toolbar-select:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - .editor-workspace { - min-height: 0; - height: 100%; - display: block; - } - - .markdown-input, - .markdown-preview { - min-height: 0; - width: 100%; - height: 100%; - border: 1px solid var(--border-soft); - border-radius: 8px; - background: var(--bg-app); - color: var(--text-primary); - padding: 12px; - font-size: 0.88rem; - line-height: 1.5; - overflow: auto; - } - - .markdown-input { - display: block; - resize: none; - } - - .markdown-input:focus { - outline: none; - border-color: var(--border-strong); - } - - .markdown-preview :global(h1), - .markdown-preview :global(h2), - .markdown-preview :global(h3), - .markdown-preview :global(h4), - .markdown-preview :global(h5), - .markdown-preview :global(h6) { - margin: 0 0 8px; - color: var(--text-primary); - } - - .markdown-preview :global(p), - .markdown-preview :global(blockquote), - .markdown-preview :global(pre), - .markdown-preview :global(ul), - .markdown-preview :global(ol) { - margin: 0 0 10px; - } - - .markdown-preview :global(ul), - .markdown-preview :global(ol) { - padding-left: 18px; - } - - .markdown-preview :global(code) { - background: var(--surface-2); - border: 1px solid var(--border-soft); - border-radius: 4px; - padding: 1px 4px; - font-family: Consolas, "Courier New", monospace; - font-size: 0.82rem; - } - - .markdown-preview :global(pre code) { - display: block; - padding: 8px; - white-space: pre-wrap; - } - - .markdown-preview :global(blockquote) { - border-left: 3px solid var(--border-strong); - padding-left: 10px; - color: var(--text-muted); - } - - .markdown-preview :global(a) { - color: var(--text-primary); - text-decoration: underline; + p { + font-size: 0.88rem; + } } diff --git a/Journal.App/src/lib/components/SidePanel.svelte b/Journal.App/src/lib/components/SidePanel.svelte index 57af8b6..101cc0c 100644 --- a/Journal.App/src/lib/components/SidePanel.svelte +++ b/Journal.App/src/lib/components/SidePanel.svelte @@ -2,13 +2,17 @@ import CalendarWidget from "$lib/components/CalendarWidget.svelte"; import { createEntryDraft, entriesStore } from "$lib/stores/entries"; import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments"; - import { createListDraft, listsStore } from "$lib/stores/lists"; - import { createTodoListDraft, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos"; + import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists"; + import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos"; export let activeSection = "entries"; export let activeDocumentId = ""; export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; + let showNewItemInput = false; + let newItemName = ""; + let newItemInput: HTMLInputElement | null = null; + type SidePanelItem = { id: string; label: string; @@ -131,22 +135,10 @@ return; } - if (activeSection === "todos") { - const draft = createTodoListDraft(); - todoListsStore.update((lists) => [draft.meta, ...lists]); - todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items })); - onOpenDocument({ - id: draft.meta.id, - label: draft.meta.label, - initialContent: serializeTodoList(draft.meta.label, draft.items) - }); - return; - } - - if (activeSection === "lists") { - const item = createListDraft(); - listsStore.update((items) => [item, ...items]); - onOpenDocument(item); + if (activeSection === "todos" || activeSection === "lists") { + showNewItemInput = true; + newItemName = ""; + queueMicrotask(() => newItemInput?.focus()); return; } @@ -171,6 +163,62 @@ } } + async function confirmNewItem() { + const label = newItemName.trim(); + if (!label) { + cancelNewItem(); + return; + } + showNewItemInput = false; + newItemName = ""; + + if (activeSection === "todos") { + try { + const { meta, items: todoItems } = await createTodoListFromLabel(label); + onOpenDocument({ + id: meta.id, + label: meta.label, + initialContent: serializeTodoList(meta.label, todoItems) + }); + } catch (error) { + const draft = createTodoListDraft(); + draft.meta.label = label; + todoListsStore.update((lists) => [draft.meta, ...lists]); + todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items })); + onOpenDocument({ + id: draft.meta.id, + label: draft.meta.label, + initialContent: serializeTodoList(draft.meta.label, draft.items) + }); + } + } else if (activeSection === "lists") { + try { + const item = await createListFromLabel(label); + onOpenDocument(item); + } catch (error) { + const item = createListDraft(); + item.label = label; + item.initialContent = `# ${label}\n\n`; + listsStore.update((items) => [item, ...items]); + onOpenDocument(item); + } + } + } + + function cancelNewItem() { + showNewItemInput = false; + newItemName = ""; + } + + function handleNewItemKeydown(event: KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); + confirmNewItem(); + } else if (event.key === "Escape") { + cancelNewItem(); + } + } + $: panelTitle = sectionTitles[activeSection] ?? "Entries"; $: todoDocuments = $todoListsStore.map(({ id, label }) => ({ id, @@ -226,6 +274,19 @@ + {#if showNewItemInput} +
+ +
+ {/if} +
    {#each items as item}
  • @@ -354,4 +415,27 @@ color: var(--text-muted); letter-spacing: 0.01em; } + + .new-item-input { + padding: 0 2px; + + input { + width: 100%; + font-size: 0.84rem; + color: var(--text-primary); + background: var(--surface-1); + border: 1px solid var(--border-strong); + border-radius: 7px; + padding: 7px 9px; + outline: none; + + &:focus { + border-color: var(--accent, #6b8afd); + } + + &::placeholder { + color: var(--text-dim); + } + } + } diff --git a/Journal.App/src/lib/components/editor/FragmentEditor.svelte b/Journal.App/src/lib/components/editor/FragmentEditor.svelte new file mode 100644 index 0000000..4fcb9de --- /dev/null +++ b/Journal.App/src/lib/components/editor/FragmentEditor.svelte @@ -0,0 +1,382 @@ + + +
    + {#if fragmentMode === "view"} +
    + {@html renderMarkdown(openDocumentContent)} +
    + + +
    +
    + {:else} +
    +

    {fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}

    + +
    + + {#if fragmentType === customTypeValue} + + {:else} + + {/if} +
    +
    + + +
    + +
    + + + {#if fragmentMode !== "create"} + + {/if} +
    +
    + {/if} +
    + + diff --git a/Journal.App/src/lib/components/editor/MarkdownEditor.svelte b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte new file mode 100644 index 0000000..e01e992 --- /dev/null +++ b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte @@ -0,0 +1,341 @@ + + +
    +

    {editorTitle}

    +
    + {#if activeSection === "entries"} + + {/if} + + +
    +
    + +
    + {#if !previewOnly} +
    + + + + + + + +
    + {/if} + +
    + {#if previewOnly} +
    + {@html renderedHtml} +
    + {:else} + + {/if} +
    +
    + + diff --git a/Journal.App/src/lib/components/editor/TodoEditor.svelte b/Journal.App/src/lib/components/editor/TodoEditor.svelte new file mode 100644 index 0000000..2ac5c45 --- /dev/null +++ b/Journal.App/src/lib/components/editor/TodoEditor.svelte @@ -0,0 +1,284 @@ + + +
    +
    +
    + + +
    + +
      + {#each todoItems as todo} +
    • + + + {#if editingTodoId === todo.id} + { + if (event.key === "Enter") saveEditTodo(); + if (event.key === "Escape") cancelEditTodo(); + }} + /> +
      + + +
      + {:else} + {todo.text} +
      + + +
      + {/if} +
    • + {/each} +
    +
    +
    + + diff --git a/Journal.App/src/lib/stores/entries.ts b/Journal.App/src/lib/stores/entries.ts index efdae2f..f02dd77 100644 --- a/Journal.App/src/lib/stores/entries.ts +++ b/Journal.App/src/lib/stores/entries.ts @@ -88,10 +88,8 @@ export function createEntryDraft(): EntryItem { export async function hydrateEntries(dataDirectory?: string): Promise { entriesBusyStore.set(true); try { - console.info("[entries] hydrate:start", { dataDirectory }); const items = await listEntriesCommand(dataDirectory); const mapped = items.map(fromListDto); - console.info("[entries] hydrate:ok", { count: mapped.length }); entriesStore.set(mapped); } catch (error) { console.error("[entries] hydrate:error", error); @@ -103,17 +101,12 @@ export async function hydrateEntries(dataDirectory?: string): Promise { export async function loadEntryByStoreId(storeId: string): Promise { const filePath = toBackendPath(storeId); - if (!filePath) { - console.warn("[entries] load:skip_invalid_store_id", { storeId }); - return null; - } + if (!filePath) return null; try { - console.info("[entries] load:start", { storeId, filePath }); const loaded = await loadEntryCommand(filePath); const item = fromLoadResult(loaded); entriesStore.update((items) => upsertById(items, item)); - console.info("[entries] load:ok", { storeId, filePath }); return item; } catch (error) { console.error("[entries] load:error", { storeId, filePath, error }); @@ -123,21 +116,16 @@ export async function loadEntryByStoreId(storeId: string): Promise { const trimmed = content?.trim(); - if (!trimmed) { - console.warn("[entries] save:skip_empty_content", { storeId }); - return null; - } + if (!trimmed) return null; const existingPath = toBackendPath(storeId); const payload = existingPath ? { content: trimmed, filePath: existingPath, mode } : { content: trimmed, mode }; try { - console.info("[entries] save:start", { storeId, hasExistingPath: Boolean(existingPath), mode }); const saved = await saveEntryCommand(payload); const loaded = await loadEntryCommand(saved.filePath); const item = fromLoadResult(loaded); entriesStore.update((items) => upsertById(items, item)); - console.info("[entries] save:ok", { storeId, filePath: saved.filePath }); return item; } catch (error) { console.error("[entries] save:error", { storeId, error }); @@ -146,7 +134,6 @@ export async function saveEntryFromStore(storeId: string, content: string, mode? } export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Promise { - console.info("[entries] search:start", payload); const results = await searchEntriesCommand(payload); const dataDirectory = payload.dataDirectory?.trim() ?? ""; const separator = dataDirectory.includes("\\") ? "\\" : "/"; @@ -160,7 +147,6 @@ export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Prom filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined, date: result.entry.date })); - console.info("[entries] search:ok", { count: mapped.length }); return mapped; } diff --git a/Journal.App/src/lib/stores/fragments.ts b/Journal.App/src/lib/stores/fragments.ts index 0d2d62d..2945308 100644 --- a/Journal.App/src/lib/stores/fragments.ts +++ b/Journal.App/src/lib/stores/fragments.ts @@ -159,9 +159,7 @@ export function removeFragmentItem(items: FragmentItem[], id: string): FragmentI export async function hydrateFragments(): Promise { fragmentsBusyStore.set(true); try { - console.info("[fragments] hydrate:start"); const items = await listFragments(); - console.info("[fragments] hydrate:ok", { count: items.length }); fragmentsStore.set(items.map(dtoToItem)); } catch (error) { console.error("[fragments] hydrate:error", error); @@ -172,17 +170,11 @@ export async function hydrateFragments(): Promise { } export async function createFragmentFromParsed(payload: ParsedFragment): Promise { - console.info("[fragments] create:start", { - title: payload.title, - type: payload.type, - tags: payload.tags - }); const created = await createFragmentCommand({ type: payload.type.trim(), description: composeDescription(payload.title, payload.body), tags: payload.tags }); - console.info("[fragments] create:ok", { id: created.id }); const item = dtoToItem(created); fragmentsStore.update((items) => prependFragmentItem(items, item)); return item; @@ -190,46 +182,30 @@ export async function createFragmentFromParsed(payload: ParsedFragment): Promise export async function updateFragmentFromParsed(storeId: string, payload: ParsedFragment): Promise { const backendId = toBackendId(storeId); - if (!backendId) { - console.warn("[fragments] update:skip_invalid_store_id", { storeId }); - return null; - } + if (!backendId) return null; - console.info("[fragments] update:start", { storeId, backendId }); const ok = await updateFragmentCommand(backendId, { type: payload.type.trim(), description: composeDescription(payload.title, payload.body), tags: payload.tags }); - if (!ok) { - console.warn("[fragments] update:backend_returned_false", { storeId, backendId }); - return null; - } + if (!ok) return null; const item: FragmentItem = { id: storeId, label: payload.title.trim() || "Untitled Fragment", initialContent: serializeFragment(payload) }; - console.info("[fragments] update:ok", { storeId, backendId }); fragmentsStore.update((items) => upsertById(items, item)); return item; } export async function deleteFragmentByStoreId(storeId: string): Promise { const backendId = toBackendId(storeId); - if (!backendId) { - console.warn("[fragments] delete:skip_invalid_store_id", { storeId }); - return false; - } + if (!backendId) return false; - console.info("[fragments] delete:start", { storeId, backendId }); const ok = await deleteFragmentCommand(backendId); - if (!ok) { - console.warn("[fragments] delete:backend_returned_false", { storeId, backendId }); - return false; - } - console.info("[fragments] delete:ok", { storeId, backendId }); + if (!ok) return false; fragmentsStore.update((items) => removeFragmentItem(items, storeId)); return true; } diff --git a/Journal.App/src/lib/stores/lists.ts b/Journal.App/src/lib/stores/lists.ts index 7362d24..6213776 100644 --- a/Journal.App/src/lib/stores/lists.ts +++ b/Journal.App/src/lib/stores/lists.ts @@ -1,4 +1,11 @@ -import { writable } from "svelte/store"; +import { get, writable } from "svelte/store"; +import { + createList as createListCommand, + deleteList as deleteListCommand, + listLists, + updateList as updateListCommand, + type ListDocumentDto +} from "$lib/backend/lists"; export type ListItem = { id: string; @@ -6,19 +13,106 @@ export type ListItem = { initialContent: string; }; -const initialLists: ListItem[] = [ - { id: "lists/reading", label: "Reading", initialContent: "# Reading\n\nBooks and articles to read." }, - { id: "lists/projects", label: "Projects", initialContent: "# Projects\n\nActive and planned projects." }, - { id: "lists/someday", label: "Someday", initialContent: "# Someday\n\nLong-term ideas." } -]; +export const listsStore = writable([]); +export const listsBusyStore = writable(false); -export const listsStore = writable(initialLists); +function toStoreId(id: string): string { + return `lists/${id}`; +} + +function toBackendId(id: string): string | null { + const prefix = "lists/"; + if (!id.startsWith(prefix)) return null; + const backendId = id.slice(prefix.length).trim(); + return backendId || null; +} + +function dtoToItem(dto: ListDocumentDto): ListItem { + return { + id: toStoreId(dto.id), + label: dto.label, + initialContent: dto.content || `# ${dto.label}\n\n` + }; +} + +function upsertById(items: ListItem[], next: ListItem): ListItem[] { + const idx = items.findIndex((item) => item.id === next.id); + if (idx === -1) return [next, ...items]; + const clone = [...items]; + clone[idx] = next; + return clone; +} export function createListDraft(): ListItem { - const id = `lists/list-${Date.now()}`; + const id = `lists/draft-${Date.now()}`; return { id, label: "Untitled List", initialContent: "# Untitled List\n\n- Item 1" }; } + +export async function hydrateLists(): Promise { + listsBusyStore.set(true); + try { + const items = await listLists(); + listsStore.set(items.map(dtoToItem)); + } catch (error) { + console.error("[lists] hydrate:error", error); + throw error; + } finally { + listsBusyStore.set(false); + } +} + +export async function createListFromLabel(label: string, content = ""): Promise { + const resolvedLabel = label.trim() || "Untitled List"; + const resolvedContent = content || `# ${resolvedLabel}\n\n`; + const created = await createListCommand({ label: resolvedLabel, content: resolvedContent }); + const item = dtoToItem(created); + listsStore.update((items) => [item, ...items]); + return item; +} + +export async function updateListByStoreId( + storeId: string, + label?: string, + content?: string +): Promise { + const backendId = toBackendId(storeId); + if (!backendId) return false; + + const payload: { label?: string; content?: string } = {}; + if (label !== undefined) payload.label = label; + if (content !== undefined) payload.content = content; + + const ok = await updateListCommand(backendId, payload); + if (!ok) return false; + + listsStore.update((items) => + items.map((item) => + item.id === storeId + ? { + ...item, + label: label ?? item.label, + initialContent: content ?? item.initialContent + } + : item + ) + ); + return true; +} + +export async function deleteListByStoreId(storeId: string): Promise { + const backendId = toBackendId(storeId); + if (!backendId) return false; + + const ok = await deleteListCommand(backendId); + if (!ok) return false; + listsStore.update((items) => items.filter((item) => item.id !== storeId)); + return true; +} + +export function hasList(storeId: string): boolean { + return get(listsStore).some((item) => item.id === storeId); +} diff --git a/Journal.App/src/lib/stores/session.ts b/Journal.App/src/lib/stores/session.ts new file mode 100644 index 0000000..d429e71 --- /dev/null +++ b/Journal.App/src/lib/stores/session.ts @@ -0,0 +1,24 @@ +import { writable, get } from "svelte/store"; + +const _password = writable(null); +const _unlocked = writable(false); + +export const vaultUnlocked = { subscribe: _unlocked.subscribe }; + +export function isVaultReady(): boolean { + return get(_unlocked); +} + +export function getSessionPassword(): string | null { + return get(_password); +} + +export function setVaultSession(password: string): void { + _password.set(password); + _unlocked.set(true); +} + +export function clearVaultSession(): void { + _password.set(null); + _unlocked.set(false); +} diff --git a/Journal.App/src/lib/stores/todos.ts b/Journal.App/src/lib/stores/todos.ts index 7a53227..20c4d43 100644 --- a/Journal.App/src/lib/stores/todos.ts +++ b/Journal.App/src/lib/stores/todos.ts @@ -1,32 +1,204 @@ -import { writable } from "svelte/store"; +import { get, writable } from "svelte/store"; +import { + createTodoItem as createTodoItemCommand, + createTodoList as createTodoListCommand, + deleteTodoItem as deleteTodoItemCommand, + deleteTodoList as deleteTodoListCommand, + listTodoLists, + updateTodoItem as updateTodoItemCommand, + updateTodoList as updateTodoListCommand, + type TodoListDto +} from "$lib/backend/todos"; -export type TodoItem = { id: number; text: string; done: boolean }; -export type TodoListMeta = { id: string; label: string }; +// TodoItem keeps a numeric `id` for local array operations (used by EditorPanel) +// plus a `backendId` (guid string) for backend persistence. +export type TodoItem = { id: number; text: string; done: boolean; backendId?: string }; +export type TodoListMeta = { id: string; label: string; backendId?: string }; -const initialTodoListMeta: TodoListMeta[] = [ - { id: "todos/today", label: "Today" }, - { id: "todos/scheduled", label: "Scheduled" }, - { id: "todos/completed", label: "Completed" } -]; -export const todoListsStore = writable(initialTodoListMeta); +export const todoListsStore = writable([]); +export const todosStore = writable>({}); +export const todosBusyStore = writable(false); -export const todosStore = writable>({ - "todos/today": [ - { id: 1, text: "Finalize journal sidebar interactions", done: false }, - { id: 2, text: "Review fragment taxonomy updates", done: false }, - { id: 3, text: "Capture daily notes summary", done: false } - ], - "todos/scheduled": [ - { id: 4, text: "Tuesday: sync settings to backend store", done: false }, - { id: 5, text: "Thursday: polish editor keyboard shortcuts", done: false }, - { id: 6, text: "Friday: QA fragment and todo workflows", done: false } - ], - "todos/completed": [ - { id: 7, text: "Replaced navbar profile with settings shortcut", done: true }, - { id: 8, text: "Centered modal presentation", done: true }, - { id: 9, text: "Added fragment creation form mode", done: true } - ] -}); +// ── ID helpers ─────────────────────────────────────────────────── + +function toStoreId(guid: string): string { + return `todos/${guid}`; +} + +function toBackendId(storeId: string): string | null { + const prefix = "todos/"; + if (!storeId.startsWith(prefix)) return null; + const backendId = storeId.slice(prefix.length).trim(); + return backendId || null; +} + +export function createTodoId(): number { + return Date.now() + Math.floor(Math.random() * 1000); +} + +// ── DTO mapping ────────────────────────────────────────────────── + +function dtoToMeta(dto: TodoListDto): TodoListMeta { + return { + id: toStoreId(dto.id), + label: dto.label, + backendId: dto.id + }; +} + +function dtoToItems(dto: TodoListDto): TodoItem[] { + return dto.items.map((item, index) => ({ + id: createTodoId() + index, + text: item.text, + done: item.done, + backendId: item.id + })); +} + +// ── Hydration ──────────────────────────────────────────────────── + +export async function hydrateTodos(): Promise { + todosBusyStore.set(true); + try { + const lists = await listTodoLists(); + + const metas: TodoListMeta[] = lists.map(dtoToMeta); + const items: Record = {}; + for (const dto of lists) { + items[toStoreId(dto.id)] = dtoToItems(dto); + } + + todoListsStore.set(metas); + todosStore.set(items); + } catch (error) { + console.error("[todos] hydrate:error", error); + throw error; + } finally { + todosBusyStore.set(false); + } +} + +// ── List CRUD ──────────────────────────────────────────────────── + +export async function createTodoListFromLabel( + label: string +): Promise<{ meta: TodoListMeta; items: TodoItem[] }> { + const resolvedLabel = label.trim() || "New List"; + const created = await createTodoListCommand({ label: resolvedLabel }); + + const meta = dtoToMeta(created); + todoListsStore.update((metas) => [meta, ...metas]); + todosStore.update((lists) => ({ ...lists, [meta.id]: [] })); + return { meta, items: [] }; +} + +export async function deleteTodoListByStoreId(storeId: string): Promise { + const backendId = toBackendId(storeId); + if (!backendId) return false; + + const ok = await deleteTodoListCommand(backendId); + if (!ok) return false; + + todoListsStore.update((metas) => metas.filter((m) => m.id !== storeId)); + todosStore.update((lists) => { + const { [storeId]: _, ...rest } = lists; + return rest; + }); + return true; +} + +// ── Item CRUD (backend-backed) ─────────────────────────────────── + +export async function addTodoItemBackend( + storeId: string, + text: string +): Promise { + const backendListId = toBackendId(storeId); + if (!backendListId || !text.trim()) return null; + + const items = get(todosStore)[storeId] ?? []; + const sortOrder = items.length; + + const created = await createTodoItemCommand({ + listId: backendListId, + text: text.trim(), + sortOrder + }); + + const item: TodoItem = { + id: createTodoId(), + text: created.text, + done: created.done, + backendId: created.id + }; + + todosStore.update((lists) => ({ + ...lists, + [storeId]: [item, ...(lists[storeId] ?? [])] + })); + return item; +} + +export async function toggleTodoItemBackend( + storeId: string, + localId: number +): Promise { + const items = get(todosStore)[storeId]; + const todo = items?.find((t) => t.id === localId); + if (!todo?.backendId) return false; + + const ok = await updateTodoItemCommand(todo.backendId, { done: !todo.done }); + if (!ok) return false; + + todosStore.update((lists) => ({ + ...lists, + [storeId]: (lists[storeId] ?? []).map((t) => + t.id === localId ? { ...t, done: !t.done } : t + ) + })); + return true; +} + +export async function updateTodoItemTextBackend( + storeId: string, + localId: number, + text: string +): Promise { + const items = get(todosStore)[storeId]; + const todo = items?.find((t) => t.id === localId); + if (!todo?.backendId || !text.trim()) return false; + + const ok = await updateTodoItemCommand(todo.backendId, { text: text.trim() }); + if (!ok) return false; + + todosStore.update((lists) => ({ + ...lists, + [storeId]: (lists[storeId] ?? []).map((t) => + t.id === localId ? { ...t, text: text.trim() } : t + ) + })); + return true; +} + +export async function removeTodoItemBackend( + storeId: string, + localId: number +): Promise { + const items = get(todosStore)[storeId]; + const todo = items?.find((t) => t.id === localId); + if (!todo?.backendId) return false; + + const ok = await deleteTodoItemCommand(todo.backendId); + if (!ok) return false; + + todosStore.update((lists) => ({ + ...lists, + [storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId) + })); + return true; +} + +// ── Pure helpers (used by EditorPanel for local state) ─────────── export function serializeTodoList(title: string, todos: TodoItem[]): string { const heading = title?.trim() ? `# ${title}` : "# To-Do List"; @@ -34,10 +206,6 @@ export function serializeTodoList(title: string, todos: TodoItem[]): string { return `${heading}\n\n${lines.join("\n")}`; } -export function createTodoId(): number { - return Date.now() + Math.floor(Math.random() * 1000); -} - export function parseTodoList(content: string): TodoItem[] { const lines = content.replace(/\r\n/g, "\n").split("\n"); const parsed: TodoItem[] = []; @@ -91,7 +259,7 @@ export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] { } export function createTodoListDraft(): { meta: TodoListMeta; items: TodoItem[] } { - const id = `todos/list-${Date.now()}`; + const id = `todos/draft-${Date.now()}`; return { meta: { id, label: "New List" }, items: [] diff --git a/Journal.App/src/lib/utils/markdown.ts b/Journal.App/src/lib/utils/markdown.ts new file mode 100644 index 0000000..3d293c2 --- /dev/null +++ b/Journal.App/src/lib/utils/markdown.ts @@ -0,0 +1,115 @@ +export function escapeHtml(input: string): string { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function parseInline(input: string): string { + let value = escapeHtml(input); + value = value.replace(/`([^`]+)`/g, "$1"); + value = value.replace(/\*\*([^*]+)\*\*/g, "$1"); + value = value.replace(/\*([^*]+)\*/g, "$1"); + value = value.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1' + ); + return value; +} + +export function renderMarkdown(markdown: string): string { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const output: string[] = []; + let i = 0; + let inCode = false; + let codeLines: string[] = []; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + if (trimmed.startsWith("```")) { + if (inCode) { + output.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + codeLines = []; + inCode = false; + } else { + inCode = true; + } + i += 1; + continue; + } + + if (inCode) { + codeLines.push(line); + i += 1; + continue; + } + + if (!trimmed) { + i += 1; + continue; + } + + const heading = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + const level = heading[1].length; + output.push(`${parseInline(heading[2])}`); + i += 1; + continue; + } + + if (/^[-*+]\s+/.test(trimmed)) { + const items: string[] = []; + while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) { + items.push(`
  • ${parseInline(lines[i].trim().replace(/^[-*+]\s+/, ""))}
  • `); + i += 1; + } + output.push(`
      ${items.join("")}
    `); + continue; + } + + if (/^\d+\.\s+/.test(trimmed)) { + const items: string[] = []; + while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) { + items.push(`
  • ${parseInline(lines[i].trim().replace(/^\d+\.\s+/, ""))}
  • `); + i += 1; + } + output.push(`
      ${items.join("")}
    `); + continue; + } + + if (/^>\s+/.test(trimmed)) { + output.push(`
    ${parseInline(trimmed.replace(/^>\s+/, ""))}
    `); + i += 1; + continue; + } + + if (/^(-{3,}|\*{3,})$/.test(trimmed)) { + output.push("
    "); + i += 1; + continue; + } + + const paragraph: string[] = []; + while (i < lines.length && lines[i].trim()) { + paragraph.push(lines[i].trim()); + i += 1; + } + output.push(`

    ${parseInline(paragraph.join(" "))}

    `); + } + + if (inCode) { + output.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + } + + return output.join(""); +} + +export function extractEditorTitle(markdown: string, fallback: string): string { + const firstLine = markdown.replace(/\r\n/g, "\n").split("\n")[0]?.trim() ?? ""; + const headingMatch = firstLine.match(/^#\s+(.+)$/); + return headingMatch ? headingMatch[1] : fallback; +} diff --git a/Journal.App/src/routes/+layout.svelte b/Journal.App/src/routes/+layout.svelte new file mode 100644 index 0000000..833a9f5 --- /dev/null +++ b/Journal.App/src/routes/+layout.svelte @@ -0,0 +1,36 @@ + + + diff --git a/Journal.App/src/routes/+page.svelte b/Journal.App/src/routes/+page.svelte index ab766ef..4aaf1ee 100644 --- a/Journal.App/src/routes/+page.svelte +++ b/Journal.App/src/routes/+page.svelte @@ -1,14 +1,15 @@ diff --git a/Journal.App/src/routes/settings/+page.svelte b/Journal.App/src/routes/settings/+page.svelte index fcd324c..985ddd0 100644 --- a/Journal.App/src/routes/settings/+page.svelte +++ b/Journal.App/src/routes/settings/+page.svelte @@ -12,6 +12,8 @@ updateFragmentType, updateSettingsTag } from "$lib/stores/settings"; + import { invoke } from "@tauri-apps/api/core"; + import { onMount } from "svelte"; const activeSection = "settings"; @@ -29,6 +31,41 @@ let editingTagValue = ""; let editingFragmentTypeIndex: number | null = null; let editingFragmentTypeValue = ""; + let sidecarRoot = ""; + let sidecarRootIsCustom = false; + let sidecarRootError = ""; + + onMount(async () => { + try { + const result: any = await invoke("get_sidecar_root"); + sidecarRoot = result.root; + sidecarRootIsCustom = result.isCustom; + } catch (e) { + sidecarRootError = String(e); + } + }); + + async function saveSidecarRoot() { + sidecarRootError = ""; + try { + const result: any = await invoke("set_sidecar_root", { path: sidecarRoot }); + sidecarRoot = result.root; + sidecarRootIsCustom = result.isCustom; + } catch (e) { + sidecarRootError = String(e); + } + } + + async function resetSidecarRoot() { + sidecarRootError = ""; + try { + const result: any = await invoke("set_sidecar_root", { path: "" }); + sidecarRoot = result.root; + sidecarRootIsCustom = result.isCustom; + } catch (e) { + sidecarRootError = String(e); + } + } function showModal(options: { action: "logout-confirm" | "logout-info"; @@ -166,6 +203,7 @@

    Configure app behavior and interface options.

    +
+ +
+

Sidecar

+

Root directory containing the Journal.Sidecar project.

+ +
+ event.key === "Enter" && saveSidecarRoot()} + /> + + {#if sidecarRootIsCustom} + + {/if} +
+ + {#if sidecarRootError} +

{sidecarRootError}

+ {/if} +
+ @@ -306,7 +367,13 @@ font-size: 0.9rem; } + .settings-grid { + columns: 2; + column-gap: 16px; + } + .route-card { + break-inside: avoid; border: 1px solid var(--border-soft); background: var(--surface-1); border-radius: 10px; @@ -314,7 +381,7 @@ display: flex; flex-direction: column; gap: 12px; - max-width: 640px; + margin-bottom: 16px; } .toggle-row { @@ -427,6 +494,11 @@ background: var(--bg-hover); color: var(--text-primary); } + + .error-text { + color: #e74c3c; + font-size: 0.82rem; + } diff --git a/Journal.Core/Dtos/ListDtos.cs b/Journal.Core/Dtos/ListDtos.cs new file mode 100644 index 0000000..ef742bc --- /dev/null +++ b/Journal.Core/Dtos/ListDtos.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Journal.Core.Dtos; + +public record ListDocumentDto( + Guid Id, + string Label, + string Content, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt +); + +public record CreateListDto( + [property: Required(AllowEmptyStrings = false)] string Label, + string? Content = null +); + +public record UpdateListDto( + string? Label = null, + string? Content = null +); diff --git a/Journal.Core/Dtos/TodoDtos.cs b/Journal.Core/Dtos/TodoDtos.cs new file mode 100644 index 0000000..c84a140 --- /dev/null +++ b/Journal.Core/Dtos/TodoDtos.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace Journal.Core.Dtos; + +public record TodoListDto( + Guid Id, + string Label, + DateTimeOffset CreatedAt, + List Items +); + +public record TodoItemDto( + Guid Id, + Guid ListId, + string Text, + bool Done, + int SortOrder +); + +public record CreateTodoListDto( + [property: Required(AllowEmptyStrings = false)] string Label +); + +public record UpdateTodoListDto( + string? Label = null +); + +public record CreateTodoItemDto( + [property: Required] Guid ListId, + [property: Required(AllowEmptyStrings = false)] string Text, + int? SortOrder = null +); + +public record UpdateTodoItemDto( + string? Text = null, + bool? Done = null, + int? SortOrder = null +); diff --git a/Journal.Core/Entry.cs b/Journal.Core/Entry.cs index ec0bcce..4557a0e 100644 --- a/Journal.Core/Entry.cs +++ b/Journal.Core/Entry.cs @@ -8,8 +8,10 @@ using Journal.Core.Services.Config; using Journal.Core.Services.Database; using Journal.Core.Services.Entries; using Journal.Core.Services.Fragments; +using Journal.Core.Services.Lists; using Journal.Core.Services.Logging; using Journal.Core.Services.Speech; +using Journal.Core.Services.Todos; using Journal.Core.Services.Vault; namespace Journal.Core; @@ -24,6 +26,8 @@ public class Entry( IAiService ai, ISpeechBridgeService speech, IEntryFileService entryFiles, + IListService lists, + ITodoService todos, CommandLogger logger) { private readonly IFragmentService _fragments = fragments; @@ -35,6 +39,8 @@ public class Entry( private readonly IAiService _ai = ai; private readonly ISpeechBridgeService _speech = speech; private readonly IEntryFileService _entryFiles = entryFiles; + private readonly IListService _lists = lists; + private readonly ITodoService _todos = todos; private readonly CommandLogger _logger = logger; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -110,6 +116,83 @@ public class Entry( case "fragments.search": result = _fragments.Search(cmd.Type, cmd.Tag); break; + + // ── Lists ──────────────────────────────────────── + case "lists.list": + result = _lists.GetAll(); + break; + case "lists.get": + if (!Guid.TryParse(cmd.Id, out var getListId)) + return Error("Invalid or missing id"); + result = _lists.GetById(getListId); + break; + case "lists.create": + var createListDto = DeserializePayload(cmd.Payload); + if (createListDto is null) + return Error("Missing or invalid payload"); + result = _lists.Create(createListDto); + break; + case "lists.update": + if (!Guid.TryParse(cmd.Id, out var updateListId)) + return Error("Invalid or missing id"); + var updateListDto = DeserializePayload(cmd.Payload); + if (updateListDto is null) + return Error("Missing or invalid payload"); + result = _lists.Update(updateListId, updateListDto); + break; + case "lists.delete": + if (!Guid.TryParse(cmd.Id, out var deleteListId)) + return Error("Invalid or missing id"); + result = _lists.Remove(deleteListId); + break; + + // ── Todos ──────────────────────────────────────── + case "todos.list": + result = _todos.GetAllLists(); + break; + case "todos.get": + if (!Guid.TryParse(cmd.Id, out var getTodoListId)) + return Error("Invalid or missing id"); + result = _todos.GetListById(getTodoListId); + break; + case "todos.create": + var createTodoListDto = DeserializePayload(cmd.Payload); + if (createTodoListDto is null) + return Error("Missing or invalid payload"); + result = _todos.CreateList(createTodoListDto); + break; + case "todos.update": + if (!Guid.TryParse(cmd.Id, out var updateTodoListId)) + return Error("Invalid or missing id"); + var updateTodoListDto = DeserializePayload(cmd.Payload); + if (updateTodoListDto is null) + return Error("Missing or invalid payload"); + result = _todos.UpdateList(updateTodoListId, updateTodoListDto); + break; + case "todos.delete": + if (!Guid.TryParse(cmd.Id, out var deleteTodoListId)) + return Error("Invalid or missing id"); + result = _todos.RemoveList(deleteTodoListId); + break; + case "todos.items.create": + var createItemDto = DeserializePayload(cmd.Payload); + if (createItemDto is null) + return Error("Missing or invalid payload"); + result = _todos.CreateItem(createItemDto); + break; + case "todos.items.update": + if (!Guid.TryParse(cmd.Id, out var updateItemId)) + return Error("Invalid or missing id"); + var updateItemDto = DeserializePayload(cmd.Payload); + if (updateItemDto is null) + return Error("Missing or invalid payload"); + result = _todos.UpdateItem(updateItemId, updateItemDto); + break; + case "todos.items.delete": + if (!Guid.TryParse(cmd.Id, out var deleteItemId)) + return Error("Invalid or missing id"); + result = _todos.RemoveItem(deleteItemId); + break; case "search.entries": var searchPayload = DeserializePayload(cmd.Payload); if (searchPayload is null || string.IsNullOrWhiteSpace(searchPayload.DataDirectory)) @@ -227,6 +310,7 @@ public class Entry( var rebuildPayload = DeserializePayload(cmd.Payload); if (rebuildPayload is null) return Error("Missing or invalid payload"); + _databaseSession.CloseConnection(); _vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, rebuildPayload.DataDirectory); result = true; break; diff --git a/Journal.Core/Models/ListDocument.cs b/Journal.Core/Models/ListDocument.cs new file mode 100644 index 0000000..c61a9c7 --- /dev/null +++ b/Journal.Core/Models/ListDocument.cs @@ -0,0 +1,40 @@ +namespace Journal.Core.Models; + +public class ListDocument +{ + public Guid Id { get; } + public string Label { get; set; } + public string Content { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public ListDocument(string label, string content = "") + { + Validate(label); + + Id = Guid.NewGuid(); + Label = label.Trim(); + Content = content; + CreatedAt = DateTimeOffset.Now; + UpdatedAt = CreatedAt; + } + + public ListDocument(Guid id, string label, string content, DateTimeOffset createdAt, DateTimeOffset updatedAt) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + Validate(label); + + Id = id; + Label = label.Trim(); + Content = content; + CreatedAt = createdAt; + UpdatedAt = updatedAt; + } + + private static void Validate(string label) + { + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Label is required", nameof(label)); + } +} diff --git a/Journal.Core/Models/TodoItem.cs b/Journal.Core/Models/TodoItem.cs new file mode 100644 index 0000000..f225aa0 --- /dev/null +++ b/Journal.Core/Models/TodoItem.cs @@ -0,0 +1,44 @@ +namespace Journal.Core.Models; + +public class TodoItem +{ + public Guid Id { get; } + public Guid ListId { get; } + public string Text { get; set; } + public bool Done { get; set; } + public int SortOrder { get; set; } + + public TodoItem(Guid listId, string text, int sortOrder = 0) + { + Validate(text); + if (listId == Guid.Empty) + throw new ArgumentException("ListId is required", nameof(listId)); + + Id = Guid.NewGuid(); + ListId = listId; + Text = text.Trim(); + Done = false; + SortOrder = sortOrder; + } + + public TodoItem(Guid id, Guid listId, string text, bool done, int sortOrder) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + if (listId == Guid.Empty) + throw new ArgumentException("ListId is required", nameof(listId)); + Validate(text); + + Id = id; + ListId = listId; + Text = text.Trim(); + Done = done; + SortOrder = sortOrder; + } + + private static void Validate(string text) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Text is required", nameof(text)); + } +} diff --git a/Journal.Core/Models/TodoList.cs b/Journal.Core/Models/TodoList.cs new file mode 100644 index 0000000..6fa533e --- /dev/null +++ b/Journal.Core/Models/TodoList.cs @@ -0,0 +1,34 @@ +namespace Journal.Core.Models; + +public class TodoList +{ + public Guid Id { get; } + public string Label { get; set; } + public DateTimeOffset CreatedAt { get; set; } + + public TodoList(string label) + { + Validate(label); + + Id = Guid.NewGuid(); + Label = label.Trim(); + CreatedAt = DateTimeOffset.Now; + } + + public TodoList(Guid id, string label, DateTimeOffset createdAt) + { + if (id == Guid.Empty) + throw new ArgumentException("Id is required", nameof(id)); + Validate(label); + + Id = id; + Label = label.Trim(); + CreatedAt = createdAt; + } + + private static void Validate(string label) + { + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Label is required", nameof(label)); + } +} diff --git a/Journal.Core/Repositories/IListRepository.cs b/Journal.Core/Repositories/IListRepository.cs new file mode 100644 index 0000000..5191443 --- /dev/null +++ b/Journal.Core/Repositories/IListRepository.cs @@ -0,0 +1,12 @@ +using Journal.Core.Models; + +namespace Journal.Core.Repositories; + +public interface IListRepository +{ + List GetAll(); + ListDocument? GetById(Guid id); + void Add(ListDocument list); + bool Update(Guid id, string? label = null, string? content = null); + bool Remove(Guid id); +} diff --git a/Journal.Core/Repositories/ITodoRepository.cs b/Journal.Core/Repositories/ITodoRepository.cs new file mode 100644 index 0000000..87fcb58 --- /dev/null +++ b/Journal.Core/Repositories/ITodoRepository.cs @@ -0,0 +1,18 @@ +using Journal.Core.Models; + +namespace Journal.Core.Repositories; + +public interface ITodoRepository +{ + List GetAllLists(); + TodoList? GetListById(Guid id); + void AddList(TodoList list); + bool UpdateList(Guid id, string? label = null); + bool RemoveList(Guid id); + + List GetItemsByListId(Guid listId); + TodoItem? GetItemById(Guid id); + void AddItem(TodoItem item); + bool UpdateItem(Guid id, string? text = null, bool? done = null, int? sortOrder = null); + bool RemoveItem(Guid id); +} diff --git a/Journal.Core/Repositories/SqliteListRepository.cs b/Journal.Core/Repositories/SqliteListRepository.cs new file mode 100644 index 0000000..4a51225 --- /dev/null +++ b/Journal.Core/Repositories/SqliteListRepository.cs @@ -0,0 +1,129 @@ +using Journal.Core.Models; +using Journal.Core.Services.Database; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Repositories; + +public sealed class SqliteListRepository(IDatabaseSessionService session) : IListRepository +{ + private readonly IDatabaseSessionService _session = session; + + public List GetAll() + { + var conn = _session.GetConnection(); + return ReadAll(conn); + } + + public ListDocument? GetById(Guid id) + { + var conn = _session.GetConnection(); + return ReadById(conn, id); + } + + public void Add(ListDocument list) + { + ArgumentNullException.ThrowIfNull(list); + var conn = _session.GetConnection(); + Insert(conn, list); + } + + public bool Update(Guid id, string? label = null, string? content = null) + { + var conn = _session.GetConnection(); + var existing = ReadById(conn, id); + if (existing is null) + return false; + + if (label is not null) + { + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Label cannot be empty", nameof(label)); + existing.Label = label.Trim(); + } + + if (content is not null) + existing.Content = content; + + existing.UpdatedAt = DateTimeOffset.Now; + UpdateRow(conn, existing); + return true; + } + + public bool Remove(Guid id) + { + var conn = _session.GetConnection(); + return Delete(conn, id); + } + + // ── Private helpers ────────────────────────────────────────────── + + private static void Insert(SqliteConnection conn, ListDocument list) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO lists (guid, label, content, created_at, updated_at) + VALUES (@guid, @label, @content, @createdAt, @updatedAt); + """; + cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D")); + cmd.Parameters.AddWithValue("@label", list.Label); + cmd.Parameters.AddWithValue("@content", list.Content); + cmd.Parameters.AddWithValue("@createdAt", list.CreatedAt.ToString("O")); + cmd.Parameters.AddWithValue("@updatedAt", list.UpdatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static void UpdateRow(SqliteConnection conn, ListDocument list) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE lists SET label = @label, content = @content, updated_at = @updatedAt + WHERE guid = @guid; + """; + cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D")); + cmd.Parameters.AddWithValue("@label", list.Label); + cmd.Parameters.AddWithValue("@content", list.Content); + cmd.Parameters.AddWithValue("@updatedAt", list.UpdatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static bool Delete(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM lists WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + return cmd.ExecuteNonQuery() > 0; + } + + private static ListDocument? ReadById(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, label, content, created_at, updated_at FROM lists WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + + using var reader = cmd.ExecuteReader(); + return reader.Read() ? MapRow(reader) : null; + } + + private static List ReadAll(SqliteConnection conn) + { + var results = new List(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, label, content, created_at, updated_at FROM lists ORDER BY created_at;"; + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + results.Add(MapRow(reader)); + + return results; + } + + private static ListDocument MapRow(SqliteDataReader reader) + { + var guid = Guid.Parse(reader.GetString(0)); + var label = reader.GetString(1); + var content = reader.IsDBNull(2) ? "" : reader.GetString(2); + var createdAt = reader.IsDBNull(3) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(3)); + var updatedAt = reader.IsDBNull(4) ? createdAt : DateTimeOffset.Parse(reader.GetString(4)); + return new ListDocument(guid, label, content, createdAt, updatedAt); + } +} diff --git a/Journal.Core/Repositories/SqliteTodoRepository.cs b/Journal.Core/Repositories/SqliteTodoRepository.cs new file mode 100644 index 0000000..725dfe8 --- /dev/null +++ b/Journal.Core/Repositories/SqliteTodoRepository.cs @@ -0,0 +1,279 @@ +using Journal.Core.Models; +using Journal.Core.Services.Database; +using Microsoft.Data.Sqlite; + +namespace Journal.Core.Repositories; + +public sealed class SqliteTodoRepository(IDatabaseSessionService session) : ITodoRepository +{ + private readonly IDatabaseSessionService _session = session; + + // ── Lists ──────────────────────────────────────────────────────── + + public List GetAllLists() + { + var conn = _session.GetConnection(); + return ReadAllLists(conn); + } + + public TodoList? GetListById(Guid id) + { + var conn = _session.GetConnection(); + return ReadListById(conn, id); + } + + public void AddList(TodoList list) + { + ArgumentNullException.ThrowIfNull(list); + var conn = _session.GetConnection(); + InsertList(conn, list); + } + + public bool UpdateList(Guid id, string? label = null) + { + var conn = _session.GetConnection(); + var existing = ReadListById(conn, id); + if (existing is null) + return false; + + if (label is not null) + { + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Label cannot be empty", nameof(label)); + existing.Label = label.Trim(); + } + + UpdateListRow(conn, existing); + return true; + } + + public bool RemoveList(Guid id) + { + var conn = _session.GetConnection(); + return DeleteList(conn, id); + } + + // ── Items ──────────────────────────────────────────────────────── + + public List GetItemsByListId(Guid listId) + { + var conn = _session.GetConnection(); + return ReadItemsByListId(conn, listId); + } + + public TodoItem? GetItemById(Guid id) + { + var conn = _session.GetConnection(); + return ReadItemById(conn, id); + } + + public void AddItem(TodoItem item) + { + ArgumentNullException.ThrowIfNull(item); + var conn = _session.GetConnection(); + InsertItem(conn, item); + } + + public bool UpdateItem(Guid id, string? text = null, bool? done = null, int? sortOrder = null) + { + var conn = _session.GetConnection(); + var existing = ReadItemById(conn, id); + if (existing is null) + return false; + + if (text is not null) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Text cannot be empty", nameof(text)); + existing.Text = text.Trim(); + } + + if (done.HasValue) + existing.Done = done.Value; + + if (sortOrder.HasValue) + existing.SortOrder = sortOrder.Value; + + UpdateItemRow(conn, existing); + return true; + } + + public bool RemoveItem(Guid id) + { + var conn = _session.GetConnection(); + return DeleteItem(conn, id); + } + + // ── Private list helpers ───────────────────────────────────────── + + private static void InsertList(SqliteConnection conn, TodoList list) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO todo_lists (guid, label, created_at) + VALUES (@guid, @label, @createdAt); + """; + cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D")); + cmd.Parameters.AddWithValue("@label", list.Label); + cmd.Parameters.AddWithValue("@createdAt", list.CreatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static void UpdateListRow(SqliteConnection conn, TodoList list) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE todo_lists SET label = @label WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D")); + cmd.Parameters.AddWithValue("@label", list.Label); + cmd.ExecuteNonQuery(); + } + + private static bool DeleteList(SqliteConnection conn, Guid id) + { + using var tx = conn.BeginTransaction(); + + var rowId = GetListRowId(conn, id); + if (rowId.HasValue) + { + using var delItems = conn.CreateCommand(); + delItems.CommandText = "DELETE FROM todo_items WHERE list_id = @listId;"; + delItems.Parameters.AddWithValue("@listId", rowId.Value); + delItems.ExecuteNonQuery(); + } + + using var delList = conn.CreateCommand(); + delList.CommandText = "DELETE FROM todo_lists WHERE guid = @guid;"; + delList.Parameters.AddWithValue("@guid", id.ToString("D")); + var rows = delList.ExecuteNonQuery(); + + tx.Commit(); + return rows > 0; + } + + private static TodoList? ReadListById(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, label, created_at FROM todo_lists WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + + using var reader = cmd.ExecuteReader(); + return reader.Read() ? MapListRow(reader) : null; + } + + private static List ReadAllLists(SqliteConnection conn) + { + var results = new List(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT guid, label, created_at FROM todo_lists ORDER BY created_at;"; + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + results.Add(MapListRow(reader)); + + return results; + } + + private static long? GetListRowId(SqliteConnection conn, Guid guid) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT id FROM todo_lists WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", guid.ToString("D")); + var result = cmd.ExecuteScalar(); + return result is long id ? id : null; + } + + private static TodoList MapListRow(SqliteDataReader reader) + { + var guid = Guid.Parse(reader.GetString(0)); + var label = reader.GetString(1); + var createdAt = reader.IsDBNull(2) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(2)); + return new TodoList(guid, label, createdAt); + } + + // ── Private item helpers ───────────────────────────────────────── + + private static void InsertItem(SqliteConnection conn, TodoItem item) + { + var listRowId = GetListRowId(conn, item.ListId) + ?? throw new InvalidOperationException($"Todo list {item.ListId} not found"); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO todo_items (guid, list_id, text, done, sort_order) + VALUES (@guid, @listId, @text, @done, @sortOrder); + """; + cmd.Parameters.AddWithValue("@guid", item.Id.ToString("D")); + cmd.Parameters.AddWithValue("@listId", listRowId); + cmd.Parameters.AddWithValue("@text", item.Text); + cmd.Parameters.AddWithValue("@done", item.Done ? 1 : 0); + cmd.Parameters.AddWithValue("@sortOrder", item.SortOrder); + cmd.ExecuteNonQuery(); + } + + private static void UpdateItemRow(SqliteConnection conn, TodoItem item) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE todo_items SET text = @text, done = @done, sort_order = @sortOrder + WHERE guid = @guid; + """; + cmd.Parameters.AddWithValue("@guid", item.Id.ToString("D")); + cmd.Parameters.AddWithValue("@text", item.Text); + cmd.Parameters.AddWithValue("@done", item.Done ? 1 : 0); + cmd.Parameters.AddWithValue("@sortOrder", item.SortOrder); + cmd.ExecuteNonQuery(); + } + + private static bool DeleteItem(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM todo_items WHERE guid = @guid;"; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + return cmd.ExecuteNonQuery() > 0; + } + + private static TodoItem? ReadItemById(SqliteConnection conn, Guid id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT ti.guid, tl.guid, ti.text, ti.done, ti.sort_order + FROM todo_items ti + INNER JOIN todo_lists tl ON tl.id = ti.list_id + WHERE ti.guid = @guid; + """; + cmd.Parameters.AddWithValue("@guid", id.ToString("D")); + + using var reader = cmd.ExecuteReader(); + return reader.Read() ? MapItemRow(reader) : null; + } + + private static List ReadItemsByListId(SqliteConnection conn, Guid listId) + { + var results = new List(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT ti.guid, tl.guid, ti.text, ti.done, ti.sort_order + FROM todo_items ti + INNER JOIN todo_lists tl ON tl.id = ti.list_id + WHERE tl.guid = @listGuid + ORDER BY ti.sort_order, ti.guid; + """; + cmd.Parameters.AddWithValue("@listGuid", listId.ToString("D")); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + results.Add(MapItemRow(reader)); + + return results; + } + + private static TodoItem MapItemRow(SqliteDataReader reader) + { + var guid = Guid.Parse(reader.GetString(0)); + var listGuid = Guid.Parse(reader.GetString(1)); + var text = reader.GetString(2); + var done = !reader.IsDBNull(3) && reader.GetInt64(3) != 0; + var sortOrder = reader.IsDBNull(4) ? 0 : (int)reader.GetInt64(4); + return new TodoItem(guid, listGuid, text, done, sortOrder); + } +} diff --git a/Journal.Core/ServiceCollectionExtensions.cs b/Journal.Core/ServiceCollectionExtensions.cs index 2866c9a..649d8a3 100644 --- a/Journal.Core/ServiceCollectionExtensions.cs +++ b/Journal.Core/ServiceCollectionExtensions.cs @@ -5,9 +5,11 @@ using Journal.Core.Services.Config; using Journal.Core.Services.Database; using Journal.Core.Services.Entries; using Journal.Core.Services.Fragments; +using Journal.Core.Services.Lists; using Journal.Core.Services.Logging; using Journal.Core.Services.Sidecar; using Journal.Core.Services.Speech; +using Journal.Core.Services.Todos; using Journal.Core.Services.Vault; namespace Journal.Core; @@ -58,6 +60,10 @@ public static class ServiceCollectionExtensions }); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; diff --git a/Journal.Core/Services/Database/DatabaseSessionService.cs b/Journal.Core/Services/Database/DatabaseSessionService.cs index b051529..8942876 100644 --- a/Journal.Core/Services/Database/DatabaseSessionService.cs +++ b/Journal.Core/Services/Database/DatabaseSessionService.cs @@ -54,7 +54,7 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I } } - public void Dispose() + public void CloseConnection() { lock (_lock) { @@ -62,4 +62,9 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I _connection = null; } } + + public void Dispose() + { + CloseConnection(); + } } diff --git a/Journal.Core/Services/Database/IDatabaseSessionService.cs b/Journal.Core/Services/Database/IDatabaseSessionService.cs index 264334d..89ddafb 100644 --- a/Journal.Core/Services/Database/IDatabaseSessionService.cs +++ b/Journal.Core/Services/Database/IDatabaseSessionService.cs @@ -7,4 +7,5 @@ public interface IDatabaseSessionService bool IsUnlocked { get; } void SetPassword(string password, string? dataDirectory = null); SqliteConnection GetConnection(); + void CloseConnection(); } diff --git a/Journal.Core/Services/Database/JournalDatabaseService.cs b/Journal.Core/Services/Database/JournalDatabaseService.cs index 301dcac..ae0c147 100644 --- a/Journal.Core/Services/Database/JournalDatabaseService.cs +++ b/Journal.Core/Services/Database/JournalDatabaseService.cs @@ -14,7 +14,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour private static readonly Lock SqliteInitLock = new(); private static bool _sqliteInitialized; private static readonly IReadOnlyList RequiredSchemaTables = - ["entries", "sections", "fragments", "tags", "fragment_tags"]; + ["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items"]; private readonly IJournalConfigService _config = config; @@ -91,6 +91,35 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour FOREIGN KEY (fragment_id) REFERENCES fragments (id), FOREIGN KEY (tag_id) REFERENCES tags (id) ); + """, + ["lists"] = """ + CREATE TABLE IF NOT EXISTS lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + label TEXT NOT NULL, + content TEXT, + created_at TEXT, + updated_at TEXT + ); + """, + ["todo_lists"] = """ + CREATE TABLE IF NOT EXISTS todo_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + label TEXT NOT NULL, + created_at TEXT + ); + """, + ["todo_items"] = """ + CREATE TABLE IF NOT EXISTS todo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE, + list_id INTEGER NOT NULL, + text TEXT NOT NULL, + done INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (list_id) REFERENCES todo_lists (id) + ); """ }; } diff --git a/Journal.Core/Services/Lists/IListService.cs b/Journal.Core/Services/Lists/IListService.cs new file mode 100644 index 0000000..b727bb9 --- /dev/null +++ b/Journal.Core/Services/Lists/IListService.cs @@ -0,0 +1,12 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Lists; + +public interface IListService +{ + List GetAll(); + ListDocumentDto? GetById(Guid id); + ListDocumentDto Create(CreateListDto dto); + bool Update(Guid id, UpdateListDto dto); + bool Remove(Guid id); +} diff --git a/Journal.Core/Services/Lists/ListService.cs b/Journal.Core/Services/Lists/ListService.cs new file mode 100644 index 0000000..8b425a7 --- /dev/null +++ b/Journal.Core/Services/Lists/ListService.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Repositories; + +namespace Journal.Core.Services.Lists; + +public class ListService(IListRepository repo) : IListService +{ + private readonly IListRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + + private static ListDocumentDto Map(ListDocument d) => new( + d.Id, + d.Label, + d.Content, + d.CreatedAt, + d.UpdatedAt + ); + + public List GetAll() + { + var items = _repo.GetAll(); + return [.. items.Select(Map)]; + } + + public ListDocumentDto? GetById(Guid id) + { + var d = _repo.GetById(id); + return d is null ? null : Map(d); + } + + public ListDocumentDto Create(CreateListDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + var ctx = new ValidationContext(dto); + Validator.ValidateObject(dto, ctx, validateAllProperties: true); + + var doc = new ListDocument(dto.Label, dto.Content ?? ""); + _repo.Add(doc); + return Map(doc); + } + + public bool Update(Guid id, UpdateListDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + if (dto.Label is not null && string.IsNullOrWhiteSpace(dto.Label)) + throw new ValidationException("Label cannot be empty"); + + return _repo.Update(id, dto.Label?.Trim(), dto.Content); + } + + public bool Remove(Guid id) => _repo.Remove(id); +} diff --git a/Journal.Core/Services/Todos/ITodoService.cs b/Journal.Core/Services/Todos/ITodoService.cs new file mode 100644 index 0000000..9b14e8b --- /dev/null +++ b/Journal.Core/Services/Todos/ITodoService.cs @@ -0,0 +1,16 @@ +using Journal.Core.Dtos; + +namespace Journal.Core.Services.Todos; + +public interface ITodoService +{ + List GetAllLists(); + TodoListDto? GetListById(Guid id); + TodoListDto CreateList(CreateTodoListDto dto); + bool UpdateList(Guid id, UpdateTodoListDto dto); + bool RemoveList(Guid id); + + TodoItemDto CreateItem(CreateTodoItemDto dto); + bool UpdateItem(Guid id, UpdateTodoItemDto dto); + bool RemoveItem(Guid id); +} diff --git a/Journal.Core/Services/Todos/TodoService.cs b/Journal.Core/Services/Todos/TodoService.cs new file mode 100644 index 0000000..9d1f587 --- /dev/null +++ b/Journal.Core/Services/Todos/TodoService.cs @@ -0,0 +1,91 @@ +using System.ComponentModel.DataAnnotations; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Repositories; + +namespace Journal.Core.Services.Todos; + +public class TodoService(ITodoRepository repo) : ITodoService +{ + private readonly ITodoRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + + private static TodoItemDto MapItem(TodoItem i) => new( + i.Id, + i.ListId, + i.Text, + i.Done, + i.SortOrder + ); + + private TodoListDto MapList(TodoList l) + { + var items = _repo.GetItemsByListId(l.Id); + return new TodoListDto( + l.Id, + l.Label, + l.CreatedAt, + [.. items.Select(MapItem)] + ); + } + + public List GetAllLists() + { + var lists = _repo.GetAllLists(); + return [.. lists.Select(MapList)]; + } + + public TodoListDto? GetListById(Guid id) + { + var l = _repo.GetListById(id); + return l is null ? null : MapList(l); + } + + public TodoListDto CreateList(CreateTodoListDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + var ctx = new ValidationContext(dto); + Validator.ValidateObject(dto, ctx, validateAllProperties: true); + + var list = new TodoList(dto.Label); + _repo.AddList(list); + return new TodoListDto(list.Id, list.Label, list.CreatedAt, []); + } + + public bool UpdateList(Guid id, UpdateTodoListDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + if (dto.Label is not null && string.IsNullOrWhiteSpace(dto.Label)) + throw new ValidationException("Label cannot be empty"); + + return _repo.UpdateList(id, dto.Label?.Trim()); + } + + public bool RemoveList(Guid id) => _repo.RemoveList(id); + + public TodoItemDto CreateItem(CreateTodoItemDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + var ctx = new ValidationContext(dto); + Validator.ValidateObject(dto, ctx, validateAllProperties: true); + + if (dto.ListId == Guid.Empty) + throw new ValidationException("ListId is required"); + + var item = new TodoItem(dto.ListId, dto.Text, dto.SortOrder ?? 0); + _repo.AddItem(item); + return MapItem(item); + } + + public bool UpdateItem(Guid id, UpdateTodoItemDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + if (dto.Text is not null && string.IsNullOrWhiteSpace(dto.Text)) + throw new ValidationException("Text cannot be empty"); + + return _repo.UpdateItem(id, dto.Text?.Trim(), dto.Done, dto.SortOrder); + } + + public bool RemoveItem(Guid id) => _repo.RemoveItem(id); +} diff --git a/Journal.Core/Services/Vault/VaultStorageService.cs b/Journal.Core/Services/Vault/VaultStorageService.cs index 01851b0..f73fdba 100644 --- a/Journal.Core/Services/Vault/VaultStorageService.cs +++ b/Journal.Core/Services/Vault/VaultStorageService.cs @@ -32,6 +32,9 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ if (vaultFiles.Length == 0) return true; + // Restore database vault files first + RestoreDatabaseVaults(password, vaultDirectory, dataDirectory); + var anyDecrypted = false; var anyVaultFiles = false; foreach (var vaultFile in vaultFiles) @@ -50,6 +53,9 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ continue; } + if (IsReservedVaultFile(fileName)) + continue; + anyVaultFiles = true; try { @@ -135,6 +141,9 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ foreach (var (monthKey, filesInMonth) in monthlyFiles.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) SaveMonth(password, monthKey, filesInMonth, vaultDirectory); + + // Save database files + SaveDatabaseVaults(password, vaultDirectory, dataDirectory); } } @@ -181,6 +190,69 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ Directory.Delete(dataDirectory, recursive: true); } + // ── Database vault helpers ───────────────────────────────────── + + private const string DatabaseVaultPrefix = "_db_"; + private const string DatabaseVaultSuffix = ".vault"; + + private static bool IsReservedVaultFile(string fileName) + => fileName.StartsWith(DatabaseVaultPrefix, StringComparison.OrdinalIgnoreCase); + + private void SaveDatabaseVaults(string password, string vaultDirectory, string dataDirectory) + { + var dbFiles = Directory.GetFiles(dataDirectory, "*.db"); + foreach (var dbPath in dbFiles) + { + try + { + var dbFileName = Path.GetFileName(dbPath); + var vaultFileName = $"{DatabaseVaultPrefix}{dbFileName}{DatabaseVaultSuffix}"; + var vaultPath = Path.Combine(vaultDirectory, vaultFileName); + + var dbBytes = File.ReadAllBytes(dbPath); + var encrypted = _crypto.EncryptData(dbBytes, password); + File.WriteAllBytes(vaultPath, encrypted); + + Debug.WriteLine($"[VaultStorageService] Saved database vault: {vaultFileName}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[VaultStorageService] Failed to save database vault for {Path.GetFileName(dbPath)}: {ex.Message}"); + } + } + } + + private void RestoreDatabaseVaults(string password, string vaultDirectory, string dataDirectory) + { + var dbVaultFiles = Directory.GetFiles(vaultDirectory, $"{DatabaseVaultPrefix}*{DatabaseVaultSuffix}"); + foreach (var vaultFile in dbVaultFiles) + { + try + { + var vaultFileName = Path.GetFileName(vaultFile); + // Strip prefix and suffix to get original DB filename + var dbFileName = vaultFileName[DatabaseVaultPrefix.Length..^DatabaseVaultSuffix.Length]; + if (string.IsNullOrWhiteSpace(dbFileName)) + continue; + + var encrypted = File.ReadAllBytes(vaultFile); + var dbBytes = _crypto.DecryptData(encrypted, password); + var targetPath = Path.Combine(dataDirectory, dbFileName); + File.WriteAllBytes(targetPath, dbBytes); + + Debug.WriteLine($"[VaultStorageService] Restored database from vault: {vaultFileName} → {dbFileName}"); + } + catch (CryptographicException) + { + Debug.WriteLine($"[VaultStorageService] Database vault decryption failed for {Path.GetFileName(vaultFile)} (likely wrong password)"); + } + catch (Exception ex) + { + Debug.WriteLine($"[VaultStorageService] Failed to restore database vault {Path.GetFileName(vaultFile)}: {ex.Message}"); + } + } + } + private static void EnsureRequiredArguments(string password, string vaultDirectory, string dataDirectory) { if (string.IsNullOrWhiteSpace(password))