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;
|
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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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_";
|
||||||
|
|||||||
@ -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; } = "";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user