Lists & todos backend, editor refactor, inline create, UX improvements

- Wire up lists and todos to C# backend with full CRUD persistence
- Add models, DTOs, repositories, and services for lists and todo lists/items
- Preserve SQLite DB across vault rebuild/load cycles
- Add session store for vault password persistence across navigation
- Add inline name input for creating lists and todo lists in SidePanel
- Clear editor panel on section change with empty state placeholder
- Default markdown editor to preview mode on item selection
- Decompose EditorPanel into sub-components:
  - editor/FragmentEditor, editor/TodoEditor, editor/MarkdownEditor
  - Shared markdown utilities in utils/markdown.ts
- Strip verbose console/eprintln logging from frontend and Tauri backend
- Add graceful shutdown with vault persistence on window close

Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
Jacob Schmidt 2026-02-26 17:33:27 -06:00
parent 0465b05845
commit c7933aeeec
39 changed files with 3101 additions and 1301 deletions

View File

@ -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",
]

View File

@ -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"] }

View File

@ -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<Value>,
}
#[derive(Deserialize, Serialize, Default)]
struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
sidecar_root: Option<String>,
}
struct ManagedSidecar {
child: Child,
stdin: ChildStdin,
@ -30,21 +38,18 @@ struct ManagedSidecar {
}
impl ManagedSidecar {
fn start() -> Result<Self, String> {
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<Self, String> {
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<String, String> {
async fn send_command_line(&mut self, input_line: &str) -> Result<String, String> {
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<Option<ManagedSidecar>>,
root_override: Mutex<Option<PathBuf>>,
config_path: PathBuf,
}
fn project_root() -> Result<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
let root = project_root()?;
fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
if let Some(root) = root_override {
return Ok(root.clone());
}
auto_detect_root()
}
fn resolve_sidecar_path(root: &Path) -> Result<PathBuf, String> {
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<PathBuf, String> {
Err("Journal.Sidecar.exe not found. Build Journal.Sidecar first.".to_string())
}
fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result<String, String> {
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<String, String> {
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<S
None => 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<S
Err("Failed to send command to sidecar.".to_string())
}
fn stop_managed_sidecar(state: &SidecarState) {
let Ok(mut guard) = state.process.lock() else {
eprintln!("[sidecar] stop_error=failed_to_lock_state");
return;
async fn stop_managed_sidecar(state: &SidecarState) {
let mut guard = state.process.lock().await;
guard.take();
}
#[tauri::command]
async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
@ -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::<Value>(&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::<SidecarState>();
stop_managed_sidecar(state.inner());
if let Ok(mut guard) = state.process.try_lock() {
guard.take();
};
}
});
}

View File

@ -10,27 +10,11 @@ export async function sendCommand<T>(command: BackendCommand): Promise<T> {
...command,
correlationId: command.correlationId ?? newCorrelationId()
};
console.info("[backend] send", {
action: envelope.action,
correlationId: envelope.correlationId,
id: envelope.id
});
const response = await invoke<BackendResponse<T>>("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;
}

View File

@ -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<ListDocumentDto[]> {
const data = await sendCommand<ListDocumentDtoRaw[]>({
action: "lists.list"
});
return data.map(normalizeList).filter((item) => Boolean(item.id));
}
export async function getList(id: string): Promise<ListDocumentDto | null> {
const data = await sendCommand<ListDocumentDtoRaw | null>({
action: "lists.get",
id
});
if (!data) return null;
const normalized = normalizeList(data);
return normalized.id ? normalized : null;
}
export async function createList(payload: CreateListPayload): Promise<ListDocumentDto> {
const data = await sendCommand<ListDocumentDtoRaw>({
action: "lists.create",
payload
});
return normalizeList(data);
}
export function updateList(id: string, payload: UpdateListPayload): Promise<boolean> {
return sendCommand<boolean>({
action: "lists.update",
id,
payload
});
}
export function deleteList(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "lists.delete",
id
});
}

View File

@ -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<TodoListDto[]> {
const data = await sendCommand<TodoListDtoRaw[]>({
action: "todos.list"
});
return data.map(normalizeList).filter((item) => Boolean(item.id));
}
export async function getTodoList(id: string): Promise<TodoListDto | null> {
const data = await sendCommand<TodoListDtoRaw | null>({
action: "todos.get",
id
});
if (!data) return null;
const normalized = normalizeList(data);
return normalized.id ? normalized : null;
}
export async function createTodoList(payload: CreateTodoListPayload): Promise<TodoListDto> {
const data = await sendCommand<TodoListDtoRaw>({
action: "todos.create",
payload
});
return normalizeList(data);
}
export function updateTodoList(id: string, payload: UpdateTodoListPayload): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.update",
id,
payload
});
}
export function deleteTodoList(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.delete",
id
});
}
export async function createTodoItem(payload: CreateTodoItemPayload): Promise<TodoItemDto> {
const data = await sendCommand<TodoItemDtoRaw>({
action: "todos.items.create",
payload
});
return normalizeItem(data);
}
export function updateTodoItem(id: string, payload: UpdateTodoItemPayload): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.items.update",
id,
payload
});
}
export function deleteTodoItem(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.items.delete",
id
});
}

