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 <oz-agent@warp.dev>
This commit is contained in:
Jacob Schmidt 2026-02-26 15:34:28 -06:00
parent e2bfa0e6ff
commit 0465b05845
17 changed files with 624 additions and 51 deletions

View File

@ -33,7 +33,11 @@ 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());
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");

View File

@ -1,4 +1,5 @@
import { sendCommand } from "./client";
import { pickCase } from "./normalize";
export function hydrateWorkspace(password: string): Promise<unknown> {
return sendCommand<unknown>({
@ -7,3 +8,69 @@ export function hydrateWorkspace(password: string): Promise<unknown> {
});
}
type RuntimeConfigRaw = {
dataDirectory?: string;
vaultDirectory?: string;
DataDirectory?: string;
VaultDirectory?: string;
};
type RuntimeConfig = {
dataDirectory: string;
vaultDirectory: string;
};
async function getRuntimeConfig(): Promise<RuntimeConfig> {
const data = await sendCommand<RuntimeConfigRaw>({
action: "config.get"
});
return {
dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""),
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", "")
};
}
export async function unlockVaultWorkspace(password: string): Promise<void> {
const config = await getRuntimeConfig();
const loaded = await sendCommand<boolean>({
action: "vault.load_all",
payload: {
password,
vaultDirectory: config.vaultDirectory,
dataDirectory: config.dataDirectory
}
});
if (!loaded) {
throw new Error("Incorrect vault password.");
}
await sendCommand<unknown>({
action: "db.hydrate_workspace",
payload: {
password,
dataDirectory: config.dataDirectory
}
});
}
export async function persistAndClearVault(password: string): Promise<void> {
const config = await getRuntimeConfig();
await sendCommand<boolean>({
action: "vault.rebuild_all",
payload: {
password,
vaultDirectory: config.vaultDirectory,
dataDirectory: config.dataDirectory
}
});
await sendCommand<boolean>({
action: "vault.clear_data_directory",
payload: {
dataDirectory: config.dataDirectory
}
});
}

View File

