Compare commits
No commits in common. "58f9f46cb9abca1e995e979432ffb01670dfc0d6" and "0465b058452b149ddefd949de80dd599faf7a60b" have entirely different histories.
58f9f46cb9
...
0465b05845
2
Journal.App/src-tauri/Cargo.lock
generated
2
Journal.App/src-tauri/Cargo.lock
generated
@ -1777,7 +1777,6 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tokio",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3864,7 +3863,6 @@ 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,13 +1,11 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::process::Stdio;
|
use std::process::{Child, ChildStdin, ChildStdout, Command, 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")]
|
||||||
@ -25,12 +23,6 @@ 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,
|
||||||
@ -38,18 +30,21 @@ struct ManagedSidecar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedSidecar {
|
impl ManagedSidecar {
|
||||||
fn start(root: &Path) -> Result<Self, String> {
|
fn start() -> Result<Self, String> {
|
||||||
let sidecar_path = resolve_sidecar_path(root)?;
|
let sidecar_path = resolve_sidecar_path()?;
|
||||||
let mut cmd = Command::new(sidecar_path);
|
let root = project_root()?;
|
||||||
cmd.stdin(Stdio::piped())
|
eprintln!(
|
||||||
|
"[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}"))?;
|
||||||
|
|
||||||
@ -72,26 +67,29 @@ 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(_)) => false,
|
Ok(Some(status)) => {
|
||||||
Err(_) => false,
|
eprintln!("[sidecar] exited status={status}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[sidecar] try_wait_error={err}");
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_command_line(&mut self, input_line: &str) -> Result<String, String> {
|
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());
|
||||||
@ -107,49 +105,43 @@ 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 load_settings(path: &Path) -> AppSettings {
|
fn project_root() -> Result<PathBuf, String> {
|
||||||
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 effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
|
fn resolve_sidecar_path() -> Result<PathBuf, String> {
|
||||||
if let Some(root) = root_override {
|
let root = project_root()?;
|
||||||
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);
|
||||||
@ -164,15 +156,11 @@ fn resolve_sidecar_path(root: &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())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_with_managed_sidecar(
|
fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result<String, String> {
|
||||||
state: &SidecarState,
|
let mut guard = state
|
||||||
input_line: &str,
|
.process
|
||||||
) -> Result<String, String> {
|
.lock()
|
||||||
let root = {
|
.map_err(|_| "Failed to lock sidecar state.".to_string())?;
|
||||||
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() {
|
||||||
@ -180,16 +168,17 @@ async fn send_with_managed_sidecar(
|
|||||||
None => true,
|
None => true,
|
||||||
};
|
};
|
||||||
if should_start {
|
if should_start {
|
||||||
*guard = Some(ManagedSidecar::start(&root)?);
|
*guard = Some(ManagedSidecar::start()?);
|
||||||
}
|
}
|
||||||
|
|
||||||
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).await {
|
match process.send_command_line(input_line) {
|
||||||
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);
|
||||||
@ -201,75 +190,26 @@ async fn send_with_managed_sidecar(
|
|||||||
Err("Failed to send command to sidecar.".to_string())
|
Err("Failed to send command to sidecar.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_managed_sidecar(state: &SidecarState) {
|
fn stop_managed_sidecar(state: &SidecarState) {
|
||||||
let mut guard = state.process.lock().await;
|
let Ok(mut guard) = state.process.lock() else {
|
||||||
guard.take();
|
eprintln!("[sidecar] stop_error=failed_to_lock_state");
|
||||||
}
|
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)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stop the current sidecar so it restarts with new root
|
if guard.take().is_some() {
|
||||||
{
|
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]
|
||||||
async fn shutdown(
|
fn shutdown(state: tauri::State<'_, SidecarState>, app_handle: tauri::AppHandle) {
|
||||||
state: tauri::State<'_, SidecarState>,
|
eprintln!("[app] shutdown requested");
|
||||||
app_handle: tauri::AppHandle,
|
stop_managed_sidecar(state.inner());
|
||||||
) -> Result<(), String> {
|
|
||||||
stop_managed_sidecar(state.inner()).await;
|
|
||||||
app_handle.exit(0);
|
app_handle.exit(0);
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn sidecar_command(
|
fn sidecar_command(
|
||||||
state: tauri::State<'_, SidecarState>,
|
state: tauri::State<'_, SidecarState>,
|
||||||
command: CommandEnvelope,
|
command: CommandEnvelope,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
@ -277,9 +217,16 @@ async 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).await?;
|
let response_line = send_with_managed_sidecar(state.inner(), &input_line)?;
|
||||||
|
|
||||||
|
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}"))
|
||||||
}
|
}
|
||||||
@ -287,36 +234,16 @@ async 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![
|
.invoke_handler(tauri::generate_handler![sidecar_command, shutdown])
|
||||||
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>();
|
||||||
if let Ok(mut guard) = state.process.try_lock() {
|
stop_managed_sidecar(state.inner());
|
||||||
guard.take();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,11 +10,27 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
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,17 +2,13 @@
|
|||||||
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, createListFromLabel, listsStore } from "$lib/stores/lists";
|
import { createListDraft, listsStore } from "$lib/stores/lists";
|
||||||
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
import { createTodoListDraft, 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;
|
||||||
@ -135,10 +131,22 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection === "todos" || activeSection === "lists") {
|
if (activeSection === "todos") {
|
||||||
showNewItemInput = true;
|
const draft = createTodoListDraft();
|
||||||
newItemName = "";
|
todoListsStore.update((lists) => [draft.meta, ...lists]);
|
||||||
queueMicrotask(() => newItemInput?.focus());
|
todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items }));
|
||||||
|
onOpenDocument({
|
||||||
|
id: draft.meta.id,
|
||||||
|
label: draft.meta.label,
|
||||||
|
initialContent: serializeTodoList(draft.meta.label, draft.items)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection === "lists") {
|
||||||
|
const item = createListDraft();
|
||||||
|
listsStore.update((items) => [item, ...items]);
|
||||||
|
onOpenDocument(item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,62 +171,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@ -274,19 +226,6 @@
|
|||||||
<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>
|
||||||
@ -415,27 +354,4 @@
|
|||||||
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>
|
||||||
|
|||||||
@ -1,382 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,306 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
|
|
||||||
|
|
||||||
export let openDocumentId = "";
|
|
||||||
export let openDocumentName = "";
|
|
||||||
export let openDocumentContent = "";
|
|
||||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
|
||||||
|
|
||||||
let markdownText = openDocumentContent;
|
|
||||||
let lastOpenDocumentId = openDocumentId;
|
|
||||||
let previewOnly = true;
|
|
||||||
let editorInput: HTMLTextAreaElement | null = null;
|
|
||||||
|
|
||||||
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)");
|
|
||||||
}
|
|
||||||
|
|
||||||
$: 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">
|
|
||||||
<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>
|
|
||||||
@ -1,284 +0,0 @@
|
|||||||
<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,8 +88,10 @@ 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);
|
||||||
@ -101,12 +103,17 @@ 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) return null;
|
if (!filePath) {
|
||||||
|
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 });
|
||||||
@ -116,16 +123,21 @@ 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) return null;
|
if (!trimmed) {
|
||||||
|
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 });
|
||||||
@ -134,6 +146,7 @@ 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("\\") ? "\\" : "/";
|
||||||
@ -147,6 +160,7 @@ 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,7 +159,9 @@ 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);
|
||||||
@ -170,11 +172,17 @@ 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;
|
||||||
@ -182,30 +190,46 @@ 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) return null;
|
if (!backendId) {
|
||||||
|
console.warn("[fragments] update:skip_invalid_store_id", { storeId });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("[fragments] update:start", { storeId, backendId });
|
||||||
const ok = await updateFragmentCommand(backendId, {
|
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) return null;
|
if (!ok) {
|
||||||
|
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) return false;
|
if (!backendId) {
|
||||||
|
console.warn("[fragments] delete:skip_invalid_store_id", { storeId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("[fragments] delete:start", { storeId, backendId });
|
||||||
const ok = await deleteFragmentCommand(backendId);
|
const ok = await deleteFragmentCommand(backendId);
|
||||||
if (!ok) return false;
|
if (!ok) {
|
||||||
|
console.warn("[fragments] delete:backend_returned_false", { storeId, backendId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.info("[fragments] delete:ok", { storeId, backendId });
|
||||||
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
|
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
import { get, writable } from "svelte/store";
|
import { 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;
|
||||||
@ -13,106 +6,19 @@ export type ListItem = {
|
|||||||
initialContent: string;
|
initialContent: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listsStore = writable<ListItem[]>([]);
|
const initialLists: ListItem[] = [
|
||||||
export const listsBusyStore = writable(false);
|
{ id: "lists/reading", label: "Reading", initialContent: "# Reading\n\nBooks and articles to read." },
|
||||||
|
{ id: "lists/projects", label: "Projects", initialContent: "# Projects\n\nActive and planned projects." },
|
||||||
|
{ id: "lists/someday", label: "Someday", initialContent: "# Someday\n\nLong-term ideas." }
|
||||||
|
];
|
||||||
|
|
||||||
function toStoreId(id: string): string {
|
export const listsStore = writable<ListItem[]>(initialLists);
|
||||||
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/draft-${Date.now()}`;
|
const id = `lists/list-${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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _flushCallback: (() => Promise<void>) | null = null;
|
|
||||||
|
|
||||||
export function setFlushCallback(fn: () => Promise<void>): void {
|
|
||||||
_flushCallback = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function flushBeforeClose(): Promise<void> {
|
|
||||||
if (_flushCallback) await _flushCallback();
|
|
||||||
}
|
|
||||||
@ -1,204 +1,32 @@
|
|||||||
import { get, writable } from "svelte/store";
|
import { 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";
|
|
||||||
|
|
||||||
// TodoItem keeps a numeric `id` for local array operations (used by EditorPanel)
|
export type TodoItem = { id: number; text: string; done: boolean };
|
||||||
// plus a `backendId` (guid string) for backend persistence.
|
export type TodoListMeta = { id: string; label: string };
|
||||||
export type TodoItem = { id: number; text: string; done: boolean; backendId?: string };
|
|
||||||
export type TodoListMeta = { id: string; label: string; backendId?: string };
|
|
||||||
|
|
||||||
export const todoListsStore = writable<TodoListMeta[]>([]);
|
const initialTodoListMeta: TodoListMeta[] = [
|
||||||
export const todosStore = writable<Record<string, TodoItem[]>>({});
|
{ id: "todos/today", label: "Today" },
|
||||||
export const todosBusyStore = writable(false);
|
{ id: "todos/scheduled", label: "Scheduled" },
|
||||||
|
{ id: "todos/completed", label: "Completed" }
|
||||||
|
];
|
||||||
|
export const todoListsStore = writable<TodoListMeta[]>(initialTodoListMeta);
|
||||||
|
|
||||||
// ── ID helpers ───────────────────────────────────────────────────
|
export const todosStore = writable<Record<string, TodoItem[]>>({
|
||||||
|
"todos/today": [
|
||||||
function toStoreId(guid: string): string {
|
{ id: 1, text: "Finalize journal sidebar interactions", done: false },
|
||||||
return `todos/${guid}`;
|
{ id: 2, text: "Review fragment taxonomy updates", done: false },
|
||||||
}
|
{ id: 3, text: "Capture daily notes summary", done: false }
|
||||||
|
],
|
||||||
function toBackendId(storeId: string): string | null {
|
"todos/scheduled": [
|
||||||
const prefix = "todos/";
|
{ id: 4, text: "Tuesday: sync settings to backend store", done: false },
|
||||||
if (!storeId.startsWith(prefix)) return null;
|
{ id: 5, text: "Thursday: polish editor keyboard shortcuts", done: false },
|
||||||
const backendId = storeId.slice(prefix.length).trim();
|
{ id: 6, text: "Friday: QA fragment and todo workflows", done: false }
|
||||||
return backendId || null;
|
],
|
||||||
}
|
"todos/completed": [
|
||||||
|
{ id: 7, text: "Replaced navbar profile with settings shortcut", done: true },
|
||||||
export function createTodoId(): number {
|
{ id: 8, text: "Centered modal presentation", done: true },
|
||||||
return Date.now() + Math.floor(Math.random() * 1000);
|
{ id: 9, text: "Added fragment creation form mode", done: true }
|
||||||
}
|
]
|
||||||
|
|
||||||
// ── 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";
|
||||||
@ -206,6 +34,10 @@ 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[] = [];
|
||||||
@ -259,7 +91,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/draft-${Date.now()}`;
|
const id = `todos/list-${Date.now()}`;
|
||||||
return {
|
return {
|
||||||
meta: { id, label: "New List" },
|
meta: { id, label: "New List" },
|
||||||
items: []
|
items: []
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { getSessionPassword, clearVaultSession, flushBeforeClose } 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;
|
|
||||||
|
|
||||||
try { await flushBeforeClose(); } catch {}
|
|
||||||
|
|
||||||
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,15 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { unlockVaultWorkspace } from "$lib/backend/auth";
|
import { persistAndClearVault, unlockVaultWorkspace } from "$lib/backend/auth";
|
||||||
import AppModal from "$lib/components/AppModal.svelte";
|
import AppModal from "$lib/components/AppModal.svelte";
|
||||||
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } 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, updateListByStoreId } from "$lib/stores/lists";
|
|
||||||
import { isVaultReady, setFlushCallback, 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";
|
||||||
|
|
||||||
@ -43,6 +42,8 @@
|
|||||||
let modalInputValue = "";
|
let modalInputValue = "";
|
||||||
let unlockResolver: ((password: string | null) => void) | null = null;
|
let unlockResolver: ((password: string | null) => void) | null = null;
|
||||||
let fragmentBootstrapInFlight = false;
|
let fragmentBootstrapInFlight = false;
|
||||||
|
let vaultPassword: string | null = null;
|
||||||
|
let closeInProgress = false;
|
||||||
|
|
||||||
function showModal(options: {
|
function showModal(options: {
|
||||||
action: "logout-confirm" | "logout-info" | "unlock-vault";
|
action: "logout-confirm" | "logout-info" | "unlock-vault";
|
||||||
@ -146,33 +147,20 @@
|
|||||||
|
|
||||||
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
||||||
if (fragmentBootstrapInFlight) return;
|
if (fragmentBootstrapInFlight) return;
|
||||||
|
|
||||||
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;
|
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) return;
|
if (!password) {
|
||||||
|
console.warn("Vault unlock canceled. Journal data remains unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await unlockVaultWorkspace(password);
|
await unlockVaultWorkspace(password);
|
||||||
setVaultSession(password);
|
vaultPassword = password;
|
||||||
|
|
||||||
await hydrateEntries();
|
await hydrateEntries();
|
||||||
const firstEntry = getDefaultEntry(get(entriesStore));
|
const firstEntry = getDefaultEntry(get(entriesStore));
|
||||||
@ -181,39 +169,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await hydrateFragments();
|
await hydrateFragments();
|
||||||
await hydrateLists().catch(() => {});
|
|
||||||
await hydrateTodos().catch(() => {});
|
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isLockedError(error)) return;
|
if (!isLockedError(error)) {
|
||||||
|
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 saveCurrentDocument() {
|
async function flushVaultOnExit(): Promise<void> {
|
||||||
if (!activeDocumentId) return;
|
if (!vaultPassword) {
|
||||||
const content = openDocuments[activeDocumentId];
|
console.warn("Skipping vault persistence on exit because session password is unavailable.");
|
||||||
if (!content?.trim()) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (selectedSection === "entries") {
|
await persistAndClearVault(vaultPassword);
|
||||||
const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite");
|
} catch (error) {
|
||||||
if (saved && saved.id !== activeDocumentId) {
|
console.error("Vault persistence on exit failed:", error);
|
||||||
const { [activeDocumentId]: _, ...rest } = openDocuments;
|
|
||||||
openDocuments = { ...rest, [saved.id]: saved.initialContent };
|
|
||||||
activeDocumentId = saved.id;
|
|
||||||
activeDocumentLabel = saved.label;
|
|
||||||
}
|
|
||||||
} else if (selectedSection === "lists" && activeDocumentId.startsWith("lists/") && !activeDocumentId.startsWith("lists/draft-")) {
|
|
||||||
await updateListByStoreId(activeDocumentId, undefined, content);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// best-effort save
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,15 +224,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCurrentDocument();
|
|
||||||
selectedSection = id;
|
selectedSection = id;
|
||||||
panelOpen = true;
|
panelOpen = true;
|
||||||
activeDocumentId = "";
|
|
||||||
activeDocumentLabel = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenDocument(doc: OpenDocument) {
|
async function handleOpenDocument(doc: OpenDocument) {
|
||||||
await saveCurrentDocument();
|
|
||||||
let resolvedDoc = doc;
|
let resolvedDoc = doc;
|
||||||
if (selectedSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
|
if (selectedSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
|
||||||
try {
|
try {
|
||||||
@ -257,8 +236,8 @@
|
|||||||
if (loaded) {
|
if (loaded) {
|
||||||
resolvedDoc = loaded;
|
resolvedDoc = loaded;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// entry content will use initialContent fallback
|
console.error("Failed to load entry content:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,8 +258,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setFlushCallback(saveCurrentDocument);
|
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,8 +12,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
@ -31,41 +29,6 @@
|
|||||||
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";
|
||||||
@ -203,7 +166,6 @@
|
|||||||
<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 />
|
||||||
@ -308,29 +270,6 @@
|
|||||||
{/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>
|
||||||
|
|
||||||
@ -367,13 +306,7 @@
|
|||||||
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;
|
||||||
@ -381,7 +314,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
@ -494,11 +427,6 @@
|
|||||||
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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
);
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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,10 +8,8 @@ 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;
|
||||||
@ -26,8 +24,6 @@ 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;
|
||||||
@ -39,8 +35,6 @@ 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()
|
||||||
{
|
{
|
||||||
@ -116,83 +110,6 @@ 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))
|
||||||
@ -310,7 +227,6 @@ 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;
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,279 +0,0 @@
|
|||||||
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,11 +5,9 @@ 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;
|
||||||
@ -60,10 +58,6 @@ 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 CloseConnection()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
@ -62,9 +62,4 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I
|
|||||||
_connection = null;
|
_connection = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
CloseConnection();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,5 +7,4 @@ 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", "lists", "todo_lists", "todo_items"];
|
["entries", "sections", "fragments", "tags", "fragment_tags"];
|
||||||
|
|
||||||
private readonly IJournalConfigService _config = config;
|
private readonly IJournalConfigService _config = config;
|
||||||
|
|
||||||
@ -91,35 +91,6 @@ 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)
|
|
||||||
);
|
|
||||||
"""
|
"""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
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,9 +32,6 @@ 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)
|
||||||
@ -53,9 +50,6 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsReservedVaultFile(fileName))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
anyVaultFiles = true;
|
anyVaultFiles = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -141,9 +135,6 @@ 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,69 +181,6 @@ 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