File diff suppressed because it is too large Load Diff

View File

@ -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 @@
<input type="text" placeholder={`Search ${panelTitle.toLowerCase()}...`} />
</div>
{#if showNewItemInput}
<div class="new-item-input">
<input
type="text"
bind:this={newItemInput}
bind:value={newItemName}
placeholder={activeSection === "todos" ? "Todo list name..." : "List name..."}
on:keydown={handleNewItemKeydown}
on:blur={confirmNewItem}
/>
</div>
{/if}
<ul class="panel-list">
{#each items as item}
<li>
@ -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);
}
}
}
</style>

View File

@ -0,0 +1,382 @@
<script lang="ts">
import {
createFragmentFromParsed,
deleteFragmentByStoreId,
fragmentsStore,
hasFragment,
parseFragmentContent,
serializeFragment,
updateFragmentFromParsed,
type FragmentItem
} from "$lib/stores/fragments";
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
import { renderMarkdown } from "$lib/utils/markdown";
import { get } from "svelte/store";
export let openDocumentId = "";
export let openDocumentName = "";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {};
let fragmentTitle = "";
let fragmentType = "";
let customFragmentType = "";
let fragmentTag = "";
let customFragmentTags = "";
let fragmentBody = "";
let fragmentMode: "view" | "edit" | "create" = "view";
let lastFragmentDocumentId = "";
let fragmentTypeOptions: string[] = [];
let tagOptions: string[] = [];
const customTypeValue = "__custom_type__";
const customTagValue = "__custom_tag__";
function buildFragmentContent(): { title: string; resolvedType: string; body: string; content: string; tags: string[] } | null {
const title = fragmentTitle.trim();
if (!title) return null;
const resolvedType = fragmentType === customTypeValue ? customFragmentType.trim() : fragmentType;
if (!resolvedType) return null;
const selectedTags = fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
const customTags = customFragmentTags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
const tagList = [...selectedTags, ...customTags];
const uniqueTagList = [...new Set(tagList.map((tag) => tag.toLowerCase()))].map((lower) => {
return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
});
const body = fragmentBody.trim() || "Add details for this fragment.";
const content = serializeFragment({
title,
type: resolvedType,
tags: uniqueTagList,
body
});
return { title, resolvedType, body, content, tags: uniqueTagList };
}
async function saveFragmentEdits() {
try {
const payload = buildFragmentContent();
if (!payload) return;
const exists = hasFragment(openDocumentId);
if (!exists) {
await createNewFragment();
return;
}
const updated = await updateFragmentFromParsed(openDocumentId, {
title: payload.title,
type: payload.resolvedType,
tags: payload.tags,
body: payload.body
});
if (!updated) return;
onDocumentContentChange(payload.content);
fragmentMode = "view";
} catch (error) {
console.error("[editor] fragment:save:error", error);
}
}
async function createNewFragment() {
try {
const payload = buildFragmentContent();
if (!payload) return;
const item: FragmentItem = await createFragmentFromParsed({
title: payload.title,
type: payload.resolvedType,
tags: payload.tags,
body: payload.body
});
onOpenDocument(item);
fragmentMode = "view";
} catch (error) {
console.error("[editor] fragment:create:error", error);
}
}
async function deleteCurrentFragment() {
try {
const ok = await deleteFragmentByStoreId(openDocumentId);
if (!ok) return;
const remaining = get(fragmentsStore);
onDeleteDocument(openDocumentId);
if (remaining.length > 0) {
onOpenDocument(remaining[0]);
fragmentMode = "view";
return;
}
fragmentTitle = "";
customFragmentType = "";
fragmentTag = customTagValue;
customFragmentTags = "";
fragmentBody = "";
fragmentMode = "create";
} catch (error) {
console.error("[editor] fragment:delete:error", error);
}
}
function startEditFragment() {
fragmentMode = "edit";
}
function cancelFragmentEdit() {
if (fragmentMode === "create") {
fragmentMode = "view";
return;
}
loadFragmentFormFromDocument();
fragmentMode = "view";
}
function loadFragmentFormFromDocument() {
const content = openDocumentContent ?? "";
const isDraftFragment = openDocumentId.startsWith("fragments/new-");
const parsed = parseFragmentContent(content, openDocumentName || "Untitled Fragment");
fragmentTitle = parsed.title;
const parsedType = parsed.type;
if (!parsedType) {
fragmentType = fragmentTypeOptions[0] ?? customTypeValue;
customFragmentType = "";
} else if (fragmentTypeOptions.includes(parsedType)) {
fragmentType = parsedType;
customFragmentType = "";
} else {
fragmentType = customTypeValue;
customFragmentType = parsedType;
}
const parsedTags = parsed.tags;
if (parsedTags.length === 0) {
fragmentTag = tagOptions[0] ?? customTagValue;
customFragmentTags = "";
} else {
const primary = parsedTags[0];
if (tagOptions.includes(primary)) {
fragmentTag = primary;
customFragmentTags = parsedTags.slice(1).join(", ");
} else {
fragmentTag = customTagValue;
customFragmentTags = parsedTags.join(", ");
}
}
fragmentBody = parsed.body;
fragmentMode = isDraftFragment ? "create" : "view";
}
$: fragmentTypeOptions = $settingsFragmentTypes.length ? $settingsFragmentTypes : ["General"];
$: tagOptions = $settingsTags;
$: if (openDocumentId !== lastFragmentDocumentId) {
loadFragmentFormFromDocument();
lastFragmentDocumentId = openDocumentId;
}
$: if (!fragmentType || (!fragmentTypeOptions.includes(fragmentType) && fragmentType !== customTypeValue)) {
fragmentType = fragmentTypeOptions[0];
}
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
fragmentTag = tagOptions[0] ?? customTagValue;
}
</script>
<section class="fragment-surface">
{#if fragmentMode === "view"}
<article class="fragment-view">
{@html renderMarkdown(openDocumentContent)}
<div class="fragment-actions">
<button type="button" class="fragment-submit" on:click={startEditFragment}>Edit</button>
<button type="button" class="fragment-danger" on:click={deleteCurrentFragment}>Delete</button>
</div>
</article>
{:else}
<form class="fragment-form" on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}>
<h2>{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}</h2>
<input type="text" placeholder="Title" bind:value={fragmentTitle} aria-label="Fragment title" />
<div class="fragment-form-row">
<select bind:value={fragmentType} aria-label="Fragment type">
{#each fragmentTypeOptions as type}
<option value={type}>{type}</option>
{/each}
<option value={customTypeValue}>Custom</option>
</select>
{#if fragmentType === customTypeValue}
<input
type="text"
placeholder="Custom type"
bind:value={customFragmentType}
aria-label="Custom fragment type"
/>
{:else}
<input type="text" value={fragmentType} disabled aria-label="Selected fragment type" />
{/if}
</div>
<div class="fragment-form-row">
<select bind:value={fragmentTag} aria-label="Primary fragment tag">
{#if tagOptions.length === 0}
<option value={customTagValue}>Custom</option>
{:else}
{#each tagOptions as tag}
<option value={tag}>{tag}</option>
{/each}
<option value={customTagValue}>Custom</option>
{/if}
</select>
<input
type="text"
placeholder={fragmentTag === customTagValue
? "Custom tags (comma separated)"
: "Additional tags (optional, comma separated)"}
bind:value={customFragmentTags}
aria-label="Custom fragment tags"
/>
</div>
<textarea
rows="5"
placeholder="Fragment text"
bind:value={fragmentBody}
aria-label="Fragment body"
></textarea>
<div class="fragment-actions">
<button type="submit" class="fragment-submit">{fragmentMode === "create" ? "Create Fragment" : "Save Fragment"}</button>
<button type="button" class="fragment-secondary" on:click={cancelFragmentEdit}>Cancel</button>
{#if fragmentMode !== "create"}
<button type="button" class="fragment-danger" on:click={deleteCurrentFragment}>Delete</button>
{/if}
</div>
</form>
{/if}
</section>
<style>
.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);
}
</style>

View File

@ -0,0 +1,341 @@
<script lang="ts">
import {
saveEntryFromStore,
type EntryItem,
} from "$lib/stores/entries";
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
export let activeSection = "entries";
export let openDocumentId = "";
export let openDocumentName = "";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {};
let markdownText = openDocumentContent;
let lastOpenDocumentId = openDocumentId;
let previewOnly = true;
let editorInput: HTMLTextAreaElement | null = null;
let entrySaveBusy = false;
function updateDraft(value: string) {
markdownText = value;
onDocumentContentChange(value);
}
function applyWrap(before: string, after = before) {
if (!editorInput) return;
const current = markdownText;
const start = editorInput.selectionStart ?? 0;
const end = editorInput.selectionEnd ?? start;
const selected = current.slice(start, end);
const insertion = `${before}${selected}${after}`;
const next = `${current.slice(0, start)}${insertion}${current.slice(end)}`;
markdownText = next;
onDocumentContentChange(next);
queueMicrotask(() => {
if (!editorInput) return;
const cursor = start + insertion.length;
editorInput.focus();
editorInput.setSelectionRange(cursor, cursor);
});
}
function applyLinePrefix(prefix: string) {
if (!editorInput) return;
const current = markdownText;
const start = editorInput.selectionStart ?? 0;
const end = editorInput.selectionEnd ?? start;
const blockStart = current.lastIndexOf("\n", start - 1) + 1;
const blockEndIndex = current.indexOf("\n", end);
const blockEnd = blockEndIndex === -1 ? current.length : blockEndIndex;
const block = current.slice(blockStart, blockEnd);
const nextBlock = block
.split("\n")
.map((line) => `${prefix}${line}`)
.join("\n");
const next = `${current.slice(0, blockStart)}${nextBlock}${current.slice(blockEnd)}`;
markdownText = next;
onDocumentContentChange(next);
}
function applyHeading(level: number) {
if (!Number.isFinite(level) || level < 1 || level > 6) return;
applyLinePrefix(`${"#".repeat(level)} `);
}
function insertLink() {
applyWrap("[", "](https://example.com)");
}
async function saveEntryDocument() {
if (activeSection !== "entries") return;
try {
entrySaveBusy = true;
const previousId = openDocumentId;
const saved: EntryItem | null = await saveEntryFromStore(previousId, markdownText, "Overwrite");
if (!saved) return;
if (saved.id !== previousId) {
onDeleteDocument(previousId);
}
onOpenDocument(saved);
onDocumentContentChange(saved.initialContent);
} catch (error) {
console.error("[editor] entries:save:error", error);
} finally {
entrySaveBusy = false;
}
}
$: if (openDocumentId !== lastOpenDocumentId) {
markdownText = openDocumentContent;
lastOpenDocumentId = openDocumentId;
}
$: editorTitle = extractEditorTitle(markdownText, openDocumentName);
$: renderedHtml = renderMarkdown(markdownText);
</script>
<header class="editor-header">
<h1>{editorTitle}</h1>
<div class="editor-actions">
{#if activeSection === "entries"}
<button type="button" on:click={saveEntryDocument} disabled={entrySaveBusy}>
{entrySaveBusy ? "Saving..." : "Save"}
</button>
{/if}
<button type="button" class:primary={!previewOnly} on:click={() => (previewOnly = false)}>Write</button>
<button type="button" class:primary={previewOnly} on:click={() => (previewOnly = true)}>Preview</button>
</div>
</header>
<section class="editor-surface" class:preview-only={previewOnly}>
{#if !previewOnly}
<div class="editor-toolbar">
<select
class="toolbar-select"
aria-label="Header size"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const level = Number(target.value);
if (level) applyHeading(level);
target.value = "";
}}
>
<option value="">Heading</option>
<option value="1">H1</option>
<option value="2">H2</option>
<option value="3">H3</option>
<option value="4">H4</option>
<option value="5">H5</option>
<option value="6">H6</option>
</select>
<button type="button" on:click={() => applyWrap("**")}>Bold</button>
<button type="button" on:click={() => applyWrap("*")}>Italic</button>
<button type="button" on:click={insertLink}>Link</button>
<button type="button" on:click={() => applyLinePrefix("- ")}>UL</button>
<button type="button" on:click={() => applyLinePrefix("1. ")}>OL</button>
<button type="button" on:click={() => applyWrap("`")}>Code</button>
</div>
{/if}
<div class="editor-workspace">
{#if previewOnly}
<article class="markdown-preview" aria-label="Markdown preview">
{@html renderedHtml}
</article>
{:else}
<textarea
bind:this={editorInput}
class="markdown-input"
bind:value={markdownText}
on:input={(event) => updateDraft((event.currentTarget as HTMLTextAreaElement).value)}
aria-label="Markdown input"
></textarea>
{/if}
</div>
</section>
<style>
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
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);
}
.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;
}
</style>

View File

@ -0,0 +1,284 @@
<script lang="ts">
import {
addTodoItem,
addTodoItemBackend,
getOrCreateTodoList,
removeTodoItem,
removeTodoItemBackend,
serializeTodoList,
setTodoList,
toggleTodoItem,
toggleTodoItemBackend,
todosStore,
type TodoItem,
updateTodoItemText,
updateTodoItemTextBackend
} from "$lib/stores/todos";
import { get } from "svelte/store";
export let openDocumentId = "";
export let openDocumentName = "";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
let todoItems: TodoItem[] = [];
let lastTodoDocumentId = "";
let newTodoText = "";
let editingTodoId: number | null = null;
let editingTodoText = "";
async function addTodo() {
const text = newTodoText.trim();
if (!text) return;
newTodoText = "";
const backendItem = await addTodoItemBackend(openDocumentId, text);
if (backendItem) {
todoItems = [backendItem, ...todoItems];
} else {
todoItems = addTodoItem(todoItems, text);
}
persistTodosForCurrentList();
}
async function toggleTodoDone(id: number) {
const ok = await toggleTodoItemBackend(openDocumentId, id);
if (!ok) {
todoItems = toggleTodoItem(todoItems, id);
} else {
todoItems = todoItems.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
}
persistTodosForCurrentList();
}
function startEditTodo(id: number) {
const todo = todoItems.find((item) => item.id === id);
if (!todo) return;
editingTodoId = id;
editingTodoText = todo.text;
}
async function saveEditTodo() {
if (editingTodoId === null) return;
const text = editingTodoText.trim();
if (!text) return;
const id = editingTodoId;
editingTodoId = null;
editingTodoText = "";
const ok = await updateTodoItemTextBackend(openDocumentId, id, text);
if (!ok) {
todoItems = updateTodoItemText(todoItems, id, text);
} else {
todoItems = todoItems.map((t) => (t.id === id ? { ...t, text: text.trim() } : t));
}
persistTodosForCurrentList();
}
function cancelEditTodo() {
editingTodoId = null;
editingTodoText = "";
}
async function removeTodo(id: number) {
if (editingTodoId === id) {
cancelEditTodo();
}
const ok = await removeTodoItemBackend(openDocumentId, id);
if (!ok) {
todoItems = removeTodoItem(todoItems, id);
} else {
todoItems = todoItems.filter((t) => t.id !== id);
}
persistTodosForCurrentList();
}
function loadTodosForDocument(documentId: string) {
if (!documentId) {
todoItems = [];
return;
}
const lists = get(todosStore);
const result = getOrCreateTodoList(lists, documentId, openDocumentContent);
if (result.lists !== lists) {
todosStore.set(result.lists);
}
todoItems = result.todos;
}
function persistTodosForCurrentList() {
if (!openDocumentId) return;
const lists = get(todosStore);
todosStore.set(setTodoList(lists, openDocumentId, todoItems));
const markdown = serializeTodoList(openDocumentName, todoItems);
onDocumentContentChange(markdown);
}
$: if (openDocumentId !== lastTodoDocumentId) {
loadTodosForDocument(openDocumentId);
editingTodoId = null;
editingTodoText = "";
newTodoText = "";
lastTodoDocumentId = openDocumentId;
}
</script>
<section class="todo-surface">
<div class="todo-card">
<form class="todo-create" on:submit|preventDefault={addTodo}>
<input
type="text"
placeholder="Add a new to-do"
bind:value={newTodoText}
aria-label="Add to-do"
/>
<button type="submit" class="todo-add-btn">Add</button>
</form>
<ul class="todo-list">
{#each todoItems as todo}
<li class="todo-item">
<label class="todo-check">
<input type="checkbox" checked={todo.done} on:change={() => toggleTodoDone(todo.id)} />
</label>
{#if editingTodoId === todo.id}
<input
type="text"
class="todo-edit-input"
bind:value={editingTodoText}
on:keydown={(event) => {
if (event.key === "Enter") saveEditTodo();
if (event.key === "Escape") cancelEditTodo();
}}
/>
<div class="todo-actions">
<button type="button" class="todo-btn save" on:click={saveEditTodo}>Save</button>
<button type="button" class="todo-btn ghost" on:click={cancelEditTodo}>Cancel</button>
</div>
{:else}
<span class="todo-text" class:is-done={todo.done}>{todo.text}</span>
<div class="todo-actions">
<button type="button" class="todo-btn ghost" on:click={() => startEditTodo(todo.id)}>Edit</button>
<button type="button" class="todo-btn danger" on:click={() => removeTodo(todo.id)}>Remove</button>
</div>
{/if}
</li>
{/each}
</ul>
</div>
</section>
<style>
.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;
}
.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);
}
</style>

