fix: sidecar project root resolution and Tauri frontend wiring
- Set JOURNAL_PROJECT_ROOT and CWD when spawning sidecar so data lands at repo root instead of under src-tauri/ (fixes dev watcher hot-reload loop triggered by schema file writes) - Add backend client layer (auth.ts, client.ts, fragments.ts, types.ts) - Wire vault unlock flow with password prompt modal on locked DB - Update fragments store with full sidecar CRUD integration - Update EditorPanel and AppModal for fragment editing support - Gitignore runtime journal/ and logs/ directories Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
parent
54bef33f0b
commit
e2bfa0e6ff
4
.gitignore
vendored
4
.gitignore
vendored
@ -35,5 +35,9 @@ secrets.json
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Runtime journal data (created by sidecar at repo root)
|
||||
journal/
|
||||
logs/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@ -1,14 +1,238 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CommandEnvelope {
|
||||
action: String,
|
||||
#[serde(default)]
|
||||
correlation_id: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
r#type: Option<String>,
|
||||
#[serde(default)]
|
||||
tag: Option<String>,
|
||||
#[serde(default)]
|
||||
payload: Option<Value>,
|
||||
}
|
||||
|
||||
struct ManagedSidecar {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
let mut child = Command::new(sidecar_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.current_dir(&root)
|
||||
.env("JOURNAL_PROJECT_ROOT", &root)
|
||||
.spawn()
|
||||
.map_err(|err| format!("Failed to start sidecar process: {err}"))?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Unable to open sidecar stdin.".to_string())?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| "Unable to open sidecar stdout.".to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout: BufReader::new(stdout),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_running(&mut self) -> bool {
|
||||
match self.child.try_wait() {
|
||||
Ok(None) => true,
|
||||
Ok(Some(status)) => {
|
||||
eprintln!("[sidecar] exited status={status}");
|
||||
false
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[sidecar] try_wait_error={err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_command_line(&mut self, input_line: &str) -> Result<String, String> {
|
||||
self.stdin
|
||||
.write_all(format!("{input_line}\n").as_bytes())
|
||||
.map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?;
|
||||
self.stdin
|
||||
.flush()
|
||||
.map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?;
|
||||
|
||||
let mut response_line = String::new();
|
||||
let read = self
|
||||
.stdout
|
||||
.read_line(&mut response_line)
|
||||
.map_err(|err| format!("Failed reading sidecar stdout: {err}"))?;
|
||||
if read == 0 {
|
||||
return Err("Sidecar stdout closed unexpectedly.".to_string());
|
||||
}
|
||||
|
||||
let trimmed = response_line.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Sidecar returned an empty response line.".to_string());
|
||||
}
|
||||
|
||||
Ok(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedSidecar {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(None) = self.child.try_wait() {
|
||||
if let Err(err) = self.child.kill() {
|
||||
eprintln!("[sidecar] kill_on_drop_error={err}");
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self.child.wait() {
|
||||
eprintln!("[sidecar] wait_on_drop_error={err}");
|
||||
} else {
|
||||
eprintln!("[sidecar] stopped_on_drop");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SidecarState {
|
||||
process: Mutex<Option<ManagedSidecar>>,
|
||||
}
|
||||
|
||||
fn project_root() -> Result<PathBuf, String> {
|
||||
let mut current =
|
||||
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
|
||||
|
||||
loop {
|
||||
if current.join("Journal.Sidecar").exists() {
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
if !current.pop() {
|
||||
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_sidecar_path() -> Result<PathBuf, String> {
|
||||
let root = project_root()?;
|
||||
let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe");
|
||||
if debug_path.exists() {
|
||||
return Ok(debug_path);
|
||||
}
|
||||
|
||||
let release_path =
|
||||
root.join("Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe");
|
||||
if release_path.exists() {
|
||||
return Ok(release_path);
|
||||
}
|
||||
|
||||
Err("Journal.Sidecar.exe not found. Build Journal.Sidecar first.".to_string())
|
||||
}
|
||||
|
||||
fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result<String, String> {
|
||||
let mut guard = state
|
||||
.process
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock sidecar state.".to_string())?;
|
||||
|
||||
for attempt in 1..=2 {
|
||||
let should_start = match guard.as_mut() {
|
||||
Some(existing) => !existing.is_running(),
|
||||
None => true,
|
||||
};
|
||||
if should_start {
|
||||
*guard = Some(ManagedSidecar::start()?);
|
||||
}
|
||||
|
||||
let Some(process) = guard.as_mut() else {
|
||||
return Err("Sidecar process unavailable.".to_string());
|
||||
};
|
||||
|
||||
match process.send_command_line(input_line) {
|
||||
Ok(line) => return Ok(line),
|
||||
Err(err) => {
|
||||
eprintln!("[sidecar] send_error attempt={attempt} error={err}");
|
||||
*guard = None;
|
||||
if attempt == 2 {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Failed to send command to sidecar.".to_string())
|
||||
}
|
||||
|
||||
fn stop_managed_sidecar(state: &SidecarState) {
|
||||
let Ok(mut guard) = state.process.lock() else {
|
||||
eprintln!("[sidecar] stop_error=failed_to_lock_state");
|
||||
return;
|
||||
};
|
||||
|
||||
if guard.take().is_some() {
|
||||
eprintln!("[sidecar] stop_requested");
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
fn sidecar_command(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
command: CommandEnvelope,
|
||||
) -> Result<Value, String> {
|
||||
if command.action.trim().is_empty() {
|
||||
return Err("Missing action".to_string());
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[sidecar_command] action={} correlationId={:?} id={:?}",
|
||||
command.action, command.correlation_id, command.id
|
||||
);
|
||||
|
||||
let input_line = serde_json::to_string(&command)
|
||||
.map_err(|err| format!("Serialize command failed: {err}"))?;
|
||||
let response_line = send_with_managed_sidecar(state.inner(), &input_line)?;
|
||||
|
||||
eprintln!("[sidecar_command] response={response_line}");
|
||||
serde_json::from_str::<Value>(&response_line)
|
||||
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
let app = tauri::Builder::default()
|
||||
.manage(SidecarState::default())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.invoke_handler(tauri::generate_handler![sidecar_command])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
let state = app_handle.state::<SidecarState>();
|
||||
stop_managed_sidecar(state.inner());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
9
Journal.App/src/lib/backend/auth.ts
Normal file
9
Journal.App/src/lib/backend/auth.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { sendCommand } from "./client";
|
||||
|
||||
export function hydrateWorkspace(password: string): Promise<unknown> {
|
||||
return sendCommand<unknown>({
|
||||
action: "db.hydrate_workspace",
|
||||
payload: { password }
|
||||
});
|
||||
}
|
||||
|
||||
36
Journal.App/src/lib/backend/client.ts
Normal file
36
Journal.App/src/lib/backend/client.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { BackendCommand, BackendResponse } from "./types";
|
||||
|
||||
function newCorrelationId(): string {
|
||||
return `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export async function sendCommand<T>(command: BackendCommand): Promise<T> {
|
||||
const envelope: BackendCommand = {
|
||||
...command,
|
||||
correlationId: command.correlationId ?? newCorrelationId()
|
||||
};
|
||||
console.info("[backend] send", {
|
||||
action: envelope.action,
|
||||
correlationId: envelope.correlationId,
|
||||
id: envelope.id
|
||||
});
|
||||
const response = await invoke<BackendResponse<T>>("sidecar_command", { command: envelope });
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("[backend] error", {
|
||||
action: envelope.action,
|
||||
correlationId: envelope.correlationId,
|
||||
id: envelope.id,
|
||||
error: response.error
|
||||
});
|
||||
throw new Error(response.error || "Backend command failed");
|
||||
}
|
||||
|
||||
console.info("[backend] ok", {
|
||||
action: envelope.action,
|
||||
correlationId: envelope.correlationId,
|
||||
id: envelope.id
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
85
Journal.App/src/lib/backend/fragments.ts
Normal file
85
Journal.App/src/lib/backend/fragments.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { sendCommand } from "./client";
|
||||
|
||||
export type FragmentDto = {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
time: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type CreateFragmentPayload = {
|
||||
type: string;
|
||||
description: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type UpdateFragmentPayload = {
|
||||
type?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
time?: string;
|
||||
};
|
||||
|
||||
type FragmentDtoRaw = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
time?: string;
|
||||
tags?: string[];
|
||||
Id?: string;
|
||||
Type?: string;
|
||||
Description?: string;
|
||||
Time?: string;
|
||||
Tags?: string[];
|
||||
};
|
||||
|
||||
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 ?? []
|
||||
};
|
||||
}
|
||||
|
||||
export async function listFragments(): Promise<FragmentDto[]> {
|
||||
const data = await sendCommand<FragmentDtoRaw[]>({
|
||||
action: "fragments.list"
|
||||
});
|
||||
return data.map(normalizeFragment).filter((item) => Boolean(item.id));
|
||||
}
|
||||
|
||||
export async function getFragment(id: string): Promise<FragmentDto | null> {
|
||||
const data = await sendCommand<FragmentDtoRaw | null>({
|
||||
action: "fragments.get",
|
||||
id
|
||||
});
|
||||
if (!data) return null;
|
||||
const normalized = normalizeFragment(data);
|
||||
return normalized.id ? normalized : null;
|
||||
}
|
||||
|
||||
export async function createFragment(payload: CreateFragmentPayload): Promise<FragmentDto> {
|
||||
const data = await sendCommand<FragmentDtoRaw>({
|
||||
action: "fragments.create",
|
||||
payload
|
||||
});
|
||||
return normalizeFragment(data);
|
||||
}
|
||||
|
||||
export function updateFragment(id: string, payload: UpdateFragmentPayload): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "fragments.update",
|
||||
id,
|
||||
payload
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteFragment(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "fragments.delete",
|
||||
id
|
||||
});
|
||||
}
|
||||
13
Journal.App/src/lib/backend/types.ts
Normal file
13
Journal.App/src/lib/backend/types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type BackendCommand = {
|
||||
action: string;
|
||||
correlationId?: string;
|
||||
id?: string;
|
||||
type?: string;
|
||||
tag?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export type BackendOk<T> = { ok: true; data: T };
|
||||
export type BackendErr = { ok: false; error: string };
|
||||
export type BackendResponse<T> = BackendOk<T> | BackendErr;
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let open = false;
|
||||
export let title = "";
|
||||
export let message = "";
|
||||
@ -6,9 +8,16 @@
|
||||
export let cancelText = "Cancel";
|
||||
export let showCancel = false;
|
||||
export let tone: "default" | "danger" = "default";
|
||||
export let inputEnabled = false;
|
||||
export let inputType = "text";
|
||||
export let inputPlaceholder = "";
|
||||
export let inputAriaLabel = "Modal input";
|
||||
export let inputValue = "";
|
||||
export let onConfirm: () => void = () => {};
|
||||
export let onCancel: () => void = () => {};
|
||||
|
||||
const dispatch = createEventDispatcher<{ input: string }>();
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm();
|
||||
}
|
||||
@ -22,6 +31,15 @@
|
||||
if (event.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
handleConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
inputValue = target.value;
|
||||
dispatch("input", inputValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -35,6 +53,17 @@
|
||||
</header>
|
||||
|
||||
<p class="modal-message">{message}</p>
|
||||
{#if inputEnabled}
|
||||
<input
|
||||
class="modal-input"
|
||||
type={inputType}
|
||||
value={inputValue}
|
||||
placeholder={inputPlaceholder}
|
||||
aria-label={inputAriaLabel}
|
||||
on:input={handleInput}
|
||||
autofocus
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
{#if showCancel}
|
||||
@ -92,6 +121,20 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.86rem;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.modal-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
createFragmentItem,
|
||||
createFragmentFromParsed,
|
||||
deleteFragmentByStoreId,
|
||||
fragmentsStore,
|
||||
hasFragment,
|
||||
parseFragmentContent,
|
||||
prependFragmentItem,
|
||||
removeFragmentItem,
|
||||
serializeFragment,
|
||||
updateFragmentItem,
|
||||
updateFragmentFromParsed,
|
||||
type FragmentItem
|
||||
} from "$lib/stores/fragments";
|
||||
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
|
||||
@ -103,7 +103,7 @@
|
||||
applyWrap("[", "](https://example.com)");
|
||||
}
|
||||
|
||||
function buildFragmentContent(): { title: string; resolvedType: string; tagsLine: string; body: string; content: string } | null {
|
||||
function buildFragmentContent(): { title: string; resolvedType: string; body: string; content: string; tags: string[] } | null {
|
||||
const title = fragmentTitle.trim();
|
||||
if (!title) return null;
|
||||
const resolvedType = fragmentType === customTypeValue ? customFragmentType.trim() : fragmentType;
|
||||
@ -117,7 +117,6 @@
|
||||
const uniqueTagList = [...new Set(tagList.map((tag) => tag.toLowerCase()))].map((lower) => {
|
||||
return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
|
||||
});
|
||||
const tagsLine = uniqueTagList.length ? uniqueTagList.map((tag) => `#${tag}`).join(" ") : "(none)";
|
||||
const body = fragmentBody.trim() || "Add details for this fragment.";
|
||||
const content = serializeFragment({
|
||||
title,
|
||||
@ -125,54 +124,77 @@
|
||||
tags: uniqueTagList,
|
||||
body
|
||||
});
|
||||
return { title, resolvedType, tagsLine, body, content };
|
||||
return { title, resolvedType, body, content, tags: uniqueTagList };
|
||||
}
|
||||
|
||||
function saveFragmentEdits() {
|
||||
if (activeSection !== "fragments") return;
|
||||
const payload = buildFragmentContent();
|
||||
if (!payload) return;
|
||||
const fragments = get(fragmentsStore);
|
||||
const exists = fragments.some((item) => item.id === openDocumentId);
|
||||
if (!exists) {
|
||||
createNewFragment();
|
||||
return;
|
||||
}
|
||||
async function saveFragmentEdits() {
|
||||
try {
|
||||
if (activeSection !== "fragments") return;
|
||||
const payload = buildFragmentContent();
|
||||
if (!payload) return;
|
||||
const exists = hasFragment(openDocumentId);
|
||||
console.info("[editor] fragment:save", { openDocumentId, exists, title: payload.title });
|
||||
if (!exists) {
|
||||
await createNewFragment();
|
||||
return;
|
||||
}
|
||||
|
||||
fragmentsStore.set(updateFragmentItem(fragments, openDocumentId, payload.title, payload.content));
|
||||
markdownText = payload.content;
|
||||
onDocumentContentChange(payload.content);
|
||||
fragmentMode = "view";
|
||||
}
|
||||
const updated = await updateFragmentFromParsed(openDocumentId, {
|
||||
title: payload.title,
|
||||
type: payload.resolvedType,
|
||||
tags: payload.tags,
|
||||
body: payload.body
|
||||
});
|
||||
if (!updated) return;
|
||||
|
||||
function createNewFragment() {
|
||||
const payload = buildFragmentContent();
|
||||
if (!payload) return;
|
||||
const item: FragmentItem = createFragmentItem(payload.title, payload.content);
|
||||
fragmentsStore.update((items) => prependFragmentItem(items, item));
|
||||
onOpenDocument(item);
|
||||
fragmentMode = "view";
|
||||
}
|
||||
|
||||
function deleteCurrentFragment() {
|
||||
if (activeSection !== "fragments") return;
|
||||
const fragments = get(fragmentsStore);
|
||||
const exists = fragments.some((item) => item.id === openDocumentId);
|
||||
if (!exists) return;
|
||||
const remaining = removeFragmentItem(fragments, openDocumentId);
|
||||
fragmentsStore.set(remaining);
|
||||
onDeleteDocument(openDocumentId);
|
||||
if (remaining.length > 0) {
|
||||
onOpenDocument(remaining[0]);
|
||||
markdownText = payload.content;
|
||||
onDocumentContentChange(payload.content);
|
||||
fragmentMode = "view";
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error("[editor] fragment:save:error", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewFragment() {
|
||||
try {
|
||||
const payload = buildFragmentContent();
|
||||
if (!payload) return;
|
||||
console.info("[editor] fragment:create", { title: payload.title, type: payload.resolvedType });
|
||||
const item: FragmentItem = await createFragmentFromParsed({
|
||||
title: payload.title,
|
||||
type: payload.resolvedType,
|
||||
tags: payload.tags,
|
||||
body: payload.body
|
||||
});
|
||||
onOpenDocument(item);
|
||||
fragmentMode = "view";
|
||||
} catch (error) {
|
||||
console.error("[editor] fragment:create:error", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentFragment() {
|
||||
try {
|
||||
if (activeSection !== "fragments") return;
|
||||
console.info("[editor] fragment:delete", { openDocumentId });
|
||||
const ok = await deleteFragmentByStoreId(openDocumentId);
|
||||
if (!ok) return;
|
||||
const remaining = get(fragmentsStore);
|
||||
onDeleteDocument(openDocumentId);
|
||||
if (remaining.length > 0) {
|
||||
onOpenDocument(remaining[0]);
|
||||
fragmentMode = "view";
|
||||
return;
|
||||
}
|
||||
fragmentTitle = "";
|
||||
customFragmentType = "";
|
||||
fragmentTag = customTagValue;
|
||||
customFragmentTags = "";
|
||||
fragmentBody = "";
|
||||
fragmentMode = "create";
|
||||
} catch (error) {
|
||||
console.error("[editor] fragment:delete:error", error);
|
||||
}
|
||||
fragmentTitle = "";
|
||||
customFragmentType = "";
|
||||
fragmentTag = customTagValue;
|
||||
customFragmentTags = "";
|
||||
fragmentBody = "";
|
||||
fragmentMode = "create";
|
||||
}
|
||||
|
||||
function startEditFragment() {
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { get, writable } from "svelte/store";
|
||||
import {
|
||||
createFragment as createFragmentCommand,
|
||||
deleteFragment as deleteFragmentCommand,
|
||||
listFragments,
|
||||
updateFragment as updateFragmentCommand,
|
||||
type FragmentDto
|
||||
} from "$lib/backend/fragments";
|
||||
|
||||
export type FragmentItem = {
|
||||
id: string;
|
||||
@ -6,14 +13,6 @@ export type FragmentItem = {
|
||||
initialContent: string;
|
||||
};
|
||||
|
||||
const initialFragments: FragmentItem[] = [
|
||||
{ id: "fragments/highlights", label: "Highlights", initialContent: "# Highlights\n\nType: Reference\nTags: #Personal\n\nImportant highlights and excerpts." },
|
||||
{ id: "fragments/quotes", label: "Quotes", initialContent: "# Quotes\n\nType: Quote\nTags: #Ideas\n\nQuotes worth revisiting." },
|
||||
{ id: "fragments/scratchpad", label: "Scratchpad", initialContent: "# Scratchpad\n\nType: Snippet\nTags: (none)\n\nTemporary notes and rough thoughts." }
|
||||
];
|
||||
|
||||
export const fragmentsStore = writable<FragmentItem[]>(initialFragments);
|
||||
|
||||
export type ParsedFragment = {
|
||||
title: string;
|
||||
type: string;
|
||||
@ -21,6 +20,68 @@ export type ParsedFragment = {
|
||||
body: string;
|
||||
};
|
||||
|
||||
const initialFragments: FragmentItem[] = [];
|
||||
|
||||
export const fragmentsStore = writable<FragmentItem[]>(initialFragments);
|
||||
export const fragmentsBusyStore = writable(false);
|
||||
|
||||
function toStoreId(id: string): string {
|
||||
return `fragments/${id}`;
|
||||
}
|
||||
|
||||
function toBackendId(id: string): string | null {
|
||||
const prefix = "fragments/";
|
||||
if (!id.startsWith(prefix)) return null;
|
||||
const backendId = id.slice(prefix.length).trim();
|
||||
return backendId || null;
|
||||
}
|
||||
|
||||
function splitDescription(description: string): { title: string; body: string } {
|
||||
const normalized = description.trim();
|
||||
if (!normalized) {
|
||||
return { title: "Untitled Fragment", body: "" };
|
||||
}
|
||||
|
||||
const separator = normalized.indexOf("\n\n");
|
||||
if (separator === -1) {
|
||||
return { title: normalized, body: "" };
|
||||
}
|
||||
|
||||
const title = normalized.slice(0, separator).trim() || "Untitled Fragment";
|
||||
const body = normalized.slice(separator + 2).trim();
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
function composeDescription(title: string, body: string): string {
|
||||
const resolvedTitle = title.trim() || "Untitled Fragment";
|
||||
const resolvedBody = body.trim() || "Add details for this fragment.";
|
||||
return `${resolvedTitle}\n\n${resolvedBody}`;
|
||||
}
|
||||
|
||||
function dtoToItem(dto: FragmentDto): FragmentItem {
|
||||
const parsed = splitDescription(dto.description);
|
||||
return {
|
||||
id: toStoreId(dto.id),
|
||||
label: parsed.title,
|
||||
initialContent: serializeFragment({
|
||||
title: parsed.title,
|
||||
type: dto.type,
|
||||
tags: dto.tags ?? [],
|
||||
body: parsed.body
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function upsertById(items: FragmentItem[], next: FragmentItem): FragmentItem[] {
|
||||
const idx = items.findIndex((item) => item.id === next.id);
|
||||
if (idx === -1) {
|
||||
return [next, ...items];
|
||||
}
|
||||
const clone = [...items];
|
||||
clone[idx] = next;
|
||||
return clone;
|
||||
}
|
||||
|
||||
export function createFragmentId(title: string): string {
|
||||
const slug = title
|
||||
.trim()
|
||||
@ -94,3 +155,85 @@ export function prependFragmentItem(items: FragmentItem[], item: FragmentItem):
|
||||
export function removeFragmentItem(items: FragmentItem[], id: string): FragmentItem[] {
|
||||
return items.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
export async function hydrateFragments(): Promise<void> {
|
||||
fragmentsBusyStore.set(true);
|
||||
try {
|
||||
console.info("[fragments] hydrate:start");
|
||||
const items = await listFragments();
|
||||
console.info("[fragments] hydrate:ok", { count: items.length });
|
||||
fragmentsStore.set(items.map(dtoToItem));
|
||||
} catch (error) {
|
||||
console.error("[fragments] hydrate:error", error);
|
||||
throw error;
|
||||
} finally {
|
||||
fragmentsBusyStore.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFragmentFromParsed(payload: ParsedFragment): Promise<FragmentItem> {
|
||||
console.info("[fragments] create:start", {
|
||||
title: payload.title,
|
||||
type: payload.type,
|
||||
tags: payload.tags
|
||||
});
|
||||
const created = await createFragmentCommand({
|
||||
type: payload.type.trim(),
|
||||
description: composeDescription(payload.title, payload.body),
|
||||
tags: payload.tags
|
||||
});
|
||||
console.info("[fragments] create:ok", { id: created.id });
|
||||
const item = dtoToItem(created);
|
||||
fragmentsStore.update((items) => prependFragmentItem(items, item));
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function updateFragmentFromParsed(storeId: string, payload: ParsedFragment): Promise<FragmentItem | null> {
|
||||
const backendId = toBackendId(storeId);
|
||||
if (!backendId) {
|
||||
console.warn("[fragments] update:skip_invalid_store_id", { storeId });
|
||||
return null;
|
||||
}
|
||||
|
||||
console.info("[fragments] update:start", { storeId, backendId });
|
||||
const ok = await updateFragmentCommand(backendId, {
|
||||
type: payload.type.trim(),
|
||||
description: composeDescription(payload.title, payload.body),
|
||||
tags: payload.tags
|
||||
});
|
||||
if (!ok) {
|
||||
console.warn("[fragments] update:backend_returned_false", { storeId, backendId });
|
||||
return null;
|
||||
}
|
||||
|
||||
const item: FragmentItem = {
|
||||
id: storeId,
|
||||
label: payload.title.trim() || "Untitled Fragment",
|
||||
initialContent: serializeFragment(payload)
|
||||
};
|
||||
console.info("[fragments] update:ok", { storeId, backendId });
|
||||
fragmentsStore.update((items) => upsertById(items, item));
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function deleteFragmentByStoreId(storeId: string): Promise<boolean> {
|
||||
const backendId = toBackendId(storeId);
|
||||
if (!backendId) {
|
||||
console.warn("[fragments] delete:skip_invalid_store_id", { storeId });
|
||||
return false;
|
||||
}
|
||||
|
||||
console.info("[fragments] delete:start", { storeId, backendId });
|
||||
const ok = await deleteFragmentCommand(backendId);
|
||||
if (!ok) {
|
||||
console.warn("[fragments] delete:backend_returned_false", { storeId, backendId });
|
||||
return false;
|
||||
}
|
||||
console.info("[fragments] delete:ok", { storeId, backendId });
|
||||
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasFragment(storeId: string): boolean {
|
||||
return get(fragmentsStore).some((item) => item.id === storeId);
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { hydrateWorkspace } from "$lib/backend/auth";
|
||||
import AppModal from "$lib/components/AppModal.svelte";
|
||||
import { entriesStore, getDefaultEntry } 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 { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
type OpenDocument = {
|
||||
@ -29,16 +32,28 @@
|
||||
let modalCancelText = "Cancel";
|
||||
let modalShowCancel = false;
|
||||
let modalTone: "default" | "danger" = "default";
|
||||
let modalAction: "logout-confirm" | "logout-info" | null = null;
|
||||
let modalAction: "logout-confirm" | "logout-info" | "unlock-vault" | null = null;
|
||||
let modalInputEnabled = false;
|
||||
let modalInputType = "text";
|
||||
let modalInputPlaceholder = "";
|
||||
let modalInputAriaLabel = "Modal input";
|
||||
let modalInputValue = "";
|
||||
let unlockResolver: ((password: string | null) => void) | null = null;
|
||||
let fragmentBootstrapInFlight = false;
|
||||
|
||||
function showModal(options: {
|
||||
action: "logout-confirm" | "logout-info";
|
||||
action: "logout-confirm" | "logout-info" | "unlock-vault";
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
showCancel?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
inputEnabled?: boolean;
|
||||
inputType?: string;
|
||||
inputPlaceholder?: string;
|
||||
inputAriaLabel?: string;
|
||||
inputValue?: string;
|
||||
}) {
|
||||
modalAction = options.action;
|
||||
modalTitle = options.title;
|
||||
@ -47,12 +62,22 @@
|
||||
modalCancelText = options.cancelText ?? "Cancel";
|
||||
modalShowCancel = options.showCancel ?? false;
|
||||
modalTone = options.tone ?? "default";
|
||||
modalInputEnabled = options.inputEnabled ?? false;
|
||||
modalInputType = options.inputType ?? "text";
|
||||
modalInputPlaceholder = options.inputPlaceholder ?? "";
|
||||
modalInputAriaLabel = options.inputAriaLabel ?? "Modal input";
|
||||
modalInputValue = options.inputValue ?? "";
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
modalAction = null;
|
||||
modalInputEnabled = false;
|
||||
modalInputType = "text";
|
||||
modalInputPlaceholder = "";
|
||||
modalInputAriaLabel = "Modal input";
|
||||
modalInputValue = "";
|
||||
}
|
||||
|
||||
function handleModalConfirm() {
|
||||
@ -66,9 +91,95 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalAction === "unlock-vault") {
|
||||
const value = modalInputValue.trim();
|
||||
if (!value) return;
|
||||
const resolve = unlockResolver;
|
||||
unlockResolver = null;
|
||||
closeModal();
|
||||
resolve?.(value);
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function handleModalCancel() {
|
||||
if (modalAction === "unlock-vault") {
|
||||
const resolve = unlockResolver;
|
||||
unlockResolver = null;
|
||||
closeModal();
|
||||
resolve?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function requestVaultPassword(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
unlockResolver = resolve;
|
||||
showModal({
|
||||
action: "unlock-vault",
|
||||
title: "Unlock Vault",
|
||||
message: "Enter your vault password to load fragments.",
|
||||
confirmText: "Unlock",
|
||||
cancelText: "Cancel",
|
||||
showCancel: true,
|
||||
inputEnabled: true,
|
||||
inputType: "password",
|
||||
inputPlaceholder: "Vault password",
|
||||
inputAriaLabel: "Vault password",
|
||||
inputValue: ""
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isLockedError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.toLowerCase().includes("database is locked");
|
||||
}
|
||||
|
||||
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
||||
if (fragmentBootstrapInFlight) return;
|
||||
fragmentBootstrapInFlight = true;
|
||||
|
||||
try {
|
||||
let attempts = 0;
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
await hydrateFragments();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isLockedError(error)) {
|
||||
console.error("Failed to load fragments 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 remains locked after ${maxAttempts} attempts. Stopping unlock prompts.`);
|
||||
} finally {
|
||||
fragmentBootstrapInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(id: string) {
|
||||
if (id === "account" || id === "settings") {
|
||||
goto(`/${id}`);
|
||||
@ -113,6 +224,10 @@
|
||||
const { [id]: _, ...remaining } = openDocuments;
|
||||
openDocuments = remaining;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
bootstrapFragmentsWithUnlock();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-shell" class:panel-closed={!panelOpen}>
|
||||
@ -143,8 +258,13 @@
|
||||
cancelText={modalCancelText}
|
||||
showCancel={modalShowCancel}
|
||||
tone={modalTone}
|
||||
inputEnabled={modalInputEnabled}
|
||||
inputType={modalInputType}
|
||||
inputPlaceholder={modalInputPlaceholder}
|
||||
inputAriaLabel={modalInputAriaLabel}
|
||||
bind:inputValue={modalInputValue}
|
||||
onConfirm={handleModalConfirm}
|
||||
onCancel={closeModal}
|
||||
onCancel={handleModalCancel}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user