feat: add shutdown command, auth/entries backend, and editor panel
- Add Rust shutdown command that kills sidecar and exits app cleanly
- Frontend calls invoke('shutdown') after vault flush on close
- Add auth.ts, entries.ts, and normalize.ts backend modules
- Add EditorPanel.svelte component
- Expand entries store with full CRUD support
- Add JournalEntryDtos and JournalEntryDtoMapper in Journal.Core
- Update entry search, fragments, and sidecar CLI
Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
parent
e2bfa0e6ff
commit
0465b05845
@ -33,7 +33,11 @@ impl ManagedSidecar {
|
|||||||
fn start() -> Result<Self, String> {
|
fn start() -> Result<Self, String> {
|
||||||
let sidecar_path = resolve_sidecar_path()?;
|
let sidecar_path = resolve_sidecar_path()?;
|
||||||
let root = project_root()?;
|
let root = project_root()?;
|
||||||
eprintln!("[sidecar] starting exe={} project_root={}", sidecar_path.display(), root.display());
|
eprintln!(
|
||||||
|
"[sidecar] starting exe={} project_root={}",
|
||||||
|
sidecar_path.display(),
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
|
||||||
let mut child = Command::new(sidecar_path)
|
let mut child = Command::new(sidecar_path)
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
@ -197,6 +201,13 @@ fn stop_managed_sidecar(state: &SidecarState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn shutdown(state: tauri::State<'_, SidecarState>, app_handle: tauri::AppHandle) {
|
||||||
|
eprintln!("[app] shutdown requested");
|
||||||
|
stop_managed_sidecar(state.inner());
|
||||||
|
app_handle.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn sidecar_command(
|
fn sidecar_command(
|
||||||
state: tauri::State<'_, SidecarState>,
|
state: tauri::State<'_, SidecarState>,
|
||||||
@ -225,7 +236,7 @@ pub fn run() {
|
|||||||
let app = tauri::Builder::default()
|
let app = tauri::Builder::default()
|
||||||
.manage(SidecarState::default())
|
.manage(SidecarState::default())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![sidecar_command])
|
.invoke_handler(tauri::generate_handler![sidecar_command, shutdown])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application");
|
.expect("error while building tauri application");
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { sendCommand } from "./client";
|
import { sendCommand } from "./client";
|
||||||
|
import { pickCase } from "./normalize";
|
||||||
|
|
||||||
export function hydrateWorkspace(password: string): Promise<unknown> {
|
export function hydrateWorkspace(password: string): Promise<unknown> {
|
||||||
return sendCommand<unknown>({
|
return sendCommand<unknown>({
|
||||||
@ -7,3 +8,69 @@ export function hydrateWorkspace(password: string): Promise<unknown> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeConfigRaw = {
|
||||||
|
dataDirectory?: string;
|
||||||
|
vaultDirectory?: string;
|
||||||
|
DataDirectory?: string;
|
||||||
|
VaultDirectory?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimeConfig = {
|
||||||
|
dataDirectory: string;
|
||||||
|
vaultDirectory: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getRuntimeConfig(): Promise<RuntimeConfig> {
|
||||||
|
const data = await sendCommand<RuntimeConfigRaw>({
|
||||||
|
action: "config.get"
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""),
|
||||||
|
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", "")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockVaultWorkspace(password: string): Promise<void> {
|
||||||
|
const config = await getRuntimeConfig();
|
||||||
|
const loaded = await sendCommand<boolean>({
|
||||||
|
action: "vault.load_all",
|
||||||
|
payload: {
|
||||||
|
password,
|
||||||
|
vaultDirectory: config.vaultDirectory,
|
||||||
|
dataDirectory: config.dataDirectory
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
throw new Error("Incorrect vault password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendCommand<unknown>({
|
||||||
|
action: "db.hydrate_workspace",
|
||||||
|
payload: {
|
||||||
|
password,
|
||||||
|
dataDirectory: config.dataDirectory
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistAndClearVault(password: string): Promise<void> {
|
||||||
|
const config = await getRuntimeConfig();
|
||||||
|
|
||||||
|
await sendCommand<boolean>({
|
||||||
|
action: "vault.rebuild_all",
|
||||||
|
payload: {
|
||||||
|
password,
|
||||||
|
vaultDirectory: config.vaultDirectory,
|
||||||
|
dataDirectory: config.dataDirectory
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendCommand<boolean>({
|
||||||
|
action: "vault.clear_data_directory",
|
||||||
|
payload: {
|
||||||
|
dataDirectory: config.dataDirectory
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
211
Journal.App/src/lib/backend/entries.ts
Normal file
211
Journal.App/src/lib/backend/entries.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { sendCommand } from "./client";
|
||||||
|
import { normalizeFragment, type FragmentDto, type FragmentDtoRaw } from "./fragments";
|
||||||
|
import { pickCase } from "./normalize";
|
||||||
|
|
||||||
|
export type ParsedSectionDto = {
|
||||||
|
title: string;
|
||||||
|
content: string[];
|
||||||
|
checkboxes: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JournalEntryDto = {
|
||||||
|
date: string;
|
||||||
|
fragments: FragmentDto[];
|
||||||
|
rawContent: string;
|
||||||
|
sections: Record<string, ParsedSectionDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntryListItemDto = {
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntryLoadResultDto = {
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
entry: JournalEntryDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntrySaveResultDto = {
|
||||||
|
filePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntrySearchRequestDto = {
|
||||||
|
dataDirectory: string;
|
||||||
|
query?: string;
|
||||||
|
section?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
tags?: string[];
|
||||||
|
types?: string[];
|
||||||
|
checked?: string[];
|
||||||
|
unchecked?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntrySearchResultDto = {
|
||||||
|
fileName: string;
|
||||||
|
entry: JournalEntryDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedSectionDtoRaw = {
|
||||||
|
title?: string;
|
||||||
|
content?: string[];
|
||||||
|
checkboxes?: Record<string, boolean>;
|
||||||
|
Title?: string;
|
||||||
|
Content?: string[];
|
||||||
|
Checkboxes?: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JournalEntryDtoRaw = {
|
||||||
|
date?: string;
|
||||||
|
fragments?: FragmentDtoRaw[];
|
||||||
|
rawContent?: string;
|
||||||
|
sections?: Record<string, ParsedSectionDtoRaw>;
|
||||||
|
Date?: string;
|
||||||
|
Fragments?: FragmentDtoRaw[];
|
||||||
|
RawContent?: string;
|
||||||
|
Sections?: Record<string, ParsedSectionDtoRaw>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntryListItemDtoRaw = {
|
||||||
|
fileName?: string;
|
||||||
|
filePath?: string;
|
||||||
|
FileName?: string;
|
||||||
|
FilePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntryLoadResultDtoRaw = {
|
||||||
|
fileName?: string;
|
||||||
|
filePath?: string;
|
||||||
|
entry?: JournalEntryDtoRaw;
|
||||||
|
date?: string;
|
||||||
|
rawContent?: string;
|
||||||
|
FileName?: string;
|
||||||
|
FilePath?: string;
|
||||||
|
Entry?: JournalEntryDtoRaw;
|
||||||
|
Date?: string;
|
||||||
|
RawContent?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntrySaveResultDtoRaw = {
|
||||||
|
filePath?: string;
|
||||||
|
FilePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntrySearchResultDtoRaw = {
|
||||||
|
fileName?: string;
|
||||||
|
entry?: JournalEntryDtoRaw;
|
||||||
|
date?: string;
|
||||||
|
rawContent?: string;
|
||||||
|
FileName?: string;
|
||||||
|
Entry?: JournalEntryDtoRaw;
|
||||||
|
Date?: string;
|
||||||
|
RawContent?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSection(raw: ParsedSectionDtoRaw): ParsedSectionDto {
|
||||||
|
return {
|
||||||
|
title: pickCase(raw, "title", "Title", ""),
|
||||||
|
content: pickCase(raw, "content", "Content", [] as string[]),
|
||||||
|
checkboxes: pickCase(raw, "checkboxes", "Checkboxes", {} as Record<string, boolean>)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJournalEntry(raw: JournalEntryDtoRaw | undefined): JournalEntryDto {
|
||||||
|
const fragments = pickCase(raw, "fragments", "Fragments", [] as FragmentDtoRaw[]);
|
||||||
|
const sections = pickCase(raw, "sections", "Sections", {} as Record<string, ParsedSectionDtoRaw>);
|
||||||
|
return {
|
||||||
|
date: pickCase(raw, "date", "Date", ""),
|
||||||
|
fragments: fragments.map(normalizeFragment),
|
||||||
|
rawContent: pickCase(raw, "rawContent", "RawContent", ""),
|
||||||
|
sections: Object.fromEntries(
|
||||||
|
Object.entries(sections).map(([key, value]) => [key, normalizeSection(value)])
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto {
|
||||||
|
return {
|
||||||
|
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||||
|
filePath: pickCase(raw, "filePath", "FilePath", "")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntryLoadResult(raw: EntryLoadResultDtoRaw): EntryLoadResultDto {
|
||||||
|
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
|
||||||
|
const entry =
|
||||||
|
nestedEntry
|
||||||
|
? normalizeJournalEntry(nestedEntry)
|
||||||
|
: normalizeJournalEntry({
|
||||||
|
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||||
|
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
|
||||||
|
fragments: [],
|
||||||
|
sections: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||||
|
filePath: pickCase(raw, "filePath", "FilePath", ""),
|
||||||
|
entry
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntrySearchResult(raw: EntrySearchResultDtoRaw): EntrySearchResultDto {
|
||||||
|
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
|
||||||
|
const entry =
|
||||||
|
nestedEntry
|
||||||
|
? normalizeJournalEntry(nestedEntry)
|
||||||
|
: normalizeJournalEntry({
|
||||||
|
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||||
|
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
|
||||||
|
fragments: [],
|
||||||
|
sections: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||||
|
entry
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEntries(dataDirectory?: string): Promise<EntryListItemDto[]> {
|
||||||
|
const data = await sendCommand<EntryListItemDtoRaw[]>({
|
||||||
|
action: "entries.list",
|
||||||
|
payload: { dataDirectory }
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.map(normalizeEntryListItem).filter((item) => Boolean(item.filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEntry(filePath: string): Promise<EntryLoadResultDto> {
|
||||||
|
const data = await sendCommand<EntryLoadResultDtoRaw>({
|
||||||
|
action: "entries.load",
|
||||||
|
payload: { filePath }
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeEntryLoadResult(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEntry(payload: {
|
||||||
|
content: string;
|
||||||
|
filePath?: string;
|
||||||
|
mode?: string;
|
||||||
|
}): Promise<EntrySaveResultDto> {
|
||||||
|
const data = await sendCommand<EntrySaveResultDtoRaw>({
|
||||||
|
action: "entries.save",
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: pickCase(data, "filePath", "FilePath", "")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEntries(payload: EntrySearchRequestDto): Promise<EntrySearchResultDto[]> {
|
||||||
|
const data = await sendCommand<EntrySearchResultDtoRaw[]>({
|
||||||
|
action: "search.entries",
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.map(normalizeEntrySearchResult).filter((item) => Boolean(item.fileName));
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { sendCommand } from "./client";
|
import { sendCommand } from "./client";
|
||||||
|
import { pickCase } from "./normalize";
|
||||||
|
|
||||||
export type FragmentDto = {
|
export type FragmentDto = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -21,7 +22,7 @@ export type UpdateFragmentPayload = {
|
|||||||
time?: string;
|
time?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FragmentDtoRaw = {
|
export type FragmentDtoRaw = {
|
||||||
id?: string;
|
id?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@ -34,13 +35,13 @@ type FragmentDtoRaw = {
|
|||||||
Tags?: string[];
|
Tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
|
export function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
|
||||||
return {
|
return {
|
||||||
id: raw.id ?? raw.Id ?? "",
|
id: pickCase(raw, "id", "Id", ""),
|
||||||
type: raw.type ?? raw.Type ?? "",
|
type: pickCase(raw, "type", "Type", ""),
|
||||||
description: raw.description ?? raw.Description ?? "",
|
description: pickCase(raw, "description", "Description", ""),
|
||||||
time: raw.time ?? raw.Time ?? "",
|
time: pickCase(raw, "time", "Time", ""),
|
||||||
tags: raw.tags ?? raw.Tags ?? []
|
tags: pickCase(raw, "tags", "Tags", [] as string[])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
Journal.App/src/lib/backend/normalize.ts
Normal file
18
Journal.App/src/lib/backend/normalize.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
type UnknownObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
function asObject(value: unknown): UnknownObject | undefined {
|
||||||
|
return value && typeof value === "object" ? (value as UnknownObject) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCase<T>(
|
||||||
|
source: unknown,
|
||||||
|
camelKey: string,
|
||||||
|
pascalKey: string,
|
||||||
|
fallback: T
|
||||||
|
): T {
|
||||||
|
const obj = asObject(source);
|
||||||
|
if (!obj) return fallback;
|
||||||
|
|
||||||
|
const value = obj[camelKey] ?? obj[pascalKey];
|
||||||
|
return (value as T | undefined) ?? fallback;
|
||||||
|
}
|
||||||
@ -1,4 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
saveEntryFromStore,
|
||||||
|
type EntryItem,
|
||||||
|
} from "$lib/stores/entries";
|
||||||
import {
|
import {
|
||||||
createFragmentFromParsed,
|
createFragmentFromParsed,
|
||||||
deleteFragmentByStoreId,
|
deleteFragmentByStoreId,
|
||||||
@ -52,6 +56,7 @@
|
|||||||
let newTodoText = "";
|
let newTodoText = "";
|
||||||
let editingTodoId: number | null = null;
|
let editingTodoId: number | null = null;
|
||||||
let editingTodoText = "";
|
let editingTodoText = "";
|
||||||
|
let entrySaveBusy = false;
|
||||||
|
|
||||||
function updateDraft(value: string) {
|
function updateDraft(value: string) {
|
||||||
markdownText = value;
|
markdownText = value;
|
||||||
@ -428,6 +433,28 @@
|
|||||||
return headingMatch ? headingMatch[1] : openDocumentName;
|
return headingMatch ? headingMatch[1] : openDocumentName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveEntryDocument() {
|
||||||
|
if (activeSection !== "entries") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
entrySaveBusy = true;
|
||||||
|
const previousId = openDocumentId;
|
||||||
|
const saved: EntryItem | null = await saveEntryFromStore(previousId, markdownText, "Overwrite");
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
|
if (saved.id !== previousId) {
|
||||||
|
onDeleteDocument(previousId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenDocument(saved);
|
||||||
|
onDocumentContentChange(saved.initialContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[editor] entries:save:error", error);
|
||||||
|
} finally {
|
||||||
|
entrySaveBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$: if (openDocumentId !== lastOpenDocumentId) {
|
$: if (openDocumentId !== lastOpenDocumentId) {
|
||||||
markdownText = openDocumentContent;
|
markdownText = openDocumentContent;
|
||||||
lastOpenDocumentId = openDocumentId;
|
lastOpenDocumentId = openDocumentId;
|
||||||
@ -461,6 +488,11 @@
|
|||||||
<h1>{activeSection === "fragments" ? "Create Fragment" : activeSection === "todos" ? "To-Do List" : editorTitle}</h1>
|
<h1>{activeSection === "fragments" ? "Create Fragment" : activeSection === "todos" ? "To-Do List" : editorTitle}</h1>
|
||||||
{#if activeSection !== "fragments" && activeSection !== "todos"}
|
{#if activeSection !== "fragments" && activeSection !== "todos"}
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
|
{#if activeSection === "entries"}
|
||||||
|
<button type="button" on:click={saveEntryDocument} disabled={entrySaveBusy}>
|
||||||
|
{entrySaveBusy ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button type="button" class:primary={!previewOnly} on:click={() => (previewOnly = false)}>Write</button>
|
<button type="button" class:primary={!previewOnly} on:click={() => (previewOnly = false)}>Write</button>
|
||||||
<button type="button" class:primary={previewOnly} on:click={() => (previewOnly = true)}>Preview</button>
|
<button type="button" class:primary={previewOnly} on:click={() => (previewOnly = true)}>Preview</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,28 +1,169 @@
|
|||||||
import { writable } from "svelte/store";
|
import { get, writable } from "svelte/store";
|
||||||
|
import {
|
||||||
|
listEntries as listEntriesCommand,
|
||||||
|
loadEntry as loadEntryCommand,
|
||||||
|
saveEntry as saveEntryCommand,
|
||||||
|
searchEntries as searchEntriesCommand,
|
||||||
|
type EntryListItemDto,
|
||||||
|
type EntrySearchRequestDto
|
||||||
|
} from "$lib/backend/entries";
|
||||||
|
|
||||||
export type EntryItem = {
|
export type EntryItem = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
initialContent: string;
|
initialContent: string;
|
||||||
|
filePath?: string;
|
||||||
|
date?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialEntries: EntryItem[] = [
|
const initialEntries: EntryItem[] = [];
|
||||||
{ id: "entries/daily-notes", label: "Daily Notes", initialContent: "# Daily Notes\n\nStart writing today's entry..." },
|
|
||||||
{ id: "entries/ideas", label: "Ideas", initialContent: "# Ideas\n\nCapture ideas before they disappear." },
|
|
||||||
{ id: "entries/archive", label: "Archive", initialContent: "# Archive\n\nOlder entries and references." }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const entriesStore = writable<EntryItem[]>(initialEntries);
|
export const entriesStore = writable<EntryItem[]>(initialEntries);
|
||||||
|
export const entriesBusyStore = writable(false);
|
||||||
|
|
||||||
|
function toStoreId(filePath: string): string {
|
||||||
|
return `entries/file/${encodeURIComponent(filePath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBackendPath(id: string): string | null {
|
||||||
|
const prefix = "entries/file/";
|
||||||
|
if (!id.startsWith(prefix)) return null;
|
||||||
|
const encoded = id.slice(prefix.length).trim();
|
||||||
|
if (!encoded) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(encoded);
|
||||||
|
return decoded || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLabel(fileName: string): string {
|
||||||
|
const normalized = fileName.trim();
|
||||||
|
if (!normalized) return "Untitled Entry";
|
||||||
|
return normalized.replace(/\.md$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertById(items: EntryItem[], next: EntryItem): EntryItem[] {
|
||||||
|
const idx = items.findIndex((item) => item.id === next.id);
|
||||||
|
if (idx === -1) return [next, ...items];
|
||||||
|
const clone = [...items];
|
||||||
|
clone[idx] = next;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromListDto(dto: EntryListItemDto): EntryItem {
|
||||||
|
return {
|
||||||
|
id: toStoreId(dto.filePath),
|
||||||
|
label: toLabel(dto.fileName),
|
||||||
|
initialContent: "",
|
||||||
|
filePath: dto.filePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromLoadResult(result: Awaited<ReturnType<typeof loadEntryCommand>>): EntryItem {
|
||||||
|
return {
|
||||||
|
id: toStoreId(result.filePath),
|
||||||
|
label: toLabel(result.fileName),
|
||||||
|
initialContent: result.entry.rawContent,
|
||||||
|
filePath: result.filePath,
|
||||||
|
date: result.entry.date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultEntry(items: EntryItem[]): EntryItem | undefined {
|
export function getDefaultEntry(items: EntryItem[]): EntryItem | undefined {
|
||||||
return items.find((entry) => entry.id === "entries/daily-notes") ?? items[0];
|
return items[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEntryDraft(): EntryItem {
|
export function createEntryDraft(): EntryItem {
|
||||||
const id = `entries/entry-${Date.now()}`;
|
const id = `entries/draft-${Date.now()}`;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: "Untitled Entry",
|
label: "Untitled Entry",
|
||||||
initialContent: "# Untitled Entry\n\nStart writing..."
|
initialContent: "# Untitled Entry\n\nStart writing..."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hydrateEntries(dataDirectory?: string): Promise<void> {
|
||||||
|
entriesBusyStore.set(true);
|
||||||
|
try {
|
||||||
|
console.info("[entries] hydrate:start", { dataDirectory });
|
||||||
|
const items = await listEntriesCommand(dataDirectory);
|
||||||
|
const mapped = items.map(fromListDto);
|
||||||
|
console.info("[entries] hydrate:ok", { count: mapped.length });
|
||||||
|
entriesStore.set(mapped);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[entries] hydrate:error", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
entriesBusyStore.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEntryByStoreId(storeId: string): Promise<EntryItem | null> {
|
||||||
|
const filePath = toBackendPath(storeId);
|
||||||
|
if (!filePath) {
|
||||||
|
console.warn("[entries] load:skip_invalid_store_id", { storeId });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.info("[entries] load:start", { storeId, filePath });
|
||||||
|
const loaded = await loadEntryCommand(filePath);
|
||||||
|
const item = fromLoadResult(loaded);
|
||||||
|
entriesStore.update((items) => upsertById(items, item));
|
||||||
|
console.info("[entries] load:ok", { storeId, filePath });
|
||||||
|
return item;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[entries] load:error", { storeId, filePath, error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEntryFromStore(storeId: string, content: string, mode?: string): Promise<EntryItem | null> {
|
||||||
|
const trimmed = content?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
console.warn("[entries] save:skip_empty_content", { storeId });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPath = toBackendPath(storeId);
|
||||||
|
const payload = existingPath ? { content: trimmed, filePath: existingPath, mode } : { content: trimmed, mode };
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.info("[entries] save:start", { storeId, hasExistingPath: Boolean(existingPath), mode });
|
||||||
|
const saved = await saveEntryCommand(payload);
|
||||||
|
const loaded = await loadEntryCommand(saved.filePath);
|
||||||
|
const item = fromLoadResult(loaded);
|
||||||
|
entriesStore.update((items) => upsertById(items, item));
|
||||||
|
console.info("[entries] save:ok", { storeId, filePath: saved.filePath });
|
||||||
|
return item;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[entries] save:error", { storeId, error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Promise<EntryItem[]> {
|
||||||
|
console.info("[entries] search:start", payload);
|
||||||
|
const results = await searchEntriesCommand(payload);
|
||||||
|
const dataDirectory = payload.dataDirectory?.trim() ?? "";
|
||||||
|
const separator = dataDirectory.includes("\\") ? "\\" : "/";
|
||||||
|
const basePath = dataDirectory.replace(/[\\/]+$/, "");
|
||||||
|
const mapped = results.map((result) => ({
|
||||||
|
id: basePath
|
||||||
|
? toStoreId(`${basePath}${separator}${result.fileName}`)
|
||||||
|
: `entries/search/${encodeURIComponent(result.fileName)}`,
|
||||||
|
label: toLabel(result.fileName),
|
||||||
|
initialContent: result.entry.rawContent,
|
||||||
|
filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined,
|
||||||
|
date: result.entry.date
|
||||||
|
}));
|
||||||
|
console.info("[entries] search:ok", { count: mapped.length });
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasEntry(storeId: string): boolean {
|
||||||
|
return get(entriesStore).some((item) => item.id === storeId);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { hydrateWorkspace } from "$lib/backend/auth";
|
import { persistAndClearVault, unlockVaultWorkspace } from "$lib/backend/auth";
|
||||||
import AppModal from "$lib/components/AppModal.svelte";
|
import AppModal from "$lib/components/AppModal.svelte";
|
||||||
import { entriesStore, getDefaultEntry } from "$lib/stores/entries";
|
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId } from "$lib/stores/entries";
|
||||||
import { hydrateFragments } from "$lib/stores/fragments";
|
import { hydrateFragments } from "$lib/stores/fragments";
|
||||||
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";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
@ -40,6 +42,8 @@
|
|||||||
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 vaultPassword: string | null = null;
|
||||||
|
let closeInProgress = false;
|
||||||
|
|
||||||
function showModal(options: {
|
function showModal(options: {
|
||||||
action: "logout-confirm" | "logout-info" | "unlock-vault";
|
action: "logout-confirm" | "logout-info" | "unlock-vault";
|
||||||
@ -122,7 +126,7 @@
|
|||||||
showModal({
|
showModal({
|
||||||
action: "unlock-vault",
|
action: "unlock-vault",
|
||||||
title: "Unlock Vault",
|
title: "Unlock Vault",
|
||||||
message: "Enter your vault password to load fragments.",
|
message: "Enter your vault password to load journal data.",
|
||||||
confirmText: "Unlock",
|
confirmText: "Unlock",
|
||||||
cancelText: "Cancel",
|
cancelText: "Cancel",
|
||||||
showCancel: true,
|
showCancel: true,
|
||||||
@ -137,7 +141,8 @@
|
|||||||
|
|
||||||
function isLockedError(error: unknown): boolean {
|
function isLockedError(error: unknown): boolean {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return message.toLowerCase().includes("database is locked");
|
const normalized = message.toLowerCase();
|
||||||
|
return normalized.includes("database is locked") || normalized.includes("incorrect vault password");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
||||||
@ -148,29 +153,31 @@
|
|||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
try {
|
try {
|
||||||
|
const password = await requestVaultPassword();
|
||||||
|
if (!password) {
|
||||||
|
console.warn("Vault unlock canceled. Journal data remains unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await unlockVaultWorkspace(password);
|
||||||
|
vaultPassword = password;
|
||||||
|
|
||||||
|
await hydrateEntries();
|
||||||
|
const firstEntry = getDefaultEntry(get(entriesStore));
|
||||||
|
if (firstEntry && activeDocumentId === "entries/daily-notes") {
|
||||||
|
await handleOpenDocument(firstEntry);
|
||||||
|
}
|
||||||
|
|
||||||
await hydrateFragments();
|
await hydrateFragments();
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isLockedError(error)) {
|
if (!isLockedError(error)) {
|
||||||
console.error("Failed to load fragments from sidecar:", error);
|
console.error("Failed to load journal data from sidecar:", error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
const password = await requestVaultPassword();
|
console.error("Vault unlock failed:", error);
|
||||||
if (!password) {
|
|
||||||
console.warn("Vault unlock canceled. Fragments remain unavailable.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await hydrateWorkspace(password);
|
|
||||||
} catch (unlockError) {
|
|
||||||
console.error("Vault unlock failed:", unlockError);
|
|
||||||
if (!isLockedError(unlockError)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,6 +187,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flushVaultOnExit(): Promise<void> {
|
||||||
|
if (!vaultPassword) {
|
||||||
|
console.warn("Skipping vault persistence on exit because session password is unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await persistAndClearVault(vaultPassword);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Vault persistence on exit failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleSelect(id: string) {
|
function handleSelect(id: string) {
|
||||||
if (id === "account" || id === "settings") {
|
if (id === "account" || id === "settings") {
|
||||||
goto(`/${id}`);
|
goto(`/${id}`);
|
||||||
@ -208,12 +228,24 @@
|
|||||||
panelOpen = true;
|
panelOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenDocument(doc: OpenDocument) {
|
async function handleOpenDocument(doc: OpenDocument) {
|
||||||
if (!(doc.id in openDocuments)) {
|
let resolvedDoc = doc;
|
||||||
openDocuments = { ...openDocuments, [doc.id]: doc.initialContent };
|
if (selectedSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
|
||||||
|
try {
|
||||||
|
const loaded = await loadEntryByStoreId(doc.id);
|
||||||
|
if (loaded) {
|
||||||
|
resolvedDoc = loaded;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load entry content:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
activeDocumentId = doc.id;
|
|
||||||
activeDocumentLabel = doc.label;
|
if (!(resolvedDoc.id in openDocuments)) {
|
||||||
|
openDocuments = { ...openDocuments, [resolvedDoc.id]: resolvedDoc.initialContent };
|
||||||
|
}
|
||||||
|
activeDocumentId = resolvedDoc.id;
|
||||||
|
activeDocumentLabel = resolvedDoc.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDocumentContentChange(content: string) {
|
function handleDocumentContentChange(content: string) {
|
||||||
@ -226,7 +258,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
|
||||||
|
let unlistenPromise = appWindow.onCloseRequested(async (event) => {
|
||||||
|
if (closeInProgress) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
closeInProgress = true;
|
||||||
|
await flushVaultOnExit();
|
||||||
|
await invoke("shutdown");
|
||||||
|
});
|
||||||
|
|
||||||
bootstrapFragmentsWithUnlock();
|
bootstrapFragmentsWithUnlock();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
void unlistenPromise.then((unlisten) => unlisten());
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ 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);
|
||||||
public sealed record EntryListItem(string FileName, string FilePath);
|
public sealed record EntryListItem(string FileName, string FilePath);
|
||||||
public sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent);
|
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 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);
|
||||||
|
|||||||
@ -12,6 +12,5 @@ public sealed record EntrySearchRequestDto(
|
|||||||
IReadOnlyList<string>? Unchecked = null);
|
IReadOnlyList<string>? Unchecked = null);
|
||||||
|
|
||||||
public sealed record EntrySearchResultDto(
|
public sealed record EntrySearchResultDto(
|
||||||
string Date,
|
|
||||||
string FileName,
|
string FileName,
|
||||||
string RawContent);
|
JournalEntryDto Entry);
|
||||||
|
|||||||
12
Journal.Core/Dtos/JournalEntryDtos.cs
Normal file
12
Journal.Core/Dtos/JournalEntryDtos.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace Journal.Core.Dtos;
|
||||||
|
|
||||||
|
public sealed record ParsedSectionDto(
|
||||||
|
string Title,
|
||||||
|
IReadOnlyList<string> Content,
|
||||||
|
IReadOnlyDictionary<string, bool> Checkboxes);
|
||||||
|
|
||||||
|
public sealed record JournalEntryDto(
|
||||||
|
string Date,
|
||||||
|
IReadOnlyList<FragmentDto> Fragments,
|
||||||
|
string RawContent,
|
||||||
|
IReadOnlyDictionary<string, ParsedSectionDto> Sections);
|
||||||
@ -234,6 +234,8 @@ public class Entry(
|
|||||||
var clearPayload = DeserializePayload<ClearDataPayload>(cmd.Payload);
|
var clearPayload = DeserializePayload<ClearDataPayload>(cmd.Payload);
|
||||||
if (clearPayload is null || string.IsNullOrWhiteSpace(clearPayload.DataDirectory))
|
if (clearPayload is null || string.IsNullOrWhiteSpace(clearPayload.DataDirectory))
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
|
if (_databaseSession is IDisposable disposableSession)
|
||||||
|
disposableSession.Dispose();
|
||||||
_vaultStorage.ClearDataDirectory(clearPayload.DataDirectory);
|
_vaultStorage.ClearDataDirectory(clearPayload.DataDirectory);
|
||||||
result = true;
|
result = true;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -26,10 +26,9 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
|
|||||||
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
||||||
|
|
||||||
return new EntryLoadResult(
|
return new EntryLoadResult(
|
||||||
Date: entry.Date,
|
|
||||||
FileName: _repo.GetFileName(normalizedPath),
|
FileName: _repo.GetFileName(normalizedPath),
|
||||||
FilePath: normalizedPath,
|
FilePath: normalizedPath,
|
||||||
RawContent: entry.RawContent);
|
Entry: entry.ToDto());
|
||||||
}
|
}
|
||||||
|
|
||||||
public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
|
public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
|
||||||
|
|||||||
@ -73,7 +73,7 @@ public class EntrySearchService : IEntrySearchService
|
|||||||
if (!checkboxMatch)
|
if (!checkboxMatch)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
results.Add(new EntrySearchResultDto(entry.Date, fileName, entry.RawContent));
|
results.Add(new EntrySearchResultDto(fileName, entry.ToDto()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult<IReadOnlyList<EntrySearchResultDto>>(results);
|
return Task.FromResult<IReadOnlyList<EntrySearchResultDto>>(results);
|
||||||
|
|||||||
32
Journal.Core/Services/Entries/JournalEntryDtoMapper.cs
Normal file
32
Journal.Core/Services/Entries/JournalEntryDtoMapper.cs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
using Journal.Core.Dtos;
|
||||||
|
using Journal.Core.Models;
|
||||||
|
|
||||||
|
namespace Journal.Core.Services.Entries;
|
||||||
|
|
||||||
|
internal static class JournalEntryDtoMapper
|
||||||
|
{
|
||||||
|
public static JournalEntryDto ToDto(this JournalEntry entry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entry);
|
||||||
|
|
||||||
|
return new JournalEntryDto(
|
||||||
|
Date: entry.Date,
|
||||||
|
Fragments:
|
||||||
|
[
|
||||||
|
.. entry.Fragments.Select(fragment => new FragmentDto(
|
||||||
|
Id: fragment.Id,
|
||||||
|
Type: fragment.Type,
|
||||||
|
Description: fragment.Description,
|
||||||
|
Time: fragment.Time,
|
||||||
|
Tags: [.. fragment.Tags]))
|
||||||
|
],
|
||||||
|
RawContent: entry.RawContent,
|
||||||
|
Sections: entry.Sections.ToDictionary(
|
||||||
|
section => section.Key,
|
||||||
|
section => new ParsedSectionDto(
|
||||||
|
Title: section.Value.Title,
|
||||||
|
Content: [.. section.Value.Content],
|
||||||
|
Checkboxes: section.Value.Checkboxes.ToDictionary(checkbox => checkbox.Key, checkbox => checkbox.Value)),
|
||||||
|
StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -146,8 +146,8 @@ public sealed class SidecarCli(IVaultStorageService vaultStorage, IEntrySearchSe
|
|||||||
|
|
||||||
foreach (var result in results)
|
foreach (var result in results)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"--- {result.Date} ---");
|
Console.WriteLine($"--- {result.Entry.Date} ---");
|
||||||
Console.WriteLine(result.RawContent);
|
Console.WriteLine(result.Entry.RawContent);
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -953,8 +953,9 @@ hello world
|
|||||||
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.load.");
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.load.");
|
||||||
|
|
||||||
var data = doc.RootElement.GetProperty("data");
|
var data = doc.RootElement.GetProperty("data");
|
||||||
Assert(data.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date from entries.load.");
|
var entryDto = data.GetProperty("Entry");
|
||||||
Assert(data.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load.");
|
Assert(entryDto.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date from entries.load.");
|
||||||
|
Assert(entryDto.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load.");
|
||||||
Assert(data.GetProperty("FileName").GetString() == "2026-02-22.md", "Expected file name from entries.load.");
|
Assert(data.GetProperty("FileName").GetString() == "2026-02-22.md", "Expected file name from entries.load.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user