View File

@ -88,10 +88,8 @@ export function createEntryDraft(): EntryItem {
export async function hydrateEntries(dataDirectory?: string): Promise<void> {
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<void> {
export async function loadEntryByStoreId(storeId: string): Promise<EntryItem | null> {
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<EntryItem | n
export async function saveEntryFromStore(storeId: string, content: string, mode?: string): Promise<EntryItem | null> {
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<EntryItem[]> {
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;
}

View File

@ -159,9 +159,7 @@ export function removeFragmentItem(items: FragmentItem[], id: string): FragmentI
export async function hydrateFragments(): Promise<void> {
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<void> {
}
export async function createFragmentFromParsed(payload: ParsedFragment): Promise<FragmentItem> {
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<FragmentItem | null> {
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<boolean> {
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;
}

View File

@ -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<ListItem[]>([]);
export const listsBusyStore = writable(false);
export const listsStore = writable<ListItem[]>(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<void> {
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<ListItem> {
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<boolean> {
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<boolean> {
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);
}

View File

@ -0,0 +1,24 @@
import { writable, get } from "svelte/store";
const _password = writable<string | null>(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);
}

View File

@ -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<TodoListMeta[]>(initialTodoListMeta);
export const todoListsStore = writable<TodoListMeta[]>([]);
export const todosStore = writable<Record<string, TodoItem[]>>({});
export const todosBusyStore = writable(false);
export const todosStore = writable<Record<string, TodoItem[]>>({
"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<void> {
todosBusyStore.set(true);
try {
const lists = await listTodoLists();
const metas: TodoListMeta[] = lists.map(dtoToMeta);
const items: Record<string, TodoItem[]> = {};
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<boolean> {
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<TodoItem | null> {
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<boolean> {
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<boolean> {
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<boolean> {
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: []

View File

@ -0,0 +1,115 @@
export function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export function parseInline(input: string): string {
let value = escapeHtml(input);
value = value.replace(/`([^`]+)`/g, "<code>$1</code>");
value = value.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
value = value.replace(/\*([^*]+)\*/g, "<em>$1</em>");
value = value.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noreferrer">$1</a>'
);
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(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
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(`<h${level}>${parseInline(heading[2])}</h${level}>`);
i += 1;
continue;
}
if (/^[-*+]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
items.push(`<li>${parseInline(lines[i].trim().replace(/^[-*+]\s+/, ""))}</li>`);
i += 1;
}
output.push(`<ul>${items.join("")}</ul>`);
continue;
}
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
items.push(`<li>${parseInline(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
i += 1;
}
output.push(`<ol>${items.join("")}</ol>`);
continue;
}
if (/^>\s+/.test(trimmed)) {
output.push(`<blockquote>${parseInline(trimmed.replace(/^>\s+/, ""))}</blockquote>`);
i += 1;
continue;
}
if (/^(-{3,}|\*{3,})$/.test(trimmed)) {
output.push("<hr />");
i += 1;
continue;
}
const paragraph: string[] = [];
while (i < lines.length && lines[i].trim()) {
paragraph.push(lines[i].trim());
i += 1;
}
output.push(`<p>${parseInline(paragraph.join(" "))}</p>`);
}
if (inCode) {
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
}
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;
}

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import { getSessionPassword, clearVaultSession } from "$lib/stores/session";
import { persistAndClearVault } from "$lib/backend/auth";
let closeInProgress = false;
onMount(() => {
const appWindow = getCurrentWindow();
const unlistenPromise = appWindow.onCloseRequested(async (event) => {
if (closeInProgress) return;
event.preventDefault();
closeInProgress = true;
const password = getSessionPassword();
if (password) {
try {
await persistAndClearVault(password);
clearVaultSession();
} catch (error) {
console.error("Vault persistence on exit failed:", error);
}
}
await invoke("shutdown");
});
return () => {
void unlistenPromise.then((unlisten) => unlisten());
};
});
</script>
<slot />

View File

@ -1,14 +1,15 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { persistAndClearVault, unlockVaultWorkspace } from "$lib/backend/auth";
import { unlockVaultWorkspace } from "$lib/backend/auth";
import AppModal from "$lib/components/AppModal.svelte";
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId } from "$lib/stores/entries";
import { hydrateFragments } from "$lib/stores/fragments";
import { hydrateLists } from "$lib/stores/lists";
import { isVaultReady, setVaultSession } from "$lib/stores/session";
import { hydrateTodos } from "$lib/stores/todos";
import Navbar from "$lib/components/Navbar.svelte";
import SidePanel from "$lib/components/SidePanel.svelte";
import EditorPanel from "$lib/components/EditorPanel.svelte";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { onMount } from "svelte";
import { get } from "svelte/store";
@ -42,8 +43,6 @@
let modalInputValue = "";
let unlockResolver: ((password: string | null) => void) | null = null;
let fragmentBootstrapInFlight = false;
let vaultPassword: string | null = null;
let closeInProgress = false;
function showModal(options: {
action: "logout-confirm" | "logout-info" | "unlock-vault";
@ -147,20 +146,33 @@
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
if (fragmentBootstrapInFlight) return;
fragmentBootstrapInFlight = true;
if (isVaultReady()) {
try {
await hydrateEntries();
const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry);
}
await hydrateFragments();
await hydrateLists().catch(() => {});
await hydrateTodos().catch(() => {});
} catch (error) {
console.error("Hydration failed:", error);
}
return;
}
fragmentBootstrapInFlight = true;
try {
let attempts = 0;
while (attempts < maxAttempts) {
try {
const password = await requestVaultPassword();
if (!password) {
console.warn("Vault unlock canceled. Journal data remains unavailable.");
return;
}
if (!password) return;
await unlockVaultWorkspace(password);
vaultPassword = password;
setVaultSession(password);
await hydrateEntries();
const firstEntry = getDefaultEntry(get(entriesStore));
@ -169,37 +181,20 @@
}
await hydrateFragments();
await hydrateLists().catch(() => {});
await hydrateTodos().catch(() => {});
return;
} catch (error) {
if (!isLockedError(error)) {
console.error("Failed to load journal data from sidecar:", error);
return;
}
if (!isLockedError(error)) return;
attempts += 1;
console.error("Vault unlock failed:", error);
}
}
console.error(`Vault remains locked after ${maxAttempts} attempts. Stopping unlock prompts.`);
} finally {
fragmentBootstrapInFlight = false;
}
}
async function flushVaultOnExit(): Promise<void> {
if (!vaultPassword) {
console.warn("Skipping vault persistence on exit because session password is unavailable.");
return;
}
try {
await persistAndClearVault(vaultPassword);
} catch (error) {
console.error("Vault persistence on exit failed:", error);
}
}
function handleSelect(id: string) {
if (id === "account" || id === "settings") {
goto(`/${id}`);
@ -226,6 +221,8 @@
selectedSection = id;
panelOpen = true;
activeDocumentId = "";
activeDocumentLabel = "";
}
async function handleOpenDocument(doc: OpenDocument) {
@ -236,8 +233,8 @@
if (loaded) {
resolvedDoc = loaded;
}
} catch (error) {
console.error("Failed to load entry content:", error);
} catch {
// entry content will use initialContent fallback
}
}
@ -258,22 +255,7 @@
}
onMount(() => {
const appWindow = getCurrentWindow();
let unlistenPromise = appWindow.onCloseRequested(async (event) => {
if (closeInProgress) return;
event.preventDefault();
closeInProgress = true;
await flushVaultOnExit();
await invoke("shutdown");
});
bootstrapFragmentsWithUnlock();
return () => {
void unlistenPromise.then((unlisten) => unlisten());
};
});
</script>

View File

@ -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 @@
<p>Configure app behavior and interface options.</p>
</header>
<div class="settings-grid">
<section class="route-card">
<label class="toggle-row">
<input type="checkbox" checked />
@ -270,6 +308,29 @@
{/each}
</ul>
</section>
<section class="route-card">
<h2>Sidecar</h2>
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
<div class="create-row">
<input
type="text"
placeholder="Auto-detected from working directory"
bind:value={sidecarRoot}
on:keydown={(event) => event.key === "Enter" && saveSidecarRoot()}
/>
<button type="button" class="secondary-btn" on:click={saveSidecarRoot}>Save</button>
{#if sidecarRootIsCustom}
<button type="button" class="ghost-btn" on:click={resetSidecarRoot}>Reset</button>
{/if}
</div>
{#if sidecarRootError}
<p class="error-text">{sidecarRootError}</p>
{/if}
</section>
</div>
</main>
</div>
@ -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;
}
</style>

View File

@ -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
);

View File

@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
namespace Journal.Core.Dtos;
public record TodoListDto(
Guid Id,
string Label,
DateTimeOffset CreatedAt,
List<TodoItemDto> 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
);

View File

@ -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<CreateListDto>(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<UpdateListDto>(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<CreateTodoListDto>(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<UpdateTodoListDto>(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<CreateTodoItemDto>(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<UpdateTodoItemDto>(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<SearchEntriesPayload>(cmd.Payload);
if (searchPayload is null || string.IsNullOrWhiteSpace(searchPayload.DataDirectory))
@ -227,6 +310,7 @@ public class Entry(
var rebuildPayload = DeserializePayload<VaultPayload>(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;

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -0,0 +1,12 @@
using Journal.Core.Models;
namespace Journal.Core.Repositories;
public interface IListRepository
{
List<ListDocument> GetAll();
ListDocument? GetById(Guid id);
void Add(ListDocument list);
bool Update(Guid id, string? label = null, string? content = null);
bool Remove(Guid id);
}

View File

@ -0,0 +1,18 @@
using Journal.Core.Models;
namespace Journal.Core.Repositories;
public interface ITodoRepository
{
List<TodoList> GetAllLists();
TodoList? GetListById(Guid id);
void AddList(TodoList list);
bool UpdateList(Guid id, string? label = null);
bool RemoveList(Guid id);
List<TodoItem> 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);
}

View File

@ -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<ListDocument> 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<ListDocument> ReadAll(SqliteConnection conn)
{
var results = new List<ListDocument>();
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);
}
}

View File

@ -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<TodoList> 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<TodoItem> 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<TodoList> ReadAllLists(SqliteConnection conn)
{
var results = new List<TodoList>();
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<TodoItem> ReadItemsByListId(SqliteConnection conn, Guid listId)
{
var results = new List<TodoItem>();
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);
}
}

View File

@ -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<IEntryFileRepository, DiskEntryFileRepository>();
services.AddSingleton<IEntryFileService, EntryFileService>();
services.AddSingleton<IListRepository, SqliteListRepository>();
services.AddSingleton<IListService, ListService>();
services.AddSingleton<ITodoRepository, SqliteTodoRepository>();
services.AddSingleton<ITodoService, TodoService>();
services.AddSingleton<CommandLogger>();
services.AddSingleton<SidecarCli>();
return services;

View File

@ -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();
}
}

View File

@ -7,4 +7,5 @@ public interface IDatabaseSessionService
bool IsUnlocked { get; }
void SetPassword(string password, string? dataDirectory = null);
SqliteConnection GetConnection();
void CloseConnection();
}

View File

@ -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<string> 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)
);
"""
};
}

View File

@ -0,0 +1,12 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services.Lists;
public interface IListService
{
List<ListDocumentDto> GetAll();
ListDocumentDto? GetById(Guid id);
ListDocumentDto Create(CreateListDto dto);
bool Update(Guid id, UpdateListDto dto);
bool Remove(Guid id);
}

View File

@ -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<ListDocumentDto> 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);
}

View File

@ -0,0 +1,16 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services.Todos;
public interface ITodoService
{
List<TodoListDto> 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);
}

View File

@ -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<TodoListDto> 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);
}

View File

@ -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))