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; content: string;
filePath?: string; filePath?: string;
mode?: string; mode?: string;
fileName?: string;
}): Promise<EntrySaveResultDto> { }): Promise<EntrySaveResultDto> {
const data = await sendCommand<EntrySaveResultDtoRaw>({ const data = await sendCommand<EntrySaveResultDtoRaw>({
action: "entries.save", 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[]> { export async function searchEntries(payload: EntrySearchRequestDto): Promise<EntrySearchResultDto[]> {
const data = await sendCommand<EntrySearchResultDtoRaw[]>({ const data = await sendCommand<EntrySearchResultDtoRaw[]>({
action: "search.entries", action: "search.entries",

View File

@ -10,6 +10,7 @@
export let onDocumentContentChange: (content: string) => void = () => {}; export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {}; export let onDeleteDocument: (id: string) => void = () => {};
export let previewOnly = true;
</script> </script>
<main class="editor-panel" aria-label="Editor area"> <main class="editor-panel" aria-label="Editor area">
@ -26,6 +27,7 @@
{onDocumentContentChange} {onDocumentContentChange}
{onOpenDocument} {onOpenDocument}
{onDeleteDocument} {onDeleteDocument}
externalEditRequested={!previewOnly}
/> />
{:else if activeSection === "todos"} {:else if activeSection === "todos"}
<TodoEditor <TodoEditor
@ -40,6 +42,7 @@
{openDocumentName} {openDocumentName}
{openDocumentContent} {openDocumentContent}
{onDocumentContentChange} {onDocumentContentChange}
{previewOnly}
/> />
{/if} {/if}
</main> </main>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import CalendarWidget from "$lib/components/CalendarWidget.svelte"; 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 { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists"; import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos"; import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
@ -8,6 +8,8 @@
export let activeSection = "entries"; export let activeSection = "entries";
export let activeDocumentId = ""; export let activeDocumentId = "";
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; 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 showNewItemInput = false;
let newItemName = ""; let newItemName = "";
@ -123,10 +125,10 @@
} }
function handleAddItem() { function handleAddItem() {
if (activeSection === "entries") { if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") {
const item = createEntryDraft(); showNewItemInput = true;
entriesStore.update((items) => [item, ...items]); newItemName = "";
onOpenDocument(item); queueMicrotask(() => newItemInput?.focus());
return; return;
} }
@ -135,13 +137,6 @@
return; return;
} }
if (activeSection === "todos" || activeSection === "lists") {
showNewItemInput = true;
newItemName = "";
queueMicrotask(() => newItemInput?.focus());
return;
}
if (activeSection === "calendar") { if (activeSection === "calendar") {
const selected = selectedCalendarDate ?? { const selected = selectedCalendarDate ?? {
year: calendarYear, year: calendarYear,
@ -172,7 +167,12 @@
showNewItemInput = false; showNewItemInput = false;
newItemName = ""; 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 { try {
const { meta, items: todoItems } = await createTodoListFromLabel(label); const { meta, items: todoItems } = await createTodoListFromLabel(label);
onOpenDocument({ onOpenDocument({
@ -236,6 +236,7 @@
: []; : [];
$: isCalendarSection = activeSection === "calendar"; $: isCalendarSection = activeSection === "calendar";
$: calendarEntries = [...customCalendarEntries, ...getCalendarEntries(calendarYear, calendarMonth)]; $: calendarEntries = [...customCalendarEntries, ...getCalendarEntries(calendarYear, calendarMonth)];
$: showItemActions = activeSection === "entries" || activeSection === "todos" || activeSection === "lists" || activeSection === "fragments";
</script> </script>
<section class="side-panel" aria-label="Section panel"> <section class="side-panel" aria-label="Section panel">
@ -256,10 +257,10 @@
<h3>{calendarMonthLabel} {calendarYear} Entries</h3> <h3>{calendarMonthLabel} {calendarYear} Entries</h3>
<ul class="panel-list"> <ul class="panel-list">
{#each calendarEntries as item} {#each calendarEntries as item}
<li> <li class:is-active={item.id === activeDocumentId}>
<button <button
type="button" type="button"
class:is-active={item.id === activeDocumentId} class="item-label"
on:click={() => onOpenDocument(item)} on:click={() => onOpenDocument(item)}
> >
{item.label} {item.label}
@ -280,7 +281,7 @@
type="text" type="text"
bind:this={newItemInput} bind:this={newItemInput}
bind:value={newItemName} 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:keydown={handleNewItemKeydown}
on:blur={confirmNewItem} on:blur={confirmNewItem}
/> />
@ -289,14 +290,24 @@
<ul class="panel-list"> <ul class="panel-list">
{#each items as item} {#each items as item}
<li> <li class:is-active={item.id === activeDocumentId}>
<button <button
type="button" type="button"
class:is-active={item.id === activeDocumentId} class="item-label"
on:click={() => onOpenDocument(item)} on:click={() => onOpenDocument(item)}
> >
{item.label} {item.label}
</button> </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> </li>
{/each} {/each}
</ul> </ul>
@ -377,29 +388,79 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
}
.panel-list li button { li {
width: 100%; display: flex;
text-align: left; align-items: center;
border-radius: 7px; border-radius: 7px;
padding: 7px 9px; border: 1px solid transparent;
font-size: 0.84rem;
color: var(--text-muted);
cursor: pointer;
border: 1px solid transparent;
}
.panel-list li button:hover { &:hover {
color: var(--text-primary); background: var(--bg-hover);
background: var(--bg-hover); border-color: var(--border-soft);
border-color: var(--border-soft); }
}
.panel-list li button.is-active { &.is-active {
color: var(--text-primary); background: var(--bg-active);
background: var(--bg-active); border-color: var(--border-strong);
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 { .calendar-entries {

View File

@ -19,6 +19,7 @@
export let onDocumentContentChange: (content: string) => void = () => {}; export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {}; export let onDeleteDocument: (id: string) => void = () => {};
export let externalEditRequested = false;
let fragmentTitle = ""; let fragmentTitle = "";
let fragmentType = ""; let fragmentType = "";
@ -183,16 +184,15 @@
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) { $: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
fragmentTag = tagOptions[0] ?? customTagValue; fragmentTag = tagOptions[0] ?? customTagValue;
} }
$: if (externalEditRequested && fragmentMode === "view") {
fragmentMode = "edit";
}
</script> </script>
<section class="fragment-surface"> <section class="fragment-surface">
{#if fragmentMode === "view"} {#if fragmentMode === "view"}
<article class="fragment-view"> <article class="fragment-view">
{@html renderMarkdown(openDocumentContent)} {@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> </article>
{:else} {:else}
<form class="fragment-form" on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}> <form class="fragment-form" on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}>
@ -245,9 +245,6 @@
<div class="fragment-actions"> <div class="fragment-actions">
<button type="submit" class="fragment-submit">{fragmentMode === "create" ? "Create Fragment" : "Save Fragment"}</button> <button type="submit" class="fragment-submit">{fragmentMode === "create" ? "Create Fragment" : "Save Fragment"}</button>
<button type="button" class="fragment-secondary" on:click={cancelFragmentEdit}>Cancel</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> </div>
</form> </form>
{/if} {/if}
@ -363,8 +360,7 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.fragment-secondary, .fragment-secondary {
.fragment-danger {
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
background: var(--surface-1); background: var(--surface-1);
@ -374,8 +370,7 @@
cursor: pointer; cursor: pointer;
} }
.fragment-secondary:hover, .fragment-secondary:hover {
.fragment-danger:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }

View File

@ -8,7 +8,7 @@
let markdownText = openDocumentContent; let markdownText = openDocumentContent;
let lastOpenDocumentId = openDocumentId; let lastOpenDocumentId = openDocumentId;
let previewOnly = true; export let previewOnly = true;
let editorInput: HTMLTextAreaElement | null = null; let editorInput: HTMLTextAreaElement | null = null;
function updateDraft(value: string) { function updateDraft(value: string) {
@ -71,10 +71,6 @@
<header class="editor-header"> <header class="editor-header">
<h1>{editorTitle}</h1> <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> </header>
<section class="editor-surface" class:preview-only={previewOnly}> <section class="editor-surface" class:preview-only={previewOnly}>
@ -138,36 +134,6 @@
color: var(--text-primary); 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 { .editor-surface {
min-height: 0; min-height: 0;
flex: 1; flex: 1;

View File

@ -1,5 +1,6 @@
import { get, writable } from "svelte/store"; import { get, writable } from "svelte/store";
import { import {
deleteEntry as deleteEntryCommand,
listEntries as listEntriesCommand, listEntries as listEntriesCommand,
loadEntry as loadEntryCommand, loadEntry as loadEntryCommand,
saveEntry as saveEntryCommand, saveEntry as saveEntryCommand,
@ -119,13 +120,22 @@ export async function saveEntryFromStore(storeId: string, content: string, mode?
if (!trimmed) return null; if (!trimmed) return null;
const existingPath = toBackendPath(storeId); 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 { try {
const saved = await saveEntryCommand(payload); const saved = await saveEntryCommand(payload);
const loaded = await loadEntryCommand(saved.filePath); const loaded = await loadEntryCommand(saved.filePath);
const item = fromLoadResult(loaded); 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; return item;
} catch (error) { } catch (error) {
console.error("[entries] save:error", { storeId, error }); console.error("[entries] save:error", { storeId, error });
@ -150,6 +160,26 @@ export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Prom
return mapped; 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 { export function hasEntry(storeId: string): boolean {
return get(entriesStore).some((item) => item.id === storeId); return get(entriesStore).some((item) => item.id === storeId);
} }

View File

@ -2,11 +2,11 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { unlockVaultWorkspace } from "$lib/backend/auth"; import { unlockVaultWorkspace } from "$lib/backend/auth";
import AppModal from "$lib/components/AppModal.svelte"; import AppModal from "$lib/components/AppModal.svelte";
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries"; import { deleteEntryByStoreId, entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries";
import { hydrateFragments } from "$lib/stores/fragments"; import { deleteFragmentByStoreId, hydrateFragments } from "$lib/stores/fragments";
import { hydrateLists, updateListByStoreId } from "$lib/stores/lists"; import { deleteListByStoreId, hydrateLists, updateListByStoreId } from "$lib/stores/lists";
import { isVaultReady, setFlushCallback, setVaultSession } from "$lib/stores/session"; 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 Navbar from "$lib/components/Navbar.svelte";
import SidePanel from "$lib/components/SidePanel.svelte"; import SidePanel from "$lib/components/SidePanel.svelte";
import EditorPanel from "$lib/components/EditorPanel.svelte"; import EditorPanel from "$lib/components/EditorPanel.svelte";
@ -23,6 +23,7 @@
let selectedSection = "entries"; let selectedSection = "entries";
let panelOpen = true; let panelOpen = true;
let editMode = false;
let activeDocumentId = initialEntry?.id ?? "entries/daily-notes"; let activeDocumentId = initialEntry?.id ?? "entries/daily-notes";
let activeDocumentLabel = initialEntry?.label ?? "Daily Notes"; let activeDocumentLabel = initialEntry?.label ?? "Daily Notes";
let openDocuments: Record<string, string> = initialEntry let openDocuments: Record<string, string> = initialEntry
@ -35,7 +36,7 @@
let modalCancelText = "Cancel"; let modalCancelText = "Cancel";
let modalShowCancel = false; let modalShowCancel = false;
let modalTone: "default" | "danger" = "default"; 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 modalInputEnabled = false;
let modalInputType = "text"; let modalInputType = "text";
let modalInputPlaceholder = ""; let modalInputPlaceholder = "";
@ -43,9 +44,10 @@
let modalInputValue = ""; let modalInputValue = "";
let unlockResolver: ((password: string | null) => void) | null = null; let unlockResolver: ((password: string | null) => void) | null = null;
let fragmentBootstrapInFlight = false; let fragmentBootstrapInFlight = false;
let pendingDeleteItemId = "";
function showModal(options: { function showModal(options: {
action: "logout-confirm" | "logout-info" | "unlock-vault"; action: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm";
title: string; title: string;
message: string; message: string;
confirmText?: string; confirmText?: string;
@ -81,9 +83,10 @@
modalInputPlaceholder = ""; modalInputPlaceholder = "";
modalInputAriaLabel = "Modal input"; modalInputAriaLabel = "Modal input";
modalInputValue = ""; modalInputValue = "";
pendingDeleteItemId = "";
} }
function handleModalConfirm() { async function handleModalConfirm() {
if (modalAction === "logout-confirm") { if (modalAction === "logout-confirm") {
showModal({ showModal({
action: "logout-info", action: "logout-info",
@ -104,6 +107,13 @@
return; return;
} }
if (modalAction === "delete-confirm") {
const id = pendingDeleteItemId;
closeModal();
await performDelete(id);
return;
}
closeModal(); closeModal();
} }
@ -201,7 +211,7 @@
if (!content?.trim()) return; if (!content?.trim()) return;
try { try {
if (selectedSection === "entries") { if (selectedSection === "entries" && (activeDocumentId.startsWith("entries/file/") || activeDocumentId.startsWith("entries/draft-"))) {
const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite"); const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite");
if (saved && saved.id !== activeDocumentId) { if (saved && saved.id !== activeDocumentId) {
const { [activeDocumentId]: _, ...rest } = openDocuments; const { [activeDocumentId]: _, ...rest } = openDocuments;
@ -217,7 +227,7 @@
} }
} }
function handleSelect(id: string) { async function handleSelect(id: string) {
if (id === "account" || id === "settings") { if (id === "account" || id === "settings") {
goto(`/${id}`); goto(`/${id}`);
return; return;
@ -241,15 +251,26 @@
return; return;
} }
saveCurrentDocument(); await saveCurrentDocument();
selectedSection = id; selectedSection = id;
panelOpen = true; panelOpen = true;
activeDocumentId = ""; activeDocumentId = "";
activeDocumentLabel = ""; activeDocumentLabel = "";
editMode = false;
} }
async function handleOpenDocument(doc: OpenDocument) { async function handleOpenDocument(doc: OpenDocument) {
const prevActiveId = activeDocumentId;
await saveCurrentDocument(); 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; let resolvedDoc = doc;
if (selectedSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) { if (selectedSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
try { try {
@ -278,6 +299,51 @@
openDocuments = remaining; 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(() => { onMount(() => {
setFlushCallback(saveCurrentDocument); setFlushCallback(saveCurrentDocument);
bootstrapFragmentsWithUnlock(); bootstrapFragmentsWithUnlock();
@ -291,6 +357,8 @@
activeSection={selectedSection} activeSection={selectedSection}
{activeDocumentId} {activeDocumentId}
onOpenDocument={handleOpenDocument} onOpenDocument={handleOpenDocument}
onEditItem={handleEditItem}
onDeleteItem={handleDeleteItem}
/> />
{/if} {/if}
<EditorPanel <EditorPanel
@ -301,6 +369,7 @@
onDocumentContentChange={handleDocumentContentChange} onDocumentContentChange={handleDocumentContentChange}
onOpenDocument={handleOpenDocument} onOpenDocument={handleOpenDocument}
onDeleteDocument={handleDeleteDocument} onDeleteDocument={handleDeleteDocument}
previewOnly={!editMode}
/> />
</div> </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 ClearDataPayload(string DataDirectory);
internal sealed record EntryListPayload(string? DataDirectory = null); internal sealed record EntryListPayload(string? DataDirectory = null);
internal sealed record EntryLoadPayload(string FilePath); 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 EntryListItem(string FileName, string FilePath);
public sealed record EntryLoadResult(string FileName, string FilePath, JournalEntryDto Entry); public sealed record EntryLoadResult(string FileName, string FilePath, JournalEntryDto Entry);
public sealed record EntrySaveResult(string FilePath); public sealed record EntrySaveResult(string FilePath);
internal sealed record EntryDeletePayload(string FilePath);
internal sealed record DatabasePayload(string Password, string? DataDirectory = null); internal sealed record DatabasePayload(string Password, string? DataDirectory = null);
internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null); internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
internal sealed record AiSummarizeAllPayload(List<string>? Entries); internal sealed record AiSummarizeAllPayload(List<string>? Entries);

View File

@ -228,6 +228,12 @@ public class Entry(
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory); result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
break; 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": case "config.get":
result = _config.Current; result = _config.Current;
break; break;

View File

@ -30,4 +30,6 @@ public sealed class DiskEntryFileRepository : IEntryFileRepository
if (!string.IsNullOrWhiteSpace(dir)) if (!string.IsNullOrWhiteSpace(dir))
Directory.CreateDirectory(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 GetFileName(string filePath);
string GetFileNameWithoutExtension(string filePath); string GetFileNameWithoutExtension(string filePath);
void EnsureDirectory(string path); 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) 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 mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim();
var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? ""); var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? "");
_repo.EnsureDirectory(targetPath); _repo.EnsureDirectory(targetPath);
@ -69,11 +69,35 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
return new EntrySaveResult(targetPath); 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)) if (!string.IsNullOrWhiteSpace(filePath))
return _repo.GetFullPath(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); IReadOnlyList<EntryListItem> ListEntries(string dataDirectory);
EntryLoadResult LoadEntry(string filePath); EntryLoadResult LoadEntry(string filePath);
EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory); 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) .OrderBy(Path.GetFileName, StringComparer.Ordinal)
.ToList(); .ToList();
if (filesInMonth.Count == 0) var savedMonth = false;
return false; if (filesInMonth.Count > 0)
var currentFingerprint = ComputeMonthFingerprint(filesInMonth);
if (_monthFingerprintCache.TryGetValue(monthKey, out var cachedFingerprint) &&
string.Equals(cachedFingerprint, currentFingerprint, StringComparison.Ordinal))
{ {
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); // Also persist custom-named entries alongside the current month vault
return true; 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)) foreach (var (monthKey, filesInMonth) in monthlyFiles.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
SaveMonth(password, monthKey, filesInMonth, vaultDirectory); SaveMonth(password, monthKey, filesInMonth, vaultDirectory);
// Save database files SaveCustomEntries(password, vaultDirectory, dataDirectory);
SaveDatabaseVaults(password, vaultDirectory, dataDirectory); SaveDatabaseVaults(password, vaultDirectory, dataDirectory);
} }
} }
@ -190,6 +194,43 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
Directory.Delete(dataDirectory, recursive: true); 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 ───────────────────────────────────── // ── Database vault helpers ─────────────────────────────────────
private const string DatabaseVaultPrefix = "_db_"; 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.Logging;
using Journal.Core.Services.Speech; using Journal.Core.Services.Speech;
using Journal.Core.Services.Sidecar; using Journal.Core.Services.Sidecar;
using Journal.Core.Services.Lists;
using Journal.Core.Services.Todos;
using Journal.Core.Services.Vault; using Journal.Core.Services.Vault;
var tests = new List<(string Name, Func<Task> Run)> 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 returns matching entries with filters", TestSidecarSearchCliFilteredAsync),
("Sidecar search CLI warns when no decrypted entries exist", TestSidecarSearchCliEmptyDataAsync), ("Sidecar search CLI warns when no decrypted entries exist", TestSidecarSearchCliEmptyDataAsync),
("Transport fixtures produce stable envelopes", TestTransportFixturesAsync), ("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; var passed = 0;
@ -128,6 +133,8 @@ static Entry NewEntry()
new DisabledAiService("none"), new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"), new DisabledSpeechBridgeService("none"),
new EntryFileService(new DiskEntryFileRepository()), new EntryFileService(new DiskEntryFileRepository()),
new ListService(new SqliteListRepository(session)),
new TodoService(new SqliteTodoRepository(session)),
new CommandLogger()); new CommandLogger());
} }
@ -2152,6 +2159,121 @@ static void Assert(bool condition, string message)
throw new InvalidOperationException(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 sealed class TransportFixture
{ {
public string Name { get; init; } = ""; public string Name { get; init; } = "";