From 0465b058452b149ddefd949de80dd599faf7a60b Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 26 Feb 2026 15:34:28 -0600 Subject: [PATCH] feat: add shutdown command, auth/entries backend, and editor panel - Add Rust shutdown command that kills sidecar and exits app cleanly - Frontend calls invoke('shutdown') after vault flush on close - Add auth.ts, entries.ts, and normalize.ts backend modules - Add EditorPanel.svelte component - Expand entries store with full CRUD support - Add JournalEntryDtos and JournalEntryDtoMapper in Journal.Core - Update entry search, fragments, and sidecar CLI Co-Authored-By: Oz --- Journal.App/src-tauri/src/lib.rs | 15 +- Journal.App/src/lib/backend/auth.ts | 67 ++++++ Journal.App/src/lib/backend/entries.ts | 211 ++++++++++++++++++ Journal.App/src/lib/backend/fragments.ts | 15 +- Journal.App/src/lib/backend/normalize.ts | 18 ++ .../src/lib/components/EditorPanel.svelte | 32 +++ Journal.App/src/lib/stores/entries.ts | 157 ++++++++++++- Journal.App/src/routes/+page.svelte | 95 ++++++-- Journal.Core/Dtos/CommandDtos.cs | 2 +- Journal.Core/Dtos/EntrySearchDtos.cs | 3 +- Journal.Core/Dtos/JournalEntryDtos.cs | 12 + Journal.Core/Entry.cs | 2 + .../Services/Entries/EntryFileService.cs | 3 +- .../Services/Entries/EntrySearchService.cs | 2 +- .../Services/Entries/JournalEntryDtoMapper.cs | 32 +++ Journal.Core/Services/Sidecar/SidecarCli.cs | 4 +- Journal.SmokeTests/Program.cs | 5 +- 17 files changed, 624 insertions(+), 51 deletions(-) create mode 100644 Journal.App/src/lib/backend/entries.ts create mode 100644 Journal.App/src/lib/backend/normalize.ts create mode 100644 Journal.Core/Dtos/JournalEntryDtos.cs create mode 100644 Journal.Core/Services/Entries/JournalEntryDtoMapper.cs diff --git a/Journal.App/src-tauri/src/lib.rs b/Journal.App/src-tauri/src/lib.rs index c62fe2a..92cbebc 100644 --- a/Journal.App/src-tauri/src/lib.rs +++ b/Journal.App/src-tauri/src/lib.rs @@ -33,7 +33,11 @@ impl ManagedSidecar { fn start() -> Result { let sidecar_path = resolve_sidecar_path()?; let root = project_root()?; - eprintln!("[sidecar] starting exe={} project_root={}", sidecar_path.display(), root.display()); + eprintln!( + "[sidecar] starting exe={} project_root={}", + sidecar_path.display(), + root.display() + ); let mut child = Command::new(sidecar_path) .stdin(Stdio::piped()) @@ -197,6 +201,13 @@ fn stop_managed_sidecar(state: &SidecarState) { } } +#[tauri::command] +fn shutdown(state: tauri::State<'_, SidecarState>, app_handle: tauri::AppHandle) { + eprintln!("[app] shutdown requested"); + stop_managed_sidecar(state.inner()); + app_handle.exit(0); +} + #[tauri::command] fn sidecar_command( state: tauri::State<'_, SidecarState>, @@ -225,7 +236,7 @@ pub fn run() { let app = tauri::Builder::default() .manage(SidecarState::default()) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![sidecar_command]) + .invoke_handler(tauri::generate_handler![sidecar_command, shutdown]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/Journal.App/src/lib/backend/auth.ts b/Journal.App/src/lib/backend/auth.ts index 8f8f20c..bf386d3 100644 --- a/Journal.App/src/lib/backend/auth.ts +++ b/Journal.App/src/lib/backend/auth.ts @@ -1,4 +1,5 @@ import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; export function hydrateWorkspace(password: string): Promise { return sendCommand({ @@ -7,3 +8,69 @@ export function hydrateWorkspace(password: string): Promise { }); } +type RuntimeConfigRaw = { + dataDirectory?: string; + vaultDirectory?: string; + DataDirectory?: string; + VaultDirectory?: string; +}; + +type RuntimeConfig = { + dataDirectory: string; + vaultDirectory: string; +}; + +async function getRuntimeConfig(): Promise { + const data = await sendCommand({ + action: "config.get" + }); + + return { + dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""), + vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", "") + }; +} + +export async function unlockVaultWorkspace(password: string): Promise { + const config = await getRuntimeConfig(); + const loaded = await sendCommand({ + action: "vault.load_all", + payload: { + password, + vaultDirectory: config.vaultDirectory, + dataDirectory: config.dataDirectory + } + }); + + if (!loaded) { + throw new Error("Incorrect vault password."); + } + + await sendCommand({ + action: "db.hydrate_workspace", + payload: { + password, + dataDirectory: config.dataDirectory + } + }); +} + +export async function persistAndClearVault(password: string): Promise { + const config = await getRuntimeConfig(); + + await sendCommand({ + action: "vault.rebuild_all", + payload: { + password, + vaultDirectory: config.vaultDirectory, + dataDirectory: config.dataDirectory + } + }); + + await sendCommand({ + action: "vault.clear_data_directory", + payload: { + dataDirectory: config.dataDirectory + } + }); +} diff --git a/Journal.App/src/lib/backend/entries.ts b/Journal.App/src/lib/backend/entries.ts new file mode 100644 index 0000000..17fb862 --- /dev/null +++ b/Journal.App/src/lib/backend/entries.ts @@ -0,0 +1,211 @@ +import { sendCommand } from "./client"; +import { normalizeFragment, type FragmentDto, type FragmentDtoRaw } from "./fragments"; +import { pickCase } from "./normalize"; + +export type ParsedSectionDto = { + title: string; + content: string[]; + checkboxes: Record; +}; + +export type JournalEntryDto = { + date: string; + fragments: FragmentDto[]; + rawContent: string; + sections: Record; +}; + +export type EntryListItemDto = { + fileName: string; + filePath: string; +}; + +export type EntryLoadResultDto = { + fileName: string; + filePath: string; + entry: JournalEntryDto; +}; + +export type EntrySaveResultDto = { + filePath: string; +}; + +export type EntrySearchRequestDto = { + dataDirectory: string; + query?: string; + section?: string; + startDate?: string; + endDate?: string; + tags?: string[]; + types?: string[]; + checked?: string[]; + unchecked?: string[]; +}; + +export type EntrySearchResultDto = { + fileName: string; + entry: JournalEntryDto; +}; + +type ParsedSectionDtoRaw = { + title?: string; + content?: string[]; + checkboxes?: Record; + Title?: string; + Content?: string[]; + Checkboxes?: Record; +}; + +type JournalEntryDtoRaw = { + date?: string; + fragments?: FragmentDtoRaw[]; + rawContent?: string; + sections?: Record; + Date?: string; + Fragments?: FragmentDtoRaw[]; + RawContent?: string; + Sections?: Record; +}; + +type EntryListItemDtoRaw = { + fileName?: string; + filePath?: string; + FileName?: string; + FilePath?: string; +}; + +type EntryLoadResultDtoRaw = { + fileName?: string; + filePath?: string; + entry?: JournalEntryDtoRaw; + date?: string; + rawContent?: string; + FileName?: string; + FilePath?: string; + Entry?: JournalEntryDtoRaw; + Date?: string; + RawContent?: string; +}; + +type EntrySaveResultDtoRaw = { + filePath?: string; + FilePath?: string; +}; + +type EntrySearchResultDtoRaw = { + fileName?: string; + entry?: JournalEntryDtoRaw; + date?: string; + rawContent?: string; + FileName?: string; + Entry?: JournalEntryDtoRaw; + Date?: string; + RawContent?: string; +}; + +function normalizeSection(raw: ParsedSectionDtoRaw): ParsedSectionDto { + return { + title: pickCase(raw, "title", "Title", ""), + content: pickCase(raw, "content", "Content", [] as string[]), + checkboxes: pickCase(raw, "checkboxes", "Checkboxes", {} as Record) + }; +} + +function normalizeJournalEntry(raw: JournalEntryDtoRaw | undefined): JournalEntryDto { + const fragments = pickCase(raw, "fragments", "Fragments", [] as FragmentDtoRaw[]); + const sections = pickCase(raw, "sections", "Sections", {} as Record); + return { + date: pickCase(raw, "date", "Date", ""), + fragments: fragments.map(normalizeFragment), + rawContent: pickCase(raw, "rawContent", "RawContent", ""), + sections: Object.fromEntries( + Object.entries(sections).map(([key, value]) => [key, normalizeSection(value)]) + ) + }; +} + +function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto { + return { + fileName: pickCase(raw, "fileName", "FileName", ""), + filePath: pickCase(raw, "filePath", "FilePath", "") + }; +} + +function normalizeEntryLoadResult(raw: EntryLoadResultDtoRaw): EntryLoadResultDto { + const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined); + const entry = + nestedEntry + ? normalizeJournalEntry(nestedEntry) + : normalizeJournalEntry({ + date: pickCase(raw, "date", "Date", undefined as string | undefined), + rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined), + fragments: [], + sections: {} + }); + + return { + fileName: pickCase(raw, "fileName", "FileName", ""), + filePath: pickCase(raw, "filePath", "FilePath", ""), + entry + }; +} + +function normalizeEntrySearchResult(raw: EntrySearchResultDtoRaw): EntrySearchResultDto { + const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined); + const entry = + nestedEntry + ? normalizeJournalEntry(nestedEntry) + : normalizeJournalEntry({ + date: pickCase(raw, "date", "Date", undefined as string | undefined), + rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined), + fragments: [], + sections: {} + }); + + return { + fileName: pickCase(raw, "fileName", "FileName", ""), + entry + }; +} + +export async function listEntries(dataDirectory?: string): Promise { + const data = await sendCommand({ + action: "entries.list", + payload: { dataDirectory } + }); + + return data.map(normalizeEntryListItem).filter((item) => Boolean(item.filePath)); +} + +export async function loadEntry(filePath: string): Promise { + const data = await sendCommand({ + action: "entries.load", + payload: { filePath } + }); + + return normalizeEntryLoadResult(data); +} + +export async function saveEntry(payload: { + content: string; + filePath?: string; + mode?: string; +}): Promise { + const data = await sendCommand({ + action: "entries.save", + payload + }); + + return { + filePath: pickCase(data, "filePath", "FilePath", "") + }; +} + +export async function searchEntries(payload: EntrySearchRequestDto): Promise { + const data = await sendCommand({ + action: "search.entries", + payload + }); + + return data.map(normalizeEntrySearchResult).filter((item) => Boolean(item.fileName)); +} diff --git a/Journal.App/src/lib/backend/fragments.ts b/Journal.App/src/lib/backend/fragments.ts index 16c4a9f..dc0f26f 100644 --- a/Journal.App/src/lib/backend/fragments.ts +++ b/Journal.App/src/lib/backend/fragments.ts @@ -1,4 +1,5 @@ import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; export type FragmentDto = { id: string; @@ -21,7 +22,7 @@ export type UpdateFragmentPayload = { time?: string; }; -type FragmentDtoRaw = { +export type FragmentDtoRaw = { id?: string; type?: string; description?: string; @@ -34,13 +35,13 @@ type FragmentDtoRaw = { Tags?: string[]; }; -function normalizeFragment(raw: FragmentDtoRaw): FragmentDto { +export function normalizeFragment(raw: FragmentDtoRaw): FragmentDto { return { - id: raw.id ?? raw.Id ?? "", - type: raw.type ?? raw.Type ?? "", - description: raw.description ?? raw.Description ?? "", - time: raw.time ?? raw.Time ?? "", - tags: raw.tags ?? raw.Tags ?? [] + id: pickCase(raw, "id", "Id", ""), + type: pickCase(raw, "type", "Type", ""), + description: pickCase(raw, "description", "Description", ""), + time: pickCase(raw, "time", "Time", ""), + tags: pickCase(raw, "tags", "Tags", [] as string[]) }; } diff --git a/Journal.App/src/lib/backend/normalize.ts b/Journal.App/src/lib/backend/normalize.ts new file mode 100644 index 0000000..6dded60 --- /dev/null +++ b/Journal.App/src/lib/backend/normalize.ts @@ -0,0 +1,18 @@ +type UnknownObject = Record; + +function asObject(value: unknown): UnknownObject | undefined { + return value && typeof value === "object" ? (value as UnknownObject) : undefined; +} + +export function pickCase( + source: unknown, + camelKey: string, + pascalKey: string, + fallback: T +): T { + const obj = asObject(source); + if (!obj) return fallback; + + const value = obj[camelKey] ?? obj[pascalKey]; + return (value as T | undefined) ?? fallback; +} diff --git a/Journal.App/src/lib/components/EditorPanel.svelte b/Journal.App/src/lib/components/EditorPanel.svelte index b181954..6a72bd5 100644 --- a/Journal.App/src/lib/components/EditorPanel.svelte +++ b/Journal.App/src/lib/components/EditorPanel.svelte @@ -1,4 +1,8 @@ diff --git a/Journal.Core/Dtos/CommandDtos.cs b/Journal.Core/Dtos/CommandDtos.cs index 0ed5dba..ae59b1e 100644 --- a/Journal.Core/Dtos/CommandDtos.cs +++ b/Journal.Core/Dtos/CommandDtos.cs @@ -7,7 +7,7 @@ internal sealed record EntryListPayload(string? DataDirectory = null); internal sealed record EntryLoadPayload(string FilePath); public sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null); public sealed record EntryListItem(string FileName, string FilePath); -public sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent); +public sealed record EntryLoadResult(string FileName, string FilePath, JournalEntryDto Entry); public sealed record EntrySaveResult(string FilePath); internal sealed record DatabasePayload(string Password, string? DataDirectory = null); internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null); diff --git a/Journal.Core/Dtos/EntrySearchDtos.cs b/Journal.Core/Dtos/EntrySearchDtos.cs index 2ac363b..7330901 100644 --- a/Journal.Core/Dtos/EntrySearchDtos.cs +++ b/Journal.Core/Dtos/EntrySearchDtos.cs @@ -12,6 +12,5 @@ public sealed record EntrySearchRequestDto( IReadOnlyList? Unchecked = null); public sealed record EntrySearchResultDto( - string Date, string FileName, - string RawContent); + JournalEntryDto Entry); diff --git a/Journal.Core/Dtos/JournalEntryDtos.cs b/Journal.Core/Dtos/JournalEntryDtos.cs new file mode 100644 index 0000000..4011700 --- /dev/null +++ b/Journal.Core/Dtos/JournalEntryDtos.cs @@ -0,0 +1,12 @@ +namespace Journal.Core.Dtos; + +public sealed record ParsedSectionDto( + string Title, + IReadOnlyList Content, + IReadOnlyDictionary Checkboxes); + +public sealed record JournalEntryDto( + string Date, + IReadOnlyList Fragments, + string RawContent, + IReadOnlyDictionary Sections); diff --git a/Journal.Core/Entry.cs b/Journal.Core/Entry.cs index 860a0ae..ec0bcce 100644 --- a/Journal.Core/Entry.cs +++ b/Journal.Core/Entry.cs @@ -234,6 +234,8 @@ public class Entry( var clearPayload = DeserializePayload(cmd.Payload); if (clearPayload is null || string.IsNullOrWhiteSpace(clearPayload.DataDirectory)) return Error("Missing or invalid payload"); + if (_databaseSession is IDisposable disposableSession) + disposableSession.Dispose(); _vaultStorage.ClearDataDirectory(clearPayload.DataDirectory); result = true; break; diff --git a/Journal.Core/Services/Entries/EntryFileService.cs b/Journal.Core/Services/Entries/EntryFileService.cs index 124f968..866900b 100644 --- a/Journal.Core/Services/Entries/EntryFileService.cs +++ b/Journal.Core/Services/Entries/EntryFileService.cs @@ -26,10 +26,9 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ var entry = JournalParser.ParseJournalContent(rawContent, fileStem); return new EntryLoadResult( - Date: entry.Date, FileName: _repo.GetFileName(normalizedPath), FilePath: normalizedPath, - RawContent: entry.RawContent); + Entry: entry.ToDto()); } public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory) diff --git a/Journal.Core/Services/Entries/EntrySearchService.cs b/Journal.Core/Services/Entries/EntrySearchService.cs index 3194015..a623512 100644 --- a/Journal.Core/Services/Entries/EntrySearchService.cs +++ b/Journal.Core/Services/Entries/EntrySearchService.cs @@ -73,7 +73,7 @@ public class EntrySearchService : IEntrySearchService if (!checkboxMatch) continue; - results.Add(new EntrySearchResultDto(entry.Date, fileName, entry.RawContent)); + results.Add(new EntrySearchResultDto(fileName, entry.ToDto())); } return Task.FromResult>(results); diff --git a/Journal.Core/Services/Entries/JournalEntryDtoMapper.cs b/Journal.Core/Services/Entries/JournalEntryDtoMapper.cs new file mode 100644 index 0000000..1cce1d5 --- /dev/null +++ b/Journal.Core/Services/Entries/JournalEntryDtoMapper.cs @@ -0,0 +1,32 @@ +using Journal.Core.Dtos; +using Journal.Core.Models; + +namespace Journal.Core.Services.Entries; + +internal static class JournalEntryDtoMapper +{ + public static JournalEntryDto ToDto(this JournalEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return new JournalEntryDto( + Date: entry.Date, + Fragments: + [ + .. entry.Fragments.Select(fragment => new FragmentDto( + Id: fragment.Id, + Type: fragment.Type, + Description: fragment.Description, + Time: fragment.Time, + Tags: [.. fragment.Tags])) + ], + RawContent: entry.RawContent, + Sections: entry.Sections.ToDictionary( + section => section.Key, + section => new ParsedSectionDto( + Title: section.Value.Title, + Content: [.. section.Value.Content], + Checkboxes: section.Value.Checkboxes.ToDictionary(checkbox => checkbox.Key, checkbox => checkbox.Value)), + StringComparer.Ordinal)); + } +} diff --git a/Journal.Core/Services/Sidecar/SidecarCli.cs b/Journal.Core/Services/Sidecar/SidecarCli.cs index 8c8057b..157e0dc 100644 --- a/Journal.Core/Services/Sidecar/SidecarCli.cs +++ b/Journal.Core/Services/Sidecar/SidecarCli.cs @@ -146,8 +146,8 @@ public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchSe foreach (var result in results) { - Console.WriteLine($"--- {result.Date} ---"); - Console.WriteLine(result.RawContent); + Console.WriteLine($"--- {result.Entry.Date} ---"); + Console.WriteLine(result.Entry.RawContent); Console.WriteLine(); } diff --git a/Journal.SmokeTests/Program.cs b/Journal.SmokeTests/Program.cs index 2360538..d59ea53 100644 --- a/Journal.SmokeTests/Program.cs +++ b/Journal.SmokeTests/Program.cs @@ -953,8 +953,9 @@ hello world Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.load."); var data = doc.RootElement.GetProperty("data"); - Assert(data.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date from entries.load."); - Assert(data.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load."); + var entryDto = data.GetProperty("Entry"); + Assert(entryDto.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date from entries.load."); + Assert(entryDto.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load."); Assert(data.GetProperty("FileName").GetString() == "2026-02-22.md", "Expected file name from entries.load."); } finally