Lists & todos backend, editor refactor, inline create, UX improvements
- Wire up lists and todos to C# backend with full CRUD persistence - Add models, DTOs, repositories, and services for lists and todo lists/items - Preserve SQLite DB across vault rebuild/load cycles - Add session store for vault password persistence across navigation - Add inline name input for creating lists and todo lists in SidePanel - Clear editor panel on section change with empty state placeholder - Default markdown editor to preview mode on item selection - Decompose EditorPanel into sub-components: - editor/FragmentEditor, editor/TodoEditor, editor/MarkdownEditor - Shared markdown utilities in utils/markdown.ts - Strip verbose console/eprintln logging from frontend and Tauri backend - Add graceful shutdown with vault persistence on window close Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
parent
0465b05845
commit
c7933aeeec
2
Journal.App/src-tauri/Cargo.lock
generated
2
Journal.App/src-tauri/Cargo.lock
generated
@ -1777,6 +1777,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3863,6 +3864,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@ -22,4 +22,4 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync"] }
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
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 std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use tauri::Manager;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -23,6 +25,12 @@ struct CommandEnvelope {
|
||||
payload: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Default)]
|
||||
struct AppSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
sidecar_root: Option<String>,
|
||||
}
|
||||
|
||||
struct ManagedSidecar {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
@ -30,21 +38,18 @@ struct ManagedSidecar {
|
||||
}
|
||||
|
||||
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())
|
||||
fn start(root: &Path) -> Result<Self, String> {
|
||||
let sidecar_path = resolve_sidecar_path(root)?;
|
||||
let mut cmd = Command::new(sidecar_path);
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.current_dir(&root)
|
||||
.env("JOURNAL_PROJECT_ROOT", &root)
|
||||
.current_dir(root)
|
||||
.env("JOURNAL_PROJECT_ROOT", root)
|
||||
.kill_on_drop(true);
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|err| format!("Failed to start sidecar process: {err}"))?;
|
||||
|
||||
@ -67,29 +72,26 @@ impl ManagedSidecar {
|
||||
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
|
||||
}
|
||||
Ok(Some(_)) => false,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn send_command_line(&mut self, input_line: &str) -> Result<String, String> {
|
||||
async fn send_command_line(&mut self, input_line: &str) -> Result<String, String> {
|
||||
self.stdin
|
||||
.write_all(format!("{input_line}\n").as_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?;
|
||||
self.stdin
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?;
|
||||
|
||||
let mut response_line = String::new();
|
||||
let read = self
|
||||
.stdout
|
||||
.read_line(&mut response_line)
|
||||
.await
|
||||
.map_err(|err| format!("Failed reading sidecar stdout: {err}"))?;
|
||||
if read == 0 {
|
||||
return Err("Sidecar stdout closed unexpectedly.".to_string());
|
||||
@ -105,43 +107,49 @@ impl ManagedSidecar {
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
fn drop(&mut self) {}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SidecarState {
|
||||
process: Mutex<Option<ManagedSidecar>>,
|
||||
root_override: Mutex<Option<PathBuf>>,
|
||||
config_path: PathBuf,
|
||||
}
|
||||
|
||||
fn project_root() -> Result<PathBuf, String> {
|
||||
fn load_settings(path: &Path) -> AppSettings {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(settings)
|
||||
.map_err(|e| format!("Failed to serialize settings: {e}"))?;
|
||||
fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}"))
|
||||
}
|
||||
|
||||
fn auto_detect_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()?;
|
||||
fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
|
||||
if let Some(root) = root_override {
|
||||
return Ok(root.clone());
|
||||
}
|
||||
auto_detect_root()
|
||||
}
|
||||
|
||||
fn resolve_sidecar_path(root: &Path) -> Result<PathBuf, String> {
|
||||
let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe");
|
||||
if debug_path.exists() {
|
||||
return Ok(debug_path);
|
||||
@ -156,11 +164,15 @@ fn resolve_sidecar_path() -> Result<PathBuf, String> {
|
||||
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())?;
|
||||
async fn send_with_managed_sidecar(
|
||||
state: &SidecarState,
|
||||
input_line: &str,
|
||||
) -> Result<String, String> {
|
||||
let root = {
|
||||
let root_override = state.root_override.lock().await;
|
||||
effective_root(&root_override)?
|
||||
};
|
||||
let mut guard = state.process.lock().await;
|
||||
|
||||
for attempt in 1..=2 {
|
||||
let should_start = match guard.as_mut() {
|
||||
@ -168,17 +180,16 @@ fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result<S
|
||||
None => true,
|
||||
};
|
||||
if should_start {
|
||||
*guard = Some(ManagedSidecar::start()?);
|
||||
*guard = Some(ManagedSidecar::start(&root)?);
|
||||
}
|
||||
|
||||
let Some(process) = guard.as_mut() else {
|
||||
return Err("Sidecar process unavailable.".to_string());
|
||||
};
|
||||
|
||||
match process.send_command_line(input_line) {
|
||||
match process.send_command_line(input_line).await {
|
||||
Ok(line) => return Ok(line),
|
||||
Err(err) => {
|
||||
eprintln!("[sidecar] send_error attempt={attempt} error={err}");
|
||||
*guard = None;
|
||||
if attempt == 2 {
|
||||
return Err(err);
|
||||
@ -190,26 +201,75 @@ fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result<S
|
||||
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;
|
||||
async fn stop_managed_sidecar(state: &SidecarState) {
|
||||
let mut guard = state.process.lock().await;
|
||||
guard.take();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value, String> {
|
||||
let root_override = state.root_override.lock().await.clone();
|
||||
let root = effective_root(&root_override)?;
|
||||
Ok(serde_json::json!({
|
||||
"root": root.to_string_lossy(),
|
||||
"isCustom": root_override.is_some()
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_sidecar_root(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
path: String,
|
||||
) -> Result<Value, String> {
|
||||
let (new_override, root) = if path.trim().is_empty() {
|
||||
let detected = auto_detect_root()?;
|
||||
(None, detected)
|
||||
} else {
|
||||
let new_root = PathBuf::from(&path);
|
||||
if !new_root.exists() {
|
||||
return Err(format!(
|
||||
"Directory '{}' does not exist.",
|
||||
new_root.display()
|
||||
));
|
||||
}
|
||||
resolve_sidecar_path(&new_root)?;
|
||||
(Some(new_root.clone()), new_root)
|
||||
};
|
||||
|
||||
if guard.take().is_some() {
|
||||
eprintln!("[sidecar] stop_requested");
|
||||
// Stop the current sidecar so it restarts with new root
|
||||
{
|
||||
let mut guard = state.process.lock().await;
|
||||
guard.take();
|
||||
}
|
||||
|
||||
let is_custom = new_override.is_some();
|
||||
*state.root_override.lock().await = new_override.clone();
|
||||
|
||||
save_settings(
|
||||
&state.config_path,
|
||||
&AppSettings {
|
||||
sidecar_root: new_override.map(|p| p.to_string_lossy().into_owned()),
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"root": root.to_string_lossy(),
|
||||
"isCustom": is_custom
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn shutdown(state: tauri::State<'_, SidecarState>, app_handle: tauri::AppHandle) {
|
||||
eprintln!("[app] shutdown requested");
|
||||
stop_managed_sidecar(state.inner());
|
||||
async fn shutdown(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
stop_managed_sidecar(state.inner()).await;
|
||||
app_handle.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn sidecar_command(
|
||||
async fn sidecar_command(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
command: CommandEnvelope,
|
||||
) -> Result<Value, String> {
|
||||
@ -217,16 +277,9 @@ fn sidecar_command(
|
||||
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}");
|
||||
let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?;
|
||||
serde_json::from_str::<Value>(&response_line)
|
||||
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
|
||||
}
|
||||
@ -234,16 +287,36 @@ fn sidecar_command(
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let app = tauri::Builder::default()
|
||||
.manage(SidecarState::default())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![sidecar_command, shutdown])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
sidecar_command,
|
||||
shutdown,
|
||||
get_sidecar_root,
|
||||
set_sidecar_root,
|
||||
])
|
||||
.setup(|app| {
|
||||
let config_dir = app.path().app_config_dir()?;
|
||||
fs::create_dir_all(&config_dir).ok();
|
||||
let config_path = config_dir.join("settings.json");
|
||||
let settings = load_settings(&config_path);
|
||||
let root_override = settings.sidecar_root.map(PathBuf::from);
|
||||
|
||||
app.manage(SidecarState {
|
||||
process: Mutex::new(None),
|
||||
root_override: Mutex::new(root_override),
|
||||
config_path,
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.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());
|
||||
if let Ok(mut guard) = state.process.try_lock() {
|
||||
guard.take();
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,27 +10,11 @@ export async function sendCommand<T>(command: BackendCommand): Promise<T> {
|
||||
...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;
|
||||
}
|
||||
|
||||
83
Journal.App/src/lib/backend/lists.ts
Normal file
83
Journal.App/src/lib/backend/lists.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { sendCommand } from "./client";
|
||||
import { pickCase } from "./normalize";
|
||||
|
||||
export type ListDocumentDto = {
|
||||
id: string;
|
||||
label: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateListPayload = {
|
||||
label: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export type UpdateListPayload = {
|
||||
label?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type ListDocumentDtoRaw = {
|
||||
id?: string;
|
||||
label?: string;
|
||||
content?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
Id?: string;
|
||||
Label?: string;
|
||||
Content?: string;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
};
|
||||
|
||||
export function normalizeList(raw: ListDocumentDtoRaw): ListDocumentDto {
|
||||
return {
|
||||
id: pickCase(raw, "id", "Id", ""),
|
||||
label: pickCase(raw, "label", "Label", ""),
|
||||
content: pickCase(raw, "content", "Content", ""),
|
||||
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
|
||||
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", "")
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLists(): Promise<ListDocumentDto[]> {
|
||||
const data = await sendCommand<ListDocumentDtoRaw[]>({
|
||||
action: "lists.list"
|
||||
});
|
||||
return data.map(normalizeList).filter((item) => Boolean(item.id));
|
||||
}
|
||||
|
||||
export async function getList(id: string): Promise<ListDocumentDto | null> {
|
||||
const data = await sendCommand<ListDocumentDtoRaw | null>({
|
||||
action: "lists.get",
|
||||
id
|
||||
});
|
||||
if (!data) return null;
|
||||
const normalized = normalizeList(data);
|
||||
return normalized.id ? normalized : null;
|
||||
}
|
||||
|
||||
export async function createList(payload: CreateListPayload): Promise<ListDocumentDto> {
|
||||
const data = await sendCommand<ListDocumentDtoRaw>({
|
||||
action: "lists.create",
|
||||
payload
|
||||
});
|
||||
return normalizeList(data);
|
||||
}
|
||||
|
||||
export function updateList(id: string, payload: UpdateListPayload): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "lists.update",
|
||||
id,
|
||||
payload
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteList(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "lists.delete",
|
||||
id
|
||||
});
|
||||
}
|
||||
144
Journal.App/src/lib/backend/todos.ts
Normal file
144
Journal.App/src/lib/backend/todos.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { sendCommand } from "./client";
|
||||
import { pickCase } from "./normalize";
|
||||
|
||||
export type TodoItemDto = {
|
||||
id: string;
|
||||
listId: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type TodoListDto = {
|
||||
id: string;
|
||||
label: string;
|
||||
createdAt: string;
|
||||
items: TodoItemDto[];
|
||||
};
|
||||
|
||||
export type CreateTodoListPayload = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type UpdateTodoListPayload = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type CreateTodoItemPayload = {
|
||||
listId: string;
|
||||
text: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type UpdateTodoItemPayload = {
|
||||
text?: string;
|
||||
done?: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type TodoItemDtoRaw = {
|
||||
id?: string;
|
||||
listId?: string;
|
||||
text?: string;
|
||||
done?: boolean;
|
||||
sortOrder?: number;
|
||||
Id?: string;
|
||||
ListId?: string;
|
||||
Text?: string;
|
||||
Done?: boolean;
|
||||
SortOrder?: number;
|
||||
};
|
||||
|
||||
type TodoListDtoRaw = {
|
||||
id?: string;
|
||||
label?: string;
|
||||
createdAt?: string;
|
||||
items?: TodoItemDtoRaw[];
|
||||
Id?: string;
|
||||
Label?: string;
|
||||
CreatedAt?: string;
|
||||
Items?: TodoItemDtoRaw[];
|
||||
};
|
||||
|
||||
function normalizeItem(raw: TodoItemDtoRaw): TodoItemDto {
|
||||
return {
|
||||
id: pickCase(raw, "id", "Id", ""),
|
||||
listId: pickCase(raw, "listId", "ListId", ""),
|
||||
text: pickCase(raw, "text", "Text", ""),
|
||||
done: pickCase(raw, "done", "Done", false),
|
||||
sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeList(raw: TodoListDtoRaw): TodoListDto {
|
||||
const rawItems = pickCase(raw, "items", "Items", [] as TodoItemDtoRaw[]);
|
||||
return {
|
||||
id: pickCase(raw, "id", "Id", ""),
|
||||
label: pickCase(raw, "label", "Label", ""),
|
||||
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
|
||||
items: rawItems.map(normalizeItem)
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTodoLists(): Promise<TodoListDto[]> {
|
||||
const data = await sendCommand<TodoListDtoRaw[]>({
|
||||
action: "todos.list"
|
||||
});
|
||||
return data.map(normalizeList).filter((item) => Boolean(item.id));
|
||||
}
|
||||
|
||||
export async function getTodoList(id: string): Promise<TodoListDto | null> {
|
||||
const data = await sendCommand<TodoListDtoRaw | null>({
|
||||
action: "todos.get",
|
||||
id
|
||||
});
|
||||
if (!data) return null;
|
||||
const normalized = normalizeList(data);
|
||||
return normalized.id ? normalized : null;
|
||||
}
|
||||
|
||||
export async function createTodoList(payload: CreateTodoListPayload): Promise<TodoListDto> {
|
||||
const data = await sendCommand<TodoListDtoRaw>({
|
||||
action: "todos.create",
|
||||
payload
|
||||
});
|
||||
return normalizeList(data);
|
||||
}
|
||||
|
||||
export function updateTodoList(id: string, payload: UpdateTodoListPayload): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.update",
|
||||
id,
|
||||
payload
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTodoList(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.delete",
|
||||
id
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTodoItem(payload: CreateTodoItemPayload): Promise<TodoItemDto> {
|
||||
const data = await sendCommand<TodoItemDtoRaw>({
|
||||
action: "todos.items.create",
|
||||
payload
|
||||
});
|
||||
return normalizeItem(data);
|
||||
}
|
||||
|
||||
export function updateTodoItem(id: string, payload: UpdateTodoItemPayload): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.items.update",
|
||||
id,
|
||||
payload
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTodoItem(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.items.delete",
|
||||
id
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,13 +2,17 @@
|
||||
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
|
||||
import { createEntryDraft, entriesStore } from "$lib/stores/entries";
|
||||
import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
|
||||
import { createListDraft, listsStore } from "$lib/stores/lists";
|
||||
import { createTodoListDraft, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
||||
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
|
||||
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
||||
|
||||
export let activeSection = "entries";
|
||||
export let activeDocumentId = "";
|
||||
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||
|
||||
let showNewItemInput = false;
|
||||
let newItemName = "";
|
||||
let newItemInput: HTMLInputElement | null = null;
|
||||
|
||||
type SidePanelItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
@ -131,22 +135,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSection === "todos") {
|
||||
const draft = createTodoListDraft();
|
||||
todoListsStore.update((lists) => [draft.meta, ...lists]);
|
||||
todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items }));
|
||||
onOpenDocument({
|
||||
id: draft.meta.id,
|
||||
label: draft.meta.label,
|
||||
initialContent: serializeTodoList(draft.meta.label, draft.items)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSection === "lists") {
|
||||
const item = createListDraft();
|
||||
listsStore.update((items) => [item, ...items]);
|
||||
onOpenDocument(item);
|
||||
if (activeSection === "todos" || activeSection === "lists") {
|
||||
showNewItemInput = true;
|
||||
newItemName = "";
|
||||
queueMicrotask(() => newItemInput?.focus());
|
||||
return;
|
||||
}
|
||||
|
||||
@ -171,6 +163,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmNewItem() {
|
||||
const label = newItemName.trim();
|
||||
if (!label) {
|
||||
cancelNewItem();
|
||||
return;
|
||||
}
|
||||
showNewItemInput = false;
|
||||
newItemName = "";
|
||||
|
||||
if (activeSection === "todos") {
|
||||
try {
|
||||
const { meta, items: todoItems } = await createTodoListFromLabel(label);
|
||||
onOpenDocument({
|
||||
id: meta.id,
|
||||
label: meta.label,
|
||||
initialContent: serializeTodoList(meta.label, todoItems)
|
||||
});
|
||||
} catch (error) {
|
||||
const draft = createTodoListDraft();
|
||||
draft.meta.label = label;
|
||||
todoListsStore.update((lists) => [draft.meta, ...lists]);
|
||||
todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items }));
|
||||
onOpenDocument({
|
||||
id: draft.meta.id,
|
||||
label: draft.meta.label,
|
||||
initialContent: serializeTodoList(draft.meta.label, draft.items)
|
||||
});
|
||||
}
|
||||
} else if (activeSection === "lists") {
|
||||
try {
|
||||
const item = await createListFromLabel(label);
|
||||
onOpenDocument(item);
|
||||
} catch (error) {
|
||||
const item = createListDraft();
|
||||
item.label = label;
|
||||
item.initialContent = `# ${label}\n\n`;
|
||||
listsStore.update((items) => [item, ...items]);
|
||||
onOpenDocument(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelNewItem() {
|
||||
showNewItemInput = false;
|
||||
newItemName = "";
|
||||
}
|
||||
|
||||
function handleNewItemKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
confirmNewItem();
|
||||
} else if (event.key === "Escape") {
|
||||
cancelNewItem();
|
||||
}
|
||||
}
|
||||
|
||||
$: panelTitle = sectionTitles[activeSection] ?? "Entries";
|
||||
$: todoDocuments = $todoListsStore.map(({ id, label }) => ({
|
||||
id,
|
||||
@ -226,6 +274,19 @@
|
||||
<input type="text" placeholder={`Search ${panelTitle.toLowerCase()}...`} />
|
||||
</div>
|
||||
|
||||
{#if showNewItemInput}
|
||||
<div class="new-item-input">
|
||||
<input
|
||||
type="text"
|
||||
bind:this={newItemInput}
|
||||
bind:value={newItemName}
|
||||
placeholder={activeSection === "todos" ? "Todo list name..." : "List name..."}
|
||||
on:keydown={handleNewItemKeydown}
|
||||
on:blur={confirmNewItem}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="panel-list">
|
||||
{#each items as item}
|
||||
<li>
|
||||
@ -354,4 +415,27 @@
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.new-item-input {
|
||||
padding: 0 2px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
padding: 7px 9px;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent, #6b8afd);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
382
Journal.App/src/lib/components/editor/FragmentEditor.svelte
Normal file
382
Journal.App/src/lib/components/editor/FragmentEditor.svelte
Normal file
@ -0,0 +1,382 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
createFragmentFromParsed,
|
||||
deleteFragmentByStoreId,
|
||||
fragmentsStore,
|
||||
hasFragment,
|
||||
parseFragmentContent,
|
||||
serializeFragment,
|
||||
updateFragmentFromParsed,
|
||||
type FragmentItem
|
||||
} from "$lib/stores/fragments";
|
||||
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
|
||||
import { renderMarkdown } from "$lib/utils/markdown";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export let openDocumentId = "";
|
||||
export let openDocumentName = "";
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||
export let onDeleteDocument: (id: string) => void = () => {};
|
||||
|
||||
let fragmentTitle = "";
|
||||
let fragmentType = "";
|
||||
let customFragmentType = "";
|
||||
let fragmentTag = "";
|
||||
let customFragmentTags = "";
|
||||
let fragmentBody = "";
|
||||
let fragmentMode: "view" | "edit" | "create" = "view";
|
||||
let lastFragmentDocumentId = "";
|
||||
let fragmentTypeOptions: string[] = [];
|
||||
let tagOptions: string[] = [];
|
||||
const customTypeValue = "__custom_type__";
|
||||
const customTagValue = "__custom_tag__";
|
||||
|
||||
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;
|
||||
if (!resolvedType) return null;
|
||||
const selectedTags = fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
|
||||
const customTags = customFragmentTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
const tagList = [...selectedTags, ...customTags];
|
||||
const uniqueTagList = [...new Set(tagList.map((tag) => tag.toLowerCase()))].map((lower) => {
|
||||
return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
|
||||
});
|
||||
const body = fragmentBody.trim() || "Add details for this fragment.";
|
||||
const content = serializeFragment({
|
||||
title,
|
||||
type: resolvedType,
|
||||
tags: uniqueTagList,
|
||||
body
|
||||
});
|
||||
return { title, resolvedType, body, content, tags: uniqueTagList };
|
||||
}
|
||||
|
||||
async function saveFragmentEdits() {
|
||||
try {
|
||||
const payload = buildFragmentContent();
|
||||
if (!payload) return;
|
||||
const exists = hasFragment(openDocumentId);
|
||||
if (!exists) {
|
||||
await createNewFragment();
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateFragmentFromParsed(openDocumentId, {
|
||||
title: payload.title,
|
||||
type: payload.resolvedType,
|
||||
tags: payload.tags,
|
||||
body: payload.body
|
||||
});
|
||||
if (!updated) return;
|
||||
|
||||
onDocumentContentChange(payload.content);
|
||||
fragmentMode = "view";
|
||||
} catch (error) {
|
||||
console.error("[editor] fragment:save:error", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewFragment() {
|
||||
try {
|
||||
const payload = buildFragmentContent();
|
||||
if (!payload) return;
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function startEditFragment() {
|
||||
fragmentMode = "edit";
|
||||
}
|
||||
|
||||
function cancelFragmentEdit() {
|
||||
if (fragmentMode === "create") {
|
||||
fragmentMode = "view";
|
||||
return;
|
||||
}
|
||||
loadFragmentFormFromDocument();
|
||||
fragmentMode = "view";
|
||||
}
|
||||
|
||||
function loadFragmentFormFromDocument() {
|
||||
const content = openDocumentContent ?? "";
|
||||
const isDraftFragment = openDocumentId.startsWith("fragments/new-");
|
||||
const parsed = parseFragmentContent(content, openDocumentName || "Untitled Fragment");
|
||||
fragmentTitle = parsed.title;
|
||||
const parsedType = parsed.type;
|
||||
if (!parsedType) {
|
||||
fragmentType = fragmentTypeOptions[0] ?? customTypeValue;
|
||||
customFragmentType = "";
|
||||
} else if (fragmentTypeOptions.includes(parsedType)) {
|
||||
fragmentType = parsedType;
|
||||
customFragmentType = "";
|
||||
} else {
|
||||
fragmentType = customTypeValue;
|
||||
customFragmentType = parsedType;
|
||||
}
|
||||
|
||||
const parsedTags = parsed.tags;
|
||||
|
||||
if (parsedTags.length === 0) {
|
||||
fragmentTag = tagOptions[0] ?? customTagValue;
|
||||
customFragmentTags = "";
|
||||
} else {
|
||||
const primary = parsedTags[0];
|
||||
if (tagOptions.includes(primary)) {
|
||||
fragmentTag = primary;
|
||||
customFragmentTags = parsedTags.slice(1).join(", ");
|
||||
} else {
|
||||
fragmentTag = customTagValue;
|
||||
customFragmentTags = parsedTags.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
fragmentBody = parsed.body;
|
||||
fragmentMode = isDraftFragment ? "create" : "view";
|
||||
}
|
||||
|
||||
$: fragmentTypeOptions = $settingsFragmentTypes.length ? $settingsFragmentTypes : ["General"];
|
||||
$: tagOptions = $settingsTags;
|
||||
$: if (openDocumentId !== lastFragmentDocumentId) {
|
||||
loadFragmentFormFromDocument();
|
||||
lastFragmentDocumentId = openDocumentId;
|
||||
}
|
||||
$: if (!fragmentType || (!fragmentTypeOptions.includes(fragmentType) && fragmentType !== customTypeValue)) {
|
||||
fragmentType = fragmentTypeOptions[0];
|
||||
}
|
||||
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
|
||||
fragmentTag = tagOptions[0] ?? customTagValue;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="fragment-surface">
|
||||
{#if fragmentMode === "view"}
|
||||
<article class="fragment-view">
|
||||
{@html renderMarkdown(openDocumentContent)}
|
||||
<div class="fragment-actions">
|
||||
<button type="button" class="fragment-submit" on:click={startEditFragment}>Edit</button>
|
||||
<button type="button" class="fragment-danger" on:click={deleteCurrentFragment}>Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
<form class="fragment-form" on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}>
|
||||
<h2>{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}</h2>
|
||||
<input type="text" placeholder="Title" bind:value={fragmentTitle} aria-label="Fragment title" />
|
||||
<div class="fragment-form-row">
|
||||
<select bind:value={fragmentType} aria-label="Fragment type">
|
||||
{#each fragmentTypeOptions as type}
|
||||
<option value={type}>{type}</option>
|
||||
{/each}
|
||||
<option value={customTypeValue}>Custom</option>
|
||||
</select>
|
||||
{#if fragmentType === customTypeValue}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Custom type"
|
||||
bind:value={customFragmentType}
|
||||
aria-label="Custom fragment type"
|
||||
/>
|
||||
{:else}
|
||||
<input type="text" value={fragmentType} disabled aria-label="Selected fragment type" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fragment-form-row">
|
||||
<select bind:value={fragmentTag} aria-label="Primary fragment tag">
|
||||
{#if tagOptions.length === 0}
|
||||
<option value={customTagValue}>Custom</option>
|
||||
{:else}
|
||||
{#each tagOptions as tag}
|
||||
<option value={tag}>{tag}</option>
|
||||
{/each}
|
||||
<option value={customTagValue}>Custom</option>
|
||||
{/if}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={fragmentTag === customTagValue
|
||||
? "Custom tags (comma separated)"
|
||||
: "Additional tags (optional, comma separated)"}
|
||||
bind:value={customFragmentTags}
|
||||
aria-label="Custom fragment tags"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
rows="5"
|
||||
placeholder="Fragment text"
|
||||
bind:value={fragmentBody}
|
||||
aria-label="Fragment body"
|
||||
></textarea>
|
||||
<div class="fragment-actions">
|
||||
<button type="submit" class="fragment-submit">{fragmentMode === "create" ? "Create Fragment" : "Save Fragment"}</button>
|
||||
<button type="button" class="fragment-secondary" on:click={cancelFragmentEdit}>Cancel</button>
|
||||
{#if fragmentMode !== "create"}
|
||||
<button type="button" class="fragment-danger" on:click={deleteCurrentFragment}>Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.fragment-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.fragment-form {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fragment-view {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fragment-view :global(h1),
|
||||
.fragment-view :global(h2),
|
||||
.fragment-view :global(h3),
|
||||
.fragment-view :global(h4),
|
||||
.fragment-view :global(h5),
|
||||
.fragment-view :global(h6) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.fragment-view :global(p),
|
||||
.fragment-view :global(ul),
|
||||
.fragment-view :global(ol),
|
||||
.fragment-view :global(blockquote),
|
||||
.fragment-view :global(pre) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.fragment-view :global(code) {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.fragment-form h2 {
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fragment-form input,
|
||||
.fragment-form select,
|
||||
.fragment-form textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
padding: 9px 10px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.fragment-form textarea {
|
||||
resize: vertical;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.fragment-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fragment-submit {
|
||||
width: fit-content;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fragment-submit:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.fragment-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fragment-secondary,
|
||||
.fragment-danger {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fragment-secondary:hover,
|
||||
.fragment-danger:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
341
Journal.App/src/lib/components/editor/MarkdownEditor.svelte
Normal file
341
Journal.App/src/lib/components/editor/MarkdownEditor.svelte
Normal file
@ -0,0 +1,341 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
saveEntryFromStore,
|
||||
type EntryItem,
|
||||
} from "$lib/stores/entries";
|
||||
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
|
||||
|
||||
export let activeSection = "entries";
|
||||
export let openDocumentId = "";
|
||||
export let openDocumentName = "";
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||
export let onDeleteDocument: (id: string) => void = () => {};
|
||||
|
||||
let markdownText = openDocumentContent;
|
||||
let lastOpenDocumentId = openDocumentId;
|
||||
let previewOnly = true;
|
||||
let editorInput: HTMLTextAreaElement | null = null;
|
||||
let entrySaveBusy = false;
|
||||
|
||||
function updateDraft(value: string) {
|
||||
markdownText = value;
|
||||
onDocumentContentChange(value);
|
||||
}
|
||||
|
||||
function applyWrap(before: string, after = before) {
|
||||
if (!editorInput) return;
|
||||
const current = markdownText;
|
||||
const start = editorInput.selectionStart ?? 0;
|
||||
const end = editorInput.selectionEnd ?? start;
|
||||
const selected = current.slice(start, end);
|
||||
const insertion = `${before}${selected}${after}`;
|
||||
const next = `${current.slice(0, start)}${insertion}${current.slice(end)}`;
|
||||
markdownText = next;
|
||||
onDocumentContentChange(next);
|
||||
queueMicrotask(() => {
|
||||
if (!editorInput) return;
|
||||
const cursor = start + insertion.length;
|
||||
editorInput.focus();
|
||||
editorInput.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
function applyLinePrefix(prefix: string) {
|
||||
if (!editorInput) return;
|
||||
const current = markdownText;
|
||||
const start = editorInput.selectionStart ?? 0;
|
||||
const end = editorInput.selectionEnd ?? start;
|
||||
const blockStart = current.lastIndexOf("\n", start - 1) + 1;
|
||||
const blockEndIndex = current.indexOf("\n", end);
|
||||
const blockEnd = blockEndIndex === -1 ? current.length : blockEndIndex;
|
||||
const block = current.slice(blockStart, blockEnd);
|
||||
const nextBlock = block
|
||||
.split("\n")
|
||||
.map((line) => `${prefix}${line}`)
|
||||
.join("\n");
|
||||
const next = `${current.slice(0, blockStart)}${nextBlock}${current.slice(blockEnd)}`;
|
||||
markdownText = next;
|
||||
onDocumentContentChange(next);
|
||||
}
|
||||
|
||||
function applyHeading(level: number) {
|
||||
if (!Number.isFinite(level) || level < 1 || level > 6) return;
|
||||
applyLinePrefix(`${"#".repeat(level)} `);
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
applyWrap("[", "](https://example.com)");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
$: editorTitle = extractEditorTitle(markdownText, openDocumentName);
|
||||
$: renderedHtml = renderMarkdown(markdownText);
|
||||
</script>
|
||||
|
||||
<header class="editor-header">
|
||||
<h1>{editorTitle}</h1>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<section class="editor-surface" class:preview-only={previewOnly}>
|
||||
{#if !previewOnly}
|
||||
<div class="editor-toolbar">
|
||||
<select
|
||||
class="toolbar-select"
|
||||
aria-label="Header size"
|
||||
on:change={(event) => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const level = Number(target.value);
|
||||
if (level) applyHeading(level);
|
||||
target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">Heading</option>
|
||||
<option value="1">H1</option>
|
||||
<option value="2">H2</option>
|
||||
<option value="3">H3</option>
|
||||
<option value="4">H4</option>
|
||||
<option value="5">H5</option>
|
||||
<option value="6">H6</option>
|
||||
</select>
|
||||
<button type="button" on:click={() => applyWrap("**")}>Bold</button>
|
||||
<button type="button" on:click={() => applyWrap("*")}>Italic</button>
|
||||
<button type="button" on:click={insertLink}>Link</button>
|
||||
<button type="button" on:click={() => applyLinePrefix("- ")}>UL</button>
|
||||
<button type="button" on:click={() => applyLinePrefix("1. ")}>OL</button>
|
||||
<button type="button" on:click={() => applyWrap("`")}>Code</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="editor-workspace">
|
||||
{#if previewOnly}
|
||||
<article class="markdown-preview" aria-label="Markdown preview">
|
||||
{@html renderedHtml}
|
||||
</article>
|
||||
{:else}
|
||||
<textarea
|
||||
bind:this={editorInput}
|
||||
class="markdown-input"
|
||||
bind:value={markdownText}
|
||||
on:input={(event) => updateDraft((event.currentTarget as HTMLTextAreaElement).value)}
|
||||
aria-label="Markdown input"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-header h1 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-actions button {
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
padding: 6px 11px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-actions button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-actions button.primary {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-actions button.primary:hover {
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.editor-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-surface.preview-only {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
padding: 5px 8px;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
padding: 5px 8px;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-toolbar button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-select:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-workspace {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-input,
|
||||
.markdown-preview {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
padding: 12px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-input {
|
||||
display: block;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.markdown-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.markdown-preview :global(h1),
|
||||
.markdown-preview :global(h2),
|
||||
.markdown-preview :global(h3),
|
||||
.markdown-preview :global(h4),
|
||||
.markdown-preview :global(h5),
|
||||
.markdown-preview :global(h6) {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.markdown-preview :global(p),
|
||||
.markdown-preview :global(blockquote),
|
||||
.markdown-preview :global(pre),
|
||||
.markdown-preview :global(ul),
|
||||
.markdown-preview :global(ol) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.markdown-preview :global(ul),
|
||||
.markdown-preview :global(ol) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.markdown-preview :global(code) {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.markdown-preview :global(pre code) {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.markdown-preview :global(blockquote) {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.markdown-preview :global(a) {
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
284
Journal.App/src/lib/components/editor/TodoEditor.svelte
Normal file
284
Journal.App/src/lib/components/editor/TodoEditor.svelte
Normal file
@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
addTodoItem,
|
||||
addTodoItemBackend,
|
||||
getOrCreateTodoList,
|
||||
removeTodoItem,
|
||||
removeTodoItemBackend,
|
||||
serializeTodoList,
|
||||
setTodoList,
|
||||
toggleTodoItem,
|
||||
toggleTodoItemBackend,
|
||||
todosStore,
|
||||
type TodoItem,
|
||||
updateTodoItemText,
|
||||
updateTodoItemTextBackend
|
||||
} from "$lib/stores/todos";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export let openDocumentId = "";
|
||||
export let openDocumentName = "";
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
|
||||
let todoItems: TodoItem[] = [];
|
||||
let lastTodoDocumentId = "";
|
||||
let newTodoText = "";
|
||||
let editingTodoId: number | null = null;
|
||||
let editingTodoText = "";
|
||||
|
||||
async function addTodo() {
|
||||
const text = newTodoText.trim();
|
||||
if (!text) return;
|
||||
newTodoText = "";
|
||||
const backendItem = await addTodoItemBackend(openDocumentId, text);
|
||||
if (backendItem) {
|
||||
todoItems = [backendItem, ...todoItems];
|
||||
} else {
|
||||
todoItems = addTodoItem(todoItems, text);
|
||||
}
|
||||
persistTodosForCurrentList();
|
||||
}
|
||||
|
||||
async function toggleTodoDone(id: number) {
|
||||
const ok = await toggleTodoItemBackend(openDocumentId, id);
|
||||
if (!ok) {
|
||||
todoItems = toggleTodoItem(todoItems, id);
|
||||
} else {
|
||||
todoItems = todoItems.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
|
||||
}
|
||||
persistTodosForCurrentList();
|
||||
}
|
||||
|
||||
function startEditTodo(id: number) {
|
||||
const todo = todoItems.find((item) => item.id === id);
|
||||
if (!todo) return;
|
||||
editingTodoId = id;
|
||||
editingTodoText = todo.text;
|
||||
}
|
||||
|
||||
async function saveEditTodo() {
|
||||
if (editingTodoId === null) return;
|
||||
const text = editingTodoText.trim();
|
||||
if (!text) return;
|
||||
const id = editingTodoId;
|
||||
editingTodoId = null;
|
||||
editingTodoText = "";
|
||||
const ok = await updateTodoItemTextBackend(openDocumentId, id, text);
|
||||
if (!ok) {
|
||||
todoItems = updateTodoItemText(todoItems, id, text);
|
||||
} else {
|
||||
todoItems = todoItems.map((t) => (t.id === id ? { ...t, text: text.trim() } : t));
|
||||
}
|
||||
persistTodosForCurrentList();
|
||||
}
|
||||
|
||||
function cancelEditTodo() {
|
||||
editingTodoId = null;
|
||||
editingTodoText = "";
|
||||
}
|
||||
|
||||
async function removeTodo(id: number) {
|
||||
if (editingTodoId === id) {
|
||||
cancelEditTodo();
|
||||
}
|
||||
const ok = await removeTodoItemBackend(openDocumentId, id);
|
||||
if (!ok) {
|
||||
todoItems = removeTodoItem(todoItems, id);
|
||||
} else {
|
||||
todoItems = todoItems.filter((t) => t.id !== id);
|
||||
}
|
||||
persistTodosForCurrentList();
|
||||
}
|
||||
|
||||
function loadTodosForDocument(documentId: string) {
|
||||
if (!documentId) {
|
||||
todoItems = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const lists = get(todosStore);
|
||||
const result = getOrCreateTodoList(lists, documentId, openDocumentContent);
|
||||
if (result.lists !== lists) {
|
||||
todosStore.set(result.lists);
|
||||
}
|
||||
todoItems = result.todos;
|
||||
}
|
||||
|
||||
function persistTodosForCurrentList() {
|
||||
if (!openDocumentId) return;
|
||||
const lists = get(todosStore);
|
||||
todosStore.set(setTodoList(lists, openDocumentId, todoItems));
|
||||
const markdown = serializeTodoList(openDocumentName, todoItems);
|
||||
onDocumentContentChange(markdown);
|
||||
}
|
||||
|
||||
$: if (openDocumentId !== lastTodoDocumentId) {
|
||||
loadTodosForDocument(openDocumentId);
|
||||
editingTodoId = null;
|
||||
editingTodoText = "";
|
||||
newTodoText = "";
|
||||
lastTodoDocumentId = openDocumentId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="todo-surface">
|
||||
<div class="todo-card">
|
||||
<form class="todo-create" on:submit|preventDefault={addTodo}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a new to-do"
|
||||
bind:value={newTodoText}
|
||||
aria-label="Add to-do"
|
||||
/>
|
||||
<button type="submit" class="todo-add-btn">Add</button>
|
||||
</form>
|
||||
|
||||
<ul class="todo-list">
|
||||
{#each todoItems as todo}
|
||||
<li class="todo-item">
|
||||
<label class="todo-check">
|
||||
<input type="checkbox" checked={todo.done} on:change={() => toggleTodoDone(todo.id)} />
|
||||
</label>
|
||||
|
||||
{#if editingTodoId === todo.id}
|
||||
<input
|
||||
type="text"
|
||||
class="todo-edit-input"
|
||||
bind:value={editingTodoText}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") saveEditTodo();
|
||||
if (event.key === "Escape") cancelEditTodo();
|
||||
}}
|
||||
/>
|
||||
<div class="todo-actions">
|
||||
<button type="button" class="todo-btn save" on:click={saveEditTodo}>Save</button>
|
||||
<button type="button" class="todo-btn ghost" on:click={cancelEditTodo}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="todo-text" class:is-done={todo.done}>{todo.text}</span>
|
||||
<div class="todo-actions">
|
||||
<button type="button" class="todo-btn ghost" on:click={() => startEditTodo(todo.id)}>Edit</button>
|
||||
<button type="button" class="todo-btn danger" on:click={() => removeTodo(todo.id)}>Remove</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.todo-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.todo-create {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.todo-create input,
|
||||
.todo-edit-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.todo-add-btn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.todo-add-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
.todo-check {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.todo-text.is-done {
|
||||
color: var(--text-dim);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.todo-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.todo-btn {
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-muted);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.todo-btn.save {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.todo-btn.danger:hover,
|
||||
.todo-btn.ghost:hover,
|
||||
.todo-btn.save:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@ -88,10 +88,8 @@ export function createEntryDraft(): EntryItem {
|
||||
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);
|
||||
@ -103,17 +101,12 @@ export async function hydrateEntries(dataDirectory?: string): Promise<void> {
|
||||
|
||||
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;
|
||||
}
|
||||
if (!filePath) 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 });
|
||||
@ -123,21 +116,16 @@ export async function loadEntryByStoreId(storeId: string): Promise<EntryItem | n
|
||||
|
||||
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;
|
||||
}
|
||||
if (!trimmed) 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 });
|
||||
@ -146,7 +134,6 @@ export async function saveEntryFromStore(storeId: string, content: string, mode?
|
||||
}
|
||||
|
||||
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("\\") ? "\\" : "/";
|
||||
@ -160,7 +147,6 @@ export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Prom
|
||||
filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined,
|
||||
date: result.entry.date
|
||||
}));
|
||||
console.info("[entries] search:ok", { count: mapped.length });
|
||||
return mapped;
|
||||
}
|
||||
|
||||
|
||||
@ -159,9 +159,7 @@ export function removeFragmentItem(items: FragmentItem[], id: string): FragmentI
|
||||
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);
|
||||
@ -172,17 +170,11 @@ export async function hydrateFragments(): Promise<void> {
|
||||
}
|
||||
|
||||
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;
|
||||
@ -190,46 +182,30 @@ export async function createFragmentFromParsed(payload: ParsedFragment): Promise
|
||||
|
||||
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;
|
||||
}
|
||||
if (!backendId) 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;
|
||||
}
|
||||
if (!ok) 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;
|
||||
}
|
||||
if (!backendId) 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 });
|
||||
if (!ok) return false;
|
||||
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { get, writable } from "svelte/store";
|
||||
import {
|
||||
createList as createListCommand,
|
||||
deleteList as deleteListCommand,
|
||||
listLists,
|
||||
updateList as updateListCommand,
|
||||
type ListDocumentDto
|
||||
} from "$lib/backend/lists";
|
||||
|
||||
export type ListItem = {
|
||||
id: string;
|
||||
@ -6,19 +13,106 @@ export type ListItem = {
|
||||
initialContent: string;
|
||||
};
|
||||
|
||||
const initialLists: ListItem[] = [
|
||||
{ id: "lists/reading", label: "Reading", initialContent: "# Reading\n\nBooks and articles to read." },
|
||||
{ id: "lists/projects", label: "Projects", initialContent: "# Projects\n\nActive and planned projects." },
|
||||
{ id: "lists/someday", label: "Someday", initialContent: "# Someday\n\nLong-term ideas." }
|
||||
];
|
||||
export const listsStore = writable<ListItem[]>([]);
|
||||
export const listsBusyStore = writable(false);
|
||||
|
||||
export const listsStore = writable<ListItem[]>(initialLists);
|
||||
function toStoreId(id: string): string {
|
||||
return `lists/${id}`;
|
||||
}
|
||||
|
||||
function toBackendId(id: string): string | null {
|
||||
const prefix = "lists/";
|
||||
if (!id.startsWith(prefix)) return null;
|
||||
const backendId = id.slice(prefix.length).trim();
|
||||
return backendId || null;
|
||||
}
|
||||
|
||||
function dtoToItem(dto: ListDocumentDto): ListItem {
|
||||
return {
|
||||
id: toStoreId(dto.id),
|
||||
label: dto.label,
|
||||
initialContent: dto.content || `# ${dto.label}\n\n`
|
||||
};
|
||||
}
|
||||
|
||||
function upsertById(items: ListItem[], next: ListItem): ListItem[] {
|
||||
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 createListDraft(): ListItem {
|
||||
const id = `lists/list-${Date.now()}`;
|
||||
const id = `lists/draft-${Date.now()}`;
|
||||
return {
|
||||
id,
|
||||
label: "Untitled List",
|
||||
initialContent: "# Untitled List\n\n- Item 1"
|
||||
};
|
||||
}
|
||||
|
||||
export async function hydrateLists(): Promise<void> {
|
||||
listsBusyStore.set(true);
|
||||
try {
|
||||
const items = await listLists();
|
||||
listsStore.set(items.map(dtoToItem));
|
||||
} catch (error) {
|
||||
console.error("[lists] hydrate:error", error);
|
||||
throw error;
|
||||
} finally {
|
||||
listsBusyStore.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createListFromLabel(label: string, content = ""): Promise<ListItem> {
|
||||
const resolvedLabel = label.trim() || "Untitled List";
|
||||
const resolvedContent = content || `# ${resolvedLabel}\n\n`;
|
||||
const created = await createListCommand({ label: resolvedLabel, content: resolvedContent });
|
||||
const item = dtoToItem(created);
|
||||
listsStore.update((items) => [item, ...items]);
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function updateListByStoreId(
|
||||
storeId: string,
|
||||
label?: string,
|
||||
content?: string
|
||||
): Promise<boolean> {
|
||||
const backendId = toBackendId(storeId);
|
||||
if (!backendId) return false;
|
||||
|
||||
const payload: { label?: string; content?: string } = {};
|
||||
if (label !== undefined) payload.label = label;
|
||||
if (content !== undefined) payload.content = content;
|
||||
|
||||
const ok = await updateListCommand(backendId, payload);
|
||||
if (!ok) return false;
|
||||
|
||||
listsStore.update((items) =>
|
||||
items.map((item) =>
|
||||
item.id === storeId
|
||||
? {
|
||||
...item,
|
||||
label: label ?? item.label,
|
||||
initialContent: content ?? item.initialContent
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteListByStoreId(storeId: string): Promise<boolean> {
|
||||
const backendId = toBackendId(storeId);
|
||||
if (!backendId) return false;
|
||||
|
||||
const ok = await deleteListCommand(backendId);
|
||||
if (!ok) return false;
|
||||
listsStore.update((items) => items.filter((item) => item.id !== storeId));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasList(storeId: string): boolean {
|
||||
return get(listsStore).some((item) => item.id === storeId);
|
||||
}
|
||||
|
||||
24
Journal.App/src/lib/stores/session.ts
Normal file
24
Journal.App/src/lib/stores/session.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
|
||||
const _password = writable<string | null>(null);
|
||||
const _unlocked = writable(false);
|
||||
|
||||
export const vaultUnlocked = { subscribe: _unlocked.subscribe };
|
||||
|
||||
export function isVaultReady(): boolean {
|
||||
return get(_unlocked);
|
||||
}
|
||||
|
||||
export function getSessionPassword(): string | null {
|
||||
return get(_password);
|
||||
}
|
||||
|
||||
export function setVaultSession(password: string): void {
|
||||
_password.set(password);
|
||||
_unlocked.set(true);
|
||||
}
|
||||
|
||||
export function clearVaultSession(): void {
|
||||
_password.set(null);
|
||||
_unlocked.set(false);
|
||||
}
|
||||
@ -1,32 +1,204 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { get, writable } from "svelte/store";
|
||||
import {
|
||||
createTodoItem as createTodoItemCommand,
|
||||
createTodoList as createTodoListCommand,
|
||||
deleteTodoItem as deleteTodoItemCommand,
|
||||
deleteTodoList as deleteTodoListCommand,
|
||||
listTodoLists,
|
||||
updateTodoItem as updateTodoItemCommand,
|
||||
updateTodoList as updateTodoListCommand,
|
||||
type TodoListDto
|
||||
} from "$lib/backend/todos";
|
||||
|
||||
export type TodoItem = { id: number; text: string; done: boolean };
|
||||
export type TodoListMeta = { id: string; label: string };
|
||||
// TodoItem keeps a numeric `id` for local array operations (used by EditorPanel)
|
||||
// plus a `backendId` (guid string) for backend persistence.
|
||||
export type TodoItem = { id: number; text: string; done: boolean; backendId?: string };
|
||||
export type TodoListMeta = { id: string; label: string; backendId?: string };
|
||||
|
||||
const initialTodoListMeta: TodoListMeta[] = [
|
||||
{ id: "todos/today", label: "Today" },
|
||||
{ id: "todos/scheduled", label: "Scheduled" },
|
||||
{ id: "todos/completed", label: "Completed" }
|
||||
];
|
||||
export const todoListsStore = writable<TodoListMeta[]>(initialTodoListMeta);
|
||||
export const todoListsStore = writable<TodoListMeta[]>([]);
|
||||
export const todosStore = writable<Record<string, TodoItem[]>>({});
|
||||
export const todosBusyStore = writable(false);
|
||||
|
||||
export const todosStore = writable<Record<string, TodoItem[]>>({
|
||||
"todos/today": [
|
||||
{ id: 1, text: "Finalize journal sidebar interactions", done: false },
|
||||
{ id: 2, text: "Review fragment taxonomy updates", done: false },
|
||||
{ id: 3, text: "Capture daily notes summary", done: false }
|
||||
],
|
||||
"todos/scheduled": [
|
||||
{ id: 4, text: "Tuesday: sync settings to backend store", done: false },
|
||||
{ id: 5, text: "Thursday: polish editor keyboard shortcuts", done: false },
|
||||
{ id: 6, text: "Friday: QA fragment and todo workflows", done: false }
|
||||
],
|
||||
"todos/completed": [
|
||||
{ id: 7, text: "Replaced navbar profile with settings shortcut", done: true },
|
||||
{ id: 8, text: "Centered modal presentation", done: true },
|
||||
{ id: 9, text: "Added fragment creation form mode", done: true }
|
||||
]
|
||||
});
|
||||
// ── ID helpers ───────────────────────────────────────────────────
|
||||
|
||||
function toStoreId(guid: string): string {
|
||||
return `todos/${guid}`;
|
||||
}
|
||||
|
||||
function toBackendId(storeId: string): string | null {
|
||||
const prefix = "todos/";
|
||||
if (!storeId.startsWith(prefix)) return null;
|
||||
const backendId = storeId.slice(prefix.length).trim();
|
||||
return backendId || null;
|
||||
}
|
||||
|
||||
export function createTodoId(): number {
|
||||
return Date.now() + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
|
||||
// ── DTO mapping ──────────────────────────────────────────────────
|
||||
|
||||
function dtoToMeta(dto: TodoListDto): TodoListMeta {
|
||||
return {
|
||||
id: toStoreId(dto.id),
|
||||
label: dto.label,
|
||||
backendId: dto.id
|
||||
};
|
||||
}
|
||||
|
||||
function dtoToItems(dto: TodoListDto): TodoItem[] {
|
||||
return dto.items.map((item, index) => ({
|
||||
id: createTodoId() + index,
|
||||
text: item.text,
|
||||
done: item.done,
|
||||
backendId: item.id
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Hydration ────────────────────────────────────────────────────
|
||||
|
||||
export async function hydrateTodos(): Promise<void> {
|
||||
todosBusyStore.set(true);
|
||||
try {
|
||||
const lists = await listTodoLists();
|
||||
|
||||
const metas: TodoListMeta[] = lists.map(dtoToMeta);
|
||||
const items: Record<string, TodoItem[]> = {};
|
||||
for (const dto of lists) {
|
||||
items[toStoreId(dto.id)] = dtoToItems(dto);
|
||||
}
|
||||
|
||||
todoListsStore.set(metas);
|
||||
todosStore.set(items);
|
||||
} catch (error) {
|
||||
console.error("[todos] hydrate:error", error);
|
||||
throw error;
|
||||
} finally {
|
||||
todosBusyStore.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── List CRUD ────────────────────────────────────────────────────
|
||||
|
||||
export async function createTodoListFromLabel(
|
||||
label: string
|
||||
): Promise<{ meta: TodoListMeta; items: TodoItem[] }> {
|
||||
const resolvedLabel = label.trim() || "New List";
|
||||
const created = await createTodoListCommand({ label: resolvedLabel });
|
||||
|
||||
const meta = dtoToMeta(created);
|
||||
todoListsStore.update((metas) => [meta, ...metas]);
|
||||
todosStore.update((lists) => ({ ...lists, [meta.id]: [] }));
|
||||
return { meta, items: [] };
|
||||
}
|
||||
|
||||
export async function deleteTodoListByStoreId(storeId: string): Promise<boolean> {
|
||||
const backendId = toBackendId(storeId);
|
||||
if (!backendId) return false;
|
||||
|
||||
const ok = await deleteTodoListCommand(backendId);
|
||||
if (!ok) return false;
|
||||
|
||||
todoListsStore.update((metas) => metas.filter((m) => m.id !== storeId));
|
||||
todosStore.update((lists) => {
|
||||
const { [storeId]: _, ...rest } = lists;
|
||||
return rest;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Item CRUD (backend-backed) ───────────────────────────────────
|
||||
|
||||
export async function addTodoItemBackend(
|
||||
storeId: string,
|
||||
text: string
|
||||
): Promise<TodoItem | null> {
|
||||
const backendListId = toBackendId(storeId);
|
||||
if (!backendListId || !text.trim()) return null;
|
||||
|
||||
const items = get(todosStore)[storeId] ?? [];
|
||||
const sortOrder = items.length;
|
||||
|
||||
const created = await createTodoItemCommand({
|
||||
listId: backendListId,
|
||||
text: text.trim(),
|
||||
sortOrder
|
||||
});
|
||||
|
||||
const item: TodoItem = {
|
||||
id: createTodoId(),
|
||||
text: created.text,
|
||||
done: created.done,
|
||||
backendId: created.id
|
||||
};
|
||||
|
||||
todosStore.update((lists) => ({
|
||||
...lists,
|
||||
[storeId]: [item, ...(lists[storeId] ?? [])]
|
||||
}));
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function toggleTodoItemBackend(
|
||||
storeId: string,
|
||||
localId: number
|
||||
): Promise<boolean> {
|
||||
const items = get(todosStore)[storeId];
|
||||
const todo = items?.find((t) => t.id === localId);
|
||||
if (!todo?.backendId) return false;
|
||||
|
||||
const ok = await updateTodoItemCommand(todo.backendId, { done: !todo.done });
|
||||
if (!ok) return false;
|
||||
|
||||
todosStore.update((lists) => ({
|
||||
...lists,
|
||||
[storeId]: (lists[storeId] ?? []).map((t) =>
|
||||
t.id === localId ? { ...t, done: !t.done } : t
|
||||
)
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateTodoItemTextBackend(
|
||||
storeId: string,
|
||||
localId: number,
|
||||
text: string
|
||||
): Promise<boolean> {
|
||||
const items = get(todosStore)[storeId];
|
||||
const todo = items?.find((t) => t.id === localId);
|
||||
if (!todo?.backendId || !text.trim()) return false;
|
||||
|
||||
const ok = await updateTodoItemCommand(todo.backendId, { text: text.trim() });
|
||||
if (!ok) return false;
|
||||
|
||||
todosStore.update((lists) => ({
|
||||
...lists,
|
||||
[storeId]: (lists[storeId] ?? []).map((t) =>
|
||||
t.id === localId ? { ...t, text: text.trim() } : t
|
||||
)
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function removeTodoItemBackend(
|
||||
storeId: string,
|
||||
localId: number
|
||||
): Promise<boolean> {
|
||||
const items = get(todosStore)[storeId];
|
||||
const todo = items?.find((t) => t.id === localId);
|
||||
if (!todo?.backendId) return false;
|
||||
|
||||
const ok = await deleteTodoItemCommand(todo.backendId);
|
||||
if (!ok) return false;
|
||||
|
||||
todosStore.update((lists) => ({
|
||||
...lists,
|
||||
[storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId)
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Pure helpers (used by EditorPanel for local state) ───────────
|
||||
|
||||
export function serializeTodoList(title: string, todos: TodoItem[]): string {
|
||||
const heading = title?.trim() ? `# ${title}` : "# To-Do List";
|
||||
@ -34,10 +206,6 @@ export function serializeTodoList(title: string, todos: TodoItem[]): string {
|
||||
return `${heading}\n\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
export function createTodoId(): number {
|
||||
return Date.now() + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
|
||||
export function parseTodoList(content: string): TodoItem[] {
|
||||
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
||||
const parsed: TodoItem[] = [];
|
||||
@ -91,7 +259,7 @@ export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] {
|
||||
}
|
||||
|
||||
export function createTodoListDraft(): { meta: TodoListMeta; items: TodoItem[] } {
|
||||
const id = `todos/list-${Date.now()}`;
|
||||
const id = `todos/draft-${Date.now()}`;
|
||||
return {
|
||||
meta: { id, label: "New List" },
|
||||
items: []
|
||||
|
||||
115
Journal.App/src/lib/utils/markdown.ts
Normal file
115
Journal.App/src/lib/utils/markdown.ts
Normal file
@ -0,0 +1,115 @@
|
||||
export function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function parseInline(input: string): string {
|
||||
let value = escapeHtml(input);
|
||||
value = value.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
value = value.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
value = value.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
value = value.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noreferrer">$1</a>'
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const output: string[] = [];
|
||||
let i = 0;
|
||||
let inCode = false;
|
||||
let codeLines: string[] = [];
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
if (inCode) {
|
||||
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
codeLines = [];
|
||||
inCode = false;
|
||||
} else {
|
||||
inCode = true;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
codeLines.push(line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
output.push(`<h${level}>${parseInline(heading[2])}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*+]\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${parseInline(lines[i].trim().replace(/^[-*+]\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
output.push(`<ul>${items.join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${parseInline(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
output.push(`<ol>${items.join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s+/.test(trimmed)) {
|
||||
output.push(`<blockquote>${parseInline(trimmed.replace(/^>\s+/, ""))}</blockquote>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(-{3,}|\*{3,})$/.test(trimmed)) {
|
||||
output.push("<hr />");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraph: string[] = [];
|
||||
while (i < lines.length && lines[i].trim()) {
|
||||
paragraph.push(lines[i].trim());
|
||||
i += 1;
|
||||
}
|
||||
output.push(`<p>${parseInline(paragraph.join(" "))}</p>`);
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
}
|
||||
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
export function extractEditorTitle(markdown: string, fallback: string): string {
|
||||
const firstLine = markdown.replace(/\r\n/g, "\n").split("\n")[0]?.trim() ?? "";
|
||||
const headingMatch = firstLine.match(/^#\s+(.+)$/);
|
||||
return headingMatch ? headingMatch[1] : fallback;
|
||||
}
|
||||
36
Journal.App/src/routes/+layout.svelte
Normal file
36
Journal.App/src/routes/+layout.svelte
Normal file
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
import { getSessionPassword, clearVaultSession } from "$lib/stores/session";
|
||||
import { persistAndClearVault } from "$lib/backend/auth";
|
||||
|
||||
let closeInProgress = false;
|
||||
|
||||
onMount(() => {
|
||||
const appWindow = getCurrentWindow();
|
||||
const unlistenPromise = appWindow.onCloseRequested(async (event) => {
|
||||
if (closeInProgress) return;
|
||||
event.preventDefault();
|
||||
closeInProgress = true;
|
||||
|
||||
const password = getSessionPassword();
|
||||
if (password) {
|
||||
try {
|
||||
await persistAndClearVault(password);
|
||||
clearVaultSession();
|
||||
} catch (error) {
|
||||
console.error("Vault persistence on exit failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
await invoke("shutdown");
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unlistenPromise.then((unlisten) => unlisten());
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { persistAndClearVault, unlockVaultWorkspace } from "$lib/backend/auth";
|
||||
import { unlockVaultWorkspace } from "$lib/backend/auth";
|
||||
import AppModal from "$lib/components/AppModal.svelte";
|
||||
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId } from "$lib/stores/entries";
|
||||
import { hydrateFragments } from "$lib/stores/fragments";
|
||||
import { hydrateLists } from "$lib/stores/lists";
|
||||
import { isVaultReady, setVaultSession } from "$lib/stores/session";
|
||||
import { hydrateTodos } from "$lib/stores/todos";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import SidePanel from "$lib/components/SidePanel.svelte";
|
||||
import EditorPanel from "$lib/components/EditorPanel.svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
@ -42,8 +43,6 @@
|
||||
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";
|
||||
@ -147,20 +146,33 @@
|
||||
|
||||
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
||||
if (fragmentBootstrapInFlight) return;
|
||||
fragmentBootstrapInFlight = true;
|
||||
|
||||
if (isVaultReady()) {
|
||||
try {
|
||||
await hydrateEntries();
|
||||
const firstEntry = getDefaultEntry(get(entriesStore));
|
||||
if (firstEntry && activeDocumentId === "entries/daily-notes") {
|
||||
await handleOpenDocument(firstEntry);
|
||||
}
|
||||
await hydrateFragments();
|
||||
await hydrateLists().catch(() => {});
|
||||
await hydrateTodos().catch(() => {});
|
||||
} catch (error) {
|
||||
console.error("Hydration failed:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fragmentBootstrapInFlight = true;
|
||||
try {
|
||||
let attempts = 0;
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const password = await requestVaultPassword();
|
||||
if (!password) {
|
||||
console.warn("Vault unlock canceled. Journal data remains unavailable.");
|
||||
return;
|
||||
}
|
||||
if (!password) return;
|
||||
|
||||
await unlockVaultWorkspace(password);
|
||||
vaultPassword = password;
|
||||
setVaultSession(password);
|
||||
|
||||
await hydrateEntries();
|
||||
const firstEntry = getDefaultEntry(get(entriesStore));
|
||||
@ -169,37 +181,20 @@
|
||||
}
|
||||
|
||||
await hydrateFragments();
|
||||
await hydrateLists().catch(() => {});
|
||||
await hydrateTodos().catch(() => {});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isLockedError(error)) {
|
||||
console.error("Failed to load journal data from sidecar:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLockedError(error)) return;
|
||||
attempts += 1;
|
||||
console.error("Vault unlock failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Vault remains locked after ${maxAttempts} attempts. Stopping unlock prompts.`);
|
||||
} finally {
|
||||
fragmentBootstrapInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
@ -226,6 +221,8 @@
|
||||
|
||||
selectedSection = id;
|
||||
panelOpen = true;
|
||||
activeDocumentId = "";
|
||||
activeDocumentLabel = "";
|
||||
}
|
||||
|
||||
async function handleOpenDocument(doc: OpenDocument) {
|
||||
@ -236,8 +233,8 @@
|
||||
if (loaded) {
|
||||
resolvedDoc = loaded;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load entry content:", error);
|
||||
} catch {
|
||||
// entry content will use initialContent fallback
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,22 +255,7 @@
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
updateFragmentType,
|
||||
updateSettingsTag
|
||||
} from "$lib/stores/settings";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const activeSection = "settings";
|
||||
|
||||
@ -29,6 +31,41 @@
|
||||
let editingTagValue = "";
|
||||
let editingFragmentTypeIndex: number | null = null;
|
||||
let editingFragmentTypeValue = "";
|
||||
let sidecarRoot = "";
|
||||
let sidecarRootIsCustom = false;
|
||||
let sidecarRootError = "";
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const result: any = await invoke("get_sidecar_root");
|
||||
sidecarRoot = result.root;
|
||||
sidecarRootIsCustom = result.isCustom;
|
||||
} catch (e) {
|
||||
sidecarRootError = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
async function saveSidecarRoot() {
|
||||
sidecarRootError = "";
|
||||
try {
|
||||
const result: any = await invoke("set_sidecar_root", { path: sidecarRoot });
|
||||
sidecarRoot = result.root;
|
||||
sidecarRootIsCustom = result.isCustom;
|
||||
} catch (e) {
|
||||
sidecarRootError = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSidecarRoot() {
|
||||
sidecarRootError = "";
|
||||
try {
|
||||
const result: any = await invoke("set_sidecar_root", { path: "" });
|
||||
sidecarRoot = result.root;
|
||||
sidecarRootIsCustom = result.isCustom;
|
||||
} catch (e) {
|
||||
sidecarRootError = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function showModal(options: {
|
||||
action: "logout-confirm" | "logout-info";
|
||||
@ -166,6 +203,7 @@
|
||||
<p>Configure app behavior and interface options.</p>
|
||||
</header>
|
||||
|
||||
<div class="settings-grid">
|
||||
<section class="route-card">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" checked />
|
||||
@ -270,6 +308,29 @@
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="route-card">
|
||||
<h2>Sidecar</h2>
|
||||
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
|
||||
|
||||
<div class="create-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Auto-detected from working directory"
|
||||
bind:value={sidecarRoot}
|
||||
on:keydown={(event) => event.key === "Enter" && saveSidecarRoot()}
|
||||
/>
|
||||
<button type="button" class="secondary-btn" on:click={saveSidecarRoot}>Save</button>
|
||||
{#if sidecarRootIsCustom}
|
||||
<button type="button" class="ghost-btn" on:click={resetSidecarRoot}>Reset</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sidecarRootError}
|
||||
<p class="error-text">{sidecarRootError}</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -306,7 +367,13 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
columns: 2;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.route-card {
|
||||
break-inside: avoid;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
border-radius: 10px;
|
||||
@ -314,7 +381,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 640px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
@ -427,6 +494,11 @@
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e74c3c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
21
Journal.Core/Dtos/ListDtos.cs
Normal file
21
Journal.Core/Dtos/ListDtos.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Journal.Core.Dtos;
|
||||
|
||||
public record ListDocumentDto(
|
||||
Guid Id,
|
||||
string Label,
|
||||
string Content,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt
|
||||
);
|
||||
|
||||
public record CreateListDto(
|
||||
[property: Required(AllowEmptyStrings = false)] string Label,
|
||||
string? Content = null
|
||||
);
|
||||
|
||||
public record UpdateListDto(
|
||||
string? Label = null,
|
||||
string? Content = null
|
||||
);
|
||||
38
Journal.Core/Dtos/TodoDtos.cs
Normal file
38
Journal.Core/Dtos/TodoDtos.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Journal.Core.Dtos;
|
||||
|
||||
public record TodoListDto(
|
||||
Guid Id,
|
||||
string Label,
|
||||
DateTimeOffset CreatedAt,
|
||||
List<TodoItemDto> Items
|
||||
);
|
||||
|
||||
public record TodoItemDto(
|
||||
Guid Id,
|
||||
Guid ListId,
|
||||
string Text,
|
||||
bool Done,
|
||||
int SortOrder
|
||||
);
|
||||
|
||||
public record CreateTodoListDto(
|
||||
[property: Required(AllowEmptyStrings = false)] string Label
|
||||
);
|
||||
|
||||
public record UpdateTodoListDto(
|
||||
string? Label = null
|
||||
);
|
||||
|
||||
public record CreateTodoItemDto(
|
||||
[property: Required] Guid ListId,
|
||||
[property: Required(AllowEmptyStrings = false)] string Text,
|
||||
int? SortOrder = null
|
||||
);
|
||||
|
||||
public record UpdateTodoItemDto(
|
||||
string? Text = null,
|
||||
bool? Done = null,
|
||||
int? SortOrder = null
|
||||
);
|
||||
@ -8,8 +8,10 @@ using Journal.Core.Services.Config;
|
||||
using Journal.Core.Services.Database;
|
||||
using Journal.Core.Services.Entries;
|
||||
using Journal.Core.Services.Fragments;
|
||||
using Journal.Core.Services.Lists;
|
||||
using Journal.Core.Services.Logging;
|
||||
using Journal.Core.Services.Speech;
|
||||
using Journal.Core.Services.Todos;
|
||||
using Journal.Core.Services.Vault;
|
||||
|
||||
namespace Journal.Core;
|
||||
@ -24,6 +26,8 @@ public class Entry(
|
||||
IAiService ai,
|
||||
ISpeechBridgeService speech,
|
||||
IEntryFileService entryFiles,
|
||||
IListService lists,
|
||||
ITodoService todos,
|
||||
CommandLogger logger)
|
||||
{
|
||||
private readonly IFragmentService _fragments = fragments;
|
||||
@ -35,6 +39,8 @@ public class Entry(
|
||||
private readonly IAiService _ai = ai;
|
||||
private readonly ISpeechBridgeService _speech = speech;
|
||||
private readonly IEntryFileService _entryFiles = entryFiles;
|
||||
private readonly IListService _lists = lists;
|
||||
private readonly ITodoService _todos = todos;
|
||||
private readonly CommandLogger _logger = logger;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@ -110,6 +116,83 @@ public class Entry(
|
||||
case "fragments.search":
|
||||
result = _fragments.Search(cmd.Type, cmd.Tag);
|
||||
break;
|
||||
|
||||
// ── Lists ────────────────────────────────────────
|
||||
case "lists.list":
|
||||
result = _lists.GetAll();
|
||||
break;
|
||||
case "lists.get":
|
||||
if (!Guid.TryParse(cmd.Id, out var getListId))
|
||||
return Error("Invalid or missing id");
|
||||
result = _lists.GetById(getListId);
|
||||
break;
|
||||
case "lists.create":
|
||||
var createListDto = DeserializePayload<CreateListDto>(cmd.Payload);
|
||||
if (createListDto is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = _lists.Create(createListDto);
|
||||
break;
|
||||
case "lists.update":
|
||||
if (!Guid.TryParse(cmd.Id, out var updateListId))
|
||||
return Error("Invalid or missing id");
|
||||
var updateListDto = DeserializePayload<UpdateListDto>(cmd.Payload);
|
||||
if (updateListDto is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = _lists.Update(updateListId, updateListDto);
|
||||
break;
|
||||
case "lists.delete":
|
||||
if (!Guid.TryParse(cmd.Id, out var deleteListId))
|
||||
return Error("Invalid or missing id");
|
||||
result = _lists.Remove(deleteListId);
|
||||
break;
|
||||
|
||||
// ── Todos ────────────────────────────────────────
|
||||
case "todos.list":
|
||||
result = _todos.GetAllLists();
|
||||
break;
|
||||
case "todos.get":
|
||||
if (!Guid.TryParse(cmd.Id, out var getTodoListId))
|
||||
return Error("Invalid or missing id");
|
||||
result = _todos.GetListById(getTodoListId);
|
||||
break;
|
||||
case "todos.create":
|
||||
var createTodoListDto = DeserializePayload<CreateTodoListDto>(cmd.Payload);
|
||||
if (createTodoListDto is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = _todos.CreateList(createTodoListDto);
|
||||
break;
|
||||
case "todos.update":
|
||||
if (!Guid.TryParse(cmd.Id, out var updateTodoListId))
|
||||
return Error("Invalid or missing id");
|
||||
var updateTodoListDto = DeserializePayload<UpdateTodoListDto>(cmd.Payload);
|
||||
if (updateTodoListDto is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = _todos.UpdateList(updateTodoListId, updateTodoListDto);
|
||||
break;
|
||||
case "todos.delete":
|
||||
if (!Guid.TryParse(cmd.Id, out var deleteTodoListId))
|
||||
return Error("Invalid or missing id");
|
||||
result = _todos.RemoveList(deleteTodoListId);
|
||||
break;
|
||||
case "todos.items.create":
|
||||
var createItemDto = DeserializePayload<CreateTodoItemDto>(cmd.Payload);
|
||||
if (createItemDto is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = _todos.CreateItem(createItemDto);
|
||||
break;
|
||||
case "todos.items.update":
|
||||
if (!Guid.TryParse(cmd.Id, out var updateItemId))
|
||||
return Error("Invalid or missing id");
|
||||
var updateItemDto = DeserializePayload<UpdateTodoItemDto>(cmd.Payload);
|
||||
if (updateItemDto is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = _todos.UpdateItem(updateItemId, updateItemDto);
|
||||
break;
|
||||
case "todos.items.delete":
|
||||
if (!Guid.TryParse(cmd.Id, out var deleteItemId))
|
||||
return Error("Invalid or missing id");
|
||||
result = _todos.RemoveItem(deleteItemId);
|
||||
break;
|
||||
case "search.entries":
|
||||
var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload);
|
||||
if (searchPayload is null || string.IsNullOrWhiteSpace(searchPayload.DataDirectory))
|
||||
@ -227,6 +310,7 @@ public class Entry(
|
||||
var rebuildPayload = DeserializePayload<VaultPayload>(cmd.Payload);
|
||||
if (rebuildPayload is null)
|
||||
return Error("Missing or invalid payload");
|
||||
_databaseSession.CloseConnection();
|
||||
_vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, rebuildPayload.DataDirectory);
|
||||
result = true;
|
||||
break;
|
||||
|
||||
40
Journal.Core/Models/ListDocument.cs
Normal file
40
Journal.Core/Models/ListDocument.cs
Normal file
@ -0,0 +1,40 @@
|
||||
namespace Journal.Core.Models;
|
||||
|
||||
public class ListDocument
|
||||
{
|
||||
public Guid Id { get; }
|
||||
public string Label { get; set; }
|
||||
public string Content { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public ListDocument(string label, string content = "")
|
||||
{
|
||||
Validate(label);
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
Label = label.Trim();
|
||||
Content = content;
|
||||
CreatedAt = DateTimeOffset.Now;
|
||||
UpdatedAt = CreatedAt;
|
||||
}
|
||||
|
||||
public ListDocument(Guid id, string label, string content, DateTimeOffset createdAt, DateTimeOffset updatedAt)
|
||||
{
|
||||
if (id == Guid.Empty)
|
||||
throw new ArgumentException("Id is required", nameof(id));
|
||||
Validate(label);
|
||||
|
||||
Id = id;
|
||||
Label = label.Trim();
|
||||
Content = content;
|
||||
CreatedAt = createdAt;
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
private static void Validate(string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
throw new ArgumentException("Label is required", nameof(label));
|
||||
}
|
||||
}
|
||||
44
Journal.Core/Models/TodoItem.cs
Normal file
44
Journal.Core/Models/TodoItem.cs
Normal file
@ -0,0 +1,44 @@
|
||||
namespace Journal.Core.Models;
|
||||
|
||||
public class TodoItem
|
||||
{
|
||||
public Guid Id { get; }
|
||||
public Guid ListId { get; }
|
||||
public string Text { get; set; }
|
||||
public bool Done { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public TodoItem(Guid listId, string text, int sortOrder = 0)
|
||||
{
|
||||
Validate(text);
|
||||
if (listId == Guid.Empty)
|
||||
throw new ArgumentException("ListId is required", nameof(listId));
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
ListId = listId;
|
||||
Text = text.Trim();
|
||||
Done = false;
|
||||
SortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public TodoItem(Guid id, Guid listId, string text, bool done, int sortOrder)
|
||||
{
|
||||
if (id == Guid.Empty)
|
||||
throw new ArgumentException("Id is required", nameof(id));
|
||||
if (listId == Guid.Empty)
|
||||
throw new ArgumentException("ListId is required", nameof(listId));
|
||||
Validate(text);
|
||||
|
||||
Id = id;
|
||||
ListId = listId;
|
||||
Text = text.Trim();
|
||||
Done = done;
|
||||
SortOrder = sortOrder;
|
||||
}
|
||||
|
||||
private static void Validate(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
throw new ArgumentException("Text is required", nameof(text));
|
||||
}
|
||||
}
|
||||
34
Journal.Core/Models/TodoList.cs
Normal file
34
Journal.Core/Models/TodoList.cs
Normal file
@ -0,0 +1,34 @@
|
||||
namespace Journal.Core.Models;
|
||||
|
||||
public class TodoList
|
||||
{
|
||||
public Guid Id { get; }
|
||||
public string Label { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public TodoList(string label)
|
||||
{
|
||||
Validate(label);
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
Label = label.Trim();
|
||||
CreatedAt = DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
public TodoList(Guid id, string label, DateTimeOffset createdAt)
|
||||
{
|
||||
if (id == Guid.Empty)
|
||||
throw new ArgumentException("Id is required", nameof(id));
|
||||
Validate(label);
|
||||
|
||||
Id = id;
|
||||
Label = label.Trim();
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
private static void Validate(string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
throw new ArgumentException("Label is required", nameof(label));
|
||||
}
|
||||
}
|
||||
12
Journal.Core/Repositories/IListRepository.cs
Normal file
12
Journal.Core/Repositories/IListRepository.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Repositories;
|
||||
|
||||
public interface IListRepository
|
||||
{
|
||||
List<ListDocument> GetAll();
|
||||
ListDocument? GetById(Guid id);
|
||||
void Add(ListDocument list);
|
||||
bool Update(Guid id, string? label = null, string? content = null);
|
||||
bool Remove(Guid id);
|
||||
}
|
||||
18
Journal.Core/Repositories/ITodoRepository.cs
Normal file
18
Journal.Core/Repositories/ITodoRepository.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Repositories;
|
||||
|
||||
public interface ITodoRepository
|
||||
{
|
||||
List<TodoList> GetAllLists();
|
||||
TodoList? GetListById(Guid id);
|
||||
void AddList(TodoList list);
|
||||
bool UpdateList(Guid id, string? label = null);
|
||||
bool RemoveList(Guid id);
|
||||
|
||||
List<TodoItem> GetItemsByListId(Guid listId);
|
||||
TodoItem? GetItemById(Guid id);
|
||||
void AddItem(TodoItem item);
|
||||
bool UpdateItem(Guid id, string? text = null, bool? done = null, int? sortOrder = null);
|
||||
bool RemoveItem(Guid id);
|
||||
}
|
||||
129
Journal.Core/Repositories/SqliteListRepository.cs
Normal file
129
Journal.Core/Repositories/SqliteListRepository.cs
Normal file
@ -0,0 +1,129 @@
|
||||
using Journal.Core.Models;
|
||||
using Journal.Core.Services.Database;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Journal.Core.Repositories;
|
||||
|
||||
public sealed class SqliteListRepository(IDatabaseSessionService session) : IListRepository
|
||||
{
|
||||
private readonly IDatabaseSessionService _session = session;
|
||||
|
||||
public List<ListDocument> GetAll()
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
return ReadAll(conn);
|
||||
}
|
||||
|
||||
public ListDocument? GetById(Guid id)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
return ReadById(conn, id);
|
||||
}
|
||||
|
||||
public void Add(ListDocument list)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(list);
|
||||
var conn = _session.GetConnection();
|
||||
Insert(conn, list);
|
||||
}
|
||||
|
||||
public bool Update(Guid id, string? label = null, string? content = null)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
var existing = ReadById(conn, id);
|
||||
if (existing is null)
|
||||
return false;
|
||||
|
||||
if (label is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
throw new ArgumentException("Label cannot be empty", nameof(label));
|
||||
existing.Label = label.Trim();
|
||||
}
|
||||
|
||||
if (content is not null)
|
||||
existing.Content = content;
|
||||
|
||||
existing.UpdatedAt = DateTimeOffset.Now;
|
||||
UpdateRow(conn, existing);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Remove(Guid id)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
return Delete(conn, id);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────
|
||||
|
||||
private static void Insert(SqliteConnection conn, ListDocument list)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO lists (guid, label, content, created_at, updated_at)
|
||||
VALUES (@guid, @label, @content, @createdAt, @updatedAt);
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D"));
|
||||
cmd.Parameters.AddWithValue("@label", list.Label);
|
||||
cmd.Parameters.AddWithValue("@content", list.Content);
|
||||
cmd.Parameters.AddWithValue("@createdAt", list.CreatedAt.ToString("O"));
|
||||
cmd.Parameters.AddWithValue("@updatedAt", list.UpdatedAt.ToString("O"));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void UpdateRow(SqliteConnection conn, ListDocument list)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE lists SET label = @label, content = @content, updated_at = @updatedAt
|
||||
WHERE guid = @guid;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D"));
|
||||
cmd.Parameters.AddWithValue("@label", list.Label);
|
||||
cmd.Parameters.AddWithValue("@content", list.Content);
|
||||
cmd.Parameters.AddWithValue("@updatedAt", list.UpdatedAt.ToString("O"));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static bool Delete(SqliteConnection conn, Guid id)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM lists WHERE guid = @guid;";
|
||||
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
||||
return cmd.ExecuteNonQuery() > 0;
|
||||
}
|
||||
|
||||
private static ListDocument? ReadById(SqliteConnection conn, Guid id)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT guid, label, content, created_at, updated_at FROM lists WHERE guid = @guid;";
|
||||
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
||||
|
||||
using var reader = cmd.ExecuteReader();
|
||||
return reader.Read() ? MapRow(reader) : null;
|
||||
}
|
||||
|
||||
private static List<ListDocument> ReadAll(SqliteConnection conn)
|
||||
{
|
||||
var results = new List<ListDocument>();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT guid, label, content, created_at, updated_at FROM lists ORDER BY created_at;";
|
||||
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
results.Add(MapRow(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static ListDocument MapRow(SqliteDataReader reader)
|
||||
{
|
||||
var guid = Guid.Parse(reader.GetString(0));
|
||||
var label = reader.GetString(1);
|
||||
var content = reader.IsDBNull(2) ? "" : reader.GetString(2);
|
||||
var createdAt = reader.IsDBNull(3) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(3));
|
||||
var updatedAt = reader.IsDBNull(4) ? createdAt : DateTimeOffset.Parse(reader.GetString(4));
|
||||
return new ListDocument(guid, label, content, createdAt, updatedAt);
|
||||
}
|
||||
}
|
||||
279
Journal.Core/Repositories/SqliteTodoRepository.cs
Normal file
279
Journal.Core/Repositories/SqliteTodoRepository.cs
Normal file
@ -0,0 +1,279 @@
|
||||
using Journal.Core.Models;
|
||||
using Journal.Core.Services.Database;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Journal.Core.Repositories;
|
||||
|
||||
public sealed class SqliteTodoRepository(IDatabaseSessionService session) : ITodoRepository
|
||||
{
|
||||
private readonly IDatabaseSessionService _session = session;
|
||||
|
||||
// ── Lists ────────────────────────────────────────────────────────
|
||||
|
||||
public List<TodoList> GetAllLists()
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
return ReadAllLists(conn);
|
||||
}
|
||||
|
||||
public TodoList? GetListById(Guid id)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
return ReadListById(conn, id);
|
||||
}
|
||||
|
||||
public void AddList(TodoList list)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(list);
|
||||
var conn = _session.GetConnection();
|
||||
InsertList(conn, list);
|
||||
}
|
||||
|
||||
public bool UpdateList(Guid id, string? label = null)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
var existing = ReadListById(conn, id);
|
||||
if (existing is null)
|
||||
return false;
|
||||
|
||||
if (label is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
throw new ArgumentException("Label cannot be empty", nameof(label));
|
||||
existing.Label = label.Trim();
|
||||
}
|
||||
|
||||
UpdateListRow(conn, existing);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RemoveList(Guid id)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
return DeleteList(conn, id);
|
||||
}
|
||||
|
||||
// ── Items ────────────────────────────────────────────────────────
|
||||
|
||||
public List<TodoItem> GetItemsByListId(Guid listId)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
return ReadItemsByListId(conn, listId);
|
||||
}
|
||||
|
||||
public TodoItem? GetItemById(Guid id)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
return ReadItemById(conn, id);
|
||||
}
|
||||
|
||||
public void AddItem(TodoItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
var conn = _session.GetConnection();
|
||||
InsertItem(conn, item);
|
||||
}
|
||||
|
||||
public bool UpdateItem(Guid id, string? text = null, bool? done = null, int? sortOrder = null)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
var existing = ReadItemById(conn, id);
|
||||
if (existing is null)
|
||||
return false;
|
||||
|
||||
if (text is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
throw new ArgumentException("Text cannot be empty", nameof(text));
|
||||
existing.Text = text.Trim();
|
||||
}
|
||||
|
||||
if (done.HasValue)
|
||||
existing.Done = done.Value;
|
||||
|
||||
if (sortOrder.HasValue)
|
||||
existing.SortOrder = sortOrder.Value;
|
||||
|
||||
UpdateItemRow(conn, existing);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RemoveItem(Guid id)
|
||||
{
|
||||
var conn = _session.GetConnection();
|
||||
return DeleteItem(conn, id);
|
||||
}
|
||||
|
||||
// ── Private list helpers ─────────────────────────────────────────
|
||||
|
||||
private static void InsertList(SqliteConnection conn, TodoList list)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO todo_lists (guid, label, created_at)
|
||||
VALUES (@guid, @label, @createdAt);
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D"));
|
||||
cmd.Parameters.AddWithValue("@label", list.Label);
|
||||
cmd.Parameters.AddWithValue("@createdAt", list.CreatedAt.ToString("O"));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void UpdateListRow(SqliteConnection conn, TodoList list)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE todo_lists SET label = @label WHERE guid = @guid;";
|
||||
cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D"));
|
||||
cmd.Parameters.AddWithValue("@label", list.Label);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static bool DeleteList(SqliteConnection conn, Guid id)
|
||||
{
|
||||
using var tx = conn.BeginTransaction();
|
||||
|
||||
var rowId = GetListRowId(conn, id);
|
||||
if (rowId.HasValue)
|
||||
{
|
||||
using var delItems = conn.CreateCommand();
|
||||
delItems.CommandText = "DELETE FROM todo_items WHERE list_id = @listId;";
|
||||
delItems.Parameters.AddWithValue("@listId", rowId.Value);
|
||||
delItems.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var delList = conn.CreateCommand();
|
||||
delList.CommandText = "DELETE FROM todo_lists WHERE guid = @guid;";
|
||||
delList.Parameters.AddWithValue("@guid", id.ToString("D"));
|
||||
var rows = delList.ExecuteNonQuery();
|
||||
|
||||
tx.Commit();
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static TodoList? ReadListById(SqliteConnection conn, Guid id)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT guid, label, created_at FROM todo_lists WHERE guid = @guid;";
|
||||
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
||||
|
||||
using var reader = cmd.ExecuteReader();
|
||||
return reader.Read() ? MapListRow(reader) : null;
|
||||
}
|
||||
|
||||
private static List<TodoList> ReadAllLists(SqliteConnection conn)
|
||||
{
|
||||
var results = new List<TodoList>();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT guid, label, created_at FROM todo_lists ORDER BY created_at;";
|
||||
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
results.Add(MapListRow(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static long? GetListRowId(SqliteConnection conn, Guid guid)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id FROM todo_lists WHERE guid = @guid;";
|
||||
cmd.Parameters.AddWithValue("@guid", guid.ToString("D"));
|
||||
var result = cmd.ExecuteScalar();
|
||||
return result is long id ? id : null;
|
||||
}
|
||||
|
||||
private static TodoList MapListRow(SqliteDataReader reader)
|
||||
{
|
||||
var guid = Guid.Parse(reader.GetString(0));
|
||||
var label = reader.GetString(1);
|
||||
var createdAt = reader.IsDBNull(2) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(2));
|
||||
return new TodoList(guid, label, createdAt);
|
||||
}
|
||||
|
||||
// ── Private item helpers ─────────────────────────────────────────
|
||||
|
||||
private static void InsertItem(SqliteConnection conn, TodoItem item)
|
||||
{
|
||||
var listRowId = GetListRowId(conn, item.ListId)
|
||||
?? throw new InvalidOperationException($"Todo list {item.ListId} not found");
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO todo_items (guid, list_id, text, done, sort_order)
|
||||
VALUES (@guid, @listId, @text, @done, @sortOrder);
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@guid", item.Id.ToString("D"));
|
||||
cmd.Parameters.AddWithValue("@listId", listRowId);
|
||||
cmd.Parameters.AddWithValue("@text", item.Text);
|
||||
cmd.Parameters.AddWithValue("@done", item.Done ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@sortOrder", item.SortOrder);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void UpdateItemRow(SqliteConnection conn, TodoItem item)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE todo_items SET text = @text, done = @done, sort_order = @sortOrder
|
||||
WHERE guid = @guid;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@guid", item.Id.ToString("D"));
|
||||
cmd.Parameters.AddWithValue("@text", item.Text);
|
||||
cmd.Parameters.AddWithValue("@done", item.Done ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@sortOrder", item.SortOrder);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static bool DeleteItem(SqliteConnection conn, Guid id)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM todo_items WHERE guid = @guid;";
|
||||
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
||||
return cmd.ExecuteNonQuery() > 0;
|
||||
}
|
||||
|
||||
private static TodoItem? ReadItemById(SqliteConnection conn, Guid id)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT ti.guid, tl.guid, ti.text, ti.done, ti.sort_order
|
||||
FROM todo_items ti
|
||||
INNER JOIN todo_lists tl ON tl.id = ti.list_id
|
||||
WHERE ti.guid = @guid;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
|
||||
|
||||
using var reader = cmd.ExecuteReader();
|
||||
return reader.Read() ? MapItemRow(reader) : null;
|
||||
}
|
||||
|
||||
private static List<TodoItem> ReadItemsByListId(SqliteConnection conn, Guid listId)
|
||||
{
|
||||
var results = new List<TodoItem>();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT ti.guid, tl.guid, ti.text, ti.done, ti.sort_order
|
||||
FROM todo_items ti
|
||||
INNER JOIN todo_lists tl ON tl.id = ti.list_id
|
||||
WHERE tl.guid = @listGuid
|
||||
ORDER BY ti.sort_order, ti.guid;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@listGuid", listId.ToString("D"));
|
||||
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
results.Add(MapItemRow(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static TodoItem MapItemRow(SqliteDataReader reader)
|
||||
{
|
||||
var guid = Guid.Parse(reader.GetString(0));
|
||||
var listGuid = Guid.Parse(reader.GetString(1));
|
||||
var text = reader.GetString(2);
|
||||
var done = !reader.IsDBNull(3) && reader.GetInt64(3) != 0;
|
||||
var sortOrder = reader.IsDBNull(4) ? 0 : (int)reader.GetInt64(4);
|
||||
return new TodoItem(guid, listGuid, text, done, sortOrder);
|
||||
}
|
||||
}
|
||||
@ -5,9 +5,11 @@ using Journal.Core.Services.Config;
|
||||
using Journal.Core.Services.Database;
|
||||
using Journal.Core.Services.Entries;
|
||||
using Journal.Core.Services.Fragments;
|
||||
using Journal.Core.Services.Lists;
|
||||
using Journal.Core.Services.Logging;
|
||||
using Journal.Core.Services.Sidecar;
|
||||
using Journal.Core.Services.Speech;
|
||||
using Journal.Core.Services.Todos;
|
||||
using Journal.Core.Services.Vault;
|
||||
|
||||
namespace Journal.Core;
|
||||
@ -58,6 +60,10 @@ public static class ServiceCollectionExtensions
|
||||
});
|
||||
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
|
||||
services.AddSingleton<IEntryFileService, EntryFileService>();
|
||||
services.AddSingleton<IListRepository, SqliteListRepository>();
|
||||
services.AddSingleton<IListService, ListService>();
|
||||
services.AddSingleton<ITodoRepository, SqliteTodoRepository>();
|
||||
services.AddSingleton<ITodoService, TodoService>();
|
||||
services.AddSingleton<CommandLogger>();
|
||||
services.AddSingleton<SidecarCli>();
|
||||
return services;
|
||||
|
||||
@ -54,7 +54,7 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
public void CloseConnection()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
@ -62,4 +62,9 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I
|
||||
_connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
CloseConnection();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,4 +7,5 @@ public interface IDatabaseSessionService
|
||||
bool IsUnlocked { get; }
|
||||
void SetPassword(string password, string? dataDirectory = null);
|
||||
SqliteConnection GetConnection();
|
||||
void CloseConnection();
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
||||
private static readonly Lock SqliteInitLock = new();
|
||||
private static bool _sqliteInitialized;
|
||||
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
||||
["entries", "sections", "fragments", "tags", "fragment_tags"];
|
||||
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items"];
|
||||
|
||||
private readonly IJournalConfigService _config = config;
|
||||
|
||||
@ -91,6 +91,35 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
||||
FOREIGN KEY (fragment_id) REFERENCES fragments (id),
|
||||
FOREIGN KEY (tag_id) REFERENCES tags (id)
|
||||
);
|
||||
""",
|
||||
["lists"] = """
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
content TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
""",
|
||||
["todo_lists"] = """
|
||||
CREATE TABLE IF NOT EXISTS todo_lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
);
|
||||
""",
|
||||
["todo_items"] = """
|
||||
CREATE TABLE IF NOT EXISTS todo_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT UNIQUE,
|
||||
list_id INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
done INTEGER DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (list_id) REFERENCES todo_lists (id)
|
||||
);
|
||||
"""
|
||||
};
|
||||
}
|
||||
|
||||
12
Journal.Core/Services/Lists/IListService.cs
Normal file
12
Journal.Core/Services/Lists/IListService.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services.Lists;
|
||||
|
||||
public interface IListService
|
||||
{
|
||||
List<ListDocumentDto> GetAll();
|
||||
ListDocumentDto? GetById(Guid id);
|
||||
ListDocumentDto Create(CreateListDto dto);
|
||||
bool Update(Guid id, UpdateListDto dto);
|
||||
bool Remove(Guid id);
|
||||
}
|
||||
54
Journal.Core/Services/Lists/ListService.cs
Normal file
54
Journal.Core/Services/Lists/ListService.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
using Journal.Core.Repositories;
|
||||
|
||||
namespace Journal.Core.Services.Lists;
|
||||
|
||||
public class ListService(IListRepository repo) : IListService
|
||||
{
|
||||
private readonly IListRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||
|
||||
private static ListDocumentDto Map(ListDocument d) => new(
|
||||
d.Id,
|
||||
d.Label,
|
||||
d.Content,
|
||||
d.CreatedAt,
|
||||
d.UpdatedAt
|
||||
);
|
||||
|
||||
public List<ListDocumentDto> GetAll()
|
||||
{
|
||||
var items = _repo.GetAll();
|
||||
return [.. items.Select(Map)];
|
||||
}
|
||||
|
||||
public ListDocumentDto? GetById(Guid id)
|
||||
{
|
||||
var d = _repo.GetById(id);
|
||||
return d is null ? null : Map(d);
|
||||
}
|
||||
|
||||
public ListDocumentDto Create(CreateListDto dto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
var ctx = new ValidationContext(dto);
|
||||
Validator.ValidateObject(dto, ctx, validateAllProperties: true);
|
||||
|
||||
var doc = new ListDocument(dto.Label, dto.Content ?? "");
|
||||
_repo.Add(doc);
|
||||
return Map(doc);
|
||||
}
|
||||
|
||||
public bool Update(Guid id, UpdateListDto dto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
if (dto.Label is not null && string.IsNullOrWhiteSpace(dto.Label))
|
||||
throw new ValidationException("Label cannot be empty");
|
||||
|
||||
return _repo.Update(id, dto.Label?.Trim(), dto.Content);
|
||||
}
|
||||
|
||||
public bool Remove(Guid id) => _repo.Remove(id);
|
||||
}
|
||||
16
Journal.Core/Services/Todos/ITodoService.cs
Normal file
16
Journal.Core/Services/Todos/ITodoService.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services.Todos;
|
||||
|
||||
public interface ITodoService
|
||||
{
|
||||
List<TodoListDto> GetAllLists();
|
||||
TodoListDto? GetListById(Guid id);
|
||||
TodoListDto CreateList(CreateTodoListDto dto);
|
||||
bool UpdateList(Guid id, UpdateTodoListDto dto);
|
||||
bool RemoveList(Guid id);
|
||||
|
||||
TodoItemDto CreateItem(CreateTodoItemDto dto);
|
||||
bool UpdateItem(Guid id, UpdateTodoItemDto dto);
|
||||
bool RemoveItem(Guid id);
|
||||
}
|
||||
91
Journal.Core/Services/Todos/TodoService.cs
Normal file
91
Journal.Core/Services/Todos/TodoService.cs
Normal file
@ -0,0 +1,91 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
using Journal.Core.Repositories;
|
||||
|
||||
namespace Journal.Core.Services.Todos;
|
||||
|
||||
public class TodoService(ITodoRepository repo) : ITodoService
|
||||
{
|
||||
private readonly ITodoRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||
|
||||
private static TodoItemDto MapItem(TodoItem i) => new(
|
||||
i.Id,
|
||||
i.ListId,
|
||||
i.Text,
|
||||
i.Done,
|
||||
i.SortOrder
|
||||
);
|
||||
|
||||
private TodoListDto MapList(TodoList l)
|
||||
{
|
||||
var items = _repo.GetItemsByListId(l.Id);
|
||||
return new TodoListDto(
|
||||
l.Id,
|
||||
l.Label,
|
||||
l.CreatedAt,
|
||||
[.. items.Select(MapItem)]
|
||||
);
|
||||
}
|
||||
|
||||
public List<TodoListDto> GetAllLists()
|
||||
{
|
||||
var lists = _repo.GetAllLists();
|
||||
return [.. lists.Select(MapList)];
|
||||
}
|
||||
|
||||
public TodoListDto? GetListById(Guid id)
|
||||
{
|
||||
var l = _repo.GetListById(id);
|
||||
return l is null ? null : MapList(l);
|
||||
}
|
||||
|
||||
public TodoListDto CreateList(CreateTodoListDto dto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
var ctx = new ValidationContext(dto);
|
||||
Validator.ValidateObject(dto, ctx, validateAllProperties: true);
|
||||
|
||||
var list = new TodoList(dto.Label);
|
||||
_repo.AddList(list);
|
||||
return new TodoListDto(list.Id, list.Label, list.CreatedAt, []);
|
||||
}
|
||||
|
||||
public bool UpdateList(Guid id, UpdateTodoListDto dto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
if (dto.Label is not null && string.IsNullOrWhiteSpace(dto.Label))
|
||||
throw new ValidationException("Label cannot be empty");
|
||||
|
||||
return _repo.UpdateList(id, dto.Label?.Trim());
|
||||
}
|
||||
|
||||
public bool RemoveList(Guid id) => _repo.RemoveList(id);
|
||||
|
||||
public TodoItemDto CreateItem(CreateTodoItemDto dto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
var ctx = new ValidationContext(dto);
|
||||
Validator.ValidateObject(dto, ctx, validateAllProperties: true);
|
||||
|
||||
if (dto.ListId == Guid.Empty)
|
||||
throw new ValidationException("ListId is required");
|
||||
|
||||
var item = new TodoItem(dto.ListId, dto.Text, dto.SortOrder ?? 0);
|
||||
_repo.AddItem(item);
|
||||
return MapItem(item);
|
||||
}
|
||||
|
||||
public bool UpdateItem(Guid id, UpdateTodoItemDto dto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
if (dto.Text is not null && string.IsNullOrWhiteSpace(dto.Text))
|
||||
throw new ValidationException("Text cannot be empty");
|
||||
|
||||
return _repo.UpdateItem(id, dto.Text?.Trim(), dto.Done, dto.SortOrder);
|
||||
}
|
||||
|
||||
public bool RemoveItem(Guid id) => _repo.RemoveItem(id);
|
||||
}
|
||||
@ -32,6 +32,9 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
||||
if (vaultFiles.Length == 0)
|
||||
return true;
|
||||
|
||||
// Restore database vault files first
|
||||
RestoreDatabaseVaults(password, vaultDirectory, dataDirectory);
|
||||
|
||||
var anyDecrypted = false;
|
||||
var anyVaultFiles = false;
|
||||
foreach (var vaultFile in vaultFiles)
|
||||
@ -50,6 +53,9 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsReservedVaultFile(fileName))
|
||||
continue;
|
||||
|
||||
anyVaultFiles = true;
|
||||
try
|
||||
{
|
||||
@ -135,6 +141,9 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
||||
|
||||
foreach (var (monthKey, filesInMonth) in monthlyFiles.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
SaveMonth(password, monthKey, filesInMonth, vaultDirectory);
|
||||
|
||||
// Save database files
|
||||
SaveDatabaseVaults(password, vaultDirectory, dataDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,6 +190,69 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
||||
Directory.Delete(dataDirectory, recursive: true);
|
||||
}
|
||||
|
||||
// ── Database vault helpers ─────────────────────────────────────
|
||||
|
||||
private const string DatabaseVaultPrefix = "_db_";
|
||||
private const string DatabaseVaultSuffix = ".vault";
|
||||
|
||||
private static bool IsReservedVaultFile(string fileName)
|
||||
=> fileName.StartsWith(DatabaseVaultPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private void SaveDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
|
||||
{
|
||||
var dbFiles = Directory.GetFiles(dataDirectory, "*.db");
|
||||
foreach (var dbPath in dbFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dbFileName = Path.GetFileName(dbPath);
|
||||
var vaultFileName = $"{DatabaseVaultPrefix}{dbFileName}{DatabaseVaultSuffix}";
|
||||
var vaultPath = Path.Combine(vaultDirectory, vaultFileName);
|
||||
|
||||
var dbBytes = File.ReadAllBytes(dbPath);
|
||||
var encrypted = _crypto.EncryptData(dbBytes, password);
|
||||
File.WriteAllBytes(vaultPath, encrypted);
|
||||
|
||||
Debug.WriteLine($"[VaultStorageService] Saved database vault: {vaultFileName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[VaultStorageService] Failed to save database vault for {Path.GetFileName(dbPath)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
|
||||
{
|
||||
var dbVaultFiles = Directory.GetFiles(vaultDirectory, $"{DatabaseVaultPrefix}*{DatabaseVaultSuffix}");
|
||||
foreach (var vaultFile in dbVaultFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var vaultFileName = Path.GetFileName(vaultFile);
|
||||
// Strip prefix and suffix to get original DB filename
|
||||
var dbFileName = vaultFileName[DatabaseVaultPrefix.Length..^DatabaseVaultSuffix.Length];
|
||||
if (string.IsNullOrWhiteSpace(dbFileName))
|
||||
continue;
|
||||
|
||||
var encrypted = File.ReadAllBytes(vaultFile);
|
||||
var dbBytes = _crypto.DecryptData(encrypted, password);
|
||||
var targetPath = Path.Combine(dataDirectory, dbFileName);
|
||||
File.WriteAllBytes(targetPath, dbBytes);
|
||||
|
||||
Debug.WriteLine($"[VaultStorageService] Restored database from vault: {vaultFileName} → {dbFileName}");
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
Debug.WriteLine($"[VaultStorageService] Database vault decryption failed for {Path.GetFileName(vaultFile)} (likely wrong password)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[VaultStorageService] Failed to restore database vault {Path.GetFileName(vaultFile)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureRequiredArguments(string password, string vaultDirectory, string dataDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user