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 <oz-agent@warp.dev>
This commit is contained in:
Jacob Schmidt 2026-02-26 19:40:43 -06:00
parent 58f9f46cb9
commit d1e4989303
15 changed files with 440 additions and 110 deletions

View File

@ -190,6 +190,7 @@ export async function saveEntry(payload: {
content: string;
filePath?: string;
mode?: string;
fileName?: string;
}): Promise<EntrySaveResultDto> {
const data = await sendCommand<EntrySaveResultDtoRaw>({
action: "entries.save",
@ -201,6 +202,13 @@ export async function saveEntry(payload: {
};
}
export async function deleteEntry(filePath: string): Promise<boolean> {
return sendCommand<boolean>({
action: "entries.delete",
payload: { filePath }
});
}
export async function searchEntries(payload: EntrySearchRequestDto): Promise<EntrySearchResultDto[]> {
const data = await sendCommand<EntrySearchResultDtoRaw[]>({
action: "search.entries",

View File

@ -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;
</script>
<main class="editor-panel" aria-label="Editor area">
@ -26,6 +27,7 @@
{onDocumentContentChange}
{onOpenDocument}
{onDeleteDocument}
externalEditRequested={!previewOnly}
/>
{:else if activeSection === "todos"}
<TodoEditor
@ -40,6 +42,7 @@
{openDocumentName}
{openDocumentContent}
{onDocumentContentChange}
{previewOnly}
/>
{/if}
</main>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
import { createEntryDraft, entriesStore } from "$lib/stores/entries";
import { entriesStore } from "$lib/stores/entries";
import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
@ -8,6 +8,8 @@
export let activeSection = "entries";
export let activeDocumentId = "";
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onEditItem: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onDeleteItem: (doc: { id: string; label: string }) => void = () => {};
let showNewItemInput = false;
let newItemName = "";
@ -123,10 +125,10 @@
}
function handleAddItem() {
if (activeSection === "entries") {
const item = createEntryDraft();
entriesStore.update((items) => [item, ...items]);
onOpenDocument(item);
if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") {
showNewItemInput = true;
newItemName = "";
queueMicrotask(() => newItemInput?.focus());
return;
}
@ -135,13 +137,6 @@
return;
}
if (activeSection === "todos" || activeSection === "lists") {
showNewItemInput = true;
newItemName = "";
queueMicrotask(() => newItemInput?.focus());
return;
}
if (activeSection === "calendar") {
const selected = selectedCalendarDate ?? {
year: calendarYear,
@ -172,7 +167,12 @@
showNewItemInput = false;
newItemName = "";
if (activeSection === "todos") {
if (activeSection === "entries") {
const id = `entries/draft-${Date.now()}`;
const item = { id, label, initialContent: `# ${label}\n\n` };
entriesStore.update((items) => [item, ...items]);
onEditItem(item);
} else if (activeSection === "todos") {
try {
const { meta, items: todoItems } = await createTodoListFromLabel(label);
onOpenDocument({
@ -236,6 +236,7 @@
: [];
$: isCalendarSection = activeSection === "calendar";
$: calendarEntries = [...customCalendarEntries, ...getCalendarEntries(calendarYear, calendarMonth)];
$: showItemActions = activeSection === "entries" || activeSection === "todos" || activeSection === "lists" || activeSection === "fragments";
</script>
<section class="side-panel" aria-label="Section panel">
@ -256,10 +257,10 @@
<h3>{calendarMonthLabel} {calendarYear} Entries</h3>
<ul class="panel-list">
{#each calendarEntries as item}
<li>
<li class:is-active={item.id === activeDocumentId}>
<button
type="button"
class:is-active={item.id === activeDocumentId}
class="item-label"
on:click={() => onOpenDocument(item)}
>
{item.label}
@ -280,7 +281,7 @@
type="text"
bind:this={newItemInput}
bind:value={newItemName}
placeholder={activeSection === "todos" ? "Todo list name..." : "List name..."}
placeholder={activeSection === "entries" ? "Entry name..." : activeSection === "todos" ? "Todo list name..." : "List name..."}
on:keydown={handleNewItemKeydown}
on:blur={confirmNewItem}
/>
@ -289,14 +290,24 @@
<ul class="panel-list">
{#each items as item}
<li>
<li class:is-active={item.id === activeDocumentId}>
<button
type="button"
class:is-active={item.id === activeDocumentId}
class="item-label"
on:click={() => onOpenDocument(item)}
>
{item.label}
</button>
{#if showItemActions}
<div class="item-actions">
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
<span class="material-symbols-outlined">edit</span>
</button>
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
<span class="material-symbols-outlined">delete</span>
</button>
</div>
{/if}
</li>
{/each}
</ul>
@ -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 {

View File

@ -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";
}
</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}>
@ -245,9 +245,6 @@
<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}
@ -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);
}

View File

@ -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 @@
<header class="editor-header">
<h1>{editorTitle}</h1>
<div class="editor-actions">
<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}>
@ -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;

View File

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

View File

@ -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<string, string> = 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}
<EditorPanel
@ -301,6 +369,7 @@
onDocumentContentChange={handleDocumentContentChange}
onOpenDocument={handleOpenDocument}
onDeleteDocument={handleDeleteDocument}
previewOnly={!editMode}
/>
</div>

View File

@ -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<string>? Entries);

View File

@ -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<EntryDeletePayload>(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;

View File

@ -30,4 +30,6 @@ public sealed class DiskEntryFileRepository : IEntryFileRepository
if (!string.IsNullOrWhiteSpace(dir))
Directory.CreateDirectory(dir);
}
public void DeleteFile(string filePath) => File.Delete(filePath);
}

View File

@ -11,4 +11,5 @@ public interface IEntryFileRepository
string GetFileName(string filePath);
string GetFileNameWithoutExtension(string filePath);
void EnsureDirectory(string path);
void DeleteFile(string filePath);
}

View File

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

View File

@ -7,4 +7,5 @@ public interface IEntryFileService
IReadOnlyList<EntryListItem> ListEntries(string dataDirectory);
EntryLoadResult LoadEntry(string filePath);
EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory);
bool DeleteEntry(string filePath);
}

View File

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

View File

@ -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<Task> Run)>
@ -88,6 +90,9 @@ var tests = new List<(string Name, Func<Task> 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<T>
var json = """{"content":"hello","mode":"Overwrite","fileName":"My Custom Name"}""";
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var element = JsonSerializer.Deserialize<JsonElement>(json);
var payload = element.Deserialize<EntrySavePayload>(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; } = "";