@ -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<string, boolean>;
};
export type JournalEntryDto = {
date: string;
fragments: FragmentDto[];
rawContent: string;
sections: Record<string, ParsedSectionDto>;
};
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<string, boolean>;
Title?: string;
Content?: string[];
Checkboxes?: Record<string, boolean>;
};
type JournalEntryDtoRaw = {
date?: string;
fragments?: FragmentDtoRaw[];
rawContent?: string;
sections?: Record<string, ParsedSectionDtoRaw>;
Date?: string;
Fragments?: FragmentDtoRaw[];
RawContent?: string;
Sections?: Record<string, ParsedSectionDtoRaw>;
};
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<string, boolean>)
};
}
function normalizeJournalEntry(raw: JournalEntryDtoRaw | undefined): JournalEntryDto {
const fragments = pickCase(raw, "fragments", "Fragments", [] as FragmentDtoRaw[]);
const sections = pickCase(raw, "sections", "Sections", {} as Record<string, ParsedSectionDtoRaw>);
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<EntryListItemDto[]> {
const data = await sendCommand<EntryListItemDtoRaw[]>({
action: "entries.list",
payload: { dataDirectory }
});
return data.map(normalizeEntryListItem).filter((item) => Boolean(item.filePath));
}
export async function loadEntry(filePath: string): Promise<EntryLoadResultDto> {
const data = await sendCommand<EntryLoadResultDtoRaw>({
action: "entries.load",
payload: { filePath }
});
return normalizeEntryLoadResult(data);
}
export async function saveEntry(payload: {
content: string;
filePath?: string;
mode?: string;
}): Promise<EntrySaveResultDto> {
const data = await sendCommand<EntrySaveResultDtoRaw>({
action: "entries.save",
payload
});
return {
filePath: pickCase(data, "filePath", "FilePath", "")
};
}
export async function searchEntries(payload: EntrySearchRequestDto): Promise<EntrySearchResultDto[]> {
const data = await sendCommand<EntrySearchResultDtoRaw[]>({
action: "search.entries",
payload
});
return data.map(normalizeEntrySearchResult).filter((item) => Boolean(item.fileName));
}

View File

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

View File

@ -0,0 +1,18 @@
type UnknownObject = Record<string, unknown>;
function asObject(value: unknown): UnknownObject | undefined {
return value && typeof value === "object" ? (value as UnknownObject) : undefined;
}
export function pickCase<T>(
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;
}

View File

@ -1,4 +1,8 @@
<script lang="ts">
import {
saveEntryFromStore,
type EntryItem,
} from "$lib/stores/entries";
import {
createFragmentFromParsed,
deleteFragmentByStoreId,
@ -52,6 +56,7 @@
let newTodoText = "";
let editingTodoId: number | null = null;
let editingTodoText = "";
let entrySaveBusy = false;
function updateDraft(value: string) {
markdownText = value;
@ -428,6 +433,28 @@
return headingMatch ? headingMatch[1] : openDocumentName;
}
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;
@ -461,6 +488,11 @@
<h1>{activeSection === "fragments" ? "Create Fragment" : activeSection === "todos" ? "To-Do List" : editorTitle}</h1>
{#if activeSection !== "fragments" && activeSection !== "todos"}
<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>

View File

@ -1,28 +1,169 @@
import { writable } from "svelte/store";
import { get, writable } from "svelte/store";
import {
listEntries as listEntriesCommand,
loadEntry as loadEntryCommand,
saveEntry as saveEntryCommand,
searchEntries as searchEntriesCommand,
type EntryListItemDto,
type EntrySearchRequestDto
} from "$lib/backend/entries";
export type EntryItem = {
id: string;
label: string;
initialContent: string;
filePath?: string;
date?: string;
};
const initialEntries: EntryItem[] = [
{ id: "entries/daily-notes", label: "Daily Notes", initialContent: "# Daily Notes\n\nStart writing today's entry..." },
{ id: "entries/ideas", label: "Ideas", initialContent: "# Ideas\n\nCapture ideas before they disappear." },
{ id: "entries/archive", label: "Archive", initialContent: "# Archive\n\nOlder entries and references." }
];
const initialEntries: EntryItem[] = [];
export const entriesStore = writable<EntryItem[]>(initialEntries);
export const entriesBusyStore = writable(false);
function toStoreId(filePath: string): string {
return `entries/file/${encodeURIComponent(filePath)}`;
}
function toBackendPath(id: string): string | null {
const prefix = "entries/file/";
if (!id.startsWith(prefix)) return null;
const encoded = id.slice(prefix.length).trim();
if (!encoded) return null;
try {
const decoded = decodeURIComponent(encoded);
return decoded || null;
} catch {
return null;
}
}
function toLabel(fileName: string): string {
const normalized = fileName.trim();
if (!normalized) return "Untitled Entry";
return normalized.replace(/\.md$/i, "");
}
function upsertById(items: EntryItem[], next: EntryItem): EntryItem[] {
const idx = items.findIndex((item) => item.id === next.id);
if (idx === -1) return [next, ...items];
const clone = [...items];
clone[idx] = next;
return clone;
}
function fromListDto(dto: EntryListItemDto): EntryItem {
return {
id: toStoreId(dto.filePath),
label: toLabel(dto.fileName),
initialContent: "",
filePath: dto.filePath
};
}
function fromLoadResult(result: Awaited<ReturnType<typeof loadEntryCommand>>): EntryItem {
return {
id: toStoreId(result.filePath),
label: toLabel(result.fileName),
initialContent: result.entry.rawContent,
filePath: result.filePath,
date: result.entry.date
};
}
export function getDefaultEntry(items: EntryItem[]): EntryItem | undefined {
return items.find((entry) => entry.id === "entries/daily-notes") ?? items[0];
return items[0];
}
export function createEntryDraft(): EntryItem {
const id = `entries/entry-${Date.now()}`;
const id = `entries/draft-${Date.now()}`;
return {
id,
label: "Untitled Entry",
initialContent: "# Untitled Entry\n\nStart writing..."
};
}
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);
throw error;
} finally {
entriesBusyStore.set(false);
}
}
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;
}
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 });
throw error;
}
}
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;
}
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 });
throw error;
}
}
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("\\") ? "\\" : "/";
const basePath = dataDirectory.replace(/[\\/]+$/, "");
const mapped = results.map((result) => ({
id: basePath
? toStoreId(`${basePath}${separator}${result.fileName}`)
: `entries/search/${encodeURIComponent(result.fileName)}`,
label: toLabel(result.fileName),
initialContent: result.entry.rawContent,
filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined,
date: result.entry.date
}));
console.info("[entries] search:ok", { count: mapped.length });
return mapped;
}
export function hasEntry(storeId: string): boolean {
return get(entriesStore).some((item) => item.id === storeId);
}

