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:
parent
58f9f46cb9
commit
d1e4989303
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -30,4 +30,6 @@ public sealed class DiskEntryFileRepository : IEntryFileRepository
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
public void DeleteFile(string filePath) => File.Delete(filePath);
|
||||
}
|
||||
|
||||
@ -11,4 +11,5 @@ public interface IEntryFileRepository
|
||||
string GetFileName(string filePath);
|
||||
string GetFileNameWithoutExtension(string filePath);
|
||||
void EnsureDirectory(string path);
|
||||
void DeleteFile(string filePath);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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_";
|
||||
|
||||
@ -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; } = "";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user