From d1e4989303ac1f8ebfc2613787c41b00fcac9fcc Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 26 Feb 2026 19:40:43 -0600 Subject: [PATCH] Add edit/delete buttons to SidePanel for all sections, custom entry filenames, vault persistence - Add edit/delete icon buttons to SidePanel items for entries, todos, lists, and fragments - Move fragment edit/delete controls from FragmentEditor main panel to SidePanel - Add externalEditRequested prop to FragmentEditor for parent-controlled edit mode - Add fragment delete handling in +page.svelte performDelete flow - Support custom entry filenames via FileName parameter in EntrySavePayload - Fix vault persistence for custom-named entries (non-date-formatted .md files) - Add VaultStorageService SaveCustomEntries helper for _custom_entries.vault - Add entries.delete backend command and wire through EntryFileService - Remove Write/Preview toggle from MarkdownEditor (previewOnly controlled by parent) - Add smoke tests for vault custom entry roundtrip and entry save with custom filename Co-Authored-By: Oz --- Journal.App/src/lib/backend/entries.ts | 8 + .../src/lib/components/EditorPanel.svelte | 3 + .../src/lib/components/SidePanel.svelte | 137 +++++++++++++----- .../components/editor/FragmentEditor.svelte | 17 +-- .../components/editor/MarkdownEditor.svelte | 36 +---- Journal.App/src/lib/stores/entries.ts | 34 ++++- Journal.App/src/routes/+page.svelte | 89 ++++++++++-- Journal.Core/Dtos/CommandDtos.cs | 3 +- Journal.Core/Entry.cs | 6 + .../Repositories/DiskEntryFileRepository.cs | 2 + .../Repositories/IEntryFileRepository.cs | 1 + .../Services/Entries/EntryFileService.cs | 30 +++- .../Services/Entries/IEntryFileService.cs | 1 + .../Services/Vault/VaultStorageService.cs | 61 ++++++-- Journal.SmokeTests/Program.cs | 122 ++++++++++++++++ 15 files changed, 440 insertions(+), 110 deletions(-) diff --git a/Journal.App/src/lib/backend/entries.ts b/Journal.App/src/lib/backend/entries.ts index 17fb862..a4dd4c3 100644 --- a/Journal.App/src/lib/backend/entries.ts +++ b/Journal.App/src/lib/backend/entries.ts @@ -190,6 +190,7 @@ export async function saveEntry(payload: { content: string; filePath?: string; mode?: string; + fileName?: string; }): Promise { const data = await sendCommand({ action: "entries.save", @@ -201,6 +202,13 @@ export async function saveEntry(payload: { }; } +export async function deleteEntry(filePath: string): Promise { + return sendCommand({ + action: "entries.delete", + payload: { filePath } + }); +} + export async function searchEntries(payload: EntrySearchRequestDto): Promise { const data = await sendCommand({ action: "search.entries", diff --git a/Journal.App/src/lib/components/EditorPanel.svelte b/Journal.App/src/lib/components/EditorPanel.svelte index 08219dc..00dcce7 100644 --- a/Journal.App/src/lib/components/EditorPanel.svelte +++ b/Journal.App/src/lib/components/EditorPanel.svelte @@ -10,6 +10,7 @@ export let onDocumentContentChange: (content: string) => void = () => {}; export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; export let onDeleteDocument: (id: string) => void = () => {}; + export let previewOnly = true;
@@ -26,6 +27,7 @@ {onDocumentContentChange} {onOpenDocument} {onDeleteDocument} + externalEditRequested={!previewOnly} /> {:else if activeSection === "todos"} {/if}
diff --git a/Journal.App/src/lib/components/SidePanel.svelte b/Journal.App/src/lib/components/SidePanel.svelte index 101cc0c..269f4b0 100644 --- a/Journal.App/src/lib/components/SidePanel.svelte +++ b/Journal.App/src/lib/components/SidePanel.svelte @@ -1,6 +1,6 @@
@@ -256,10 +257,10 @@

{calendarMonthLabel} {calendarYear} Entries

    {#each calendarEntries as item} -
  • +
  • + {#if showItemActions} +
    + + +
    + {/if}
  • {/each}
@@ -377,29 +388,79 @@ display: flex; flex-direction: column; gap: 4px; - } - .panel-list li button { - width: 100%; - text-align: left; - border-radius: 7px; - padding: 7px 9px; - font-size: 0.84rem; - color: var(--text-muted); - cursor: pointer; - border: 1px solid transparent; - } + li { + display: flex; + align-items: center; + border-radius: 7px; + border: 1px solid transparent; - .panel-list li button:hover { - color: var(--text-primary); - background: var(--bg-hover); - border-color: var(--border-soft); - } + &:hover { + background: var(--bg-hover); + border-color: var(--border-soft); + } - .panel-list li button.is-active { - color: var(--text-primary); - background: var(--bg-active); - border-color: var(--border-strong); + &.is-active { + background: var(--bg-active); + border-color: var(--border-strong); + } + } + + .item-label { + flex: 1; + min-width: 0; + text-align: left; + padding: 7px 9px; + font-size: 0.84rem; + color: var(--text-muted); + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + li:hover .item-label, + li.is-active .item-label { + color: var(--text-primary); + } + + .item-actions { + display: none; + flex-shrink: 0; + align-items: center; + gap: 2px; + padding-right: 4px; + } + + li:hover .item-actions, + li.is-active .item-actions { + display: flex; + } + + .item-action { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border-radius: 4px; + color: var(--text-dim); + cursor: pointer; + border: 1px solid transparent; + + &:hover { + background: var(--surface-2); + color: var(--text-primary); + border-color: var(--border-soft); + } + + &.item-action-danger:hover { + color: #e06c75; + } + + .material-symbols-outlined { + font-size: 0.85rem; + } + } } .calendar-entries { diff --git a/Journal.App/src/lib/components/editor/FragmentEditor.svelte b/Journal.App/src/lib/components/editor/FragmentEditor.svelte index 4fcb9de..524b1d6 100644 --- a/Journal.App/src/lib/components/editor/FragmentEditor.svelte +++ b/Journal.App/src/lib/components/editor/FragmentEditor.svelte @@ -19,6 +19,7 @@ export let onDocumentContentChange: (content: string) => void = () => {}; export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; export let onDeleteDocument: (id: string) => void = () => {}; + export let externalEditRequested = false; let fragmentTitle = ""; let fragmentType = ""; @@ -183,16 +184,15 @@ $: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) { fragmentTag = tagOptions[0] ?? customTagValue; } + $: if (externalEditRequested && fragmentMode === "view") { + fragmentMode = "edit"; + }
{#if fragmentMode === "view"}
{@html renderMarkdown(openDocumentContent)} -
- - -
{:else}
@@ -245,9 +245,6 @@
- {#if fragmentMode !== "create"} - - {/if}
{/if} @@ -363,8 +360,7 @@ flex-wrap: wrap; } - .fragment-secondary, - .fragment-danger { + .fragment-secondary { border-radius: 8px; border: 1px solid var(--border-soft); background: var(--surface-1); @@ -374,8 +370,7 @@ cursor: pointer; } - .fragment-secondary:hover, - .fragment-danger:hover { + .fragment-secondary:hover { background: var(--bg-hover); color: var(--text-primary); } diff --git a/Journal.App/src/lib/components/editor/MarkdownEditor.svelte b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte index c8074de..2713b59 100644 --- a/Journal.App/src/lib/components/editor/MarkdownEditor.svelte +++ b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte @@ -8,7 +8,7 @@ let markdownText = openDocumentContent; let lastOpenDocumentId = openDocumentId; - let previewOnly = true; + export let previewOnly = true; let editorInput: HTMLTextAreaElement | null = null; function updateDraft(value: string) { @@ -71,10 +71,6 @@

{editorTitle}

-
- - -
@@ -138,36 +134,6 @@ 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; diff --git a/Journal.App/src/lib/stores/entries.ts b/Journal.App/src/lib/stores/entries.ts index f02dd77..8b11d85 100644 --- a/Journal.App/src/lib/stores/entries.ts +++ b/Journal.App/src/lib/stores/entries.ts @@ -1,5 +1,6 @@ import { get, writable } from "svelte/store"; import { + deleteEntry as deleteEntryCommand, listEntries as listEntriesCommand, loadEntry as loadEntryCommand, saveEntry as saveEntryCommand, @@ -119,13 +120,22 @@ export async function saveEntryFromStore(storeId: string, content: string, mode? if (!trimmed) return null; const existingPath = toBackendPath(storeId); - const payload = existingPath ? { content: trimmed, filePath: existingPath, mode } : { content: trimmed, mode }; + let payload: { content: string; filePath?: string; mode?: string; fileName?: string }; + if (existingPath) { + payload = { content: trimmed, filePath: existingPath, mode }; + } else { + const draft = get(entriesStore).find((item) => item.id === storeId); + payload = { content: trimmed, mode, fileName: draft?.label }; + } try { const saved = await saveEntryCommand(payload); const loaded = await loadEntryCommand(saved.filePath); const item = fromLoadResult(loaded); - entriesStore.update((items) => upsertById(items, item)); + entriesStore.update((items) => { + const filtered = existingPath ? items : items.filter((i) => i.id !== storeId); + return upsertById(filtered, item); + }); return item; } catch (error) { console.error("[entries] save:error", { storeId, error }); @@ -150,6 +160,26 @@ export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Prom return mapped; } +export async function deleteEntryByStoreId(storeId: string): Promise { + if (storeId.startsWith("entries/draft-")) { + entriesStore.update((items) => items.filter((item) => item.id !== storeId)); + return true; + } + + const filePath = toBackendPath(storeId); + if (!filePath) return false; + + try { + const ok = await deleteEntryCommand(filePath); + if (!ok) return false; + entriesStore.update((items) => items.filter((item) => item.id !== storeId)); + return true; + } catch (error) { + console.error("[entries] delete:error", { storeId, error }); + return false; + } +} + export function hasEntry(storeId: string): boolean { return get(entriesStore).some((item) => item.id === storeId); } diff --git a/Journal.App/src/routes/+page.svelte b/Journal.App/src/routes/+page.svelte index 40a42a1..b34f81f 100644 --- a/Journal.App/src/routes/+page.svelte +++ b/Journal.App/src/routes/+page.svelte @@ -2,11 +2,11 @@ import { goto } from "$app/navigation"; import { unlockVaultWorkspace } from "$lib/backend/auth"; import AppModal from "$lib/components/AppModal.svelte"; - import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries"; - import { hydrateFragments } from "$lib/stores/fragments"; - import { hydrateLists, updateListByStoreId } from "$lib/stores/lists"; + import { deleteEntryByStoreId, entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries"; + import { deleteFragmentByStoreId, hydrateFragments } from "$lib/stores/fragments"; + import { deleteListByStoreId, hydrateLists, updateListByStoreId } from "$lib/stores/lists"; import { isVaultReady, setFlushCallback, setVaultSession } from "$lib/stores/session"; - import { hydrateTodos } from "$lib/stores/todos"; + import { deleteTodoListByStoreId, 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"; @@ -23,6 +23,7 @@ let selectedSection = "entries"; let panelOpen = true; + let editMode = false; let activeDocumentId = initialEntry?.id ?? "entries/daily-notes"; let activeDocumentLabel = initialEntry?.label ?? "Daily Notes"; let openDocuments: Record = initialEntry @@ -35,7 +36,7 @@ let modalCancelText = "Cancel"; let modalShowCancel = false; let modalTone: "default" | "danger" = "default"; - let modalAction: "logout-confirm" | "logout-info" | "unlock-vault" | null = null; + let modalAction: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm" | null = null; let modalInputEnabled = false; let modalInputType = "text"; let modalInputPlaceholder = ""; @@ -43,9 +44,10 @@ let modalInputValue = ""; let unlockResolver: ((password: string | null) => void) | null = null; let fragmentBootstrapInFlight = false; + let pendingDeleteItemId = ""; function showModal(options: { - action: "logout-confirm" | "logout-info" | "unlock-vault"; + action: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm"; title: string; message: string; confirmText?: string; @@ -81,9 +83,10 @@ modalInputPlaceholder = ""; modalInputAriaLabel = "Modal input"; modalInputValue = ""; + pendingDeleteItemId = ""; } - function handleModalConfirm() { + async function handleModalConfirm() { if (modalAction === "logout-confirm") { showModal({ action: "logout-info", @@ -104,6 +107,13 @@ return; } + if (modalAction === "delete-confirm") { + const id = pendingDeleteItemId; + closeModal(); + await performDelete(id); + return; + } + closeModal(); } @@ -201,7 +211,7 @@ if (!content?.trim()) return; try { - if (selectedSection === "entries") { + if (selectedSection === "entries" && (activeDocumentId.startsWith("entries/file/") || activeDocumentId.startsWith("entries/draft-"))) { const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite"); if (saved && saved.id !== activeDocumentId) { const { [activeDocumentId]: _, ...rest } = openDocuments; @@ -217,7 +227,7 @@ } } - function handleSelect(id: string) { + async function handleSelect(id: string) { if (id === "account" || id === "settings") { goto(`/${id}`); return; @@ -241,15 +251,26 @@ return; } - saveCurrentDocument(); + await saveCurrentDocument(); selectedSection = id; panelOpen = true; activeDocumentId = ""; activeDocumentLabel = ""; + editMode = false; } async function handleOpenDocument(doc: OpenDocument) { + const prevActiveId = activeDocumentId; await saveCurrentDocument(); + editMode = false; + + // If saveCurrentDocument promoted a draft to a file-backed entry and the + // caller passed the now-stale draft reference, the editor is already + // showing the promoted entry — nothing more to do. + if (doc.id === prevActiveId && activeDocumentId !== prevActiveId) { + return; + } + let resolvedDoc = doc; if (selectedSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) { try { @@ -278,6 +299,51 @@ openDocuments = remaining; } + async function performDelete(id: string) { + try { + let ok = false; + if (selectedSection === "entries") { + ok = await deleteEntryByStoreId(id); + } else if (selectedSection === "todos") { + ok = await deleteTodoListByStoreId(id); + } else if (selectedSection === "lists") { + ok = await deleteListByStoreId(id); + } else if (selectedSection === "fragments") { + ok = await deleteFragmentByStoreId(id); + } + if (!ok) return; + + handleDeleteDocument(id); + if (activeDocumentId === id) { + activeDocumentId = ""; + activeDocumentLabel = ""; + editMode = false; + } + } catch (error) { + console.error("Delete failed:", error); + } + } + + async function handleEditItem(doc: OpenDocument) { + if (doc.id !== activeDocumentId) { + await handleOpenDocument(doc); + } + editMode = true; + } + + function handleDeleteItem(doc: { id: string; label: string }) { + pendingDeleteItemId = doc.id; + showModal({ + action: "delete-confirm", + title: "Confirm Delete", + message: `Are you sure you want to delete "${doc.label}"? This action cannot be undone.`, + confirmText: "Delete", + cancelText: "Cancel", + showCancel: true, + tone: "danger" + }); + } + onMount(() => { setFlushCallback(saveCurrentDocument); bootstrapFragmentsWithUnlock(); @@ -291,6 +357,8 @@ activeSection={selectedSection} {activeDocumentId} onOpenDocument={handleOpenDocument} + onEditItem={handleEditItem} + onDeleteItem={handleDeleteItem} /> {/if} diff --git a/Journal.Core/Dtos/CommandDtos.cs b/Journal.Core/Dtos/CommandDtos.cs index ae59b1e..9509ccd 100644 --- a/Journal.Core/Dtos/CommandDtos.cs +++ b/Journal.Core/Dtos/CommandDtos.cs @@ -5,10 +5,11 @@ internal sealed record VaultPayload(string Password, string VaultDirectory, stri internal sealed record ClearDataPayload(string DataDirectory); 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 EntrySavePayload(string Content, string? FilePath = null, string? Mode = null, string? FileName = null); public sealed record EntryListItem(string FileName, string FilePath); public sealed record EntryLoadResult(string FileName, string FilePath, JournalEntryDto Entry); public sealed record EntrySaveResult(string FilePath); +internal sealed record EntryDeletePayload(string FilePath); internal sealed record DatabasePayload(string Password, string? DataDirectory = null); internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null); internal sealed record AiSummarizeAllPayload(List? Entries); diff --git a/Journal.Core/Entry.cs b/Journal.Core/Entry.cs index 4557a0e..9bf5d1b 100644 --- a/Journal.Core/Entry.cs +++ b/Journal.Core/Entry.cs @@ -228,6 +228,12 @@ public class Entry( return Error("Missing or invalid payload"); result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory); break; + case "entries.delete": + var deleteEntryPayload = DeserializePayload(cmd.Payload); + if (deleteEntryPayload is null || string.IsNullOrWhiteSpace(deleteEntryPayload.FilePath)) + return Error("Missing or invalid payload"); + result = _entryFiles.DeleteEntry(deleteEntryPayload.FilePath); + break; case "config.get": result = _config.Current; break; diff --git a/Journal.Core/Repositories/DiskEntryFileRepository.cs b/Journal.Core/Repositories/DiskEntryFileRepository.cs index 43df2a9..d24c4d8 100644 --- a/Journal.Core/Repositories/DiskEntryFileRepository.cs +++ b/Journal.Core/Repositories/DiskEntryFileRepository.cs @@ -30,4 +30,6 @@ public sealed class DiskEntryFileRepository : IEntryFileRepository if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); } + + public void DeleteFile(string filePath) => File.Delete(filePath); } diff --git a/Journal.Core/Repositories/IEntryFileRepository.cs b/Journal.Core/Repositories/IEntryFileRepository.cs index 8242bec..ac93437 100644 --- a/Journal.Core/Repositories/IEntryFileRepository.cs +++ b/Journal.Core/Repositories/IEntryFileRepository.cs @@ -11,4 +11,5 @@ public interface IEntryFileRepository string GetFileName(string filePath); string GetFileNameWithoutExtension(string filePath); void EnsureDirectory(string path); + void DeleteFile(string filePath); } diff --git a/Journal.Core/Services/Entries/EntryFileService.cs b/Journal.Core/Services/Entries/EntryFileService.cs index 866900b..4bbfb0a 100644 --- a/Journal.Core/Services/Entries/EntryFileService.cs +++ b/Journal.Core/Services/Entries/EntryFileService.cs @@ -33,7 +33,7 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory) { - var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory); + var targetPath = ResolveTargetPath(payload.FilePath, payload.FileName, defaultDataDirectory); var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim(); var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? ""); _repo.EnsureDirectory(targetPath); @@ -69,11 +69,35 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ return new EntrySaveResult(targetPath); } - private string ResolveTargetPath(string? filePath, string defaultDataDirectory) + public bool DeleteEntry(string filePath) + { + var normalizedPath = _repo.GetFullPath(filePath); + if (!_repo.FileExists(normalizedPath)) + return false; + _repo.DeleteFile(normalizedPath); + return true; + } + + private string ResolveTargetPath(string? filePath, string? fileName, string defaultDataDirectory) { if (!string.IsNullOrWhiteSpace(filePath)) return _repo.GetFullPath(filePath); - return _repo.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md")); + var name = !string.IsNullOrWhiteSpace(fileName) + ? SanitizeFileName(fileName) + : $"{DateTime.Now:yyyy-MM-dd}"; + + return _repo.GetFullPath(Path.Combine(defaultDataDirectory, $"{name}.md")); + } + + private static string SanitizeFileName(string name) + { + var trimmed = name.Trim(); + if (trimmed.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + trimmed = trimmed[..^3]; + + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(trimmed.Select(c => Array.IndexOf(invalid, c) >= 0 ? '_' : c).ToArray()); + return string.IsNullOrWhiteSpace(sanitized) ? "untitled" : sanitized; } } diff --git a/Journal.Core/Services/Entries/IEntryFileService.cs b/Journal.Core/Services/Entries/IEntryFileService.cs index 107d85a..a8b8223 100644 --- a/Journal.Core/Services/Entries/IEntryFileService.cs +++ b/Journal.Core/Services/Entries/IEntryFileService.cs @@ -7,4 +7,5 @@ public interface IEntryFileService IReadOnlyList ListEntries(string dataDirectory); EntryLoadResult LoadEntry(string filePath); EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory); + bool DeleteEntry(string filePath); } diff --git a/Journal.Core/Services/Vault/VaultStorageService.cs b/Journal.Core/Services/Vault/VaultStorageService.cs index f73fdba..649663c 100644 --- a/Journal.Core/Services/Vault/VaultStorageService.cs +++ b/Journal.Core/Services/Vault/VaultStorageService.cs @@ -97,18 +97,22 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ .OrderBy(Path.GetFileName, StringComparer.Ordinal) .ToList(); - if (filesInMonth.Count == 0) - return false; - - var currentFingerprint = ComputeMonthFingerprint(filesInMonth); - if (_monthFingerprintCache.TryGetValue(monthKey, out var cachedFingerprint) && - string.Equals(cachedFingerprint, currentFingerprint, StringComparison.Ordinal)) + var savedMonth = false; + if (filesInMonth.Count > 0) { - return false; + var currentFingerprint = ComputeMonthFingerprint(filesInMonth); + if (!_monthFingerprintCache.TryGetValue(monthKey, out var cachedFingerprint) || + !string.Equals(cachedFingerprint, currentFingerprint, StringComparison.Ordinal)) + { + SaveMonth(password, monthKey, filesInMonth, vaultDirectory); + savedMonth = true; + } } - SaveMonth(password, monthKey, filesInMonth, vaultDirectory); - return true; + // Also persist custom-named entries alongside the current month vault + SaveCustomEntries(password, vaultDirectory, dataDirectory); + + return savedMonth; } } @@ -142,7 +146,7 @@ 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 + SaveCustomEntries(password, vaultDirectory, dataDirectory); SaveDatabaseVaults(password, vaultDirectory, dataDirectory); } } @@ -190,6 +194,43 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ Directory.Delete(dataDirectory, recursive: true); } + // ── Custom entries vault helpers ────────────────────────────── + + private const string CustomEntriesVaultFileName = "_custom_entries.vault"; + + private List GetCustomEntryFiles(string dataDirectory) + { + return Directory.GetFiles(dataDirectory, "*.md") + .Where(path => + { + var stem = Path.GetFileNameWithoutExtension(path); + return !DateTime.TryParseExact(stem, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + }) + .OrderBy(Path.GetFileName, StringComparer.Ordinal) + .ToList(); + } + + private void SaveCustomEntries(string password, string vaultDirectory, string dataDirectory) + { + var customFiles = GetCustomEntryFiles(dataDirectory); + var vaultPath = Path.Combine(vaultDirectory, CustomEntriesVaultFileName); + + if (customFiles.Count == 0) + { + // Remove stale custom vault if no custom entries remain + if (File.Exists(vaultPath)) + File.Delete(vaultPath); + return; + } + + var zipBytes = CreateMonthlyArchive(customFiles); + var encrypted = _crypto.EncryptData(zipBytes, password); + File.WriteAllBytes(vaultPath, encrypted); + } + + private static bool IsCustomEntriesVaultFile(string fileName) + => string.Equals(fileName, CustomEntriesVaultFileName, StringComparison.OrdinalIgnoreCase); + // ── Database vault helpers ───────────────────────────────────── private const string DatabaseVaultPrefix = "_db_"; diff --git a/Journal.SmokeTests/Program.cs b/Journal.SmokeTests/Program.cs index d59ea53..c54d9b0 100644 --- a/Journal.SmokeTests/Program.cs +++ b/Journal.SmokeTests/Program.cs @@ -14,6 +14,8 @@ using Journal.Core.Services.Fragments; using Journal.Core.Services.Logging; using Journal.Core.Services.Speech; using Journal.Core.Services.Sidecar; +using Journal.Core.Services.Lists; +using Journal.Core.Services.Todos; using Journal.Core.Services.Vault; var tests = new List<(string Name, Func Run)> @@ -88,6 +90,9 @@ var tests = new List<(string Name, Func Run)> ("Sidecar search CLI returns matching entries with filters", TestSidecarSearchCliFilteredAsync), ("Sidecar search CLI warns when no decrypted entries exist", TestSidecarSearchCliEmptyDataAsync), ("Transport fixtures produce stable envelopes", TestTransportFixturesAsync), + ("EntrySavePayload deserializes camelCase fileName from JsonElement", TestEntrySavePayloadFileNameDeserializationAsync), + ("entries.save with fileName creates custom-named file", TestEntrySaveWithFileNameAsync), + ("Vault rebuild and load preserves custom-named entries", TestVaultCustomEntryRoundtripAsync), }; var passed = 0; @@ -128,6 +133,8 @@ static Entry NewEntry() new DisabledAiService("none"), new DisabledSpeechBridgeService("none"), new EntryFileService(new DiskEntryFileRepository()), + new ListService(new SqliteListRepository(session)), + new TodoService(new SqliteTodoRepository(session)), new CommandLogger()); } @@ -2152,6 +2159,121 @@ static void Assert(bool condition, string message) throw new InvalidOperationException(message); } +static Task TestEntrySavePayloadFileNameDeserializationAsync() +{ + // Simulate what DeserializePayload does: parse JSON to JsonElement, then Deserialize + var json = """{"content":"hello","mode":"Overwrite","fileName":"My Custom Name"}"""; + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var element = JsonSerializer.Deserialize(json); + var payload = element.Deserialize(options); + + Assert(payload is not null, "Payload should not be null."); + Assert(payload!.Content == "hello", "Content should be deserialized."); + Assert(payload.Mode == "Overwrite", "Mode should be deserialized."); + Assert(payload.FileName == "My Custom Name", "FileName should be deserialized from camelCase JSON via JsonElement."); + Assert(payload.FilePath is null, "FilePath should be null when not provided."); + + return Task.CompletedTask; +} + +static async Task TestEntrySaveWithFileNameAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + // Use EntryFileService directly to test the full save path with fileName + var service = new EntryFileService(new DiskEntryFileRepository()); + var payload = new EntrySavePayload( + Content: "# Custom Entry\n\nHello world", + FilePath: null, + Mode: "Overwrite", + FileName: "My Custom Name"); + + var result = service.SaveEntry(payload, root); + + var expectedPath = Path.GetFullPath(Path.Combine(root, "My Custom Name.md")); + Assert(result.FilePath == expectedPath, $"Expected path '{expectedPath}' but got '{result.FilePath}'."); + Assert(File.Exists(expectedPath), "Custom-named file should exist on disk."); + Assert(File.ReadAllText(expectedPath).Contains("Hello world"), "File content should match."); + + // Also test via Entry.HandleCommandAsync with JSON to verify full deserialization chain + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "entries.save", + payload = new + { + content = "# Second Entry", + mode = "Overwrite", + fileName = "Another Custom Name" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save with fileName."); + + var savedFilePath = doc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? ""; + Assert(savedFilePath.Contains("Another Custom Name.md", StringComparison.OrdinalIgnoreCase), + $"Expected file path to contain 'Another Custom Name.md' but got '{savedFilePath}'."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } +} + +static Task TestVaultCustomEntryRoundtripAsync() +{ + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + // Create both date-named and custom-named entries + File.WriteAllText(Path.Combine(dataDir, "2026-02-01.md"), "date entry"); + File.WriteAllText(Path.Combine(dataDir, "My Custom Entry.md"), "custom entry body"); + File.WriteAllText(Path.Combine(dataDir, "Work Notes.md"), "work notes body"); + + // Rebuild vaults (simulates app close) + IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); + storage.RebuildAllVaults("vault-pass-123", vaultDir, dataDir); + + // Verify custom vault was created + var customVaultPath = Path.Combine(vaultDir, "_custom_entries.vault"); + Assert(File.Exists(customVaultPath), "Expected _custom_entries.vault to be created."); + Assert(File.Exists(Path.Combine(vaultDir, "2026-02.vault")), "Expected monthly vault for date entry."); + + // Clear data directory (simulates app close step 2) + storage.ClearDataDirectory(dataDir); + Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Data directory should be empty after clear."); + + // Load vaults (simulates app restart) + var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir); + Assert(ok, "Expected vault load to succeed."); + + // Verify all entries are restored + Assert(File.Exists(Path.Combine(dataDir, "2026-02-01.md")), "Date entry should be restored from vault."); + Assert(File.Exists(Path.Combine(dataDir, "My Custom Entry.md")), "Custom entry should be restored from vault."); + Assert(File.Exists(Path.Combine(dataDir, "Work Notes.md")), "Second custom entry should be restored from vault."); + Assert(File.ReadAllText(Path.Combine(dataDir, "My Custom Entry.md")) == "custom entry body", "Custom entry content mismatch."); + Assert(File.ReadAllText(Path.Combine(dataDir, "Work Notes.md")) == "work notes body", "Second custom entry content mismatch."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; +} + sealed class TransportFixture { public string Name { get; init; } = "";