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> {
|
||||
let sidecar_path = resolve_sidecar_path()?;
|
||||
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)
|
||||
.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]
|
||||
fn sidecar_command(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
@ -225,7 +236,7 @@ pub fn run() {
|
||||
let app = tauri::Builder::default()
|
||||
.manage(SidecarState::default())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![sidecar_command])
|
||||
.invoke_handler(tauri::generate_handler![sidecar_command, shutdown])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { sendCommand } from "./client";
|
||||
import { pickCase } from "./normalize";
|
||||
|
||||
export function hydrateWorkspace(password: string): Promise<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 { pickCase } from "./normalize";
|
||||
|
||||
export type FragmentDto = {
|
||||
id: string;
|
||||
@ -21,7 +22,7 @@ export type UpdateFragmentPayload = {
|
||||
time?: string;
|
||||
};
|
||||
|
||||
type FragmentDtoRaw = {
|
||||
export type FragmentDtoRaw = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
@ -34,13 +35,13 @@ type FragmentDtoRaw = {
|
||||
Tags?: string[];
|
||||
};
|
||||
|
||||
function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
|
||||
export function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
|
||||
return {
|
||||
id: raw.id ?? raw.Id ?? "",
|
||||
type: raw.type ?? raw.Type ?? "",
|
||||
description: raw.description ?? raw.Description ?? "",
|
||||
time: raw.time ?? raw.Time ?? "",
|
||||
tags: raw.tags ?? raw.Tags ?? []
|
||||
id: pickCase(raw, "id", "Id", ""),
|
||||
type: pickCase(raw, "type", "Type", ""),
|
||||
description: pickCase(raw, "description", "Description", ""),
|
||||
time: pickCase(raw, "time", "Time", ""),
|
||||
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">
|
||||
import {
|
||||
saveEntryFromStore,
|
||||
type EntryItem,
|
||||
} from "$lib/stores/entries";
|
||||
import {
|
||||
createFragmentFromParsed,
|
||||
deleteFragmentByStoreId,
|
||||
@ -52,6 +56,7 @@
|
||||
let newTodoText = "";
|
||||
let editingTodoId: number | null = null;
|
||||
let editingTodoText = "";
|
||||
let entrySaveBusy = false;
|
||||
|
||||
function updateDraft(value: string) {
|
||||
markdownText = value;
|
||||
@ -428,6 +433,28 @@
|
||||
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) {
|
||||
markdownText = openDocumentContent;
|
||||
lastOpenDocumentId = openDocumentId;
|
||||
@ -461,6 +488,11 @@
|
||||
<h1>{activeSection === "fragments" ? "Create Fragment" : activeSection === "todos" ? "To-Do List" : editorTitle}</h1>
|
||||
{#if activeSection !== "fragments" && activeSection !== "todos"}
|
||||
<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 = true)}>Preview</button>
|
||||
</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 = {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
filePath?: string;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
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." }
|
||||
];
|
||||
const initialEntries: EntryItem[] = [];
|
||||
|
||||
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 {
|
||||
return items.find((entry) => entry.id === "entries/daily-notes") ?? items[0];
|
||||
return items[0];
|
||||
}
|
||||
|
||||
export function createEntryDraft(): EntryItem {
|
||||
const id = `entries/entry-${Date.now()}`;
|
||||
const id = `entries/draft-${Date.now()}`;
|
||||
return {
|
||||
id,
|
||||
label: "Untitled Entry",
|
||||
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">
|
||||
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 { entriesStore, getDefaultEntry } from "$lib/stores/entries";
|
||||
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId } from "$lib/stores/entries";
|
||||
import { hydrateFragments } from "$lib/stores/fragments";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import SidePanel from "$lib/components/SidePanel.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 { get } from "svelte/store";
|
||||
|
||||
@ -40,6 +42,8 @@
|
||||
let modalInputValue = "";
|
||||
let unlockResolver: ((password: string | null) => void) | null = null;
|
||||
let fragmentBootstrapInFlight = false;
|
||||
let vaultPassword: string | null = null;
|
||||
let closeInProgress = false;
|
||||
|
||||
function showModal(options: {
|
||||
action: "logout-confirm" | "logout-info" | "unlock-vault";
|
||||
@ -122,7 +126,7 @@
|
||||
showModal({
|
||||
action: "unlock-vault",
|
||||
title: "Unlock Vault",
|
||||
message: "Enter your vault password to load fragments.",
|
||||
message: "Enter your vault password to load journal data.",
|
||||
confirmText: "Unlock",
|
||||
cancelText: "Cancel",
|
||||
showCancel: true,
|
||||
@ -137,7 +141,8 @@
|
||||
|
||||
function isLockedError(error: unknown): boolean {
|
||||
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) {
|
||||
@ -148,29 +153,31 @@
|
||||
let attempts = 0;
|
||||
while (attempts < maxAttempts) {
|
||||
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();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isLockedError(error)) {
|
||||
console.error("Failed to load fragments from sidecar:", error);
|
||||
console.error("Failed to load journal data from sidecar:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
const password = await requestVaultPassword();
|
||||
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;
|
||||
}
|
||||
}
|
||||
console.error("Vault unlock failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
if (id === "account" || id === "settings") {
|
||||
goto(`/${id}`);
|
||||
@ -208,12 +228,24 @@
|
||||
panelOpen = true;
|
||||
}
|
||||
|
||||
function handleOpenDocument(doc: OpenDocument) {
|
||||
if (!(doc.id in openDocuments)) {
|
||||
openDocuments = { ...openDocuments, [doc.id]: doc.initialContent };
|
||||
async function handleOpenDocument(doc: OpenDocument) {
|
||||
let resolvedDoc = doc;
|
||||
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) {
|
||||
@ -226,7 +258,22 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
let unlistenPromise = appWindow.onCloseRequested(async (event) => {
|
||||
if (closeInProgress) return;
|
||||
|
||||
event.preventDefault();
|
||||
closeInProgress = true;
|
||||
await flushVaultOnExit();
|
||||
await invoke("shutdown");
|
||||
});
|
||||
|
||||
bootstrapFragmentsWithUnlock();
|
||||
|
||||
return () => {
|
||||
void unlistenPromise.then((unlisten) => unlisten());
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ internal sealed record EntryListPayload(string? DataDirectory = null);
|
||||
internal sealed record EntryLoadPayload(string FilePath);
|
||||
public sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null);
|
||||
public sealed record 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);
|
||||
internal sealed record DatabasePayload(string Password, string? DataDirectory = null);
|
||||
internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
|
||||
|
||||
@ -12,6 +12,5 @@ public sealed record EntrySearchRequestDto(
|
||||
IReadOnlyList<string>? Unchecked = null);
|
||||
|
||||
public sealed record EntrySearchResultDto(
|
||||
string Date,
|
||||
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);
|
||||
if (clearPayload is null || string.IsNullOrWhiteSpace(clearPayload.DataDirectory))
|
||||
return Error("Missing or invalid payload");
|
||||
if (_databaseSession is IDisposable disposableSession)
|
||||
disposableSession.Dispose();
|
||||
_vaultStorage.ClearDataDirectory(clearPayload.DataDirectory);
|
||||
result = true;
|
||||
break;
|
||||
|
||||
@ -26,10 +26,9 @@ public sealed class EntryFileService(IEntryFileRepository repo) : IEntryFileServ
|
||||
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
||||
|
||||
return new EntryLoadResult(
|
||||
Date: entry.Date,
|
||||
FileName: _repo.GetFileName(normalizedPath),
|
||||
FilePath: normalizedPath,
|
||||
RawContent: entry.RawContent);
|
||||
Entry: entry.ToDto());
|
||||
}
|
||||
|
||||
public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
|
||||
|
||||
@ -73,7 +73,7 @@ public class EntrySearchService : IEntrySearchService
|
||||
if (!checkboxMatch)
|
||||
continue;
|
||||
|
||||
results.Add(new EntrySearchResultDto(entry.Date, fileName, entry.RawContent));
|
||||
results.Add(new EntrySearchResultDto(fileName, entry.ToDto()));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
Console.WriteLine($"--- {result.Date} ---");
|
||||
Console.WriteLine(result.RawContent);
|
||||
Console.WriteLine($"--- {result.Entry.Date} ---");
|
||||
Console.WriteLine(result.Entry.RawContent);
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
|
||||
@ -953,8 +953,9 @@ hello world
|
||||
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.load.");
|
||||
|
||||
var data = doc.RootElement.GetProperty("data");
|
||||
Assert(data.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date from entries.load.");
|
||||
Assert(data.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load.");
|
||||
var entryDto = data.GetProperty("Entry");
|
||||
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.");
|
||||
}
|
||||
finally
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user