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",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3863,6 +3864,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -22,4 +22,4 @@ tauri = { version = "2", features = [] }
|
|||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["process", "io-util", "sync"] }
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
use std::process::Stdio;
|
||||||
use std::sync::Mutex;
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -23,6 +25,12 @@ struct CommandEnvelope {
|
|||||||
payload: Option<Value>,
|
payload: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Default)]
|
||||||
|
struct AppSettings {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
sidecar_root: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
struct ManagedSidecar {
|
struct ManagedSidecar {
|
||||||
child: Child,
|
child: Child,
|
||||||
stdin: ChildStdin,
|
stdin: ChildStdin,
|
||||||
@ -30,21 +38,18 @@ struct ManagedSidecar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedSidecar {
|
impl ManagedSidecar {
|
||||||
fn start() -> Result<Self, String> {
|
fn start(root: &Path) -> Result<Self, String> {
|
||||||
let sidecar_path = resolve_sidecar_path()?;
|
let sidecar_path = resolve_sidecar_path(root)?;
|
||||||
let root = project_root()?;
|
let mut cmd = Command::new(sidecar_path);
|
||||||
eprintln!(
|
cmd.stdin(Stdio::piped())
|
||||||
"[sidecar] starting exe={} project_root={}",
|
|
||||||
sidecar_path.display(),
|
|
||||||
root.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut child = Command::new(sidecar_path)
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::inherit())
|
.stderr(Stdio::inherit())
|
||||||
.current_dir(&root)
|
.current_dir(root)
|
||||||
.env("JOURNAL_PROJECT_ROOT", &root)
|
.env("JOURNAL_PROJECT_ROOT", root)
|
||||||
|
.kill_on_drop(true);
|
||||||
|
#[cfg(windows)]
|
||||||
|
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||||
|
let mut child = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|err| format!("Failed to start sidecar process: {err}"))?;
|
.map_err(|err| format!("Failed to start sidecar process: {err}"))?;
|
||||||
|
|
||||||
@ -67,29 +72,26 @@ impl ManagedSidecar {
|
|||||||
fn is_running(&mut self) -> bool {
|
fn is_running(&mut self) -> bool {
|
||||||
match self.child.try_wait() {
|
match self.child.try_wait() {
|
||||||
Ok(None) => true,
|
Ok(None) => true,
|
||||||
Ok(Some(status)) => {
|
Ok(Some(_)) => false,
|
||||||
eprintln!("[sidecar] exited status={status}");
|
Err(_) => false,
|
||||||
false
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("[sidecar] try_wait_error={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
|
self.stdin
|
||||||
.write_all(format!("{input_line}\n").as_bytes())
|
.write_all(format!("{input_line}\n").as_bytes())
|
||||||
|
.await
|
||||||
.map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?;
|
.map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?;
|
||||||
self.stdin
|
self.stdin
|
||||||
.flush()
|
.flush()
|
||||||
|
.await
|
||||||
.map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?;
|
.map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?;
|
||||||
|
|
||||||
let mut response_line = String::new();
|
let mut response_line = String::new();
|
||||||
let read = self
|
let read = self
|
||||||
.stdout
|
.stdout
|
||||||
.read_line(&mut response_line)
|
.read_line(&mut response_line)
|
||||||
|
.await
|
||||||
.map_err(|err| format!("Failed reading sidecar stdout: {err}"))?;
|
.map_err(|err| format!("Failed reading sidecar stdout: {err}"))?;
|
||||||
if read == 0 {
|
if read == 0 {
|
||||||
return Err("Sidecar stdout closed unexpectedly.".to_string());
|
return Err("Sidecar stdout closed unexpectedly.".to_string());
|
||||||
@ -105,43 +107,49 @@ impl ManagedSidecar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for ManagedSidecar {
|
impl Drop for ManagedSidecar {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {}
|
||||||
if let Ok(None) = self.child.try_wait() {
|
|
||||||
if let Err(err) = self.child.kill() {
|
|
||||||
eprintln!("[sidecar] kill_on_drop_error={err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(err) = self.child.wait() {
|
|
||||||
eprintln!("[sidecar] wait_on_drop_error={err}");
|
|
||||||
} else {
|
|
||||||
eprintln!("[sidecar] stopped_on_drop");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct SidecarState {
|
struct SidecarState {
|
||||||
process: Mutex<Option<ManagedSidecar>>,
|
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 =
|
let mut current =
|
||||||
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
|
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if current.join("Journal.Sidecar").exists() {
|
if current.join("Journal.Sidecar").exists() {
|
||||||
return Ok(current);
|
return Ok(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !current.pop() {
|
if !current.pop() {
|
||||||
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
|
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_sidecar_path() -> Result<PathBuf, String> {
|
fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
|
||||||
let root = project_root()?;
|
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");
|
let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe");
|
||||||
if debug_path.exists() {
|
if debug_path.exists() {
|
||||||
return Ok(debug_path);
|
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())
|
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> {
|
async fn send_with_managed_sidecar(
|
||||||
let mut guard = state
|
state: &SidecarState,
|
||||||
.process
|
input_line: &str,
|
||||||
.lock()
|
) -> Result<String, String> {
|
||||||
.map_err(|_| "Failed to lock sidecar state.".to_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 {
|
for attempt in 1..=2 {
|
||||||
let should_start = match guard.as_mut() {
|
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,
|
None => true,
|
||||||
};
|
};
|
||||||
if should_start {
|
if should_start {
|
||||||
*guard = Some(ManagedSidecar::start()?);
|
*guard = Some(ManagedSidecar::start(&root)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(process) = guard.as_mut() else {
|
let Some(process) = guard.as_mut() else {
|
||||||
return Err("Sidecar process unavailable.".to_string());
|
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),
|
Ok(line) => return Ok(line),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("[sidecar] send_error attempt={attempt} error={err}");
|
|
||||||
*guard = None;
|
*guard = None;
|
||||||
if attempt == 2 {
|
if attempt == 2 {
|
||||||
return Err(err);
|
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())
|
Err("Failed to send command to sidecar.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop_managed_sidecar(state: &SidecarState) {
|
async fn stop_managed_sidecar(state: &SidecarState) {
|
||||||
let Ok(mut guard) = state.process.lock() else {
|
let mut guard = state.process.lock().await;
|
||||||
eprintln!("[sidecar] stop_error=failed_to_lock_state");
|
guard.take();
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
#[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() {
|
// Stop the current sidecar so it restarts with new root
|
||||||
eprintln!("[sidecar] stop_requested");
|
{
|
||||||
|
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]
|
#[tauri::command]
|
||||||
fn shutdown(state: tauri::State<'_, SidecarState>, app_handle: tauri::AppHandle) {
|
async fn shutdown(
|
||||||
eprintln!("[app] shutdown requested");
|
state: tauri::State<'_, SidecarState>,
|
||||||
stop_managed_sidecar(state.inner());
|
app_handle: tauri::AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
stop_managed_sidecar(state.inner()).await;
|
||||||
app_handle.exit(0);
|
app_handle.exit(0);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn sidecar_command(
|
async fn sidecar_command(
|
||||||
state: tauri::State<'_, SidecarState>,
|
state: tauri::State<'_, SidecarState>,
|
||||||
command: CommandEnvelope,
|
command: CommandEnvelope,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
@ -217,16 +277,9 @@ fn sidecar_command(
|
|||||||
return Err("Missing action".to_string());
|
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)
|
let input_line = serde_json::to_string(&command)
|
||||||
.map_err(|err| format!("Serialize command failed: {err}"))?;
|
.map_err(|err| format!("Serialize command failed: {err}"))?;
|
||||||
let response_line = send_with_managed_sidecar(state.inner(), &input_line)?;
|
let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?;
|
||||||
|
|
||||||
eprintln!("[sidecar_command] response={response_line}");
|
|
||||||
serde_json::from_str::<Value>(&response_line)
|
serde_json::from_str::<Value>(&response_line)
|
||||||
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
|
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
|
||||||
}
|
}
|
||||||
@ -234,16 +287,36 @@ fn sidecar_command(
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let app = tauri::Builder::default()
|
let app = tauri::Builder::default()
|
||||||
.manage(SidecarState::default())
|
|
||||||
.plugin(tauri_plugin_opener::init())
|
.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!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application");
|
.expect("error while building tauri application");
|
||||||
|
|
||||||
app.run(|app_handle, event| {
|
app.run(|app_handle, event| {
|
||||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||||
let state = app_handle.state::<SidecarState>();
|
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,
|
...command,
|
||||||
correlationId: command.correlationId ?? newCorrelationId()
|
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 });
|
const response = await invoke<BackendResponse<T>>("sidecar_command", { command: envelope });
|
||||||
|
|
||||||
if (!response.ok) {
|
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");
|
throw new Error(response.error || "Backend command failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info("[backend] ok", {
|
|
||||||
action: envelope.action,
|
|
||||||
correlationId: envelope.correlationId,
|
|
||||||
id: envelope.id
|
|
||||||
});
|
|
||||||
return response.data;
|
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 CalendarWidget from "$lib/components/CalendarWidget.svelte";
|
||||||
import { createEntryDraft, entriesStore } from "$lib/stores/entries";
|
import { createEntryDraft, entriesStore } from "$lib/stores/entries";
|
||||||
import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
|
import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
|
||||||
import { createListDraft, listsStore } from "$lib/stores/lists";
|
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
|
||||||
import { createTodoListDraft, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
||||||
|
|
||||||
export let activeSection = "entries";
|
export let activeSection = "entries";
|
||||||
export let activeDocumentId = "";
|
export let activeDocumentId = "";
|
||||||
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||||
|
|
||||||
|
let showNewItemInput = false;
|
||||||
|
let newItemName = "";
|
||||||
|
let newItemInput: HTMLInputElement | null = null;
|
||||||
|
|
||||||
type SidePanelItem = {
|
type SidePanelItem = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -131,22 +135,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection === "todos") {
|
if (activeSection === "todos" || activeSection === "lists") {
|
||||||
const draft = createTodoListDraft();
|
showNewItemInput = true;
|
||||||
todoListsStore.update((lists) => [draft.meta, ...lists]);
|
newItemName = "";
|
||||||
todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items }));
|
queueMicrotask(() => newItemInput?.focus());
|
||||||
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);
|
|
||||||
return;
|
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";
|
$: panelTitle = sectionTitles[activeSection] ?? "Entries";
|
||||||
$: todoDocuments = $todoListsStore.map(({ id, label }) => ({
|
$: todoDocuments = $todoListsStore.map(({ id, label }) => ({
|
||||||
id,
|
id,
|
||||||
@ -226,6 +274,19 @@
|
|||||||
<input type="text" placeholder={`Search ${panelTitle.toLowerCase()}...`} />
|
<input type="text" placeholder={`Search ${panelTitle.toLowerCase()}...`} />
|
||||||
</div>
|
</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">
|
<ul class="panel-list">
|
||||||
{#each items as item}
|
{#each items as item}
|
||||||
<li>
|
<li>
|
||||||
@ -354,4 +415,27 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.01em;
|
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>
|
</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> {
|
export async function hydrateEntries(dataDirectory?: string): Promise<void> {
|
||||||
entriesBusyStore.set(true);
|
entriesBusyStore.set(true);
|
||||||
try {
|
try {
|
||||||
console.info("[entries] hydrate:start", { dataDirectory });
|
|
||||||
const items = await listEntriesCommand(dataDirectory);
|
const items = await listEntriesCommand(dataDirectory);
|
||||||
const mapped = items.map(fromListDto);
|
const mapped = items.map(fromListDto);
|
||||||
console.info("[entries] hydrate:ok", { count: mapped.length });
|
|
||||||
entriesStore.set(mapped);
|
entriesStore.set(mapped);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[entries] hydrate:error", 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> {
|
export async function loadEntryByStoreId(storeId: string): Promise<EntryItem | null> {
|
||||||
const filePath = toBackendPath(storeId);
|
const filePath = toBackendPath(storeId);
|
||||||
if (!filePath) {
|
if (!filePath) return null;
|
||||||
console.warn("[entries] load:skip_invalid_store_id", { storeId });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.info("[entries] load:start", { storeId, filePath });
|
|
||||||
const loaded = await loadEntryCommand(filePath);
|
const loaded = await loadEntryCommand(filePath);
|
||||||
const item = fromLoadResult(loaded);
|
const item = fromLoadResult(loaded);
|
||||||
entriesStore.update((items) => upsertById(items, item));
|
entriesStore.update((items) => upsertById(items, item));
|
||||||
console.info("[entries] load:ok", { storeId, filePath });
|
|
||||||
return item;
|
return item;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[entries] load:error", { storeId, filePath, 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> {
|
export async function saveEntryFromStore(storeId: string, content: string, mode?: string): Promise<EntryItem | null> {
|
||||||
const trimmed = content?.trim();
|
const trimmed = content?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) return null;
|
||||||
console.warn("[entries] save:skip_empty_content", { storeId });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingPath = toBackendPath(storeId);
|
const existingPath = toBackendPath(storeId);
|
||||||
const payload = existingPath ? { content: trimmed, filePath: existingPath, mode } : { content: trimmed, mode };
|
const payload = existingPath ? { content: trimmed, filePath: existingPath, mode } : { content: trimmed, mode };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.info("[entries] save:start", { storeId, hasExistingPath: Boolean(existingPath), mode });
|
|
||||||
const saved = await saveEntryCommand(payload);
|
const saved = await saveEntryCommand(payload);
|
||||||
const loaded = await loadEntryCommand(saved.filePath);
|
const loaded = await loadEntryCommand(saved.filePath);
|
||||||
const item = fromLoadResult(loaded);
|
const item = fromLoadResult(loaded);
|
||||||
entriesStore.update((items) => upsertById(items, item));
|
entriesStore.update((items) => upsertById(items, item));
|
||||||
console.info("[entries] save:ok", { storeId, filePath: saved.filePath });
|
|
||||||
return item;
|
return item;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[entries] save:error", { storeId, 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[]> {
|
export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Promise<EntryItem[]> {
|
||||||
console.info("[entries] search:start", payload);
|
|
||||||
const results = await searchEntriesCommand(payload);
|
const results = await searchEntriesCommand(payload);
|
||||||
const dataDirectory = payload.dataDirectory?.trim() ?? "";
|
const dataDirectory = payload.dataDirectory?.trim() ?? "";
|
||||||
const separator = dataDirectory.includes("\\") ? "\\" : "/";
|
const separator = dataDirectory.includes("\\") ? "\\" : "/";
|
||||||
@ -160,7 +147,6 @@ export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Prom
|
|||||||
filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined,
|
filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined,
|
||||||
date: result.entry.date
|
date: result.entry.date
|
||||||
}));
|
}));
|
||||||
console.info("[entries] search:ok", { count: mapped.length });
|
|
||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -159,9 +159,7 @@ export function removeFragmentItem(items: FragmentItem[], id: string): FragmentI
|
|||||||
export async function hydrateFragments(): Promise<void> {
|
export async function hydrateFragments(): Promise<void> {
|
||||||
fragmentsBusyStore.set(true);
|
fragmentsBusyStore.set(true);
|
||||||
try {
|
try {
|
||||||
console.info("[fragments] hydrate:start");
|
|
||||||
const items = await listFragments();
|
const items = await listFragments();
|
||||||
console.info("[fragments] hydrate:ok", { count: items.length });
|
|
||||||
fragmentsStore.set(items.map(dtoToItem));
|
fragmentsStore.set(items.map(dtoToItem));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[fragments] hydrate:error", 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> {
|
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({
|
const created = await createFragmentCommand({
|
||||||
type: payload.type.trim(),
|
type: payload.type.trim(),
|
||||||
description: composeDescription(payload.title, payload.body),
|
description: composeDescription(payload.title, payload.body),
|
||||||
tags: payload.tags
|
tags: payload.tags
|
||||||
});
|
});
|
||||||
console.info("[fragments] create:ok", { id: created.id });
|
|
||||||
const item = dtoToItem(created);
|
const item = dtoToItem(created);
|
||||||
fragmentsStore.update((items) => prependFragmentItem(items, item));
|
fragmentsStore.update((items) => prependFragmentItem(items, item));
|
||||||
return 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> {
|
export async function updateFragmentFromParsed(storeId: string, payload: ParsedFragment): Promise<FragmentItem | null> {
|
||||||
const backendId = toBackendId(storeId);
|
const backendId = toBackendId(storeId);
|
||||||
if (!backendId) {
|
if (!backendId) return null;
|
||||||
console.warn("[fragments] update:skip_invalid_store_id", { storeId });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info("[fragments] update:start", { storeId, backendId });
|
|
||||||
const ok = await updateFragmentCommand(backendId, {
|
const ok = await updateFragmentCommand(backendId, {
|
||||||
type: payload.type.trim(),
|
type: payload.type.trim(),
|
||||||
description: composeDescription(payload.title, payload.body),
|
description: composeDescription(payload.title, payload.body),
|
||||||
tags: payload.tags
|
tags: payload.tags
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) return null;
|
||||||
console.warn("[fragments] update:backend_returned_false", { storeId, backendId });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item: FragmentItem = {
|
const item: FragmentItem = {
|
||||||
id: storeId,
|
id: storeId,
|
||||||
label: payload.title.trim() || "Untitled Fragment",
|
label: payload.title.trim() || "Untitled Fragment",
|
||||||
initialContent: serializeFragment(payload)
|
initialContent: serializeFragment(payload)
|
||||||
};
|
};
|
||||||
console.info("[fragments] update:ok", { storeId, backendId });
|
|
||||||
fragmentsStore.update((items) => upsertById(items, item));
|
fragmentsStore.update((items) => upsertById(items, item));
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFragmentByStoreId(storeId: string): Promise<boolean> {
|
export async function deleteFragmentByStoreId(storeId: string): Promise<boolean> {
|
||||||
const backendId = toBackendId(storeId);
|
const backendId = toBackendId(storeId);
|
||||||
if (!backendId) {
|
if (!backendId) return false;
|
||||||
console.warn("[fragments] delete:skip_invalid_store_id", { storeId });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info("[fragments] delete:start", { storeId, backendId });
|
|
||||||
const ok = await deleteFragmentCommand(backendId);
|
const ok = await deleteFragmentCommand(backendId);
|
||||||
if (!ok) {
|
if (!ok) return false;
|
||||||
console.warn("[fragments] delete:backend_returned_false", { storeId, backendId });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
console.info("[fragments] delete:ok", { storeId, backendId });
|
|
||||||
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
|
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
|
||||||
return true;
|
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 = {
|
export type ListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -6,19 +13,106 @@ export type ListItem = {
|
|||||||
initialContent: string;
|
initialContent: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialLists: ListItem[] = [
|
export const listsStore = writable<ListItem[]>([]);
|
||||||
{ id: "lists/reading", label: "Reading", initialContent: "# Reading\n\nBooks and articles to read." },
|
export const listsBusyStore = writable(false);
|
||||||
{ 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[]>(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 {
|
export function createListDraft(): ListItem {
|
||||||
const id = `lists/list-${Date.now()}`;
|
const id = `lists/draft-${Date.now()}`;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: "Untitled List",
|
label: "Untitled List",
|
||||||
initialContent: "# Untitled List\n\n- Item 1"
|
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 };
|
// TodoItem keeps a numeric `id` for local array operations (used by EditorPanel)
|
||||||
export type TodoListMeta = { id: string; label: string };
|
// 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[] = [
|
export const todoListsStore = writable<TodoListMeta[]>([]);
|
||||||
{ id: "todos/today", label: "Today" },
|
export const todosStore = writable<Record<string, TodoItem[]>>({});
|
||||||
{ id: "todos/scheduled", label: "Scheduled" },
|
export const todosBusyStore = writable(false);
|
||||||
{ id: "todos/completed", label: "Completed" }
|
|
||||||
];
|
|
||||||
export const todoListsStore = writable<TodoListMeta[]>(initialTodoListMeta);
|
|
||||||
|
|
||||||
export const todosStore = writable<Record<string, TodoItem[]>>({
|
// ── ID helpers ───────────────────────────────────────────────────
|
||||||
"todos/today": [
|
|
||||||
{ id: 1, text: "Finalize journal sidebar interactions", done: false },
|
function toStoreId(guid: string): string {
|
||||||
{ id: 2, text: "Review fragment taxonomy updates", done: false },
|
return `todos/${guid}`;
|
||||||
{ id: 3, text: "Capture daily notes summary", done: false }
|
}
|
||||||
],
|
|
||||||
"todos/scheduled": [
|
function toBackendId(storeId: string): string | null {
|
||||||
{ id: 4, text: "Tuesday: sync settings to backend store", done: false },
|
const prefix = "todos/";
|
||||||
{ id: 5, text: "Thursday: polish editor keyboard shortcuts", done: false },
|
if (!storeId.startsWith(prefix)) return null;
|
||||||
{ id: 6, text: "Friday: QA fragment and todo workflows", done: false }
|
const backendId = storeId.slice(prefix.length).trim();
|
||||||
],
|
return backendId || null;
|
||||||
"todos/completed": [
|
}
|
||||||
{ id: 7, text: "Replaced navbar profile with settings shortcut", done: true },
|
|
||||||
{ id: 8, text: "Centered modal presentation", done: true },
|
export function createTodoId(): number {
|
||||||
{ id: 9, text: "Added fragment creation form mode", done: true }
|
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 {
|
export function serializeTodoList(title: string, todos: TodoItem[]): string {
|
||||||
const heading = title?.trim() ? `# ${title}` : "# To-Do List";
|
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")}`;
|
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[] {
|
export function parseTodoList(content: string): TodoItem[] {
|
||||||
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
||||||
const parsed: TodoItem[] = [];
|
const parsed: TodoItem[] = [];
|
||||||
@ -91,7 +259,7 @@ export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createTodoListDraft(): { meta: TodoListMeta; items: TodoItem[] } {
|
export function createTodoListDraft(): { meta: TodoListMeta; items: TodoItem[] } {
|
||||||
const id = `todos/list-${Date.now()}`;
|
const id = `todos/draft-${Date.now()}`;
|
||||||
return {
|
return {
|
||||||
meta: { id, label: "New List" },
|
meta: { id, label: "New List" },
|
||||||
items: []
|
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">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
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 AppModal from "$lib/components/AppModal.svelte";
|
||||||
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId } from "$lib/stores/entries";
|
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId } from "$lib/stores/entries";
|
||||||
import { hydrateFragments } from "$lib/stores/fragments";
|
import { hydrateFragments } from "$lib/stores/fragments";
|
||||||
|
import { 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 Navbar from "$lib/components/Navbar.svelte";
|
||||||
import SidePanel from "$lib/components/SidePanel.svelte";
|
import SidePanel from "$lib/components/SidePanel.svelte";
|
||||||
import EditorPanel from "$lib/components/EditorPanel.svelte";
|
import EditorPanel from "$lib/components/EditorPanel.svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
@ -42,8 +43,6 @@
|
|||||||
let modalInputValue = "";
|
let modalInputValue = "";
|
||||||
let unlockResolver: ((password: string | null) => void) | null = null;
|
let unlockResolver: ((password: string | null) => void) | null = null;
|
||||||
let fragmentBootstrapInFlight = false;
|
let fragmentBootstrapInFlight = false;
|
||||||
let vaultPassword: string | null = null;
|
|
||||||
let closeInProgress = false;
|
|
||||||
|
|
||||||
function showModal(options: {
|
function showModal(options: {
|
||||||
action: "logout-confirm" | "logout-info" | "unlock-vault";
|
action: "logout-confirm" | "logout-info" | "unlock-vault";
|
||||||
@ -147,20 +146,33 @@
|
|||||||
|
|
||||||
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
||||||
if (fragmentBootstrapInFlight) return;
|
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 {
|
try {
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
try {
|
try {
|
||||||
const password = await requestVaultPassword();
|
const password = await requestVaultPassword();
|
||||||
if (!password) {
|
if (!password) return;
|
||||||
console.warn("Vault unlock canceled. Journal data remains unavailable.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await unlockVaultWorkspace(password);
|
await unlockVaultWorkspace(password);
|
||||||
vaultPassword = password;
|
setVaultSession(password);
|
||||||
|
|
||||||
await hydrateEntries();
|
await hydrateEntries();
|
||||||
const firstEntry = getDefaultEntry(get(entriesStore));
|
const firstEntry = getDefaultEntry(get(entriesStore));
|
||||||
@ -169,37 +181,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await hydrateFragments();
|
await hydrateFragments();
|
||||||
|
await hydrateLists().catch(() => {});
|
||||||
|
await hydrateTodos().catch(() => {});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isLockedError(error)) {
|
if (!isLockedError(error)) return;
|
||||||
console.error("Failed to load journal data from sidecar:", error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
console.error("Vault unlock failed:", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`Vault remains locked after ${maxAttempts} attempts. Stopping unlock prompts.`);
|
|
||||||
} finally {
|
} finally {
|
||||||
fragmentBootstrapInFlight = false;
|
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) {
|
function handleSelect(id: string) {
|
||||||
if (id === "account" || id === "settings") {
|
if (id === "account" || id === "settings") {
|
||||||
goto(`/${id}`);
|
goto(`/${id}`);
|
||||||
@ -226,6 +221,8 @@
|
|||||||
|
|
||||||
selectedSection = id;
|
selectedSection = id;
|
||||||
panelOpen = true;
|
panelOpen = true;
|
||||||
|
activeDocumentId = "";
|
||||||
|
activeDocumentLabel = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenDocument(doc: OpenDocument) {
|
async function handleOpenDocument(doc: OpenDocument) {
|
||||||
@ -236,8 +233,8 @@
|
|||||||
if (loaded) {
|
if (loaded) {
|
||||||
resolvedDoc = loaded;
|
resolvedDoc = loaded;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Failed to load entry content:", error);
|
// entry content will use initialContent fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,22 +255,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const appWindow = getCurrentWindow();
|
|
||||||
|
|
||||||
let unlistenPromise = appWindow.onCloseRequested(async (event) => {
|
|
||||||
if (closeInProgress) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
closeInProgress = true;
|
|
||||||
await flushVaultOnExit();
|
|
||||||
await invoke("shutdown");
|
|
||||||
});
|
|
||||||
|
|
||||||
bootstrapFragmentsWithUnlock();
|
bootstrapFragmentsWithUnlock();
|
||||||
|
|
||||||
return () => {
|
|
||||||
void unlistenPromise.then((unlisten) => unlisten());
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,8 @@
|
|||||||
updateFragmentType,
|
updateFragmentType,
|
||||||
updateSettingsTag
|
updateSettingsTag
|
||||||
} from "$lib/stores/settings";
|
} from "$lib/stores/settings";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
const activeSection = "settings";
|
const activeSection = "settings";
|
||||||
|
|
||||||
@ -29,6 +31,41 @@
|
|||||||
let editingTagValue = "";
|
let editingTagValue = "";
|
||||||
let editingFragmentTypeIndex: number | null = null;
|
let editingFragmentTypeIndex: number | null = null;
|
||||||
let editingFragmentTypeValue = "";
|
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: {
|
function showModal(options: {
|
||||||
action: "logout-confirm" | "logout-info";
|
action: "logout-confirm" | "logout-info";
|
||||||
@ -166,6 +203,7 @@
|
|||||||
<p>Configure app behavior and interface options.</p>
|
<p>Configure app behavior and interface options.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
<section class="route-card">
|
<section class="route-card">
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<input type="checkbox" checked />
|
<input type="checkbox" checked />
|
||||||
@ -270,6 +308,29 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -306,7 +367,13 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
columns: 2;
|
||||||
|
column-gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.route-card {
|
.route-card {
|
||||||
|
break-inside: avoid;
|
||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -314,7 +381,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-width: 640px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
@ -427,6 +494,11 @@
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
</style>
|
</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.Database;
|
||||||
using Journal.Core.Services.Entries;
|
using Journal.Core.Services.Entries;
|
||||||
using Journal.Core.Services.Fragments;
|
using Journal.Core.Services.Fragments;
|
||||||
|
using Journal.Core.Services.Lists;
|
||||||
using Journal.Core.Services.Logging;
|
using Journal.Core.Services.Logging;
|
||||||
using Journal.Core.Services.Speech;
|
using Journal.Core.Services.Speech;
|
||||||
|
using Journal.Core.Services.Todos;
|
||||||
using Journal.Core.Services.Vault;
|
using Journal.Core.Services.Vault;
|
||||||
|
|
||||||
namespace Journal.Core;
|
namespace Journal.Core;
|
||||||
@ -24,6 +26,8 @@ public class Entry(
|
|||||||
IAiService ai,
|
IAiService ai,
|
||||||
ISpeechBridgeService speech,
|
ISpeechBridgeService speech,
|
||||||
IEntryFileService entryFiles,
|
IEntryFileService entryFiles,
|
||||||
|
IListService lists,
|
||||||
|
ITodoService todos,
|
||||||
CommandLogger logger)
|
CommandLogger logger)
|
||||||
{
|
{
|
||||||
private readonly IFragmentService _fragments = fragments;
|
private readonly IFragmentService _fragments = fragments;
|
||||||
@ -35,6 +39,8 @@ public class Entry(
|
|||||||
private readonly IAiService _ai = ai;
|
private readonly IAiService _ai = ai;
|
||||||
private readonly ISpeechBridgeService _speech = speech;
|
private readonly ISpeechBridgeService _speech = speech;
|
||||||
private readonly IEntryFileService _entryFiles = entryFiles;
|
private readonly IEntryFileService _entryFiles = entryFiles;
|
||||||
|
private readonly IListService _lists = lists;
|
||||||
|
private readonly ITodoService _todos = todos;
|
||||||
private readonly CommandLogger _logger = logger;
|
private readonly CommandLogger _logger = logger;
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@ -110,6 +116,83 @@ public class Entry(
|
|||||||
case "fragments.search":
|
case "fragments.search":
|
||||||
result = _fragments.Search(cmd.Type, cmd.Tag);
|
result = _fragments.Search(cmd.Type, cmd.Tag);
|
||||||
break;
|
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":
|
case "search.entries":
|
||||||
var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload);
|
var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload);
|
||||||
if (searchPayload is null || string.IsNullOrWhiteSpace(searchPayload.DataDirectory))
|
if (searchPayload is null || string.IsNullOrWhiteSpace(searchPayload.DataDirectory))
|
||||||
@ -227,6 +310,7 @@ public class Entry(
|
|||||||
var rebuildPayload = DeserializePayload<VaultPayload>(cmd.Payload);
|
var rebuildPayload = DeserializePayload<VaultPayload>(cmd.Payload);
|
||||||
if (rebuildPayload is null)
|
if (rebuildPayload is null)
|
||||||
return Error("Missing or invalid payload");
|
return Error("Missing or invalid payload");
|
||||||
|
_databaseSession.CloseConnection();
|
||||||
_vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, rebuildPayload.DataDirectory);
|
_vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, rebuildPayload.DataDirectory);
|
||||||
result = true;
|
result = true;
|
||||||
break;
|
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.Database;
|
||||||
using Journal.Core.Services.Entries;
|
using Journal.Core.Services.Entries;
|
||||||
using Journal.Core.Services.Fragments;
|
using Journal.Core.Services.Fragments;
|
||||||
|
using Journal.Core.Services.Lists;
|
||||||
using Journal.Core.Services.Logging;
|
using Journal.Core.Services.Logging;
|
||||||
using Journal.Core.Services.Sidecar;
|
using Journal.Core.Services.Sidecar;
|
||||||
using Journal.Core.Services.Speech;
|
using Journal.Core.Services.Speech;
|
||||||
|
using Journal.Core.Services.Todos;
|
||||||
using Journal.Core.Services.Vault;
|
using Journal.Core.Services.Vault;
|
||||||
|
|
||||||
namespace Journal.Core;
|
namespace Journal.Core;
|
||||||
@ -58,6 +60,10 @@ public static class ServiceCollectionExtensions
|
|||||||
});
|
});
|
||||||
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
|
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
|
||||||
services.AddSingleton<IEntryFileService, EntryFileService>();
|
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<CommandLogger>();
|
||||||
services.AddSingleton<SidecarCli>();
|
services.AddSingleton<SidecarCli>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@ -54,7 +54,7 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void CloseConnection()
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
@ -62,4 +62,9 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I
|
|||||||
_connection = null;
|
_connection = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
CloseConnection();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,4 +7,5 @@ public interface IDatabaseSessionService
|
|||||||
bool IsUnlocked { get; }
|
bool IsUnlocked { get; }
|
||||||
void SetPassword(string password, string? dataDirectory = null);
|
void SetPassword(string password, string? dataDirectory = null);
|
||||||
SqliteConnection GetConnection();
|
SqliteConnection GetConnection();
|
||||||
|
void CloseConnection();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
|||||||
private static readonly Lock SqliteInitLock = new();
|
private static readonly Lock SqliteInitLock = new();
|
||||||
private static bool _sqliteInitialized;
|
private static bool _sqliteInitialized;
|
||||||
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
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;
|
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 (fragment_id) REFERENCES fragments (id),
|
||||||
FOREIGN KEY (tag_id) REFERENCES tags (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)
|
if (vaultFiles.Length == 0)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
// Restore database vault files first
|
||||||
|
RestoreDatabaseVaults(password, vaultDirectory, dataDirectory);
|
||||||
|
|
||||||
var anyDecrypted = false;
|
var anyDecrypted = false;
|
||||||
var anyVaultFiles = false;
|
var anyVaultFiles = false;
|
||||||
foreach (var vaultFile in vaultFiles)
|
foreach (var vaultFile in vaultFiles)
|
||||||
@ -50,6 +53,9 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsReservedVaultFile(fileName))
|
||||||
|
continue;
|
||||||
|
|
||||||
anyVaultFiles = true;
|
anyVaultFiles = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -135,6 +141,9 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
|||||||
|
|
||||||
foreach (var (monthKey, filesInMonth) in monthlyFiles.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
foreach (var (monthKey, filesInMonth) in monthlyFiles.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||||
SaveMonth(password, monthKey, filesInMonth, vaultDirectory);
|
SaveMonth(password, monthKey, filesInMonth, vaultDirectory);
|
||||||
|
|
||||||
|
// Save database files
|
||||||
|
SaveDatabaseVaults(password, vaultDirectory, dataDirectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,6 +190,69 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
|||||||
Directory.Delete(dataDirectory, recursive: true);
|
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)
|
private static void EnsureRequiredArguments(string password, string vaultDirectory, string dataDirectory)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user