View File

@ -1,12 +1,14 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { hydrateWorkspace } from "$lib/backend/auth";
import { persistAndClearVault, unlockVaultWorkspace } from "$lib/backend/auth";
import AppModal from "$lib/components/AppModal.svelte";
import { entriesStore, getDefaultEntry } from "$lib/stores/entries";
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId } from "$lib/stores/entries";
import { hydrateFragments } from "$lib/stores/fragments";
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";
@ -40,6 +42,8 @@
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";
@ -122,7 +126,7 @@
showModal({
action: "unlock-vault",
title: "Unlock Vault",
message: "Enter your vault password to load fragments.",
message: "Enter your vault password to load journal data.",
confirmText: "Unlock",
cancelText: "Cancel",
showCancel: true,
@ -137,7 +141,8 @@
function isLockedError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.toLowerCase().includes("database is locked");
const normalized = message.toLowerCase();
return normalized.includes("database is locked") || normalized.includes("incorrect vault password");
}
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
@ -148,29 +153,31 @@
let attempts = 0;
while (attempts < maxAttempts) {
try {
const password = await requestVaultPassword();
if (!password) {
console.warn("Vault unlock canceled. Journal data remains unavailable.");
return;
}
await unlockVaultWorkspace(password);
vaultPassword = password;
await hydrateEntries();
const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry);
}
await hydrateFragments();
return;
} catch (error) {
if (!isLockedError(error)) {
console.error("Failed to load fragments from sidecar:", error);
console.error("Failed to load journal data from sidecar:", error);
return;
}
attempts += 1;
const password = await requestVaultPassword();
if (!password) {
console.warn("Vault unlock canceled. Fragments remain unavailable.");
return;
}
try {
await hydrateWorkspace(password);
} catch (unlockError) {
console.error("Vault unlock failed:", unlockError);
if (!isLockedError(unlockError)) {
return;
}
}
console.error("Vault unlock failed:", error);
}
}
@ -180,6 +187,19 @@
}
}
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}`);
@ -208,12 +228,24 @@
panelOpen = true;
}
function handleOpenDocument(doc: OpenDocument) {
if (!(doc.id in openDocuments)) {
openDocuments = { ...openDocuments, [doc.id]: doc.initialContent };
async function handleOpenDocument(doc: OpenDocument) {
let resolvedDoc = doc;
if (selectedSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
try {
const loaded = await loadEntryByStoreId(doc.id);
if (loaded) {
resolvedDoc = loaded;
}
} catch (error) {
console.error("Failed to load entry content:", error);
}
}
activeDocumentId = doc.id;
activeDocumentLabel = doc.label;
if (!(resolvedDoc.id in openDocuments)) {
openDocuments = { ...openDocuments, [resolvedDoc.id]: resolvedDoc.initialContent };
}
activeDocumentId = resolvedDoc.id;
activeDocumentLabel = resolvedDoc.label;
}
function handleDocumentContentChange(content: string) {
@ -226,7 +258,22 @@
}
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

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

View File

@ -12,6 +12,5 @@ public sealed record EntrySearchRequestDto(
IReadOnlyList<string>? Unchecked = null);
public sealed record EntrySearchResultDto(
string Date,
string FileName,
string RawContent);
JournalEntryDto Entry);

View File

@ -0,0 +1,12 @@
namespace Journal.Core.Dtos;
public sealed record ParsedSectionDto(
string Title,
IReadOnlyList<string> Content,
IReadOnlyDictionary<string, bool> Checkboxes);
public sealed record JournalEntryDto(
string Date,
IReadOnlyList<FragmentDto> Fragments,
string RawContent,
IReadOnlyDictionary<string, ParsedSectionDto> Sections);

View File

@ -234,6 +234,8 @@ public class Entry(
var clearPayload = DeserializePayload<ClearDataPayload>(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;

View File

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

View File

@ -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<IReadOnlyList<EntrySearchResultDto>>(results);

View File

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

View File

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

View File

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