Initial commit: Journal.Core library + Sidecar console app

This commit is contained in:
Jacob Schmidt 2026-02-21 02:01:00 -06:00
commit 53078c351a
186 changed files with 27441 additions and 0 deletions

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.cs]
# Prefer expression body for single-line constructors/methods/properties
csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion

52
.gitignore vendored Normal file
View File

@ -0,0 +1,52 @@
# Build output
bin/
obj/
# Visual Studio
.vs/
*.user
*.suo
*.userosscache
*.sln.docstates
# Rider
.idea/
*.sln.iml
# VS Code
.vscode/
# NuGet
*.nupkg
**/packages/
project.lock.json
project.fragment.lock.json
.nuget/
.dotnet_home/
.journal-sidecar/
.tmp
.npm
output/
# Publish output
publish/
# User secrets
secrets.json
# Windows
Thumbs.db
desktop.ini
# Runtime journal data (created by sidecar at repo root)
journal/
logs/
# macOS
.DS_Store
# OTHER
.just/
journalapp.exe
Journal.App/node_modules.old/@rollup/.rollup-win32-x64-msvc-IjiZshxL/rollup.win32-x64-msvc.node
journalapp(1).exe

10
Journal.App/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

77
Journal.App/README.md Normal file
View File

@ -0,0 +1,77 @@
# Journal.App
SvelteKit 5 + Tauri 2 desktop application for Project Journal.
## Tech Stack
- **Frontend**: SvelteKit 5, TypeScript, Vite 6
- **Tauri shell**: Rust (Tauri 2), `tokio` async runtime
- **Backend bridge**: `Journal.Sidecar.exe` managed as a persistent long-lived child process
## Dev Setup
```powershell
npm install
npm run dev # SvelteKit dev server at http://localhost:1420
npm run tauri dev # Tauri desktop window (connects to dev server)
```
## Build Targets
| Command | Output | Use case |
|---------|--------|----------|
| `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` |
| `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script |
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe |
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer |
## Frontend State Management
Svelte stores are the source of truth for all feature state.
### Current Stores
| Store file | State exports | Notes |
|-----------|---------------|-------|
| `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` |
| `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers |
| `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD |
| `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` |
| `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config |
### Store-First Rule
- Components call **store helper functions** for CRUD operations — not inline mutations.
- Components should focus on rendering, local form state, and invoking store operations.
- Backend calls (`sendCommand`) belong inside store/service helpers, not components.
## Tauri Commands (Rust → Frontend)
| Command | Description |
|---------|-------------|
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON |
| `get_sidecar_root` | Get currently resolved sidecar root path |
| `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) |
| `get_ui_settings` | Load tag/fragment-type settings |
| `set_ui_settings` | Persist tag/fragment-type settings |
| `shutdown` | Stop sidecar, exit app |
## Sidecar Path Resolution
The Rust shell looks for `Journal.Sidecar.exe` starting from the auto-detected repository root:
1. `<root>/Journal.Sidecar.exe`
2. `<root>/publish/Journal.Sidecar.exe`
3. `<root>/Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe`
4. `<root>/Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe`
5. Recursive scan of `<root>/Journal.Sidecar/`
Build the sidecar before running the Tauri app:
```powershell
.\scripts\publish-sidecar.ps1 -Configuration Release -Runtime win-x64
```
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

1886
Journal.App/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
Journal.App/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "journalapp",
"version": "0.1.0",
"description": "",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
}

7
Journal.App/src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5375
Journal.App/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
[package]
name = "journalapp"
version = "0.1.0"
description = "A Tauri App"
authors = ["Stan"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "journalapp_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["process", "io-util", "sync"] }

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"opener:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,478 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tauri::Manager;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::Mutex;
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct CommandEnvelope {
action: String,
#[serde(default)]
correlation_id: Option<String>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
r#type: Option<String>,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
payload: Option<Value>,
}
const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"];
const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"];
const DEFAULT_STARTUP_VIEW: &str = "entries";
#[derive(Deserialize, Serialize)]
struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
sidecar_root: Option<String>,
#[serde(default = "default_settings_tags")]
tags: Vec<String>,
#[serde(default = "default_fragment_types")]
fragment_types: Vec<String>,
#[serde(default = "default_startup_view")]
default_startup_view: String,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
sidecar_root: None,
tags: default_settings_tags(),
fragment_types: default_fragment_types(),
default_startup_view: default_startup_view(),
}
}
}
fn default_settings_tags() -> Vec<String> {
DEFAULT_SETTINGS_TAGS
.iter()
.map(|v| (*v).to_string())
.collect()
}
fn default_fragment_types() -> Vec<String> {
DEFAULT_FRAGMENT_TYPES
.iter()
.map(|v| (*v).to_string())
.collect()
}
fn default_startup_view() -> String {
DEFAULT_STARTUP_VIEW.to_string()
}
fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
let mut seen = HashSet::new();
let mut normalized = Vec::new();
for item in values {
let trimmed = item.trim();
if trimmed.is_empty() {
continue;
}
let key = trimmed.to_lowercase();
if seen.insert(key) {
normalized.push(trimmed.to_string());
}
}
if normalized.is_empty() {
return fallback.iter().map(|v| (*v).to_string()).collect();
}
normalized
}
fn normalize_startup_view(value: Option<String>) -> String {
let normalized = value
.unwrap_or_else(default_startup_view)
.trim()
.to_lowercase();
match normalized.as_str() {
"entries" | "calendar" | "fragments" | "todos" | "lists" => normalized,
_ => default_startup_view(),
}
}
struct ManagedSidecar {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl ManagedSidecar {
fn start(root: &Path) -> Result<Self, String> {
let sidecar_path = resolve_sidecar_path(root)?;
let mut cmd = Command::new(sidecar_path);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.current_dir(root)
.env("JOURNAL_PROJECT_ROOT", root)
.kill_on_drop(true);
#[cfg(windows)]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let mut child = cmd
.spawn()
.map_err(|err| format!("Failed to start sidecar process: {err}"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| "Unable to open sidecar stdin.".to_string())?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "Unable to open sidecar stdout.".to_string())?;
Ok(Self {
child,
stdin,
stdout: BufReader::new(stdout),
})
}
fn is_running(&mut self) -> bool {
match self.child.try_wait() {
Ok(None) => true,
Ok(Some(_)) => false,
Err(_) => false,
}
}
async fn send_command_line(&mut self, input_line: &str) -> Result<String, String> {
self.stdin
.write_all(format!("{input_line}\n").as_bytes())
.await
.map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?;
self.stdin
.flush()
.await
.map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?;
let mut response_line = String::new();
let read = self
.stdout
.read_line(&mut response_line)
.await
.map_err(|err| format!("Failed reading sidecar stdout: {err}"))?;
if read == 0 {
return Err("Sidecar stdout closed unexpectedly.".to_string());
}
let trimmed = response_line.trim().to_string();
if trimmed.is_empty() {
return Err("Sidecar returned an empty response line.".to_string());
}
Ok(trimmed)
}
}
impl Drop for ManagedSidecar {
fn drop(&mut self) {}
}
struct SidecarState {
process: Mutex<Option<ManagedSidecar>>,
root_override: Mutex<Option<PathBuf>>,
config_path: PathBuf,
}
fn load_settings(path: &Path) -> AppSettings {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> {
let json = serde_json::to_string_pretty(settings)
.map_err(|e| format!("Failed to serialize settings: {e}"))?;
fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}"))
}
fn auto_detect_root() -> Result<PathBuf, String> {
let mut current =
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
loop {
if current.join("Journal.Sidecar").exists() {
return Ok(current);
}
if !current.pop() {
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
}
}
}
fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
if let Some(root) = root_override {
return Ok(root.clone());
}
auto_detect_root()
}
fn resolve_sidecar_path(root: &Path) -> Result<PathBuf, String> {
let root_exe_path = root.join("Journal.Sidecar.exe");
if root_exe_path.exists() {
return Ok(root_exe_path);
}
let root_publish_exe_path = root.join("publish").join("Journal.Sidecar.exe");
if root_publish_exe_path.exists() {
return Ok(root_publish_exe_path);
}
let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe");
if debug_path.exists() {
return Ok(debug_path);
}
let release_path =
root.join("Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe");
if release_path.exists() {
return Ok(release_path);
}
let sidecar_root = root.join("Journal.Sidecar");
if let Some(path) = find_sidecar_executable(&sidecar_root) {
return Ok(path);
}
Err("Journal.Sidecar.exe not found. Build Journal.Sidecar first.".to_string())
}
fn find_sidecar_executable(search_root: &Path) -> Option<PathBuf> {
if !search_root.exists() {
return None;
}
let mut stack = vec![search_root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries {
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let is_sidecar_exe = path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.eq_ignore_ascii_case("Journal.Sidecar.exe"))
.unwrap_or(false);
if is_sidecar_exe {
return Some(path);
}
}
}
None
}
async fn send_with_managed_sidecar(
state: &SidecarState,
input_line: &str,
) -> Result<String, String> {
let root = {
let root_override = state.root_override.lock().await;
effective_root(&root_override)?
};
let mut guard = state.process.lock().await;
for attempt in 1..=2 {
let should_start = match guard.as_mut() {
Some(existing) => !existing.is_running(),
None => true,
};
if should_start {
*guard = Some(ManagedSidecar::start(&root)?);
}
let Some(process) = guard.as_mut() else {
return Err("Sidecar process unavailable.".to_string());
};
match process.send_command_line(input_line).await {
Ok(line) => return Ok(line),
Err(err) => {
*guard = None;
if attempt == 2 {
return Err(err);
}
}
}
}
Err("Failed to send command to sidecar.".to_string())
}
async fn stop_managed_sidecar(state: &SidecarState) {
let mut guard = state.process.lock().await;
guard.take();
}
#[tauri::command]
async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value, String> {
let root_override = state.root_override.lock().await.clone();
let root = effective_root(&root_override)?;
Ok(serde_json::json!({
"root": root.to_string_lossy(),
"isCustom": root_override.is_some()
}))
}
#[tauri::command]
async fn set_sidecar_root(
state: tauri::State<'_, SidecarState>,
path: String,
) -> Result<Value, String> {
let (new_override, root) = if path.trim().is_empty() {
let detected = auto_detect_root()?;
(None, detected)
} else {
let new_root = PathBuf::from(&path);
if !new_root.exists() {
return Err(format!(
"Directory '{}' does not exist.",
new_root.display()
));
}
resolve_sidecar_path(&new_root)?;
(Some(new_root.clone()), new_root)
};
// Stop the current sidecar so it restarts with new root
{
let mut guard = state.process.lock().await;
guard.take();
}
let is_custom = new_override.is_some();
*state.root_override.lock().await = new_override.clone();
let mut settings = load_settings(&state.config_path);
settings.sidecar_root = new_override.map(|p| p.to_string_lossy().into_owned());
save_settings(&state.config_path, &settings)?;
Ok(serde_json::json!({
"root": root.to_string_lossy(),
"isCustom": is_custom
}))
}
#[tauri::command]
async fn get_ui_settings(state: tauri::State<'_, SidecarState>) -> Result<Value, String> {
let settings = load_settings(&state.config_path);
let tags = normalize_items(settings.tags, DEFAULT_SETTINGS_TAGS);
let fragment_types = normalize_items(settings.fragment_types, DEFAULT_FRAGMENT_TYPES);
let startup_view = normalize_startup_view(Some(settings.default_startup_view));
Ok(serde_json::json!({
"tags": tags,
"fragmentTypes": fragment_types,
"defaultStartupView": startup_view
}))
}
#[tauri::command]
async fn set_ui_settings(
state: tauri::State<'_, SidecarState>,
tags: Vec<String>,
fragment_types: Vec<String>,
default_startup_view: Option<String>,
) -> Result<Value, String> {
let mut settings = load_settings(&state.config_path);
settings.tags = normalize_items(tags, DEFAULT_SETTINGS_TAGS);
settings.fragment_types = normalize_items(fragment_types, DEFAULT_FRAGMENT_TYPES);
settings.default_startup_view = normalize_startup_view(default_startup_view);
save_settings(&state.config_path, &settings)?;
Ok(serde_json::json!({
"tags": settings.tags,
"fragmentTypes": settings.fragment_types,
"defaultStartupView": settings.default_startup_view
}))
}
#[tauri::command]
async fn shutdown(
state: tauri::State<'_, SidecarState>,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
stop_managed_sidecar(state.inner()).await;
app_handle.exit(0);
Ok(())
}
#[tauri::command]
async fn sidecar_command(
state: tauri::State<'_, SidecarState>,
command: CommandEnvelope,
) -> Result<Value, String> {
if command.action.trim().is_empty() {
return Err("Missing action".to_string());
}
let input_line = serde_json::to_string(&command)
.map_err(|err| format!("Serialize command failed: {err}"))?;
let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?;
serde_json::from_str::<Value>(&response_line)
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
sidecar_command,
shutdown,
get_sidecar_root,
set_sidecar_root,
get_ui_settings,
set_ui_settings,
])
.setup(|app| {
let config_dir = app.path().app_config_dir()?;
fs::create_dir_all(&config_dir).ok();
let config_path = config_dir.join("settings.json");
let settings = load_settings(&config_path);
let root_override = settings.sidecar_root.map(PathBuf::from);
app.manage(SidecarState {
process: Mutex::new(None),
root_override: Mutex::new(root_override),
config_path,
});
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|app_handle, event| {
if let tauri::RunEvent::ExitRequested { .. } = event {
let state = app_handle.state::<SidecarState>();
if let Ok(mut guard) = state.process.try_lock() {
guard.take();
};
}
});
}

View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
journalapp_lib::run()
}

View File

@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Project Journal",
"version": "0.1.0",
"identifier": "com.stan.journal",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"title": "Project Journal",
"width": 1366,
"height": 768
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

15
Journal.App/src/app.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
<link rel="stylesheet" href="style.css">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Journal</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1,89 @@
import { sendCommand } from "./client";
import { pickCase } from "./normalize";
export function hydrateWorkspace(password: string): Promise<unknown> {
return sendCommand<unknown>({
action: "db.hydrate_workspace",
payload: { password }
});
}
type RuntimeConfigRaw = {
dataDirectory?: string;
vaultDirectory?: string;
DataDirectory?: string;
VaultDirectory?: string;
};
type RuntimeConfig = {
dataDirectory: string;
vaultDirectory: string;
};
type PersistOptions = {
keepalive?: boolean;
};
async function getRuntimeConfig(options: PersistOptions = {}): Promise<RuntimeConfig> {
const data = await sendCommand<RuntimeConfigRaw>(
{
action: "config.get"
},
options
);
return {
dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""),
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", "")
};
}
export async function unlockVaultWorkspace(password: string): Promise<void> {
const config = await getRuntimeConfig();
const loaded = await sendCommand<boolean>({
action: "vault.load_all",
payload: {
password,
vaultDirectory: config.vaultDirectory,
dataDirectory: config.dataDirectory
}
});
if (!loaded) {
throw new Error("Incorrect vault password.");
}
await sendCommand<unknown>({
action: "db.hydrate_workspace",
payload: {
password,
dataDirectory: config.dataDirectory
}
});
}
export async function persistAndClearVault(password: string, options: PersistOptions = {}): Promise<void> {
const config = await getRuntimeConfig(options);
await sendCommand<boolean>(
{
action: "vault.rebuild_all",
payload: {
password,
vaultDirectory: config.vaultDirectory,
dataDirectory: config.dataDirectory
}
},
options
);
await sendCommand<boolean>(
{
action: "vault.clear_data_directory",
payload: {
dataDirectory: config.dataDirectory
}
},
options
);
}

View File

@ -0,0 +1,27 @@
import { invoke } from "$lib/runtime/invoke";
import type { BackendCommand, BackendResponse } from "./types";
function newCorrelationId(): string {
return `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
type SendCommandOptions = {
keepalive?: boolean;
};
export async function sendCommand<T>(command: BackendCommand, options: SendCommandOptions = {}): Promise<T> {
const envelope: BackendCommand = {
...command,
correlationId: command.correlationId ?? newCorrelationId()
};
const response = await invoke<BackendResponse<T>>("sidecar_command", {
command: envelope,
keepalive: options.keepalive === true
});
if (!response.ok) {
throw new Error(response.error || "Backend command failed");
}
return response.data;
}

View File

@ -0,0 +1,219 @@
import { sendCommand } from "./client";
import { normalizeFragment, type FragmentDto, type FragmentDtoRaw } from "./fragments";
import { pickCase } from "./normalize";
export type ParsedSectionDto = {
title: string;
content: string[];
checkboxes: Record<string, boolean>;
};
export type JournalEntryDto = {
date: string;
fragments: FragmentDto[];
rawContent: string;
sections: Record<string, ParsedSectionDto>;
};
export type EntryListItemDto = {
fileName: string;
filePath: string;
};
export type EntryLoadResultDto = {
fileName: string;
filePath: string;
entry: JournalEntryDto;
};
export type EntrySaveResultDto = {
filePath: string;
};
export type EntrySearchRequestDto = {
dataDirectory: string;
query?: string;
section?: string;
startDate?: string;
endDate?: string;
tags?: string[];
types?: string[];
checked?: string[];
unchecked?: string[];
};
export type EntrySearchResultDto = {
fileName: string;
entry: JournalEntryDto;
};
type ParsedSectionDtoRaw = {
title?: string;
content?: string[];
checkboxes?: Record<string, boolean>;
Title?: string;
Content?: string[];
Checkboxes?: Record<string, boolean>;
};
type JournalEntryDtoRaw = {
date?: string;
fragments?: FragmentDtoRaw[];
rawContent?: string;
sections?: Record<string, ParsedSectionDtoRaw>;
Date?: string;
Fragments?: FragmentDtoRaw[];
RawContent?: string;
Sections?: Record<string, ParsedSectionDtoRaw>;
};
type EntryListItemDtoRaw = {
fileName?: string;
filePath?: string;
FileName?: string;
FilePath?: string;
};
type EntryLoadResultDtoRaw = {
fileName?: string;
filePath?: string;
entry?: JournalEntryDtoRaw;
date?: string;
rawContent?: string;
FileName?: string;
FilePath?: string;
Entry?: JournalEntryDtoRaw;
Date?: string;
RawContent?: string;
};
type EntrySaveResultDtoRaw = {
filePath?: string;
FilePath?: string;
};
type EntrySearchResultDtoRaw = {
fileName?: string;
entry?: JournalEntryDtoRaw;
date?: string;
rawContent?: string;
FileName?: string;
Entry?: JournalEntryDtoRaw;
Date?: string;
RawContent?: string;
};
function normalizeSection(raw: ParsedSectionDtoRaw): ParsedSectionDto {
return {
title: pickCase(raw, "title", "Title", ""),
content: pickCase(raw, "content", "Content", [] as string[]),
checkboxes: pickCase(raw, "checkboxes", "Checkboxes", {} as Record<string, boolean>)
};
}
function normalizeJournalEntry(raw: JournalEntryDtoRaw | undefined): JournalEntryDto {
const fragments = pickCase(raw, "fragments", "Fragments", [] as FragmentDtoRaw[]);
const sections = pickCase(raw, "sections", "Sections", {} as Record<string, ParsedSectionDtoRaw>);
return {
date: pickCase(raw, "date", "Date", ""),
fragments: fragments.map(normalizeFragment),
rawContent: pickCase(raw, "rawContent", "RawContent", ""),
sections: Object.fromEntries(
Object.entries(sections).map(([key, value]) => [key, normalizeSection(value)])
)
};
}
function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto {
return {
fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", "")
};
}
function normalizeEntryLoadResult(raw: EntryLoadResultDtoRaw): EntryLoadResultDto {
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
const entry =
nestedEntry
? normalizeJournalEntry(nestedEntry)
: normalizeJournalEntry({
date: pickCase(raw, "date", "Date", undefined as string | undefined),
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
fragments: [],
sections: {}
});
return {
fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", ""),
entry
};
}
function normalizeEntrySearchResult(raw: EntrySearchResultDtoRaw): EntrySearchResultDto {
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
const entry =
nestedEntry
? normalizeJournalEntry(nestedEntry)
: normalizeJournalEntry({
date: pickCase(raw, "date", "Date", undefined as string | undefined),
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
fragments: [],
sections: {}
});
return {
fileName: pickCase(raw, "fileName", "FileName", ""),
entry
};
}
export async function listEntries(dataDirectory?: string): Promise<EntryListItemDto[]> {
const data = await sendCommand<EntryListItemDtoRaw[]>({
action: "entries.list",
payload: { dataDirectory }
});
return data.map(normalizeEntryListItem).filter((item) => Boolean(item.filePath));
}
export async function loadEntry(filePath: string): Promise<EntryLoadResultDto> {
const data = await sendCommand<EntryLoadResultDtoRaw>({
action: "entries.load",
payload: { filePath }
});
return normalizeEntryLoadResult(data);
}
export async function saveEntry(payload: {
content: string;
filePath?: string;
mode?: string;
fileName?: string;
}): Promise<EntrySaveResultDto> {
const data = await sendCommand<EntrySaveResultDtoRaw>({
action: "entries.save",
payload
});
return {
filePath: pickCase(data, "filePath", "FilePath", "")
};
}
export async function deleteEntry(filePath: string): Promise<boolean> {
return sendCommand<boolean>({
action: "entries.delete",
payload: { filePath }
});
}
export async function searchEntries(payload: EntrySearchRequestDto): Promise<EntrySearchResultDto[]> {
const data = await sendCommand<EntrySearchResultDtoRaw[]>({
action: "search.entries",
payload
});
return data.map(normalizeEntrySearchResult).filter((item) => Boolean(item.fileName));
}

View File

@ -0,0 +1,86 @@
import { sendCommand } from "./client";
import { pickCase } from "./normalize";
export type FragmentDto = {
id: string;
type: string;
description: string;
time: string;
tags: string[];
};
export type CreateFragmentPayload = {
type: string;
description: string;
tags?: string[];
};
export type UpdateFragmentPayload = {
type?: string;
description?: string;
tags?: string[];
time?: string;
};
export type FragmentDtoRaw = {
id?: string;
type?: string;
description?: string;
time?: string;
tags?: string[];
Id?: string;
Type?: string;
Description?: string;
Time?: string;
Tags?: string[];
};
export function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
return {
id: pickCase(raw, "id", "Id", ""),
type: pickCase(raw, "type", "Type", ""),
description: pickCase(raw, "description", "Description", ""),
time: pickCase(raw, "time", "Time", ""),
tags: pickCase(raw, "tags", "Tags", [] as string[])
};
}
export async function listFragments(): Promise<FragmentDto[]> {
const data = await sendCommand<FragmentDtoRaw[]>({
action: "fragments.list"
});
return data.map(normalizeFragment).filter((item) => Boolean(item.id));
}
export async function getFragment(id: string): Promise<FragmentDto | null> {
const data = await sendCommand<FragmentDtoRaw | null>({
action: "fragments.get",
id
});
if (!data) return null;
const normalized = normalizeFragment(data);
return normalized.id ? normalized : null;
}
export async function createFragment(payload: CreateFragmentPayload): Promise<FragmentDto> {
const data = await sendCommand<FragmentDtoRaw>({
action: "fragments.create",
payload
});
return normalizeFragment(data);
}
export function updateFragment(id: string, payload: UpdateFragmentPayload): Promise<boolean> {
return sendCommand<boolean>({
action: "fragments.update",
id,
payload
});
}
export function deleteFragment(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "fragments.delete",
id
});
}

View File

@ -0,0 +1,83 @@
import { sendCommand } from "./client";
import { pickCase } from "./normalize";
export type ListDocumentDto = {
id: string;
label: string;
content: string;
createdAt: string;
updatedAt: string;
};
export type CreateListPayload = {
label: string;
content?: string;
};
export type UpdateListPayload = {
label?: string;
content?: string;
};
type ListDocumentDtoRaw = {
id?: string;
label?: string;
content?: string;
createdAt?: string;
updatedAt?: string;
Id?: string;
Label?: string;
Content?: string;
CreatedAt?: string;
UpdatedAt?: string;
};
export function normalizeList(raw: ListDocumentDtoRaw): ListDocumentDto {
return {
id: pickCase(raw, "id", "Id", ""),
label: pickCase(raw, "label", "Label", ""),
content: pickCase(raw, "content", "Content", ""),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", "")
};
}
export async function listLists(): Promise<ListDocumentDto[]> {
const data = await sendCommand<ListDocumentDtoRaw[]>({
action: "lists.list"
});
return data.map(normalizeList).filter((item) => Boolean(item.id));
}
export async function getList(id: string): Promise<ListDocumentDto | null> {
const data = await sendCommand<ListDocumentDtoRaw | null>({
action: "lists.get",
id
});
if (!data) return null;
const normalized = normalizeList(data);
return normalized.id ? normalized : null;
}
export async function createList(payload: CreateListPayload): Promise<ListDocumentDto> {
const data = await sendCommand<ListDocumentDtoRaw>({
action: "lists.create",
payload
});
return normalizeList(data);
}
export function updateList(id: string, payload: UpdateListPayload): Promise<boolean> {
return sendCommand<boolean>({
action: "lists.update",
id,
payload
});
}
export function deleteList(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "lists.delete",
id
});
}

View File

@ -0,0 +1,18 @@
type UnknownObject = Record<string, unknown>;
function asObject(value: unknown): UnknownObject | undefined {
return value && typeof value === "object" ? (value as UnknownObject) : undefined;
}
export function pickCase<T>(
source: unknown,
camelKey: string,
pascalKey: string,
fallback: T
): T {
const obj = asObject(source);
if (!obj) return fallback;
const value = obj[camelKey] ?? obj[pascalKey];
return (value as T | undefined) ?? fallback;
}

View File

@ -0,0 +1,90 @@
import { sendCommand } from "./client";
import { pickCase } from "./normalize";
export type EntryTemplateItemDto = {
fileName: string;
filePath: string;
};
export type EntryTemplateLoadResultDto = {
fileName: string;
filePath: string;
content: string;
};
export type EntryTemplateSaveResultDto = {
filePath: string;
};
type EntryTemplateItemDtoRaw = {
fileName?: string;
filePath?: string;
FileName?: string;
FilePath?: string;
};
type EntryTemplateLoadResultDtoRaw = {
fileName?: string;
filePath?: string;
content?: string;
FileName?: string;
FilePath?: string;
Content?: string;
};
type EntryTemplateSaveResultDtoRaw = {
filePath?: string;
FilePath?: string;
};
function normalizeTemplateItem(raw: EntryTemplateItemDtoRaw): EntryTemplateItemDto {
return {
fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", "")
};
}
export async function listEntryTemplates(dataDirectory?: string): Promise<EntryTemplateItemDto[]> {
const data = await sendCommand<EntryTemplateItemDtoRaw[]>({
action: "templates.list",
payload: { dataDirectory }
});
return data.map(normalizeTemplateItem).filter((item) => Boolean(item.filePath));
}
export async function loadEntryTemplate(filePath: string): Promise<EntryTemplateLoadResultDto> {
const data = await sendCommand<EntryTemplateLoadResultDtoRaw>({
action: "templates.load",
payload: { filePath }
});
return {
fileName: pickCase(data, "fileName", "FileName", ""),
filePath: pickCase(data, "filePath", "FilePath", ""),
content: pickCase(data, "content", "Content", "")
};
}
export async function saveEntryTemplate(payload: {
name: string;
content: string;
filePath?: string;
dataDirectory?: string;
}): Promise<EntryTemplateSaveResultDto> {
const data = await sendCommand<EntryTemplateSaveResultDtoRaw>({
action: "templates.save",
payload
});
return {
filePath: pickCase(data, "filePath", "FilePath", "")
};
}
export async function deleteEntryTemplate(filePath: string): Promise<boolean> {
return sendCommand<boolean>({
action: "templates.delete",
payload: { filePath }
});
}

View File

@ -0,0 +1,144 @@
import { sendCommand } from "./client";
import { pickCase } from "./normalize";
export type TodoItemDto = {
id: string;
listId: string;
text: string;
done: boolean;
sortOrder: number;
};
export type TodoListDto = {
id: string;
label: string;
createdAt: string;
items: TodoItemDto[];
};
export type CreateTodoListPayload = {
label: string;
};
export type UpdateTodoListPayload = {
label?: string;
};
export type CreateTodoItemPayload = {
listId: string;
text: string;
sortOrder?: number;
};
export type UpdateTodoItemPayload = {
text?: string;
done?: boolean;
sortOrder?: number;
};
type TodoItemDtoRaw = {
id?: string;
listId?: string;
text?: string;
done?: boolean;
sortOrder?: number;
Id?: string;
ListId?: string;
Text?: string;
Done?: boolean;
SortOrder?: number;
};
type TodoListDtoRaw = {
id?: string;
label?: string;
createdAt?: string;
items?: TodoItemDtoRaw[];
Id?: string;
Label?: string;
CreatedAt?: string;
Items?: TodoItemDtoRaw[];
};
function normalizeItem(raw: TodoItemDtoRaw): TodoItemDto {
return {
id: pickCase(raw, "id", "Id", ""),
listId: pickCase(raw, "listId", "ListId", ""),
text: pickCase(raw, "text", "Text", ""),
done: pickCase(raw, "done", "Done", false),
sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0)
};
}
function normalizeList(raw: TodoListDtoRaw): TodoListDto {
const rawItems = pickCase(raw, "items", "Items", [] as TodoItemDtoRaw[]);
return {
id: pickCase(raw, "id", "Id", ""),
label: pickCase(raw, "label", "Label", ""),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
items: rawItems.map(normalizeItem)
};
}
export async function listTodoLists(): Promise<TodoListDto[]> {
const data = await sendCommand<TodoListDtoRaw[]>({
action: "todos.list"
});
return data.map(normalizeList).filter((item) => Boolean(item.id));
}
export async function getTodoList(id: string): Promise<TodoListDto | null> {
const data = await sendCommand<TodoListDtoRaw | null>({
action: "todos.get",
id
});
if (!data) return null;
const normalized = normalizeList(data);
return normalized.id ? normalized : null;
}
export async function createTodoList(payload: CreateTodoListPayload): Promise<TodoListDto> {
const data = await sendCommand<TodoListDtoRaw>({
action: "todos.create",
payload
});
return normalizeList(data);
}
export function updateTodoList(id: string, payload: UpdateTodoListPayload): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.update",
id,
payload
});
}
export function deleteTodoList(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.delete",
id
});
}
export async function createTodoItem(payload: CreateTodoItemPayload): Promise<TodoItemDto> {
const data = await sendCommand<TodoItemDtoRaw>({
action: "todos.items.create",
payload
});
return normalizeItem(data);
}
export function updateTodoItem(id: string, payload: UpdateTodoItemPayload): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.items.update",
id,
payload
});
}
export function deleteTodoItem(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.items.delete",
id
});
}

View File

@ -0,0 +1,13 @@
export type BackendCommand = {
action: string;
correlationId?: string;
id?: string;
type?: string;
tag?: string;
payload?: unknown;
};
export type BackendOk<T> = { ok: true; data: T };
export type BackendErr = { ok: false; error: string };
export type BackendResponse<T> = BackendOk<T> | BackendErr;

View File

@ -0,0 +1,158 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
export let open = false;
export let title = "";
export let message = "";
export let confirmText = "OK";
export let cancelText = "Cancel";
export let showCancel = false;
export let tone: "default" | "danger" = "default";
export let inputEnabled = false;
export let inputType = "text";
export let inputPlaceholder = "";
export let inputAriaLabel = "Modal input";
export let inputValue = "";
export let onConfirm: () => void = () => {};
export let onCancel: () => void = () => {};
const dispatch = createEventDispatcher<{ input: string }>();
function handleConfirm() {
onConfirm();
}
function handleCancel() {
onCancel();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (!open) return;
if (event.key === "Escape") {
handleCancel();
}
if (event.key === "Enter") {
handleConfirm();
}
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
inputValue = target.value;
dispatch("input", inputValue);
}
</script>
<svelte:window on:keydown={handleWindowKeydown} />
{#if open}
<div class="modal-backdrop">
<dialog class="modal" open aria-label={title}>
<header class="modal-header">
<h2>{title}</h2>
</header>
<p class="modal-message">{message}</p>
{#if inputEnabled}
<input
class="modal-input"
type={inputType}
value={inputValue}
placeholder={inputPlaceholder}
aria-label={inputAriaLabel}
on:input={handleInput}
autofocus
/>
{/if}
<div class="modal-actions">
{#if showCancel}
<button type="button" class="secondary" on:click={handleCancel}>{cancelText}</button>
{/if}
<button type="button" class:danger={tone === "danger"} on:click={handleConfirm}>
{confirmText}
</button>
</div>
</dialog>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(7, 9, 12, 0.6);
backdrop-filter: blur(2px);
display: grid;
place-items: center;
z-index: 1000;
padding: 16px;
}
.modal {
position: static;
margin: 0;
width: min(420px, 100%);
border-radius: 12px;
border: 1px solid var(--border-strong);
background: var(--surface-1);
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.45);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.modal-header h2 {
font-size: 0.98rem;
color: var(--text-primary);
}
.modal-message {
color: var(--text-muted);
font-size: 0.86rem;
line-height: 1.45;
}
.modal-actions {
margin-top: 2px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.modal-input {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
color: var(--text-primary);
font-size: 0.86rem;
padding: 9px 10px;
}
.modal-input::placeholder {
color: var(--text-dim);
}
.modal-actions button {
border-radius: 7px;
border: 1px solid var(--border-soft);
color: var(--text-primary);
background: var(--surface-2);
padding: 6px 11px;
font-size: 0.8rem;
cursor: pointer;
}
.modal-actions button.secondary {
background: var(--surface-1);
color: var(--text-muted);
}
.modal-actions button.danger {
border-color: var(--border-strong);
background: var(--surface-3);
color: var(--text-primary);
}
</style>

View File

@ -0,0 +1,260 @@
<script lang="ts">
export let onVisibleMonthChange: (month: { year: number; month: number; label: string }) => void = () => {};
export let onSelectedDateChange: (payload: { year: number; month: number; day: number; key: string }) => void =
() => {};
export let onDateActivate: (payload: { year: number; month: number; day: number; key: string }) => void = () => {};
const today = new Date();
let currentYear = today.getFullYear();
let currentMonth = today.getMonth();
let selectedDateKey = getDateKey(today.getFullYear(), today.getMonth(), today.getDate());
const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
type CalendarCell = {
day: number;
month: number;
year: number;
inMonth: boolean;
isToday: boolean;
isSelected: boolean;
};
function getDateKey(year: number, month: number, day: number): string {
const mm = String(month + 1).padStart(2, "0");
const dd = String(day).padStart(2, "0");
return `${year}-${mm}-${dd}`;
}
function setViewDate(year: number, month: number) {
const next = new Date(year, month, 1);
currentYear = next.getFullYear();
currentMonth = next.getMonth();
}
function changeMonth(offset: number) {
setViewDate(currentYear, currentMonth + offset);
}
function selectCell(cell: CalendarCell) {
if (!cell.inMonth) {
setViewDate(cell.year, cell.month);
}
const key = getDateKey(cell.year, cell.month, cell.day);
selectedDateKey = key;
onDateActivate({
year: cell.year,
month: cell.month,
day: cell.day,
key
});
}
function getCalendarCells(year: number, month: number): CalendarCell[] {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const prevMonthLastDay = new Date(year, month, 0).getDate();
const startOffset = (firstDay.getDay() + 6) % 7;
const daysInMonth = lastDay.getDate();
const nextCells: CalendarCell[] = [];
for (let i = 0; i < startOffset; i += 1) {
const day = prevMonthLastDay - startOffset + i + 1;
const prevMonthDate = new Date(year, month - 1, day);
const key = getDateKey(prevMonthDate.getFullYear(), prevMonthDate.getMonth(), day);
nextCells.push({
day,
month: prevMonthDate.getMonth(),
year: prevMonthDate.getFullYear(),
inMonth: false,
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey
});
}
for (let day = 1; day <= daysInMonth; day += 1) {
const key = getDateKey(year, month, day);
nextCells.push({
day,
month,
year,
inMonth: true,
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey
});
}
const trailing = (7 - (nextCells.length % 7)) % 7;
for (let day = 1; day <= trailing; day += 1) {
const nextMonthDate = new Date(year, month + 1, day);
const key = getDateKey(nextMonthDate.getFullYear(), nextMonthDate.getMonth(), day);
nextCells.push({
day,
month: nextMonthDate.getMonth(),
year: nextMonthDate.getFullYear(),
inMonth: false,
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey
});
}
return nextCells;
}
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(undefined, { month: "long" });
$: cells = getCalendarCells(currentYear, currentMonth);
$: onVisibleMonthChange({ year: currentYear, month: currentMonth, label: monthLabel });
$: {
const parts = selectedDateKey.split("-");
const [year, month, day] = parts.map((value) => Number(value));
if (parts.length === 3 && !Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
onSelectedDateChange({ year, month: month - 1, day, key: selectedDateKey });
}
}
</script>
<section class="calendar-widget" aria-label="Monthly calendar">
<header class="calendar-header">
<button type="button" class="nav-icon" aria-label="Previous month" on:click={() => changeMonth(-1)}>
<span class="material-symbols-outlined">chevron_left</span>
</button>
<div class="month-title">
<h3>{monthLabel}</h3>
<span>{currentYear}</span>
</div>
<button type="button" class="nav-icon" aria-label="Next month" on:click={() => changeMonth(1)}>
<span class="material-symbols-outlined">chevron_right</span>
</button>
</header>
<div class="calendar-weekdays">
{#each weekdays as weekday}
<span>{weekday}</span>
{/each}
</div>
<div class="calendar-grid">
{#each cells as cell}
<button
type="button"
class="calendar-cell"
class:is-muted={!cell.inMonth}
class:is-today={cell.isToday}
class:is-selected={cell.isSelected}
aria-label={`Day ${cell.day}`}
on:click={() => selectCell(cell)}
>
<span class="day-number">{cell.day}</span>
</button>
{/each}
</div>
</section>
<style>
.calendar-widget {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid var(--border-soft);
border-radius: 10px;
background: var(--surface-1);
padding: 10px;
}
.calendar-header {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 28px;
align-items: center;
gap: 8px;
}
.nav-icon {
width: 28px;
height: 28px;
border: 1px solid var(--border-soft);
border-radius: 6px;
display: grid;
place-items: center;
color: var(--text-muted);
cursor: pointer;
}
.nav-icon .material-symbols-outlined {
font-size: 1rem;
}
.nav-icon:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.month-title {
text-align: center;
}
.month-title h3 {
font-size: 0.86rem;
font-weight: 600;
color: var(--text-primary);
}
.month-title span {
font-size: 0.74rem;
color: var(--text-muted);
}
.calendar-weekdays,
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 4px;
}
.calendar-weekdays span {
text-align: center;
font-size: 0.7rem;
color: var(--text-dim);
font-weight: 500;
}
.calendar-cell {
height: 36px;
border-radius: 7px;
border: 1px solid transparent;
font-size: 0.76rem;
color: var(--text-muted);
cursor: pointer;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.day-number {
line-height: 1;
}
.calendar-cell:hover {
color: var(--text-primary);
background: var(--bg-hover);
border-color: var(--border-soft);
}
.calendar-cell.is-muted {
color: var(--text-dim);
}
.calendar-cell.is-today {
color: var(--text-primary);
background: var(--surface-3);
border-color: var(--border-strong);
}
.calendar-cell.is-selected {
border-color: var(--zinc-300);
box-shadow: inset 0 0 0 1px var(--zinc-300);
}
</style>

View File

@ -0,0 +1,339 @@
<script lang="ts">
import FragmentEditor from "$lib/components/editor/FragmentEditor.svelte";
import ListEditor from "$lib/components/editor/ListEditor.svelte";
import TodoEditor from "$lib/components/editor/TodoEditor.svelte";
import MarkdownEditor from "$lib/components/editor/MarkdownEditor.svelte";
export let activeSection = "entries";
export let openDocumentId = "entries/daily-notes";
export let openDocumentName = "Daily Notes";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: {
id: string;
label: string;
initialContent: string;
linkedFrom?: {
id: string;
label: string;
initialContent: string;
section: "entries" | "fragments" | "todos" | "lists" | "calendar";
};
}) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {};
export let showLinkedBackButton = false;
export let onLinkedBack: () => void = () => {};
export let calendarItems: Array<{ id: string; label: string; initialContent: string }> = [];
export let calendarBusy = false;
export let calendarError = "";
export let previewOnly = true;
type CalendarCard = {
id: string;
label: string;
initialContent: string;
title: string;
summary: string;
hasTrigger: boolean;
hasMood: boolean;
hasOpenTodos: boolean;
};
function deriveSummary(content: string): string {
const lines = content.replace(/\r\n/g, "\n").split("\n");
let inFrontmatter = false;
let frontmatterDone = false;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!frontmatterDone && line === "---") {
inFrontmatter = !inFrontmatter;
if (!inFrontmatter) frontmatterDone = true;
continue;
}
if (inFrontmatter || !line) continue;
if (/^#/.test(line)) continue;
if (/^\*\*Date:\*\*/i.test(line)) continue;
if (/^Date:/i.test(line)) continue;
if (/^(Type:|Tags:)/i.test(line)) continue;
return line.length > 180 ? `${line.slice(0, 177)}...` : line;
}
return "No summary available.";
}
function deriveTitle(label: string, content: string): string {
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim();
if (heading) return heading;
return label?.trim() || "Untitled Entry";
}
function toCalendarCard(item: { id: string; label: string; initialContent: string }): CalendarCard {
const content = item.initialContent ?? "";
const lower = content.toLowerCase();
return {
...item,
title: deriveTitle(item.label, content),
summary: deriveSummary(content),
hasTrigger: lower.includes("!trigger") || lower.includes("#trigger") || lower.includes("#stress"),
hasMood: lower.includes("mental / emotional snapshot") || lower.includes("cognitive state"),
hasOpenTodos: /-\s*\[\s\]/.test(content)
};
}
$: calendarCards = calendarItems.map(toCalendarCard);
</script>
<main class="editor-panel" aria-label="Editor area">
{#if showLinkedBackButton}
<div class="editor-nav">
<button type="button" class="back-btn" on:click={onLinkedBack} aria-label="Back to source entry">
<span class="material-symbols-outlined" aria-hidden="true">arrow_back</span>
</button>
</div>
{/if}
{#if activeSection === "calendar"}
<section class="calendar-main" aria-label="Calendar timeline results">
<header class="calendar-main-header">
<h2>Filtered Entries</h2>
</header>
{#if calendarBusy}
<p class="calendar-copy">Loading timeline...</p>
{:else if calendarError}
<p class="calendar-copy is-error">{calendarError}</p>
{:else if calendarItems.length === 0}
<p class="calendar-copy">No entries matched the current filters.</p>
{:else}
<ul class="calendar-list">
{#each calendarCards as item}
<li class:is-active={item.id === openDocumentId}>
<button type="button" class="calendar-item-btn" on:click={() => onOpenDocument(item)}>
<div class="calendar-item-head">
<h3>{item.title}</h3>
<span class="calendar-date">{item.label}</span>
</div>
<p class="calendar-summary">{item.summary}</p>
<div class="calendar-badges">
{#if item.hasMood}<span class="badge mood">Mood</span>{/if}
{#if item.hasTrigger}<span class="badge trigger">Trigger</span>{/if}
{#if item.hasOpenTodos}<span class="badge todo">Open To-Dos</span>{/if}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</section>
{:else if !openDocumentId}
<div class="editor-empty">
<span class="material-symbols-outlined empty-icon">edit_note</span>
<p>Select or create an item to get started</p>
</div>
{:else if activeSection === "fragments"}
<FragmentEditor
{openDocumentId}
{openDocumentName}
{openDocumentContent}
{onDocumentContentChange}
{onOpenDocument}
{onDeleteDocument}
externalEditRequested={!previewOnly}
/>
{:else if activeSection === "todos"}
<TodoEditor
{openDocumentId}
{openDocumentName}
{openDocumentContent}
{onDocumentContentChange}
/>
{:else if activeSection === "lists"}
<ListEditor
{openDocumentId}
{openDocumentName}
{openDocumentContent}
{onDocumentContentChange}
/>
{:else}
<MarkdownEditor
{openDocumentId}
{openDocumentName}
{openDocumentContent}
{onDocumentContentChange}
{onOpenDocument}
{previewOnly}
/>
{/if}
</main>
<style>
.editor-panel {
background: var(--bg-editor);
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
overflow: hidden;
}
.editor-nav {
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-primary);
padding: 0;
cursor: pointer;
}
.back-btn:hover {
background: var(--bg-hover);
}
.editor-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-dim);
.empty-icon {
font-size: 2.4rem;
opacity: 0.5;
}
p {
font-size: 0.88rem;
}
}
.calendar-main {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.calendar-main-header h2 {
font-size: 0.96rem;
font-weight: 600;
color: var(--text-primary);
}
.calendar-copy {
font-size: 0.84rem;
color: var(--text-dim);
}
.calendar-copy.is-error {
color: #e74c3c;
}
.calendar-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.calendar-list li {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
}
.calendar-list li:hover {
background: var(--bg-hover);
}
.calendar-list li.is-active {
border-color: var(--border-strong);
background: var(--bg-active);
}
.calendar-item-btn {
width: 100%;
text-align: left;
padding: 10px 12px;
color: var(--text-primary);
font-size: 0.86rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 7px;
}
.calendar-item-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.calendar-item-head h3 {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-date {
font-size: 0.74rem;
color: var(--text-dim);
white-space: nowrap;
}
.calendar-summary {
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.45;
}
.calendar-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.badge {
font-size: 0.68rem;
border-radius: 999px;
border: 1px solid var(--border-soft);
padding: 2px 7px;
color: var(--text-dim);
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
}
.badge.mood {
border-color: color-mix(in srgb, #6ba7ff 40%, var(--border-soft) 60%);
color: #8dbbff;
}
.badge.trigger {
border-color: color-mix(in srgb, #f08c6c 40%, var(--border-soft) 60%);
color: #f5ad95;
}
.badge.todo {
border-color: color-mix(in srgb, #f2c266 40%, var(--border-soft) 60%);
color: #f4d690;
}
</style>

View File

@ -0,0 +1,183 @@
<script lang="ts">
export let activeSection: string | null = "entries";
export let onSelect: (id: string) => void = () => {};
type NavItem = {
id: string;
label: string;
icon: string;
};
const workspaceItems: NavItem[] = [
{ id: "entries", label: "Entries", icon: "menu_book" },
{ id: "calendar", label: "Calendar", icon: "calendar_month" },
{ id: "fragments", label: "Fragments", icon: "auto_stories" },
{ id: "todos", label: "To-Do List", icon: "checklist" },
{ id: "lists", label: "Lists", icon: "lists" }
];
function selectItem(id: string) {
onSelect(id);
}
</script>
<aside class="navbar" aria-label="Primary navigation">
<div class="navbar-header">
<img src="svelte.svg" alt="Journal logo" />
</div>
<nav class="nav-groups" aria-label="Journal sections">
<div class="nav-group">
{#each workspaceItems as item}
<button
type="button"
class="nav-button"
class:is-active={activeSection === item.id}
on:click={() => selectItem(item.id)}
aria-label={item.label}
>
<span class="material-symbols-outlined">{item.icon}</span>
<span class="nav-tooltip" role="tooltip">{item.label}</span>
</button>
{/each}
</div>
</nav>
<button
type="button"
class="settings-chip"
class:is-active={activeSection === "settings"}
aria-label="Settings"
on:click={() => selectItem("settings")}
>
<span class="material-symbols-outlined">settings</span>
<span class="nav-tooltip" role="tooltip">Settings</span>
</button>
</aside>
<style>
.navbar {
position: relative;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 14px 10px;
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-navbar) 100%);
border-right: 1px solid var(--border-soft);
}
.navbar-header img {
width: 34px;
height: 34px;
object-fit: cover;
border-radius: 9px;
border: 1px solid var(--border-strong);
display: block;
}
.nav-groups {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
}
.nav-button,
.settings-chip {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 10px;
color: var(--text-dim);
border: 1px solid transparent;
cursor: pointer;
transition: background-color 0.14s ease, color 0.14s ease, border-color 0.14s ease;
}
.nav-button .material-symbols-outlined {
font-size: 1.18rem;
}
.settings-chip .material-symbols-outlined {
font-size: 1.18rem;
}
.nav-tooltip {
position: absolute;
left: calc(100% + 12px);
top: 50%;
transform: translateY(-50%) translateX(-4px);
opacity: 0;
pointer-events: none;
white-space: nowrap;
padding: 4px 9px;
border-radius: 6px;
font-size: 0.76rem;
font-weight: 500;
color: var(--text-primary);
background: var(--surface-1);
border: 1px solid var(--border-strong);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.nav-button:hover,
.nav-button:focus-visible,
.settings-chip:hover,
.settings-chip:focus-visible {
color: var(--text-primary);
background: var(--bg-hover);
border-color: var(--border-soft);
}
.nav-button:hover .nav-tooltip,
.nav-button:focus-visible .nav-tooltip,
.settings-chip:hover .nav-tooltip,
.settings-chip:focus-visible .nav-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.nav-button.is-active {
color: var(--text-primary);
background: var(--bg-active);
border-color: var(--border-strong);
}
.nav-button.is-active .material-symbols-outlined {
color: var(--accent);
}
.settings-chip {
margin-top: auto;
}
.settings-chip.is-active {
color: var(--text-primary);
background: var(--bg-active);
border-color: var(--border-strong);
}
.settings-chip.is-active .material-symbols-outlined {
color: var(--accent);
}
@media (max-width: 980px) {
.nav-button,
.settings-chip {
width: 40px;
height: 40px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,406 @@
<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 = () => {};
export let externalEditRequested = false;
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[] = [];
let suppressExternalEditRequest = false;
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";
suppressExternalEditRequest = true;
return;
}
loadFragmentFormFromDocument();
fragmentMode = "view";
suppressExternalEditRequest = true;
}
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;
}
$: if (!externalEditRequested) {
suppressExternalEditRequest = false;
}
$: if (externalEditRequested && !suppressExternalEditRequest && fragmentMode === "view") {
fragmentMode = "edit";
}
</script>
<section class="fragment-surface">
{#if fragmentMode === "view"}
<article class="fragment-view">
{@html renderMarkdown(openDocumentContent)}
</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>
</div>
</form>
{/if}
</section>
<style>
.fragment-surface {
min-height: 0;
flex: 1;
overflow: auto;
padding: 0 14px 14px;
}
.fragment-form {
width: min(100%, 920px);
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
padding: 28px 36px;
display: flex;
flex-direction: column;
gap: 12px;
}
.fragment-view {
width: min(100%, 920px);
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
padding: 28px 36px;
display: flex;
flex-direction: column;
gap: 14px;
color: var(--text-primary);
font-size: 0.92rem;
line-height: 1.65;
}
.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: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.fragment-form input,
.fragment-form select,
.fragment-form textarea {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 10px 11px;
font-size: 0.88rem;
}
.fragment-form textarea {
resize: vertical;
min-height: 220px;
line-height: 1.55;
}
.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: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
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 {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-muted);
padding: 8px 12px;
font-size: 0.82rem;
cursor: pointer;
}
.fragment-secondary:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
@media (max-width: 980px) {
.fragment-surface {
padding: 4px 8px 10px;
}
.fragment-form,
.fragment-view {
width: 100%;
padding: 18px 16px;
font-size: 0.89rem;
}
.fragment-form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,278 @@
<script lang="ts">
export let openDocumentId = "";
export let openDocumentName = "";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
type SimpleListItem = {
id: number;
text: string;
};
let items: SimpleListItem[] = [];
let nextItemId = 1;
let lastDocumentId = "";
let newItemText = "";
let editingItemId: number | null = null;
let editingItemText = "";
function parseListItems(content: string): SimpleListItem[] {
const lines = (content ?? "").split(/\r?\n/);
const parsed: SimpleListItem[] = [];
for (const line of lines) {
const bullet = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.+)$/);
if (bullet) {
parsed.push({ id: parsed.length + 1, text: bullet[1].trim() });
continue;
}
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
parsed.push({ id: parsed.length + 1, text: trimmed });
}
return parsed;
}
function serializeList(title: string, listItems: SimpleListItem[]): string {
const heading = (title ?? "").trim() || "Untitled List";
if (!listItems.length) {
return `# ${heading}\n\n`;
}
const body = listItems
.map((item) => item.text.trim())
.filter(Boolean)
.map((text) => `- ${text}`)
.join("\n");
return `# ${heading}\n\n${body}`;
}
function persist() {
onDocumentContentChange(serializeList(openDocumentName, items));
}
function resetForDocument() {
items = parseListItems(openDocumentContent);
nextItemId = (items[items.length - 1]?.id ?? 0) + 1;
newItemText = "";
editingItemId = null;
editingItemText = "";
}
function addItem() {
const text = newItemText.trim();
if (!text) return;
items = [{ id: nextItemId, text }, ...items];
nextItemId += 1;
newItemText = "";
persist();
}
function startEditItem(id: number) {
const existing = items.find((item) => item.id === id);
if (!existing) return;
editingItemId = id;
editingItemText = existing.text;
}
function saveEditItem() {
if (editingItemId === null) return;
const text = editingItemText.trim();
if (!text) return;
const id = editingItemId;
items = items.map((item) => (item.id === id ? { ...item, text } : item));
editingItemId = null;
editingItemText = "";
persist();
}
function cancelEditItem() {
editingItemId = null;
editingItemText = "";
}
function removeItem(id: number) {
if (editingItemId === id) {
cancelEditItem();
}
items = items.filter((item) => item.id !== id);
persist();
}
$: if (openDocumentId !== lastDocumentId) {
resetForDocument();
lastDocumentId = openDocumentId;
}
</script>
<section class="list-surface">
<div class="list-card">
<form class="list-create" on:submit|preventDefault={addItem}>
<input
type="text"
placeholder="Add a list item"
bind:value={newItemText}
aria-label="Add list item"
/>
<button type="submit" class="list-add-btn">Add</button>
</form>
<ul class="list-items">
{#each items as item}
<li class="list-item">
{#if editingItemId === item.id}
<input
type="text"
class="list-edit-input"
bind:value={editingItemText}
on:keydown={(event) => {
if (event.key === "Enter") saveEditItem();
if (event.key === "Escape") cancelEditItem();
}}
/>
<div class="list-actions">
<button type="button" class="list-btn save" on:click={saveEditItem}>Save</button>
<button type="button" class="list-btn ghost" on:click={cancelEditItem}>Cancel</button>
</div>
{:else}
<span class="list-text">{item.text}</span>
<div class="list-actions">
<button type="button" class="list-btn ghost" on:click={() => startEditItem(item.id)}>Edit</button>
<button type="button" class="list-btn danger" on:click={() => removeItem(item.id)}>Remove</button>
</div>
{/if}
</li>
{/each}
</ul>
</div>
</section>
<style>
.list-surface {
min-height: 0;
flex: 1;
overflow: auto;
padding: 0 14px 14px;
}
.list-card {
width: min(100%, 920px);
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
padding: 28px 36px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 100%;
overflow: visible;
}
.list-create {
display: flex;
gap: 8px;
}
.list-create input,
.list-edit-input {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 10px 11px;
font-size: 0.88rem;
}
.list-add-btn {
border-radius: 8px;
border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
padding: 9px 14px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.list-add-btn:hover {
background: var(--bg-hover);
}
.list-items {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.list-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 10px 12px;
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
}
.list-text {
font-size: 0.9rem;
color: var(--text-primary);
line-height: 1.45;
word-break: break-word;
}
.list-actions {
display: flex;
gap: 6px;
}
.list-btn {
border-radius: 7px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-muted);
padding: 6px 10px;
font-size: 0.78rem;
cursor: pointer;
}
.list-btn.save {
border-color: var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
}
.list-btn.danger:hover,
.list-btn.ghost:hover,
.list-btn.save:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
@media (max-width: 980px) {
.list-surface {
padding: 4px 8px 10px;
}
.list-card {
width: 100%;
padding: 18px 16px;
}
.list-item {
grid-template-columns: minmax(0, 1fr);
row-gap: 8px;
}
.list-actions {
justify-content: flex-end;
}
}
</style>

View File

@ -0,0 +1,575 @@
<script lang="ts">
import { listEntryTemplates, loadEntryTemplate, type EntryTemplateItemDto } from "$lib/backend/templates";
import MarkdownToolbar from "$lib/components/editor/MarkdownToolbar.svelte";
import { entriesStore } from "$lib/stores/entries";
import { fragmentsStore } from "$lib/stores/fragments";
import { listsStore } from "$lib/stores/lists";
import { serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
import { onMount } from "svelte";
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;
linkedFrom?: {
id: string;
label: string;
initialContent: string;
section: "entries" | "fragments" | "todos" | "lists" | "calendar";
};
}) => void = () => {};
type ListMode = "ul" | "ol" | null;
type AttachmentOption = { id: string; label: string };
let markdownText = openDocumentContent;
let lastOpenDocumentId = openDocumentId;
export let previewOnly = true;
let editorInput: HTMLTextAreaElement | null = null;
let templateOptions: EntryTemplateItemDto[] = [];
let templatesBusy = false;
let templateError = "";
let templateRefreshRequested = false;
let listMode: ListMode = null;
let fragmentAttachmentOptions: AttachmentOption[] = [];
let listAttachmentOptions: AttachmentOption[] = [];
let todoAttachmentOptions: AttachmentOption[] = [];
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)");
}
function lineMatchesListMode(line: string, mode: Exclude<ListMode, null>): boolean {
if (mode === "ul") {
return /^\s*[-*+]\s/.test(line);
}
return /^\s*\d+\.\s/.test(line);
}
function isMarkerOnlyLine(line: string, mode: Exclude<ListMode, null>): boolean {
if (mode === "ul") {
return /^\s*[-*+]\s$/.test(line);
}
return /^\s*\d+\.\s$/.test(line);
}
function handleMarkdownInput(event: Event) {
const target = event.currentTarget as HTMLTextAreaElement;
const nextValue = target.value;
updateDraft(nextValue);
if (!listMode) return;
const native = event as InputEvent;
if (native.inputType !== "deleteContentBackward") return;
const cursor = target.selectionStart ?? 0;
const lineStart = nextValue.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
const lineEndIndex = nextValue.indexOf("\n", cursor);
const lineEnd = lineEndIndex === -1 ? nextValue.length : lineEndIndex;
const line = nextValue.slice(lineStart, lineEnd);
if (!lineMatchesListMode(line, listMode)) {
listMode = null;
}
}
function toggleListMode(mode: Exclude<ListMode, null>) {
if (listMode === mode) {
listMode = null;
return;
}
listMode = mode;
applyLinePrefix(mode === "ul" ? "- " : "1. ");
queueMicrotask(() => editorInput?.focus());
}
async function refreshTemplates() {
templatesBusy = true;
templateError = "";
try {
templateOptions = await listEntryTemplates();
} catch (error) {
templateError = String(error);
templateOptions = [];
} finally {
templatesBusy = false;
}
}
function insertTextAtCursor(content: string) {
const current = markdownText;
if (!editorInput) {
const spacer = current.endsWith("\n") || !current ? "" : "\n\n";
updateDraft(`${current}${spacer}${content}`);
return;
}
const start = editorInput.selectionStart ?? current.length;
const end = editorInput.selectionEnd ?? start;
const next = `${current.slice(0, start)}${content}${current.slice(end)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
const cursor = start + content.length;
editorInput.focus();
editorInput.setSelectionRange(cursor, cursor);
});
}
function escapeMarkdownLinkText(value: string): string {
return value.replace(/]/g, "\\]");
}
function appendToAttachmentsSection(lineToAppend: string, attachmentId: string) {
const current = markdownText;
const normalized = current.replace(/\r\n/g, "\n");
if (normalized.includes(`(journal:${attachmentId})`)) {
return;
}
const attachmentsHeaderPattern = /^##\s+Attachments\s*$/im;
const headerMatch = attachmentsHeaderPattern.exec(normalized);
if (!headerMatch || headerMatch.index < 0) {
const spacer = normalized.trim().length > 0 ? "\n\n" : "";
updateDraft(`${normalized}${spacer}## Attachments\n${lineToAppend}\n`);
return;
}
const headerStart = headerMatch.index;
const headerEnd = headerStart + headerMatch[0].length;
const bodyStart = normalized.indexOf("\n", headerEnd);
const sectionBodyStart = bodyStart === -1 ? normalized.length : bodyStart + 1;
const nextHeaderMatch = /^##\s+/m.exec(normalized.slice(sectionBodyStart));
const sectionEnd = nextHeaderMatch ? sectionBodyStart + nextHeaderMatch.index : normalized.length;
const sectionBody = normalized.slice(sectionBodyStart, sectionEnd);
const bodyPrefix = sectionBody.length > 0 && !sectionBody.endsWith("\n") ? "\n" : "";
const insertion = `${bodyPrefix}${lineToAppend}\n`;
const next = `${normalized.slice(0, sectionEnd)}${insertion}${normalized.slice(sectionEnd)}`;
updateDraft(next);
}
function attachReference(kind: "Fragment" | "List" | "To-Do", option: AttachmentOption) {
const label = escapeMarkdownLinkText(option.label.trim() || `${kind} Item`);
const line = `- ${kind}: [${label}](journal:${option.id})`;
appendToAttachmentsSection(line, option.id);
}
function resolveJournalLinkTarget(targetId: string): { id: string; label: string; initialContent: string } | null {
if (!targetId) return null;
if (targetId.startsWith("fragments/")) {
const fragment = get(fragmentsStore).find((item) => item.id === targetId);
if (!fragment) return null;
return { id: fragment.id, label: fragment.label, initialContent: fragment.initialContent };
}
if (targetId.startsWith("lists/")) {
const list = get(listsStore).find((item) => item.id === targetId);
if (!list) return null;
return { id: list.id, label: list.label, initialContent: list.initialContent };
}
if (targetId.startsWith("todos/")) {
const todoList = get(todoListsStore).find((item) => item.id === targetId);
if (!todoList) return null;
const todoItems = get(todosStore)[targetId] ?? [];
return {
id: todoList.id,
label: todoList.label,
initialContent: serializeTodoList(todoList.label, todoItems)
};
}
if (targetId.startsWith("entries/")) {
const entry = get(entriesStore).find((item) => item.id === targetId);
if (!entry) return null;
return { id: entry.id, label: entry.label, initialContent: entry.initialContent };
}
return null;
}
function handlePreviewClick(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const anchor = target?.closest("a");
const href = anchor?.getAttribute("href");
if (!href || !href.startsWith("journal:")) return;
const targetId = href.slice("journal:".length).trim();
const doc = resolveJournalLinkTarget(targetId);
if (!doc) return;
event.preventDefault();
onOpenDocument({
...doc,
linkedFrom: {
id: openDocumentId,
label: openDocumentName,
initialContent: openDocumentContent,
section: "entries"
}
});
}
function interceptJournalLinks(node: HTMLElement) {
const onClick = (event: MouseEvent) => handlePreviewClick(event);
node.addEventListener("click", onClick);
return {
destroy() {
node.removeEventListener("click", onClick);
}
};
}
async function applyTemplateByPath(filePath: string) {
if (!filePath) return;
try {
const loaded = await loadEntryTemplate(filePath);
insertTextAtCursor(loaded.content);
} catch (error) {
templateError = String(error);
}
}
function handleMarkdownKeydown(event: KeyboardEvent) {
if (!editorInput) return;
if (event.key === "Escape" && listMode) {
event.preventDefault();
const current = markdownText;
const start = editorInput.selectionStart ?? current.length;
const lineStart = current.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
const lineEndIndex = current.indexOf("\n", start);
const lineEnd = lineEndIndex === -1 ? current.length : lineEndIndex;
const line = current.slice(lineStart, lineEnd);
if (isMarkerOnlyLine(line, listMode)) {
const next = `${current.slice(0, lineStart)}${current.slice(lineEnd)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
editorInput.focus();
editorInput.setSelectionRange(lineStart, lineStart);
});
}
listMode = null;
return;
}
if (event.key !== "Enter") return;
const current = markdownText;
const start = editorInput.selectionStart ?? current.length;
const lineStart = current.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
const lineUntilCursor = current.slice(lineStart, start);
let effectiveListMode: ListMode = listMode;
if (!effectiveListMode) {
if (/^\s*[-*+]\s/.test(lineUntilCursor)) {
effectiveListMode = "ul";
} else if (/^\s*\d+\.\s/.test(lineUntilCursor)) {
effectiveListMode = "ol";
}
}
if (!effectiveListMode) return;
event.preventDefault();
let marker = "- ";
if (effectiveListMode === "ol") {
const match = lineUntilCursor.match(/^(\s*)(\d+)\.\s/);
marker = match ? `${match[1]}${Number(match[2]) + 1}. ` : "1. ";
} else {
const match = lineUntilCursor.match(/^(\s*)[-*+]\s/);
marker = match ? `${match[1]}- ` : "- ";
}
listMode = effectiveListMode;
const insertion = `\n${marker}`;
const next = `${current.slice(0, start)}${insertion}${current.slice(start)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
const cursor = start + insertion.length;
editorInput.focus();
editorInput.setSelectionRange(cursor, cursor);
});
}
onMount(() => {
void refreshTemplates();
});
$: if (!previewOnly && !templateRefreshRequested) {
templateRefreshRequested = true;
void refreshTemplates();
}
$: if (previewOnly) {
templateRefreshRequested = false;
}
$: if (openDocumentId !== lastOpenDocumentId) {
markdownText = openDocumentContent;
lastOpenDocumentId = openDocumentId;
listMode = null;
}
$: isEntryDocument = openDocumentId.startsWith("entries/file/") || openDocumentId.startsWith("entries/draft-");
$: fragmentAttachmentOptions = $fragmentsStore
.filter((item) => item.id && item.label)
.map((item) => ({ id: item.id, label: item.label }));
$: listAttachmentOptions = $listsStore
.filter((item) => item.id && item.label)
.map((item) => ({ id: item.id, label: item.label }));
$: todoAttachmentOptions = $todoListsStore
.filter((item) => item.id && item.label)
.map((item) => ({ id: item.id, label: item.label }));
$: editorTitle = extractEditorTitle(markdownText, openDocumentName);
$: renderedHtml = renderMarkdown(markdownText);
</script>
<header class="editor-header">
<h1>{editorTitle}</h1>
</header>
<section class="editor-surface" class:preview-only={previewOnly}>
{#if !previewOnly}
<MarkdownToolbar
{isEntryDocument}
{templatesBusy}
{templateOptions}
{listMode}
fragmentOptions={fragmentAttachmentOptions}
listOptions={listAttachmentOptions}
todoOptions={todoAttachmentOptions}
onApplyHeading={applyHeading}
onApplyTemplate={(filePath) => void applyTemplateByPath(filePath)}
onAttachFragment={(option) => attachReference("Fragment", option)}
onAttachList={(option) => attachReference("List", option)}
onAttachTodo={(option) => attachReference("To-Do", option)}
onBold={() => applyWrap("**")}
onItalic={() => applyWrap("*")}
onLink={insertLink}
onToggleUl={() => toggleListMode("ul")}
onToggleOl={() => toggleListMode("ol")}
onCode={() => applyWrap("`")}
/>
{#if templateError}
<p class="template-error">{templateError}</p>
{/if}
{/if}
<div class="editor-workspace">
{#if previewOnly}
<article class="markdown-preview" aria-label="Markdown preview" use:interceptJournalLinks>
{@html renderedHtml}
</article>
{:else}
<textarea
bind:this={editorInput}
class="markdown-input"
bind:value={markdownText}
on:input={handleMarkdownInput}
on:keydown={handleMarkdownKeydown}
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-surface {
min-height: 0;
flex: 1;
border-radius: 0;
background: transparent;
padding: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
}
.editor-surface.preview-only {
grid-template-rows: minmax(0, 1fr);
}
.template-error {
color: #e74c3c;
font-size: 0.78rem;
margin: -2px 0 0;
padding: 0 14px;
}
.editor-workspace {
min-height: 0;
height: 100%;
overflow: auto;
padding: 0 14px 14px;
}
.markdown-input,
.markdown-preview {
min-height: 0;
width: min(100%, 920px);
height: auto;
min-height: 100%;
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
color: var(--text-primary);
padding: 28px 36px;
font-size: 0.92rem;
line-height: 1.65;
overflow: visible;
}
.markdown-input {
display: block;
resize: none;
overflow: auto;
font-family: "Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
}
.markdown-input:focus {
outline: none;
box-shadow: none;
}
.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;
}
@media (max-width: 980px) {
.editor-workspace {
padding: 4px 8px 10px;
}
.template-error {
padding: 0 8px;
}
.markdown-input,
.markdown-preview {
width: 100%;
border-radius: 0;
padding: 18px 16px;
font-size: 0.89rem;
}
}
</style>

View File

@ -0,0 +1,236 @@
<script lang="ts">
import type { EntryTemplateItemDto } from "$lib/backend/templates";
type AttachmentOption = { id: string; label: string };
export let isEntryDocument = false;
export let templatesBusy = false;
export let templateOptions: EntryTemplateItemDto[] = [];
export let listMode: "ul" | "ol" | null = null;
export let fragmentOptions: AttachmentOption[] = [];
export let listOptions: AttachmentOption[] = [];
export let todoOptions: AttachmentOption[] = [];
export let onApplyHeading: (level: number) => void = () => {};
export let onApplyTemplate: (filePath: string) => void = () => {};
export let onAttachFragment: (option: AttachmentOption) => void = () => {};
export let onAttachList: (option: AttachmentOption) => void = () => {};
export let onAttachTodo: (option: AttachmentOption) => void = () => {};
export let onBold: () => void = () => {};
export let onItalic: () => void = () => {};
export let onLink: () => void = () => {};
export let onToggleUl: () => void = () => {};
export let onToggleOl: () => void = () => {};
export let onCode: () => void = () => {};
</script>
<div class="editor-toolbar">
<div class="toolbar-group">
<select
class="toolbar-select"
aria-label="Header size"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const level = Number(target.value);
if (level) onApplyHeading(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>
{#if isEntryDocument}
<select
class="toolbar-select"
aria-label="Insert template"
disabled={templatesBusy}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const filePath = target.value;
if (filePath) {
onApplyTemplate(filePath);
}
target.value = "";
}}
>
<option value="">{templatesBusy ? "Loading templates..." : "Template"}</option>
{#each templateOptions as template}
<option value={template.filePath}>{template.fileName.replace(/\.template\.md$/i, "")}</option>
{/each}
</select>
<select
class="toolbar-select"
aria-label="Attach fragment"
disabled={fragmentOptions.length === 0}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = fragmentOptions.find((option) => option.id === target.value);
if (selected) {
onAttachFragment(selected);
}
target.value = "";
}}
>
<option value="">Attach Fragment</option>
{#each fragmentOptions as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
<select
class="toolbar-select"
aria-label="Attach list"
disabled={listOptions.length === 0}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = listOptions.find((option) => option.id === target.value);
if (selected) {
onAttachList(selected);
}
target.value = "";
}}
>
<option value="">Attach List</option>
{#each listOptions as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
<select
class="toolbar-select"
aria-label="Attach to-do list"
disabled={todoOptions.length === 0}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = todoOptions.find((option) => option.id === target.value);
if (selected) {
onAttachTodo(selected);
}
target.value = "";
}}
>
<option value="">Attach To-Do</option>
{#each todoOptions as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
{/if}
</div>
<div class="toolbar-divider" aria-hidden="true"></div>
<div class="toolbar-group">
<button type="button" class="toolbar-btn" on:click={onBold}>Bold</button>
<button type="button" class="toolbar-btn" on:click={onItalic}>Italic</button>
<button type="button" class="toolbar-btn" on:click={onLink}>Link</button>
<button type="button" class="toolbar-btn" class:is-active={listMode === "ul"} on:click={onToggleUl}>UL</button>
<button type="button" class="toolbar-btn" class:is-active={listMode === "ol"} on:click={onToggleOl}>OL</button>
<button type="button" class="toolbar-btn" on:click={onCode}>Code</button>
</div>
</div>
<style>
.editor-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
border: 1px solid var(--border-soft);
border-radius: 12px;
background: color-mix(in srgb, var(--surface-1) 90%, var(--zinc-700) 10%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--zinc-300) 9%, transparent 91%),
0 8px 24px color-mix(in srgb, var(--bg-app) 38%, transparent 62%);
padding: 8px 10px;
margin: 0 14px;
}
.toolbar-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.toolbar-divider {
width: 1px;
height: 22px;
background: color-mix(in srgb, var(--border-soft) 78%, transparent 22%);
}
.toolbar-btn {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
padding: 6px 10px;
min-height: 30px;
font-size: 0.73rem;
letter-spacing: 0.01em;
font-weight: 600;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.toolbar-select {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
padding: 6px 10px;
min-height: 30px;
font-size: 0.73rem;
letter-spacing: 0.01em;
font-weight: 600;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}
.toolbar-btn:hover,
.toolbar-btn:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
color: var(--text-primary);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.toolbar-btn:focus-visible {
outline: none;
}
.toolbar-btn.is-active {
background: color-mix(in srgb, var(--bg-active) 84%, var(--surface-2) 16%);
border-color: var(--border-strong);
color: var(--text-primary);
}
.toolbar-select:hover,
.toolbar-select:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
color: var(--text-primary);
border-color: var(--border-strong);
outline: none;
}
.toolbar-select:disabled,
.toolbar-btn:disabled {
opacity: 0.55;
cursor: default;
transform: none;
}
@media (max-width: 980px) {
.editor-toolbar {
margin: 0 8px;
padding: 7px 8px;
gap: 6px;
}
.toolbar-divider {
display: none;
}
}
</style>

View File

@ -0,0 +1,306 @@
<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;
overflow: auto;
padding: 0 14px 14px;
}
.todo-card {
width: min(100%, 920px);
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
padding: 28px 36px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 100%;
overflow: visible;
}
.todo-create {
display: flex;
gap: 8px;
}
.todo-create input,
.todo-edit-input {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 10px 11px;
font-size: 0.88rem;
}
.todo-add-btn {
border-radius: 8px;
border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
padding: 9px 14px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.todo-add-btn:hover {
background: var(--bg-hover);
}
.todo-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.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: 10px 12px;
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
}
.todo-check {
display: grid;
place-items: center;
}
.todo-text {
font-size: 0.9rem;
color: var(--text-primary);
line-height: 1.45;
}
.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: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-muted);
padding: 6px 10px;
font-size: 0.78rem;
cursor: pointer;
}
.todo-btn.save {
border-color: var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
}
.todo-btn.danger:hover,
.todo-btn.ghost:hover,
.todo-btn.save:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
@media (max-width: 980px) {
.todo-surface {
padding: 4px 8px 10px;
}
.todo-card {
width: 100%;
padding: 18px 16px;
}
.todo-item {
grid-template-columns: auto minmax(0, 1fr);
row-gap: 8px;
}
.todo-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
</style>

View File

@ -0,0 +1,150 @@
import type { BackendCommand } from "$lib/backend/types";
type InvokeArgs = Record<string, unknown> | undefined;
type WindowWithTauri = Window & {
__TAURI_INTERNALS__?: unknown;
};
type UiSettingsPayload = {
tags?: string[];
fragmentTypes?: string[];
defaultStartupView?: string;
};
type FetchJsonOptions = {
keepalive?: boolean;
};
const UI_SETTINGS_KEY = "journal.ui.settings";
function normalizedApiBase(): string {
const configured = import.meta.env.VITE_JOURNAL_API_BASE?.trim();
if (!configured) {
return "/api";
}
return configured.endsWith("/") ? configured.slice(0, -1) : configured;
}
export function isTauriRuntime(): boolean {
if (typeof window === "undefined") {
return false;
}
return Object.prototype.hasOwnProperty.call(window as WindowWithTauri, "__TAURI_INTERNALS__");
}
function readUiSettingsFromLocalStorage(): UiSettingsPayload {
if (typeof window === "undefined") {
return {};
}
const raw = window.localStorage.getItem(UI_SETTINGS_KEY);
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw) as UiSettingsPayload;
return {
tags: Array.isArray(parsed.tags) ? parsed.tags : undefined,
fragmentTypes: Array.isArray(parsed.fragmentTypes) ? parsed.fragmentTypes : undefined,
defaultStartupView: typeof parsed.defaultStartupView === "string" ? parsed.defaultStartupView : undefined
};
} catch {
return {};
}
}
function writeUiSettingsToLocalStorage(payload: UiSettingsPayload): void {
if (typeof window === "undefined") {
return;
}
const safePayload: UiSettingsPayload = {
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
fragmentTypes: Array.isArray(payload.fragmentTypes) ? payload.fragmentTypes : undefined,
defaultStartupView: typeof payload.defaultStartupView === "string" ? payload.defaultStartupView : undefined
};
window.localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(safePayload));
}
async function fetchJson<T>(path: string, init: RequestInit = {}, options: FetchJsonOptions = {}): Promise<T> {
const response = await fetch(`${normalizedApiBase()}${path}`, {
...init,
keepalive: options.keepalive === true,
headers: {
"Content-Type": "application/json",
...(init.headers ?? {})
}
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Request failed (${response.status})`);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T> {
if (isTauriRuntime()) {
const tauriCore = await import("@tauri-apps/api/core");
return tauriCore.invoke<T>(command, args);
}
switch (command) {
case "sidecar_command": {
const envelope = args?.command;
if (!envelope || typeof envelope !== "object") {
throw new Error("Missing command payload.");
}
const keepalive = args?.keepalive === true;
return fetchJson<T>(
"/command",
{
method: "POST",
body: JSON.stringify(envelope as BackendCommand)
},
{ keepalive }
);
}
case "get_sidecar_root":
return fetchJson<T>("/sidecar/root");
case "set_sidecar_root": {
const path = typeof args?.path === "string" ? args.path : "";
return fetchJson<T>("/sidecar/root", {
method: "POST",
body: JSON.stringify({ path })
});
}
case "get_ui_settings":
return readUiSettingsFromLocalStorage() as T;
case "set_ui_settings": {
const tags = Array.isArray(args?.tags) ? (args?.tags as string[]) : undefined;
const fragmentTypes =
Array.isArray(args?.fragmentTypes) ? (args?.fragmentTypes as string[]) :
Array.isArray(args?.fragment_types) ? (args?.fragment_types as string[]) :
undefined;
const defaultStartupView =
typeof args?.defaultStartupView === "string" ? args.defaultStartupView :
typeof args?.default_startup_view === "string" ? args.default_startup_view :
undefined;
writeUiSettingsToLocalStorage({ tags, fragmentTypes, defaultStartupView });
return undefined as T;
}
case "shutdown":
return undefined as T;
default:
throw new Error(`Unsupported command in web runtime: ${command}`);
}
}

View File

@ -0,0 +1,185 @@
import { get, writable } from "svelte/store";
import {
deleteEntry as deleteEntryCommand,
listEntries as listEntriesCommand,
loadEntry as loadEntryCommand,
saveEntry as saveEntryCommand,
searchEntries as searchEntriesCommand,
type EntryListItemDto,
type EntrySearchRequestDto
} from "$lib/backend/entries";
export type EntryItem = {
id: string;
label: string;
initialContent: string;
filePath?: string;
date?: string;
};
const initialEntries: EntryItem[] = [];
export const entriesStore = writable<EntryItem[]>(initialEntries);
export const entriesBusyStore = writable(false);
function toStoreId(filePath: string): string {
return `entries/file/${encodeURIComponent(filePath)}`;
}
function toBackendPath(id: string): string | null {
const prefix = "entries/file/";
if (!id.startsWith(prefix)) return null;
const encoded = id.slice(prefix.length).trim();
if (!encoded) return null;
try {
const decoded = decodeURIComponent(encoded);
return decoded || null;
} catch {
return null;
}
}
function toLabel(fileName: string): string {
const normalized = fileName.trim();
if (!normalized) return "Untitled Entry";
return normalized.replace(/\.md$/i, "");
}
function upsertById(items: EntryItem[], next: EntryItem): EntryItem[] {
const idx = items.findIndex((item) => item.id === next.id);
if (idx === -1) return [next, ...items];
const clone = [...items];
clone[idx] = next;
return clone;
}
function fromListDto(dto: EntryListItemDto): EntryItem {
return {
id: toStoreId(dto.filePath),
label: toLabel(dto.fileName),
initialContent: "",
filePath: dto.filePath
};
}
function fromLoadResult(result: Awaited<ReturnType<typeof loadEntryCommand>>): EntryItem {
return {
id: toStoreId(result.filePath),
label: toLabel(result.fileName),
initialContent: result.entry.rawContent,
filePath: result.filePath,
date: result.entry.date
};
}
export function getDefaultEntry(items: EntryItem[]): EntryItem | undefined {
return items[0];
}
export function createEntryDraft(): EntryItem {
const id = `entries/draft-${Date.now()}`;
return {
id,
label: "Untitled Entry",
initialContent: "# Untitled Entry\n\nStart writing..."
};
}
export async function hydrateEntries(dataDirectory?: string): Promise<void> {
entriesBusyStore.set(true);
try {
const items = await listEntriesCommand(dataDirectory);
const mapped = items.map(fromListDto);
entriesStore.set(mapped);
} catch (error) {
console.error("[entries] hydrate:error", error);
throw error;
} finally {
entriesBusyStore.set(false);
}
}
export async function loadEntryByStoreId(storeId: string): Promise<EntryItem | null> {
const filePath = toBackendPath(storeId);
if (!filePath) return null;
try {
const loaded = await loadEntryCommand(filePath);
const item = fromLoadResult(loaded);
entriesStore.update((items) => upsertById(items, item));
return item;
} catch (error) {
console.error("[entries] load:error", { storeId, filePath, error });
throw error;
}
}
export async function saveEntryFromStore(storeId: string, content: string, mode?: string): Promise<EntryItem | null> {
const trimmed = content?.trim();
if (!trimmed) return null;
const existingPath = toBackendPath(storeId);
let payload: { content: string; filePath?: string; mode?: string; fileName?: string };
if (existingPath) {
payload = { content: trimmed, filePath: existingPath, mode };
} else {
const draft = get(entriesStore).find((item) => item.id === storeId);
payload = { content: trimmed, mode, fileName: draft?.label };
}
try {
const saved = await saveEntryCommand(payload);
const loaded = await loadEntryCommand(saved.filePath);
const item = fromLoadResult(loaded);
entriesStore.update((items) => {
const filtered = existingPath ? items : items.filter((i) => i.id !== storeId);
return upsertById(filtered, item);
});
return item;
} catch (error) {
console.error("[entries] save:error", { storeId, error });
throw error;
}
}
export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Promise<EntryItem[]> {
const results = await searchEntriesCommand(payload);
const dataDirectory = payload.dataDirectory?.trim() ?? "";
const separator = dataDirectory.includes("\\") ? "\\" : "/";
const basePath = dataDirectory.replace(/[\\/]+$/, "");
const mapped = results.map((result) => ({
id: basePath
? toStoreId(`${basePath}${separator}${result.fileName}`)
: `entries/search/${encodeURIComponent(result.fileName)}`,
label: toLabel(result.fileName),
initialContent: result.entry.rawContent,
filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined,
date: result.entry.date
}));
return mapped;
}
export async function deleteEntryByStoreId(storeId: string): Promise<boolean> {
if (storeId.startsWith("entries/draft-")) {
entriesStore.update((items) => items.filter((item) => item.id !== storeId));
return true;
}
const filePath = toBackendPath(storeId);
if (!filePath) return false;
try {
const ok = await deleteEntryCommand(filePath);
if (!ok) return false;
entriesStore.update((items) => items.filter((item) => item.id !== storeId));
return true;
} catch (error) {
console.error("[entries] delete:error", { storeId, error });
return false;
}
}
export function hasEntry(storeId: string): boolean {
return get(entriesStore).some((item) => item.id === storeId);
}

View File

@ -0,0 +1,215 @@
import { get, writable } from "svelte/store";
import {
createFragment as createFragmentCommand,
deleteFragment as deleteFragmentCommand,
listFragments,
updateFragment as updateFragmentCommand,
type FragmentDto
} from "$lib/backend/fragments";
export type FragmentItem = {
id: string;
label: string;
initialContent: string;
};
export type ParsedFragment = {
title: string;
type: string;
tags: string[];
body: string;
};
const initialFragments: FragmentItem[] = [];
export const fragmentsStore = writable<FragmentItem[]>(initialFragments);
export const fragmentsBusyStore = writable(false);
function toStoreId(id: string): string {
return `fragments/${id}`;
}
function toBackendId(id: string): string | null {
const prefix = "fragments/";
if (!id.startsWith(prefix)) return null;
const backendId = id.slice(prefix.length).trim();
return backendId || null;
}
function splitDescription(description: string): { title: string; body: string } {
const normalized = description.trim();
if (!normalized) {
return { title: "Untitled Fragment", body: "" };
}
const separator = normalized.indexOf("\n\n");
if (separator === -1) {
return { title: normalized, body: "" };
}
const title = normalized.slice(0, separator).trim() || "Untitled Fragment";
const body = normalized.slice(separator + 2).trim();
return { title, body };
}
function composeDescription(title: string, body: string): string {
const resolvedTitle = title.trim() || "Untitled Fragment";
const resolvedBody = body.trim() || "Add details for this fragment.";
return `${resolvedTitle}\n\n${resolvedBody}`;
}
function dtoToItem(dto: FragmentDto): FragmentItem {
const parsed = splitDescription(dto.description);
return {
id: toStoreId(dto.id),
label: parsed.title,
initialContent: serializeFragment({
title: parsed.title,
type: dto.type,
tags: dto.tags ?? [],
body: parsed.body
})
};
}
function upsertById(items: FragmentItem[], next: FragmentItem): FragmentItem[] {
const idx = items.findIndex((item) => item.id === next.id);
if (idx === -1) {
return [next, ...items];
}
const clone = [...items];
clone[idx] = next;
return clone;
}
export function createFragmentId(title: string): string {
const slug = title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return `fragments/${slug || "fragment"}-${Date.now()}`;
}
export function serializeFragment(payload: ParsedFragment): string {
const title = payload.title.trim() || "Untitled Fragment";
const type = payload.type.trim();
const tagsLine = payload.tags.length ? payload.tags.map((tag) => `#${tag}`).join(" ") : "(none)";
const body = payload.body.trim() || "Add details for this fragment.";
return `# ${title}\n\nType: ${type}\nTags: ${tagsLine}\n\n${body}`;
}
export function parseFragmentContent(content: string, fallbackTitle = "Untitled Fragment"): ParsedFragment {
const headingMatch = content.match(/^#\s+(.+)$/m);
const typeMatch = content.match(/^Type:\s*(.+)$/m);
const tagsMatch = content.match(/^Tags:\s*(.+)$/m);
const bodyMatch = content.match(/^#.*\n\nType:.*\nTags:.*\n\n([\s\S]*)$/);
const rawTags = tagsMatch?.[1]?.trim() ?? "(none)";
const tags =
rawTags.toLowerCase() === "(none)"
? []
: rawTags
.split(/\s+/)
.map((tag) => tag.replace(/^#/, "").trim())
.filter(Boolean);
return {
title: headingMatch?.[1]?.trim() || fallbackTitle,
type: typeMatch?.[1]?.trim() || "",
tags,
body: bodyMatch?.[1]?.trim() || ""
};
}
export function createFragmentDraft(): FragmentItem {
const id = `fragments/new-${Date.now()}`;
return {
id,
label: "New Fragment",
initialContent: "# New Fragment\n\nType: \nTags: (none)\n\n"
};
}
export function createFragmentItem(title: string, content: string): FragmentItem {
return {
id: createFragmentId(title),
label: title.trim() || "Untitled Fragment",
initialContent: content
};
}
export function updateFragmentItem(items: FragmentItem[], id: string, title: string, content: string): FragmentItem[] {
return items.map((item) =>
item.id === id
? { ...item, label: title.trim() || "Untitled Fragment", initialContent: content }
: item
);
}
export function prependFragmentItem(items: FragmentItem[], item: FragmentItem): FragmentItem[] {
return [item, ...items];
}
export function removeFragmentItem(items: FragmentItem[], id: string): FragmentItem[] {
return items.filter((item) => item.id !== id);
}
export async function hydrateFragments(): Promise<void> {
fragmentsBusyStore.set(true);
try {
const items = await listFragments();
fragmentsStore.set(items.map(dtoToItem));
} catch (error) {
console.error("[fragments] hydrate:error", error);
throw error;
} finally {
fragmentsBusyStore.set(false);
}
}
export async function createFragmentFromParsed(payload: ParsedFragment): Promise<FragmentItem> {
const created = await createFragmentCommand({
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags
});
const item = dtoToItem(created);
fragmentsStore.update((items) => prependFragmentItem(items, item));
return item;
}
export async function updateFragmentFromParsed(storeId: string, payload: ParsedFragment): Promise<FragmentItem | null> {
const backendId = toBackendId(storeId);
if (!backendId) return null;
const ok = await updateFragmentCommand(backendId, {
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags
});
if (!ok) return null;
const item: FragmentItem = {
id: storeId,
label: payload.title.trim() || "Untitled Fragment",
initialContent: serializeFragment(payload)
};
fragmentsStore.update((items) => upsertById(items, item));
return item;
}
export async function deleteFragmentByStoreId(storeId: string): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;
const ok = await deleteFragmentCommand(backendId);
if (!ok) return false;
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
return true;
}
export function hasFragment(storeId: string): boolean {
return get(fragmentsStore).some((item) => item.id === storeId);
}

View File

@ -0,0 +1,118 @@
import { get, writable } from "svelte/store";
import {
createList as createListCommand,
deleteList as deleteListCommand,
listLists,
updateList as updateListCommand,
type ListDocumentDto
} from "$lib/backend/lists";
export type ListItem = {
id: string;
label: string;
initialContent: string;
};
export const listsStore = writable<ListItem[]>([]);
export const listsBusyStore = writable(false);
function toStoreId(id: string): string {
return `lists/${id}`;
}
function toBackendId(id: string): string | null {
const prefix = "lists/";
if (!id.startsWith(prefix)) return null;
const backendId = id.slice(prefix.length).trim();
return backendId || null;
}
function dtoToItem(dto: ListDocumentDto): ListItem {
return {
id: toStoreId(dto.id),
label: dto.label,
initialContent: dto.content || `# ${dto.label}\n\n`
};
}
function upsertById(items: ListItem[], next: ListItem): ListItem[] {
const idx = items.findIndex((item) => item.id === next.id);
if (idx === -1) return [next, ...items];
const clone = [...items];
clone[idx] = next;
return clone;
}
export function createListDraft(): ListItem {
const id = `lists/draft-${Date.now()}`;
return {
id,
label: "Untitled List",
initialContent: "# Untitled List\n\n- Item 1"
};
}
export async function hydrateLists(): Promise<void> {
listsBusyStore.set(true);
try {
const items = await listLists();
listsStore.set(items.map(dtoToItem));
} catch (error) {
console.error("[lists] hydrate:error", error);
throw error;
} finally {
listsBusyStore.set(false);
}
}
export async function createListFromLabel(label: string, content = ""): Promise<ListItem> {
const resolvedLabel = label.trim() || "Untitled List";
const resolvedContent = content || `# ${resolvedLabel}\n\n`;
const created = await createListCommand({ label: resolvedLabel, content: resolvedContent });
const item = dtoToItem(created);
listsStore.update((items) => [item, ...items]);
return item;
}
export async function updateListByStoreId(
storeId: string,
label?: string,
content?: string
): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;
const payload: { label?: string; content?: string } = {};
if (label !== undefined) payload.label = label;
if (content !== undefined) payload.content = content;
const ok = await updateListCommand(backendId, payload);
if (!ok) return false;
listsStore.update((items) =>
items.map((item) =>
item.id === storeId
? {
...item,
label: label ?? item.label,
initialContent: content ?? item.initialContent
}
: item
)
);
return true;
}
export async function deleteListByStoreId(storeId: string): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;
const ok = await deleteListCommand(backendId);
if (!ok) return false;
listsStore.update((items) => items.filter((item) => item.id !== storeId));
return true;
}
export function hasList(storeId: string): boolean {
return get(listsStore).some((item) => item.id === storeId);
}

View File

@ -0,0 +1,34 @@
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();
}

View File

@ -0,0 +1,160 @@
import { writable } from "svelte/store";
import { get } from "svelte/store";
import { invoke } from "$lib/runtime/invoke";
const defaultTags = ["Personal", "Work", "Ideas", "Journal"];
const defaultFragmentTypes = ["Quote", "Snippet", "Reference"];
const startupViews = ["entries", "calendar", "fragments", "todos", "lists"] as const;
const defaultStartupView = "entries";
export type StartupView = typeof startupViews[number];
export const settingsTags = writable<string[]>([...defaultTags]);
export const settingsFragmentTypes = writable<string[]>([...defaultFragmentTypes]);
export const settingsDefaultStartupView = writable<StartupView>(defaultStartupView);
let hydrationComplete = false;
let hydrating = false;
type UiSettingsPayload = {
tags?: string[];
fragmentTypes?: string[];
defaultStartupView?: string;
};
function normalize(value: string): string {
return value.trim().toLowerCase();
}
function hasDuplicate(values: string[], candidate: string, excludeIndex?: number): boolean {
const normalized = normalize(candidate);
return values.some((value, index) => index !== excludeIndex && normalize(value) === normalized);
}
function normalizeValues(values: string[], fallback: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const trimmed = value.trim();
if (!trimmed) continue;
const key = normalize(trimmed);
if (seen.has(key)) continue;
seen.add(key);
result.push(trimmed);
}
return result.length ? result : [...fallback];
}
function normalizeStartupView(value: string | undefined): StartupView {
const normalized = (value ?? "").trim().toLowerCase();
if (startupViews.includes(normalized as StartupView)) {
return normalized as StartupView;
}
return defaultStartupView;
}
export async function hydrateUiSettings(): Promise<void> {
if (hydrating || hydrationComplete) return;
hydrating = true;
try {
const payload = await invoke<UiSettingsPayload>("get_ui_settings");
settingsTags.set(normalizeValues(payload.tags ?? [], defaultTags));
settingsFragmentTypes.set(normalizeValues(payload.fragmentTypes ?? [], defaultFragmentTypes));
settingsDefaultStartupView.set(normalizeStartupView(payload.defaultStartupView));
} catch (error) {
console.error("[settings] hydrate failed", error);
} finally {
hydrating = false;
hydrationComplete = true;
}
}
export async function persistUiSettings(): Promise<void> {
const tags = normalizeValues(get(settingsTags), defaultTags);
const fragmentTypes = normalizeValues(get(settingsFragmentTypes), defaultFragmentTypes);
const startupView = normalizeStartupView(get(settingsDefaultStartupView));
settingsTags.set(tags);
settingsFragmentTypes.set(fragmentTypes);
settingsDefaultStartupView.set(startupView);
await invoke("set_ui_settings", {
tags,
fragmentTypes,
fragment_types: fragmentTypes,
defaultStartupView: startupView,
default_startup_view: startupView
});
}
function queuePersist(): void {
if (!hydrationComplete || hydrating) return;
void persistUiSettings().catch((error) => {
console.error("[settings] persist failed", error);
});
}
export function addSettingsTag(value: string): boolean {
const next = value.trim();
if (!next) return false;
const tags = get(settingsTags);
if (hasDuplicate(tags, next)) return false;
settingsTags.set([...tags, next]);
queuePersist();
return true;
}
export function updateSettingsTag(index: number, value: string): boolean {
const next = value.trim();
if (!next) return false;
const tags = get(settingsTags);
if (index < 0 || index >= tags.length) return false;
if (hasDuplicate(tags, next, index)) return false;
settingsTags.set(tags.map((tag, idx) => (idx === index ? next : tag)));
queuePersist();
return true;
}
export function removeSettingsTag(index: number): boolean {
const tags = get(settingsTags);
if (index < 0 || index >= tags.length) return false;
settingsTags.set(tags.filter((_, idx) => idx !== index));
queuePersist();
return true;
}
export function addFragmentType(value: string): boolean {
const next = value.trim();
if (!next) return false;
const types = get(settingsFragmentTypes);
if (hasDuplicate(types, next)) return false;
settingsFragmentTypes.set([...types, next]);
queuePersist();
return true;
}
export function updateFragmentType(index: number, value: string): boolean {
const next = value.trim();
if (!next) return false;
const types = get(settingsFragmentTypes);
if (index < 0 || index >= types.length) return false;
if (hasDuplicate(types, next, index)) return false;
settingsFragmentTypes.set(types.map((type, idx) => (idx === index ? next : type)));
queuePersist();
return true;
}
export function removeFragmentType(index: number): boolean {
const types = get(settingsFragmentTypes);
if (index < 0 || index >= types.length) return false;
settingsFragmentTypes.set(types.filter((_, idx) => idx !== index));
queuePersist();
return true;
}
export function setDefaultStartupView(value: string): boolean {
const next = normalizeStartupView(value);
if (get(settingsDefaultStartupView) === next) return false;
settingsDefaultStartupView.set(next);
queuePersist();
return true;
}

View File

@ -0,0 +1,267 @@
import { get, writable } from "svelte/store";
import {
createTodoItem as createTodoItemCommand,
createTodoList as createTodoListCommand,
deleteTodoItem as deleteTodoItemCommand,
deleteTodoList as deleteTodoListCommand,
listTodoLists,
updateTodoItem as updateTodoItemCommand,
updateTodoList as updateTodoListCommand,
type TodoListDto
} from "$lib/backend/todos";
// TodoItem keeps a numeric `id` for local array operations (used by EditorPanel)
// plus a `backendId` (guid string) for backend persistence.
export type TodoItem = { id: number; text: string; done: boolean; backendId?: string };
export type TodoListMeta = { id: string; label: string; backendId?: string };
export const todoListsStore = writable<TodoListMeta[]>([]);
export const todosStore = writable<Record<string, TodoItem[]>>({});
export const todosBusyStore = writable(false);
// ── ID helpers ───────────────────────────────────────────────────
function toStoreId(guid: string): string {
return `todos/${guid}`;
}
function toBackendId(storeId: string): string | null {
const prefix = "todos/";
if (!storeId.startsWith(prefix)) return null;
const backendId = storeId.slice(prefix.length).trim();
return backendId || null;
}
export function createTodoId(): number {
return Date.now() + Math.floor(Math.random() * 1000);
}
// ── DTO mapping ──────────────────────────────────────────────────
function dtoToMeta(dto: TodoListDto): TodoListMeta {
return {
id: toStoreId(dto.id),
label: dto.label,
backendId: dto.id
};
}
function dtoToItems(dto: TodoListDto): TodoItem[] {
return dto.items.map((item, index) => ({
id: createTodoId() + index,
text: item.text,
done: item.done,
backendId: item.id
}));
}
// ── Hydration ────────────────────────────────────────────────────
export async function hydrateTodos(): Promise<void> {
todosBusyStore.set(true);
try {
const lists = await listTodoLists();
const metas: TodoListMeta[] = lists.map(dtoToMeta);
const items: Record<string, TodoItem[]> = {};
for (const dto of lists) {
items[toStoreId(dto.id)] = dtoToItems(dto);
}
todoListsStore.set(metas);
todosStore.set(items);
} catch (error) {
console.error("[todos] hydrate:error", error);
throw error;
} finally {
todosBusyStore.set(false);
}
}
// ── List CRUD ────────────────────────────────────────────────────
export async function createTodoListFromLabel(
label: string
): Promise<{ meta: TodoListMeta; items: TodoItem[] }> {
const resolvedLabel = label.trim() || "New List";
const created = await createTodoListCommand({ label: resolvedLabel });
const meta = dtoToMeta(created);
todoListsStore.update((metas) => [meta, ...metas]);
todosStore.update((lists) => ({ ...lists, [meta.id]: [] }));
return { meta, items: [] };
}
export async function deleteTodoListByStoreId(storeId: string): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;
const ok = await deleteTodoListCommand(backendId);
if (!ok) return false;
todoListsStore.update((metas) => metas.filter((m) => m.id !== storeId));
todosStore.update((lists) => {
const { [storeId]: _, ...rest } = lists;
return rest;
});
return true;
}
// ── Item CRUD (backend-backed) ───────────────────────────────────
export async function addTodoItemBackend(
storeId: string,
text: string
): Promise<TodoItem | null> {
const backendListId = toBackendId(storeId);
if (!backendListId || !text.trim()) return null;
const items = get(todosStore)[storeId] ?? [];
const sortOrder = items.length;
const created = await createTodoItemCommand({
listId: backendListId,
text: text.trim(),
sortOrder
});
const item: TodoItem = {
id: createTodoId(),
text: created.text,
done: created.done,
backendId: created.id
};
todosStore.update((lists) => ({
...lists,
[storeId]: [item, ...(lists[storeId] ?? [])]
}));
return item;
}
export async function toggleTodoItemBackend(
storeId: string,
localId: number
): Promise<boolean> {
const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId);
if (!todo?.backendId) return false;
const ok = await updateTodoItemCommand(todo.backendId, { done: !todo.done });
if (!ok) return false;
todosStore.update((lists) => ({
...lists,
[storeId]: (lists[storeId] ?? []).map((t) =>
t.id === localId ? { ...t, done: !t.done } : t
)
}));
return true;
}
export async function updateTodoItemTextBackend(
storeId: string,
localId: number,
text: string
): Promise<boolean> {
const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId);
if (!todo?.backendId || !text.trim()) return false;
const ok = await updateTodoItemCommand(todo.backendId, { text: text.trim() });
if (!ok) return false;
todosStore.update((lists) => ({
...lists,
[storeId]: (lists[storeId] ?? []).map((t) =>
t.id === localId ? { ...t, text: text.trim() } : t
)
}));
return true;
}
export async function removeTodoItemBackend(
storeId: string,
localId: number
): Promise<boolean> {
const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId);
if (!todo?.backendId) return false;
const ok = await deleteTodoItemCommand(todo.backendId);
if (!ok) return false;
todosStore.update((lists) => ({
...lists,
[storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId)
}));
return true;
}
// ── Pure helpers (used by EditorPanel for local state) ───────────
export function serializeTodoList(title: string, todos: TodoItem[]): string {
const heading = title?.trim() ? `# ${title}` : "# To-Do List";
const lines = todos.map((todo) => `- [${todo.done ? "x" : " "}] ${todo.text}`);
return `${heading}\n\n${lines.join("\n")}`;
}
export function parseTodoList(content: string): TodoItem[] {
const lines = content.replace(/\r\n/g, "\n").split("\n");
const parsed: TodoItem[] = [];
for (const line of lines) {
const match = line.match(/^- \[( |x)\]\s+(.+)$/i);
if (!match) continue;
parsed.push({
id: createTodoId(),
text: match[2].trim(),
done: match[1].toLowerCase() === "x"
});
}
return parsed;
}
export function getOrCreateTodoList(
lists: Record<string, TodoItem[]>,
documentId: string,
fallbackContent: string
): { lists: Record<string, TodoItem[]>; todos: TodoItem[] } {
const existing = lists[documentId];
if (existing) {
return { lists, todos: existing };
}
const parsed = parseTodoList(fallbackContent);
return { lists: { ...lists, [documentId]: parsed }, todos: parsed };
}
export function setTodoList(
lists: Record<string, TodoItem[]>,
documentId: string,
todos: TodoItem[]
): Record<string, TodoItem[]> {
return { ...lists, [documentId]: todos };
}
export function addTodoItem(todos: TodoItem[], text: string): TodoItem[] {
return [{ id: createTodoId(), text: text.trim(), done: false }, ...todos];
}
export function toggleTodoItem(todos: TodoItem[], id: number): TodoItem[] {
return todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo));
}
export function updateTodoItemText(todos: TodoItem[], id: number, text: string): TodoItem[] {
return todos.map((todo) => (todo.id === id ? { ...todo, text: text.trim() } : todo));
}
export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] {
return todos.filter((todo) => todo.id !== id);
}
export function createTodoListDraft(): { meta: TodoListMeta; items: TodoItem[] } {
const id = `todos/draft-${Date.now()}`;
return {
meta: { id, label: "New List" },
items: []
};
}

View File

@ -0,0 +1,119 @@
export function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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(
/\[([^\]]+)\]\((journal:[^\s)]+)\)/g,
'<a href="$2">$1</a>'
);
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;
}

View File

@ -0,0 +1,72 @@
<script lang="ts">
import { onMount } from "svelte";
import { getSessionPassword, clearVaultSession, flushBeforeClose } from "$lib/stores/session";
import { persistAndClearVault } from "$lib/backend/auth";
import { hydrateUiSettings } from "$lib/stores/settings";
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";
let closeInProgress = false;
async function persistSession(keepalive = false): Promise<void> {
if (closeInProgress) return;
closeInProgress = true;
try {
await flushBeforeClose();
} catch {
// best effort
}
const password = getSessionPassword();
if (!password) {
return;
}
try {
await persistAndClearVault(password, { keepalive });
clearVaultSession();
} catch (error) {
console.error("Vault persistence on exit failed:", error);
}
}
onMount(() => {
void hydrateUiSettings();
if (isTauriRuntime()) {
const unlistenPromise = (async () => {
const tauriWindow = await import("@tauri-apps/api/window");
const appWindow = tauriWindow.getCurrentWindow();
return appWindow.onCloseRequested(async (event) => {
if (closeInProgress) return;
event.preventDefault();
await persistSession();
await invoke("shutdown");
});
})();
return () => {
void unlistenPromise.then((unlisten) => unlisten());
};
}
const handlePageHide = () => {
void persistSession(true);
};
const handleBeforeUnload = () => {
void persistSession(true);
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
});
</script>
<slot />

View File

@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;

View File

@ -0,0 +1,575 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { unlockVaultWorkspace } from "$lib/backend/auth";
import AppModal from "$lib/components/AppModal.svelte";
import { deleteEntryTemplate, loadEntryTemplate, saveEntryTemplate } from "$lib/backend/templates";
import { deleteEntryByStoreId, entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries";
import { deleteFragmentByStoreId, hydrateFragments } from "$lib/stores/fragments";
import { deleteListByStoreId, hydrateLists, updateListByStoreId } from "$lib/stores/lists";
import { hydrateUiSettings, settingsDefaultStartupView, type StartupView } from "$lib/stores/settings";
import { isVaultReady, setFlushCallback, setVaultSession } from "$lib/stores/session";
import { deleteTodoListByStoreId, hydrateTodos } from "$lib/stores/todos";
import Navbar from "$lib/components/Navbar.svelte";
import SidePanel from "$lib/components/SidePanel.svelte";
import EditorPanel from "$lib/components/EditorPanel.svelte";
import { onMount } from "svelte";
import { get } from "svelte/store";
type OpenDocument = {
id: string;
label: string;
initialContent: string;
linkedFrom?: {
id: string;
label: string;
initialContent: string;
section: StartupView;
};
};
type CalendarPanelState = {
items: OpenDocument[];
busy: boolean;
error: string;
};
const initialEntry = getDefaultEntry(get(entriesStore));
let selectedSection = "entries";
let panelOpen = true;
let editMode = false;
let activeDocumentId = initialEntry?.id ?? "entries/daily-notes";
let activeDocumentLabel = initialEntry?.label ?? "Daily Notes";
let openDocuments: Record<string, string> = initialEntry
? { [initialEntry.id]: initialEntry.initialContent }
: { "entries/daily-notes": "# Daily Notes\n\nStart writing today's entry..." };
let modalOpen = false;
let modalTitle = "";
let modalMessage = "";
let modalConfirmText = "OK";
let modalCancelText = "Cancel";
let modalShowCancel = false;
let modalTone: "default" | "danger" = "default";
let modalAction: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm" | null = null;
let modalInputEnabled = false;
let modalInputType = "text";
let modalInputPlaceholder = "";
let modalInputAriaLabel = "Modal input";
let modalInputValue = "";
let unlockResolver: ((password: string | null) => void) | null = null;
let fragmentBootstrapInFlight = false;
let pendingDeleteItemId = "";
let templateRefreshToken = 0;
let linkedBackTarget: OpenDocument["linkedFrom"] | null = null;
let calendarPanelState: CalendarPanelState = {
items: [],
busy: false,
error: ""
};
function resolveStartupSection(value: string): StartupView {
switch (value) {
case "calendar":
case "fragments":
case "todos":
case "lists":
case "entries":
return value;
default:
return "entries";
}
}
function parseSectionQuery(value: string | null): StartupView | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
switch (normalized) {
case "entries":
case "calendar":
case "fragments":
case "todos":
case "lists":
return normalized;
default:
return null;
}
}
function sectionFromDocumentId(id: string): StartupView | null {
if (id.startsWith("entries/")) return "entries";
if (id.startsWith("fragments/")) return "fragments";
if (id.startsWith("todos/")) return "todos";
if (id.startsWith("lists/")) return "lists";
return null;
}
function applyStartupSection(section: StartupView) {
selectedSection = section;
editMode = false;
if (section !== "entries") {
activeDocumentId = "";
activeDocumentLabel = "";
}
}
function toTemplatePath(id: string): string | null {
const prefix = "entries/template-file/";
if (!id.startsWith(prefix)) return null;
const encoded = id.slice(prefix.length).trim();
if (!encoded) return null;
try {
const decoded = decodeURIComponent(encoded);
return decoded || null;
} catch {
return null;
}
}
function templateNameFromPath(path: string): string {
const fileName = path.split(/[\\/]/).pop() ?? path;
return fileName.replace(/\.template\.md$/i, "");
}
function showModal(options: {
action: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm";
title: string;
message: string;
confirmText?: string;
cancelText?: string;
showCancel?: boolean;
tone?: "default" | "danger";
inputEnabled?: boolean;
inputType?: string;
inputPlaceholder?: string;
inputAriaLabel?: string;
inputValue?: string;
}) {
modalAction = options.action;
modalTitle = options.title;
modalMessage = options.message;
modalConfirmText = options.confirmText ?? "OK";
modalCancelText = options.cancelText ?? "Cancel";
modalShowCancel = options.showCancel ?? false;
modalTone = options.tone ?? "default";
modalInputEnabled = options.inputEnabled ?? false;
modalInputType = options.inputType ?? "text";
modalInputPlaceholder = options.inputPlaceholder ?? "";
modalInputAriaLabel = options.inputAriaLabel ?? "Modal input";
modalInputValue = options.inputValue ?? "";
modalOpen = true;
}
function closeModal() {
modalOpen = false;
modalAction = null;
modalInputEnabled = false;
modalInputType = "text";
modalInputPlaceholder = "";
modalInputAriaLabel = "Modal input";
modalInputValue = "";
pendingDeleteItemId = "";
}
async function handleModalConfirm() {
if (modalAction === "logout-confirm") {
showModal({
action: "logout-info",
title: "Logout Requested",
message: "You have been logged out.",
confirmText: "Close"
});
return;
}
if (modalAction === "unlock-vault") {
const value = modalInputValue.trim();
if (!value) return;
const resolve = unlockResolver;
unlockResolver = null;
closeModal();
resolve?.(value);
return;
}
if (modalAction === "delete-confirm") {
const id = pendingDeleteItemId;
closeModal();
await performDelete(id);
return;
}
closeModal();
}
function handleModalCancel() {
if (modalAction === "unlock-vault") {
const resolve = unlockResolver;
unlockResolver = null;
closeModal();
resolve?.(null);
return;
}
closeModal();
}
function requestVaultPassword(): Promise<string | null> {
return new Promise((resolve) => {
unlockResolver = resolve;
showModal({
action: "unlock-vault",
title: "Unlock Vault",
message: "Enter your vault password to load journal data.",
confirmText: "Unlock",
cancelText: "Cancel",
showCancel: true,
inputEnabled: true,
inputType: "password",
inputPlaceholder: "Vault password",
inputAriaLabel: "Vault password",
inputValue: ""
});
});
}
function isLockedError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
return normalized.includes("database is locked") || normalized.includes("incorrect vault password");
}
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
if (fragmentBootstrapInFlight) return;
if (isVaultReady()) {
try {
await hydrateEntries();
templateRefreshToken += 1;
const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry);
}
await hydrateFragments();
await hydrateLists().catch(() => {});
await hydrateTodos().catch(() => {});
} catch (error) {
console.error("Hydration failed:", error);
}
return;
}
fragmentBootstrapInFlight = true;
try {
let attempts = 0;
while (attempts < maxAttempts) {
try {
const password = await requestVaultPassword();
if (!password) return;
await unlockVaultWorkspace(password);
setVaultSession(password);
await hydrateEntries();
templateRefreshToken += 1;
const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry);
}
await hydrateFragments();
await hydrateLists().catch(() => {});
await hydrateTodos().catch(() => {});
return;
} catch (error) {
if (!isLockedError(error)) return;
attempts += 1;
}
}
} finally {
fragmentBootstrapInFlight = false;
}
}
async function saveCurrentDocument() {
if (!activeDocumentId) return;
const content = openDocuments[activeDocumentId];
if (!content?.trim()) return;
try {
if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-draft-")) {
const draft = get(entriesStore).find((item) => item.id === activeDocumentId);
const draftLabel = (draft?.label ?? activeDocumentLabel ?? "").trim();
const templateName = draftLabel.replace(/_template$/i, "").trim() || draftLabel;
await saveEntryTemplate({
name: templateName,
content
});
templateRefreshToken += 1;
entriesStore.update((items) => items.filter((item) => item.id !== activeDocumentId));
const { [activeDocumentId]: _, ...rest } = openDocuments;
openDocuments = rest;
activeDocumentId = "";
activeDocumentLabel = "";
editMode = false;
} else if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-file/")) {
const filePath = toTemplatePath(activeDocumentId);
if (!filePath) return;
await saveEntryTemplate({
name: templateNameFromPath(filePath),
content,
filePath
});
templateRefreshToken += 1;
} else if (selectedSection === "entries" && (activeDocumentId.startsWith("entries/file/") || activeDocumentId.startsWith("entries/draft-"))) {
const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite");
if (saved && saved.id !== activeDocumentId) {
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
}
}
async function handleSelect(id: string) {
if (id === "account") {
goto("/account");
return;
}
if (id === "settings") {
goto(`/settings?return=${encodeURIComponent(selectedSection)}`);
return;
}
if (id === "logout") {
showModal({
action: "logout-confirm",
title: "Confirm Logout",
message: "Are you sure you want to log out?",
confirmText: "Log Out",
cancelText: "Cancel",
showCancel: true,
tone: "danger"
});
return;
}
if (selectedSection === id) {
panelOpen = !panelOpen;
return;
}
await saveCurrentDocument();
selectedSection = id;
panelOpen = true;
activeDocumentId = "";
activeDocumentLabel = "";
editMode = false;
}
async function handleOpenDocument(doc: OpenDocument) {
const prevActiveId = activeDocumentId;
await saveCurrentDocument();
editMode = false;
linkedBackTarget = doc.linkedFrom ?? null;
const targetSection = sectionFromDocumentId(doc.id);
const effectiveSection = targetSection ?? selectedSection;
if (targetSection && targetSection !== selectedSection) {
selectedSection = targetSection;
panelOpen = true;
}
// If saveCurrentDocument promoted a draft to a file-backed entry and the
// caller passed the now-stale draft reference, the editor is already
// showing the promoted entry — nothing more to do.
if (doc.id === prevActiveId && activeDocumentId !== prevActiveId) {
return;
}
let resolvedDoc = doc;
if (effectiveSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
try {
const loaded = await loadEntryByStoreId(doc.id);
if (loaded) {
resolvedDoc = loaded;
}
} catch {
// entry content will use initialContent fallback
}
} else if (effectiveSection === "entries" && doc.id.startsWith("entries/template-file/") && !doc.initialContent) {
try {
const filePath = toTemplatePath(doc.id);
if (filePath) {
const loaded = await loadEntryTemplate(filePath);
resolvedDoc = {
id: doc.id,
label: loaded.fileName.replace(/\.template\.md$/i, ""),
initialContent: loaded.content
};
}
} catch {
// template content will use initialContent fallback
}
}
if (!(resolvedDoc.id in openDocuments)) {
openDocuments = { ...openDocuments, [resolvedDoc.id]: resolvedDoc.initialContent };
}
activeDocumentId = resolvedDoc.id;
activeDocumentLabel = resolvedDoc.label;
}
async function handleLinkedBack() {
if (!linkedBackTarget) return;
const target = linkedBackTarget;
linkedBackTarget = null;
await handleOpenDocument({
id: target.id,
label: target.label,
initialContent: target.initialContent
});
}
function handleDocumentContentChange(content: string) {
openDocuments = { ...openDocuments, [activeDocumentId]: content };
}
function handleDeleteDocument(id: string) {
const { [id]: _, ...remaining } = openDocuments;
openDocuments = remaining;
}
async function performDelete(id: string) {
try {
let ok = false;
if (selectedSection === "entries") {
const templatePath = toTemplatePath(id);
if (templatePath) {
ok = await deleteEntryTemplate(templatePath);
if (ok) templateRefreshToken += 1;
} else if (id.startsWith("entries/template-draft-")) {
entriesStore.update((items) => items.filter((item) => item.id !== id));
ok = true;
} else {
ok = await deleteEntryByStoreId(id);
}
} else if (selectedSection === "todos") {
ok = await deleteTodoListByStoreId(id);
} else if (selectedSection === "lists") {
ok = await deleteListByStoreId(id);
} else if (selectedSection === "fragments") {
ok = await deleteFragmentByStoreId(id);
}
if (!ok) return;
handleDeleteDocument(id);
if (activeDocumentId === id) {
activeDocumentId = "";
activeDocumentLabel = "";
editMode = false;
}
} catch (error) {
console.error("Delete failed:", error);
}
}
async function handleEditItem(doc: OpenDocument) {
if (doc.id !== activeDocumentId) {
await handleOpenDocument(doc);
}
editMode = true;
}
function handleDeleteItem(doc: { id: string; label: string }) {
pendingDeleteItemId = doc.id;
showModal({
action: "delete-confirm",
title: "Confirm Delete",
message: `Are you sure you want to delete "${doc.label}"? This action cannot be undone.`,
confirmText: "Delete",
cancelText: "Cancel",
showCancel: true,
tone: "danger"
});
}
onMount(() => {
setFlushCallback(saveCurrentDocument);
void (async () => {
await hydrateUiSettings();
const startupSection = resolveStartupSection(get(settingsDefaultStartupView));
const sectionFromQuery = parseSectionQuery(new URLSearchParams(window.location.search).get("section"));
applyStartupSection(sectionFromQuery ?? startupSection);
await bootstrapFragmentsWithUnlock();
})();
});
</script>
<div class="app-shell" class:panel-closed={!panelOpen}>
<Navbar activeSection={selectedSection} onSelect={handleSelect} />
{#if panelOpen}
<SidePanel
activeSection={selectedSection}
{activeDocumentId}
{templateRefreshToken}
onOpenDocument={handleOpenDocument}
onEditItem={handleEditItem}
onDeleteItem={handleDeleteItem}
onCalendarStateChange={(state) => {
calendarPanelState = state;
}}
/>
{/if}
<EditorPanel
activeSection={selectedSection}
openDocumentId={activeDocumentId}
openDocumentName={activeDocumentLabel}
openDocumentContent={openDocuments[activeDocumentId] ?? ""}
onDocumentContentChange={handleDocumentContentChange}
onOpenDocument={handleOpenDocument}
onDeleteDocument={handleDeleteDocument}
showLinkedBackButton={linkedBackTarget !== null}
onLinkedBack={handleLinkedBack}
calendarItems={calendarPanelState.items}
calendarBusy={calendarPanelState.busy}
calendarError={calendarPanelState.error}
previewOnly={!editMode}
/>
</div>
<AppModal
open={modalOpen}
title={modalTitle}
message={modalMessage}
confirmText={modalConfirmText}
cancelText={modalCancelText}
showCancel={modalShowCancel}
tone={modalTone}
inputEnabled={modalInputEnabled}
inputType={modalInputType}
inputPlaceholder={modalInputPlaceholder}
inputAriaLabel={modalInputAriaLabel}
bind:inputValue={modalInputValue}
onConfirm={handleModalConfirm}
onCancel={handleModalCancel}
/>
<style>
.app-shell {
height: 100vh;
overflow: hidden;
}
.app-shell :global(.side-panel),
.app-shell :global(.editor-panel) {
min-height: 0;
min-width: 0;
}
</style>

View File

@ -0,0 +1,675 @@
<script lang="ts">
import { goto } from "$app/navigation";
import AppModal from "$lib/components/AppModal.svelte";
import Navbar from "$lib/components/Navbar.svelte";
import {
addFragmentType,
addSettingsTag,
removeFragmentType,
removeSettingsTag,
setDefaultStartupView,
settingsDefaultStartupView,
settingsFragmentTypes,
settingsTags,
updateFragmentType,
updateSettingsTag
} from "$lib/stores/settings";
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";
import { onMount } from "svelte";
const activeSection = "settings";
let modalOpen = false;
let modalTitle = "";
let modalMessage = "";
let modalConfirmText = "OK";
let modalCancelText = "Cancel";
let modalShowCancel = false;
let modalTone: "default" | "danger" = "default";
let modalAction: "logout-confirm" | "logout-info" | null = null;
let newTag = "";
let newFragmentType = "";
let editingTagIndex: number | null = null;
let editingTagValue = "";
let editingFragmentTypeIndex: number | null = null;
let editingFragmentTypeValue = "";
let sidecarRoot = "";
let sidecarRootIsCustom = false;
let sidecarRootError = "";
let sidecarBrowseBusy = false;
let returnSection = "entries";
onMount(async () => {
const queryReturn = new URLSearchParams(window.location.search).get("return")?.trim().toLowerCase() ?? "";
if (["entries", "calendar", "fragments", "todos", "lists"].includes(queryReturn)) {
returnSection = queryReturn;
}
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);
}
}
async function browseSidecarRoot() {
sidecarRootError = "";
if (!isTauriRuntime()) {
sidecarRootError = "Folder picker is only available in desktop app.";
return;
}
sidecarBrowseBusy = true;
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
directory: true,
multiple: false,
title: "Select Sidecar Root Directory"
});
if (typeof picked === "string" && picked.trim()) {
sidecarRoot = picked;
await saveSidecarRoot();
}
} catch (e) {
sidecarRootError = String(e);
} finally {
sidecarBrowseBusy = false;
}
}
function showModal(options: {
action: "logout-confirm" | "logout-info";
title: string;
message: string;
confirmText?: string;
cancelText?: string;
showCancel?: boolean;
tone?: "default" | "danger";
}) {
modalAction = options.action;
modalTitle = options.title;
modalMessage = options.message;
modalConfirmText = options.confirmText ?? "OK";
modalCancelText = options.cancelText ?? "Cancel";
modalShowCancel = options.showCancel ?? false;
modalTone = options.tone ?? "default";
modalOpen = true;
}
function closeModal() {
modalOpen = false;
modalAction = null;
}
function handleModalConfirm() {
if (modalAction === "logout-confirm") {
showModal({
action: "logout-info",
title: "Logout Requested",
message: "You have been logged out.",
confirmText: "Close"
});
return;
}
closeModal();
}
function handleSelect(id: string) {
if (id === "logout") {
showModal({
action: "logout-confirm",
title: "Confirm Logout",
message: "Are you sure you want to log out?",
confirmText: "Log Out",
cancelText: "Cancel",
showCancel: true,
tone: "danger"
});
return;
}
if (id === "settings") {
return;
}
if (id === "account") {
goto("/account");
return;
}
goto(`/?section=${encodeURIComponent(id)}`);
}
function closeSettings() {
goto(`/?section=${encodeURIComponent(returnSection)}`);
}
function addTag() {
if (addSettingsTag(newTag)) {
newTag = "";
}
}
function startEditTag(index: number, tag: string) {
editingTagIndex = index;
editingTagValue = tag;
}
function saveEditTag() {
if (editingTagIndex === null) return;
if (updateSettingsTag(editingTagIndex, editingTagValue)) {
editingTagIndex = null;
editingTagValue = "";
}
}
function cancelEditTag() {
editingTagIndex = null;
editingTagValue = "";
}
function removeTag(index: number) {
if (!removeSettingsTag(index)) return;
if (editingTagIndex === index) {
cancelEditTag();
}
}
function addFragmentTypeLocal() {
if (addFragmentType(newFragmentType)) {
newFragmentType = "";
}
}
function startEditFragmentType(index: number, fragmentType: string) {
editingFragmentTypeIndex = index;
editingFragmentTypeValue = fragmentType;
}
function saveEditFragmentType() {
if (editingFragmentTypeIndex === null) return;
if (updateFragmentType(editingFragmentTypeIndex, editingFragmentTypeValue)) {
editingFragmentTypeIndex = null;
editingFragmentTypeValue = "";
}
}
function cancelEditFragmentType() {
editingFragmentTypeIndex = null;
editingFragmentTypeValue = "";
}
function removeFragmentTypeLocal(index: number) {
if (!removeFragmentType(index)) return;
if (editingFragmentTypeIndex === index) {
cancelEditFragmentType();
}
}
function updateDefaultStartupView(value: string) {
setDefaultStartupView(value);
}
</script>
<div class="app-shell panel-closed">
<Navbar {activeSection} onSelect={handleSelect} />
<main class="route-view">
<header class="route-header">
<div class="route-header-main">
<h1>Settings</h1>
<p>Configure app behavior and interface options.</p>
</div>
<button type="button" class="header-close-btn" on:click={closeSettings} aria-label="Close settings">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</header>
<div class="settings-grid">
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
Startup
</h2>
</div>
<label>
Default startup view
<select
value={$settingsDefaultStartupView}
on:change={(event) => updateDefaultStartupView((event.currentTarget as HTMLSelectElement).value)}
>
<option value="entries">Entries</option>
<option value="calendar">Calendar</option>
<option value="fragments">Fragments</option>
<option value="todos">To-Do List</option>
<option value="lists">Lists</option>
</select>
</label>
</section>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">label</span>
Tags
</h2>
<p class="section-copy">Add and manage tags used for notes and entries.</p>
</div>
<div class="create-row">
<input
type="text"
placeholder="Add tag (example: Research)"
bind:value={newTag}
on:keydown={(event) => event.key === "Enter" && addTag()}
/>
<button type="button" class="secondary-btn" on:click={addTag}>Add</button>
</div>
<ul class="item-list">
{#each $settingsTags as tag, index}
<li class="item-row">
{#if editingTagIndex === index}
<input
type="text"
bind:value={editingTagValue}
on:keydown={(event) => {
if (event.key === "Enter") saveEditTag();
if (event.key === "Escape") cancelEditTag();
}}
/>
<div class="row-actions">
<button type="button" class="secondary-btn" on:click={saveEditTag}>Save</button>
<button type="button" class="ghost-btn" on:click={cancelEditTag}>Cancel</button>
</div>
{:else}
<span>{tag}</span>
<div class="row-actions">
<button type="button" class="ghost-btn" on:click={() => startEditTag(index, tag)}>Edit</button>
<button type="button" class="danger-btn" on:click={() => removeTag(index)}>Remove</button>
</div>
{/if}
</li>
{/each}
</ul>
</section>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">category</span>
Fragment Types
</h2>
<p class="section-copy">Configure custom fragment types for the Fragments section.</p>
</div>
<div class="create-row">
<input
type="text"
placeholder="Add fragment type (example: Observation)"
bind:value={newFragmentType}
on:keydown={(event) => event.key === "Enter" && addFragmentTypeLocal()}
/>
<button type="button" class="secondary-btn" on:click={addFragmentTypeLocal}>Add</button>
</div>
<ul class="item-list">
{#each $settingsFragmentTypes as type, index}
<li class="item-row">
{#if editingFragmentTypeIndex === index}
<input
type="text"
bind:value={editingFragmentTypeValue}
on:keydown={(event) => {
if (event.key === "Enter") saveEditFragmentType();
if (event.key === "Escape") cancelEditFragmentType();
}}
/>
<div class="row-actions">
<button type="button" class="secondary-btn" on:click={saveEditFragmentType}>Save</button>
<button type="button" class="ghost-btn" on:click={cancelEditFragmentType}>Cancel</button>
</div>
{:else}
<span>{type}</span>
<div class="row-actions">
<button type="button" class="ghost-btn" on:click={() => startEditFragmentType(index, type)}>Edit</button>
<button type="button" class="danger-btn" on:click={() => removeFragmentTypeLocal(index)}>Remove</button>
</div>
{/if}
</li>
{/each}
</ul>
</section>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">hub</span>
Sidecar
</h2>
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
</div>
<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="ghost-btn" on:click={browseSidecarRoot} disabled={sidecarBrowseBusy}>
{sidecarBrowseBusy ? "Browsing..." : "Browse"}
</button>
{#if sidecarRootIsCustom}
<button type="button" class="ghost-btn" on:click={resetSidecarRoot}>Reset</button>
{/if}
</div>
{#if sidecarRootError}
<p class="error-text">{sidecarRootError}</p>
{/if}
</section>
</div>
</main>
</div>
<AppModal
open={modalOpen}
title={modalTitle}
message={modalMessage}
confirmText={modalConfirmText}
cancelText={modalCancelText}
showCancel={modalShowCancel}
tone={modalTone}
onConfirm={handleModalConfirm}
onCancel={closeModal}
/>
<style>
.route-view {
min-height: 100vh;
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
background: var(--bg-editor);
color: var(--text-primary);
}
.route-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 2px 2px 0;
}
.route-header-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.route-header h1 {
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.route-header p {
color: var(--text-muted);
font-size: 0.88rem;
}
.header-close-btn {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
color: var(--text-muted);
display: grid;
place-items: center;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.header-close-btn .material-symbols-outlined {
font-size: 1rem;
}
.header-close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.settings-grid {
columns: 2;
column-gap: 14px;
}
.route-card {
break-inside: avoid;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
border-radius: 12px;
padding: 14px 14px 13px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--zinc-300) 8%, transparent 92%),
0 8px 24px color-mix(in srgb, var(--bg-app) 32%, transparent 68%);
margin-bottom: 14px;
}
.card-head {
display: flex;
flex-direction: column;
gap: 5px;
}
.card-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.96rem;
font-weight: 650;
color: var(--text-primary);
letter-spacing: 0.01em;
}
.card-title .material-symbols-outlined {
font-size: 1rem;
color: var(--text-dim);
}
.route-card label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.route-card select {
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 9px 10px;
font-size: 0.84rem;
}
.section-copy {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.4;
}
.create-row {
display: flex;
gap: 8px;
align-items: center;
}
.create-row input {
flex: 1;
min-width: 0;
}
.item-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 7px;
}
.item-row {
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 9px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 0.83rem;
color: var(--text-primary);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
}
.item-row input,
.route-card input {
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 8px 10px;
font-size: 0.84rem;
min-width: 200px;
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.secondary-btn,
.ghost-btn,
.danger-btn {
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 7px 11px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
color: var(--text-primary);
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.secondary-btn {
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
border-color: var(--border-strong);
}
.ghost-btn {
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
color: var(--text-muted);
}
.danger-btn {
background: transparent;
color: var(--text-muted);
}
.secondary-btn:hover,
.ghost-btn:hover,
.danger-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.error-text {
color: #e74c3c;
font-size: 0.82rem;
}
@media (max-width: 1100px) {
.settings-grid {
columns: 1;
}
}
@media (max-width: 720px) {
.route-view {
padding: 14px 12px;
gap: 14px;
}
.route-card {
border-radius: 10px;
padding: 12px;
gap: 10px;
}
.route-header {
align-items: center;
}
.create-row {
flex-wrap: wrap;
}
.create-row > * {
flex: 1 1 auto;
}
.item-row {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.row-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,98 @@
:root {
font-family: "Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.45;
font-weight: 400;
--zinc-50: #fafafa;
--zinc-100: #f4f4f5;
--zinc-200: #e4e4e7;
--zinc-300: #d4d4d8;
--zinc-400: #a1a1aa;
--zinc-500: #71717a;
--zinc-600: #52525b;
--zinc-700: #3f3f46;
--zinc-800: #27272a;
--zinc-900: #18181b;
--zinc-950: #09090b;
--bg-app: var(--zinc-950);
--bg-navbar: var(--zinc-900);
--bg-panel: var(--zinc-800);
--bg-editor: var(--zinc-900);
--bg-hover: var(--zinc-800);
--bg-active: var(--zinc-700);
--surface-1: var(--zinc-900);
--surface-2: var(--zinc-800);
--surface-3: var(--zinc-700);
--border-soft: var(--zinc-700);
--border-strong: var(--zinc-600);
--text-primary: var(--zinc-100);
--text-muted: var(--zinc-300);
--text-dim: var(--zinc-500);
--accent: var(--zinc-200);
color: var(--text-primary);
background-color: var(--bg-app);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
html,
body {
min-height: 100%;
}
body {
background: radial-gradient(circle at 15% -10%, var(--zinc-800) 0%, var(--bg-app) 42%);
color: var(--text-primary);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: inherit;
}
button,
input {
border: none;
outline: none;
background: none;
color: inherit;
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 72px 300px minmax(0, 1fr);
}
.app-shell.panel-closed {
grid-template-columns: 72px minmax(0, 1fr);
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 64px minmax(0, 1fr);
grid-template-rows: 280px minmax(0, 1fr);
}
.app-shell:not(.panel-closed) > .side-panel {
grid-column: 2;
grid-row: 1;
}
.app-shell:not(.panel-closed) > .editor-panel {
grid-column: 2;
grid-row: 2;
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,18 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: "index.html",
}),
},
};
export default config;

19
Journal.App/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));

View File

@ -0,0 +1,7 @@
namespace Journal.Core.Dtos;
public sealed record AiHealthDto(
string Provider,
bool Enabled,
bool Healthy,
string Message);

View File

@ -0,0 +1,41 @@
namespace Journal.Core.Dtos;
internal sealed record VaultInitializePayload(string Password, string VaultDirectory);
internal sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null);
internal sealed record ClearDataPayload(string DataDirectory);
internal sealed record EntryListPayload(string? DataDirectory = null);
internal sealed record EntryLoadPayload(string FilePath);
public sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null, string? FileName = null);
public sealed record EntryListItem(string FileName, string FilePath);
public sealed record EntryLoadResult(string FileName, string FilePath, JournalEntryDto Entry);
public sealed record EntrySaveResult(string FilePath);
internal sealed record EntryDeletePayload(string FilePath);
internal sealed record EntryTemplateListPayload(string? DataDirectory = null);
internal sealed record EntryTemplateLoadPayload(string FilePath);
internal sealed record EntryTemplateDeletePayload(string FilePath);
public sealed record EntryTemplateLoadResult(string FileName, string FilePath, string Content);
public sealed record EntryTemplateSavePayload(string Name, string Content, string? FilePath = null, string? DataDirectory = null);
internal sealed record DatabasePayload(string Password, string? DataDirectory = null);
internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
internal sealed record AiSummarizeAllPayload(List<string>? Entries);
internal sealed record AiChatPayload(string Prompt);
internal sealed record AiEmbedPayload(string Content);
internal sealed record SpeechTranscribePayload(
string? AudioBase64 = null,
string? Audio_Base64 = null,
string? Engine = null,
string? WhisperModel = null,
string? Whisper_Model = null,
string? Text = null,
int? SimulateDelayMs = null,
int? Simulate_Delay_Ms = null);
internal sealed record SearchEntriesPayload(
string DataDirectory,
string? Query = null,
string? Section = null,
string? StartDate = null,
string? EndDate = null,
List<string>? Tags = null,
List<string>? Types = null,
List<string>? Checked = null,
List<string>? Unchecked = null);

View File

@ -0,0 +1,18 @@
namespace Journal.Core.Dtos;
public sealed record JournalDatabaseStatus(
string DatabasePath,
int KeyLengthBytes,
int Iterations,
string KeyDerivation,
IReadOnlyList<string> SchemaTables,
string SchemaBootstrapPath,
bool RuntimeReady,
string RuntimeMessage);
public sealed record JournalDatabaseHydrationResult(
string DatabasePath,
string SchemaBootstrapPath,
int EntryFilesProcessed,
bool RuntimeReady,
string Message);

View File

@ -0,0 +1,16 @@
namespace Journal.Core.Dtos;
public sealed record EntrySearchRequestDto(
string DataDirectory,
string? Query = null,
string? Section = null,
string? StartDate = null,
string? EndDate = null,
IReadOnlyList<string>? Tags = null,
IReadOnlyList<string>? Types = null,
IReadOnlyList<string>? Checked = null,
IReadOnlyList<string>? Unchecked = null);
public sealed record EntrySearchResultDto(
string FileName,
JournalEntryDto Entry);

View File

@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace Journal.Core.Dtos;
public record FragmentDto(
Guid Id,
string Type,
string Description,
DateTimeOffset Time,
List<string> Tags
);
public record CreateFragmentDto(
[property: Required(AllowEmptyStrings = false)] string Type,
[property: Required(AllowEmptyStrings = false)] string Description,
List<string>? Tags = null
);
public record UpdateFragmentDto(
string? Type = null,
string? Description = null,
List<string>? Tags = null,
DateTimeOffset? Time = null
);

View File

@ -0,0 +1,12 @@
namespace Journal.Core.Dtos;
public sealed record ParsedSectionDto(
string Title,
IReadOnlyList<string> Content,
IReadOnlyDictionary<string, bool> Checkboxes);
public sealed record JournalEntryDto(
string Date,
IReadOnlyList<FragmentDto> Fragments,
string RawContent,
IReadOnlyDictionary<string, ParsedSectionDto> Sections);

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Journal.Core.Dtos;
public record ListDocumentDto(
Guid Id,
string Label,
string Content,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
);
public record CreateListDto(
[property: Required(AllowEmptyStrings = false)] string Label,
string? Content = null
);
public record UpdateListDto(
string? Label = null,
string? Content = null
);

View File

@ -0,0 +1,21 @@
namespace Journal.Core.Dtos;
public sealed record SpeechDeviceDto(
int Index,
string Name);
public sealed record SpeechDevicesResultDto(
IReadOnlyList<SpeechDeviceDto> Devices,
string? Warning = null);
public sealed record SpeechTranscribeRequestDto(
string? AudioBase64 = null,
string? Engine = null,
string? WhisperModel = null,
string? Text = null,
int? SimulateDelayMs = null);
public sealed record SpeechTranscribeResultDto(
string Text,
string Engine,
string? Warning = null);

View File

@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
namespace Journal.Core.Dtos;
public record TodoListDto(
Guid Id,
string Label,
DateTimeOffset CreatedAt,
List<TodoItemDto> Items
);
public record TodoItemDto(
Guid Id,
Guid ListId,
string Text,
bool Done,
int SortOrder
);
public record CreateTodoListDto(
[property: Required(AllowEmptyStrings = false)] string Label
);
public record UpdateTodoListDto(
string? Label = null
);
public record CreateTodoItemDto(
[property: Required] Guid ListId,
[property: Required(AllowEmptyStrings = false)] string Text,
int? SortOrder = null
);
public record UpdateTodoItemDto(
string? Text = null,
bool? Done = null,
int? SortOrder = null
);

494
Journal.Core/Entry.cs Normal file
View File

@ -0,0 +1,494 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Services.Ai;
using Journal.Core.Services.Config;
using Journal.Core.Services.Database;
using Journal.Core.Services.Entries;
using Journal.Core.Services.Fragments;
using Journal.Core.Services.Lists;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Todos;
using Journal.Core.Services.Vault;
namespace Journal.Core;
public class Entry(
IFragmentService fragments,
IEntrySearchService entrySearch,
IVaultStorageService vaultStorage,
IJournalDatabaseService database,
IDatabaseSessionService databaseSession,
IJournalConfigService config,
IAiService ai,
ISpeechBridgeService speech,
IEntryFileService entryFiles,
IListService lists,
ITodoService todos,
CommandLogger logger)
{
private readonly IFragmentService _fragments = fragments;
private readonly IEntrySearchService _entrySearch = entrySearch;
private readonly IVaultStorageService _vaultStorage = vaultStorage;
private readonly IJournalDatabaseService _database = database;
private readonly IDatabaseSessionService _databaseSession = databaseSession;
private readonly IJournalConfigService _config = config;
private readonly IAiService _ai = ai;
private readonly ISpeechBridgeService _speech = speech;
private readonly IEntryFileService _entryFiles = entryFiles;
private readonly IListService _lists = lists;
private readonly ITodoService _todos = todos;
private readonly CommandLogger _logger = logger;
private static readonly HashSet<string> VaultSyncActions = new(StringComparer.Ordinal)
{
"entries.save",
"entries.delete",
"templates.save",
"templates.delete",
"fragments.create",
"fragments.update",
"fragments.delete",
"lists.create",
"lists.update",
"lists.delete",
"todos.create",
"todos.update",
"todos.delete",
"todos.items.create",
"todos.items.update",
"todos.items.delete"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public async Task RunAsync()
{
string? line;
while ((line = Console.ReadLine()) is not null)
{
var response = await HandleCommandAsync(line);
Console.WriteLine(response);
}
}
public async Task<string> HandleCommandAsync(string json)
{
if (string.IsNullOrWhiteSpace(json))
return Error("Invalid command");
Command? cmd;
try
{
cmd = JsonSerializer.Deserialize<Command>(json, JsonOptions);
}
catch (JsonException)
{
return Error("Invalid command JSON");
}
if (cmd is null || string.IsNullOrWhiteSpace(cmd.Action))
return Error("Invalid command");
var action = cmd.Action.Trim();
var correlationId = string.IsNullOrWhiteSpace(cmd.CorrelationId)
? Guid.NewGuid().ToString("N")
: cmd.CorrelationId.Trim();
CommandLogger.LogStart(action, correlationId, cmd.Payload);
object? result;
try
{
switch (action)
{
case "fragments.list":
result = _fragments.GetAll();
break;
case "fragments.get":
if (!Guid.TryParse(cmd.Id, out var getId))
return Error("Invalid or missing id");
result = _fragments.GetById(getId);
break;
case "fragments.create":
var createDto = DeserializePayload<CreateFragmentDto>(cmd.Payload);
if (createDto is null)
return Error("Missing or invalid payload");
result = _fragments.Create(createDto);
break;
case "fragments.update":
if (!Guid.TryParse(cmd.Id, out var updateId))
return Error("Invalid or missing id");
var updateDto = DeserializePayload<UpdateFragmentDto>(cmd.Payload);
if (updateDto is null)
return Error("Missing or invalid payload");
result = _fragments.Update(updateId, updateDto);
break;
case "fragments.delete":
if (!Guid.TryParse(cmd.Id, out var deleteId))
return Error("Invalid or missing id");
result = _fragments.Remove(deleteId);
break;
case "fragments.search":
result = _fragments.Search(cmd.Type, cmd.Tag);
break;
// ── Lists ────────────────────────────────────────
case "lists.list":
result = _lists.GetAll();
break;
case "lists.get":
if (!Guid.TryParse(cmd.Id, out var getListId))
return Error("Invalid or missing id");
result = _lists.GetById(getListId);
break;
case "lists.create":
var createListDto = DeserializePayload<CreateListDto>(cmd.Payload);
if (createListDto is null)
return Error("Missing or invalid payload");
result = _lists.Create(createListDto);
break;
case "lists.update":
if (!Guid.TryParse(cmd.Id, out var updateListId))
return Error("Invalid or missing id");
var updateListDto = DeserializePayload<UpdateListDto>(cmd.Payload);
if (updateListDto is null)
return Error("Missing or invalid payload");
result = _lists.Update(updateListId, updateListDto);
break;
case "lists.delete":
if (!Guid.TryParse(cmd.Id, out var deleteListId))
return Error("Invalid or missing id");
result = _lists.Remove(deleteListId);
break;
// ── Todos ────────────────────────────────────────
case "todos.list":
result = _todos.GetAllLists();
break;
case "todos.get":
if (!Guid.TryParse(cmd.Id, out var getTodoListId))
return Error("Invalid or missing id");
result = _todos.GetListById(getTodoListId);
break;
case "todos.create":
var createTodoListDto = DeserializePayload<CreateTodoListDto>(cmd.Payload);
if (createTodoListDto is null)
return Error("Missing or invalid payload");
result = _todos.CreateList(createTodoListDto);
break;
case "todos.update":
if (!Guid.TryParse(cmd.Id, out var updateTodoListId))
return Error("Invalid or missing id");
var updateTodoListDto = DeserializePayload<UpdateTodoListDto>(cmd.Payload);
if (updateTodoListDto is null)
return Error("Missing or invalid payload");
result = _todos.UpdateList(updateTodoListId, updateTodoListDto);
break;
case "todos.delete":
if (!Guid.TryParse(cmd.Id, out var deleteTodoListId))
return Error("Invalid or missing id");
result = _todos.RemoveList(deleteTodoListId);
break;
case "todos.items.create":
var createItemDto = DeserializePayload<CreateTodoItemDto>(cmd.Payload);
if (createItemDto is null)
return Error("Missing or invalid payload");
result = _todos.CreateItem(createItemDto);
break;
case "todos.items.update":
if (!Guid.TryParse(cmd.Id, out var updateItemId))
return Error("Invalid or missing id");
var updateItemDto = DeserializePayload<UpdateTodoItemDto>(cmd.Payload);
if (updateItemDto is null)
return Error("Missing or invalid payload");
result = _todos.UpdateItem(updateItemId, updateItemDto);
break;
case "todos.items.delete":
if (!Guid.TryParse(cmd.Id, out var deleteItemId))
return Error("Invalid or missing id");
result = _todos.RemoveItem(deleteItemId);
break;
case "search.entries":
var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload);
if (searchPayload is null || string.IsNullOrWhiteSpace(searchPayload.DataDirectory))
return Error("Missing or invalid payload");
var searchRequest = new EntrySearchRequestDto(
DataDirectory: searchPayload.DataDirectory,
Query: searchPayload.Query,
Section: searchPayload.Section,
StartDate: searchPayload.StartDate,
EndDate: searchPayload.EndDate,
Tags: searchPayload.Tags,
Types: searchPayload.Types,
Checked: searchPayload.Checked,
Unchecked: searchPayload.Unchecked);
result = await _entrySearch.SearchEntriesAsync(searchRequest);
break;
case "entries.list":
var listPayload = DeserializePayload<EntryListPayload>(cmd.Payload);
var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory)
? listPayload.DataDirectory
: _config.Current.DataDirectory;
result = _entryFiles.ListEntries(listDataDirectory);
break;
case "templates.list":
var templateListPayload = DeserializePayload<EntryTemplateListPayload>(cmd.Payload);
var templateListDirectory = !string.IsNullOrWhiteSpace(templateListPayload?.DataDirectory)
? templateListPayload.DataDirectory
: _config.Current.DataDirectory;
result = _entryFiles.ListTemplates(templateListDirectory);
break;
case "entries.load":
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
break;
case "templates.load":
var loadTemplatePayload = DeserializePayload<EntryTemplateLoadPayload>(cmd.Payload);
if (loadTemplatePayload is null || string.IsNullOrWhiteSpace(loadTemplatePayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.LoadTemplate(loadTemplatePayload.FilePath);
break;
case "entries.save":
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
return Error("Missing or invalid payload");
result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
break;
case "templates.save":
var saveTemplatePayload = DeserializePayload<EntryTemplateSavePayload>(cmd.Payload);
if (saveTemplatePayload is null || string.IsNullOrWhiteSpace(saveTemplatePayload.Name))
return Error("Missing or invalid payload");
result = _entryFiles.SaveTemplate(saveTemplatePayload, _config.Current.DataDirectory);
break;
case "entries.delete":
var deleteEntryPayload = DeserializePayload<EntryDeletePayload>(cmd.Payload);
if (deleteEntryPayload is null || string.IsNullOrWhiteSpace(deleteEntryPayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.DeleteEntry(deleteEntryPayload.FilePath);
break;
case "templates.delete":
var deleteTemplatePayload = DeserializePayload<EntryTemplateDeletePayload>(cmd.Payload);
if (deleteTemplatePayload is null || string.IsNullOrWhiteSpace(deleteTemplatePayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.DeleteTemplate(deleteTemplatePayload.FilePath);
break;
case "config.get":
result = _config.Current;
break;
case "ai.health":
result = await _ai.HealthAsync();
break;
case "ai.summarize_entry":
var summarizeEntryPayload = DeserializePayload<AiSummarizeEntryPayload>(cmd.Payload);
if (summarizeEntryPayload is null || string.IsNullOrWhiteSpace(summarizeEntryPayload.Content))
return Error("Missing or invalid payload");
result = await _ai.SummarizeEntryAsync(summarizeEntryPayload.Content, summarizeEntryPayload.FileStem);
break;
case "ai.summarize_all":
var summarizeAllPayload = DeserializePayload<AiSummarizeAllPayload>(cmd.Payload);
if (summarizeAllPayload is null)
return Error("Missing or invalid payload");
result = await _ai.SummarizeAllAsync(summarizeAllPayload.Entries ?? []);
break;
case "ai.chat":
var chatPayload = DeserializePayload<AiChatPayload>(cmd.Payload);
if (chatPayload is null || string.IsNullOrWhiteSpace(chatPayload.Prompt))
return Error("Missing or invalid payload");
result = await _ai.ChatAsync(chatPayload.Prompt);
break;
case "ai.embed":
var embedPayload = DeserializePayload<AiEmbedPayload>(cmd.Payload);
if (embedPayload is null || string.IsNullOrWhiteSpace(embedPayload.Content))
return Error("Missing or invalid payload");
result = await _ai.EmbedAsync(embedPayload.Content);
break;
case "speech.devices.list":
result = await _speech.ListDevicesAsync();
break;
case "speech.transcribe":
var speechPayload = DeserializePayload<SpeechTranscribePayload>(cmd.Payload);
if (speechPayload is null)
return Error("Missing or invalid payload");
var audioBase64 = !string.IsNullOrWhiteSpace(speechPayload.AudioBase64)
? speechPayload.AudioBase64
: speechPayload.Audio_Base64;
var text = speechPayload.Text;
var whisperModel = !string.IsNullOrWhiteSpace(speechPayload.WhisperModel)
? speechPayload.WhisperModel
: speechPayload.Whisper_Model;
var simulateDelayMs = speechPayload.SimulateDelayMs ?? speechPayload.Simulate_Delay_Ms;
if (string.IsNullOrWhiteSpace(audioBase64) && string.IsNullOrWhiteSpace(text))
return Error("Missing or invalid payload");
result = await _speech.TranscribeAsync(new SpeechTranscribeRequestDto(
AudioBase64: audioBase64,
Engine: speechPayload.Engine,
WhisperModel: whisperModel,
Text: text,
SimulateDelayMs: simulateDelayMs));
break;
case "vault.initialize":
var initPayload = DeserializePayload<VaultInitializePayload>(cmd.Payload);
if (initPayload is null || string.IsNullOrWhiteSpace(initPayload.Password) || string.IsNullOrWhiteSpace(initPayload.VaultDirectory))
return Error("Missing or invalid payload");
Directory.CreateDirectory(initPayload.VaultDirectory);
result = true;
break;
case "vault.load_all":
var loadPayload = DeserializePayload<VaultPayload>(cmd.Payload);
if (loadPayload is null)
return Error("Missing or invalid payload");
var loaded = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, loadPayload.DataDirectory);
if (loaded)
_databaseSession.SetPassword(loadPayload.Password, loadPayload.DataDirectory);
result = loaded;
break;
case "vault.save_current_month":
var saveCurrentPayload = DeserializePayload<VaultPayload>(cmd.Payload);
if (saveCurrentPayload is null)
return Error("Missing or invalid payload");
result = _vaultStorage.SaveCurrentMonthVault(
saveCurrentPayload.Password,
saveCurrentPayload.VaultDirectory,
saveCurrentPayload.DataDirectory,
ParseNowOrDefault(saveCurrentPayload.NowUtc));
break;
case "vault.rebuild_all":
var rebuildPayload = DeserializePayload<VaultPayload>(cmd.Payload);
if (rebuildPayload is null)
return Error("Missing or invalid payload");
_databaseSession.CloseConnection();
_vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, rebuildPayload.DataDirectory);
result = true;
break;
case "vault.clear_data_directory":
var clearPayload = DeserializePayload<ClearDataPayload>(cmd.Payload);
if (clearPayload is null || string.IsNullOrWhiteSpace(clearPayload.DataDirectory))
return Error("Missing or invalid payload");
if (_databaseSession is IDisposable disposableSession)
disposableSession.Dispose();
_vaultStorage.ClearDataDirectory(clearPayload.DataDirectory);
result = true;
break;
case "db.status":
var dbStatusPayload = DeserializePayload<DatabasePayload>(cmd.Payload);
if (dbStatusPayload is null || string.IsNullOrWhiteSpace(dbStatusPayload.Password))
return Error("Missing or invalid payload");
result = _database.GetStatus(dbStatusPayload.Password, dbStatusPayload.DataDirectory);
break;
case "db.initialize_schema":
var dbInitPayload = DeserializePayload<DatabasePayload>(cmd.Payload);
if (dbInitPayload is null)
return Error("Missing or invalid payload");
var schemaPath = _database.WriteSchemaBootstrap(dbInitPayload.DataDirectory);
result = new { schemaPath };
break;
case "db.hydrate_workspace":
var dbHydratePayload = DeserializePayload<DatabasePayload>(cmd.Payload);
if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password))
return Error("Missing or invalid payload");
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
_databaseSession.SetPassword(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
break;
default:
CommandLogger.LogFailure(action, correlationId, "unknown_action");
return Error($"Unknown action: {action}");
}
}
catch (JsonException)
{
CommandLogger.LogFailure(action, correlationId, "invalid_payload_json");
return Error("Missing or invalid payload");
}
catch (ValidationException ex)
{
CommandLogger.LogFailure(action, correlationId, "validation", ex.Message);
return Error(ex.Message);
}
catch (ArgumentException ex)
{
CommandLogger.LogFailure(action, correlationId, "argument", ex.Message);
return Error(ex.Message);
}
catch (TimeoutException ex)
{
CommandLogger.LogFailure(action, correlationId, "timeout", ex.Message);
return Error(ex.Message);
}
catch (InvalidOperationException ex)
{
CommandLogger.LogFailure(action, correlationId, "invalid_operation", ex.Message);
return Error(ex.Message);
}
catch (FileNotFoundException ex)
{
CommandLogger.LogFailure(action, correlationId, "not_found", ex.Message);
return Error(ex.Message);
}
catch
{
CommandLogger.LogFailure(action, correlationId, "internal_error");
return Error("Internal error");
}
TryAutoSyncVault(action, correlationId);
CommandLogger.LogSuccess(action, correlationId);
return JsonSerializer.Serialize(new { ok = true, data = result });
}
private static string Error(string message)
=> JsonSerializer.Serialize(new { ok = false, error = message });
private static T? DeserializePayload<T>(JsonElement? payload)
{
if (payload is null)
return default;
return payload.Value.Deserialize<T>(JsonOptions);
}
private static DateTime ParseNowOrDefault(string? nowUtc)
{
if (string.IsNullOrWhiteSpace(nowUtc))
return DateTime.UtcNow;
if (DateTime.TryParse(
nowUtc,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed;
}
throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time.");
}
private void TryAutoSyncVault(string action, string correlationId)
{
if (!VaultSyncActions.Contains(action))
return;
if (!_databaseSession.TryGetSession(out var password, out var sessionDataDirectory))
return;
try
{
var config = _config.Current;
var dataDirectory = string.IsNullOrWhiteSpace(sessionDataDirectory)
? config.DataDirectory
: sessionDataDirectory;
_databaseSession.CloseConnection();
_vaultStorage.RebuildAllVaults(password, config.VaultDirectory, dataDirectory);
}
catch (Exception ex)
{
CommandLogger.LogFailure(action, correlationId, "vault_auto_sync_failed", ex.Message);
}
}
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.11" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,13 @@
using System.Text.Json;
namespace Journal.Core.Models;
public class Command
{
public string Action { get; set; } = "";
public string? CorrelationId { get; set; }
public string? Id { get; set; }
public string? Type { get; set; }
public string? Tag { get; set; }
public JsonElement? Payload { get; set; }
}

View File

@ -0,0 +1,42 @@
namespace Journal.Core.Models;
public class Fragment
{
public Guid Id { get; }
public string Type { get; set; }
public string Description { get; set; }
public DateTimeOffset Time { get; set; }
public List<string> Tags { get; set; } = [];
public Fragment(string type, string description)
{
Validate(type, description);
Id = Guid.NewGuid();
Type = type.Trim();
Description = description.Trim();
Time = DateTimeOffset.Now;
}
public Fragment(Guid id, string type, string description, DateTimeOffset time, IEnumerable<string>? tags = null)
{
if (id == Guid.Empty)
throw new ArgumentException("Id is required", nameof(id));
Validate(type, description);
Id = id;
Type = type.Trim();
Description = description.Trim();
Time = time;
if (tags is not null)
Tags = [.. tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim())];
}
private static void Validate(string type, string description)
{
if (string.IsNullOrWhiteSpace(type))
throw new ArgumentException("Type is required", nameof(type));
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentException("Description is required", nameof(description));
}
}

View File

@ -0,0 +1,29 @@
namespace Journal.Core.Models;
public sealed record JournalConfig(
string ProjectRoot,
string AppDirectory,
string DataDirectory,
string VaultDirectory,
string LogDirectory,
string PidFile,
string ServerControlFile,
string DatabaseFilename,
string MonthlyVaultFormat,
string CloudAiApiKey,
string CloudAiApiUrl,
string LlamaCppUrl,
string LlamaCppModel,
int LlamaCppTimeout,
string EmbeddingApiUrl,
string EmbeddingModelName,
int ModelContextTokens,
int ChunkTokenBudget,
int? MicrophoneDeviceIndex,
string SpeechRecognitionEngine,
string WhisperModelSize,
string NlpBackend,
string AiProvider,
string PythonExecutable,
string PythonAiSidecarPath,
int AiSidecarTimeoutMs);

View File

@ -0,0 +1,98 @@
namespace Journal.Core.Models;
public class JournalEntry
{
public string Date { get; set; }
public List<Fragment> Fragments { get; set; }
public string RawContent { get; set; }
public Dictionary<string, ParsedSection> Sections { get; set; }
public JournalEntry(
string date,
IEnumerable<Fragment>? fragments = null,
string rawContent = "",
IDictionary<string, ParsedSection>? sections = null)
{
if (string.IsNullOrWhiteSpace(date))
throw new ArgumentException("Date is required", nameof(date));
Date = date.Trim();
Fragments = fragments is null ? [] : [.. fragments];
RawContent = rawContent ?? "";
Sections = sections is null ? [] : new Dictionary<string, ParsedSection>(sections);
}
public string GetSection(string sectionTitle)
{
if (string.IsNullOrWhiteSpace(sectionTitle))
return "";
if (!Sections.TryGetValue(sectionTitle, out var section))
return "";
return string.Join("\n", section.Content);
}
public bool? GetCheckboxState(string sectionTitle, string checkboxText)
{
if (string.IsNullOrWhiteSpace(sectionTitle) || string.IsNullOrWhiteSpace(checkboxText))
return null;
if (!Sections.TryGetValue(sectionTitle, out var section))
return null;
return section.Checkboxes.TryGetValue(checkboxText, out var checkedState) ? checkedState : null;
}
public void MergeWith(JournalEntry otherEntry)
{
ArgumentNullException.ThrowIfNull(otherEntry);
foreach (var (title, newSection) in otherEntry.Sections)
{
if (newSection.Content.Any(line => !string.IsNullOrWhiteSpace(line)))
Sections[title] = newSection;
}
var existingFragmentDescriptions = Fragments
.Select(fragment => fragment.Description)
.ToHashSet(StringComparer.Ordinal);
foreach (var newFragment in otherEntry.Fragments)
{
if (!existingFragmentDescriptions.Contains(newFragment.Description))
Fragments.Add(newFragment);
}
}
public string ToMarkdown()
{
var lines = new List<string>
{
"---",
"type: journal",
"---",
$"**Date:** {Date}\n"
};
foreach (var title in SectionTitles.Canonical)
{
if (!Sections.TryGetValue(title, out var section))
continue;
lines.Add($"## {section.Title}\n");
lines.AddRange(section.Content);
lines.Add("");
}
if (Fragments.Count > 0)
{
lines.Add("# Fragments\n");
foreach (var fragment in Fragments)
{
var timeStr = fragment.Time != default ? $"@{fragment.Time:O}" : "";
var tagsStr = string.Join(" ", fragment.Tags.Select(tag => $"#{tag}"));
var header = $"{fragment.Type} {timeStr} {tagsStr}".Trim();
lines.Add($"{header}\n{fragment.Description}\n");
}
}
return string.Join("\n", lines);
}
}

View File

@ -0,0 +1,40 @@
namespace Journal.Core.Models;
public class ListDocument
{
public Guid Id { get; }
public string Label { get; set; }
public string Content { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public ListDocument(string label, string content = "")
{
Validate(label);
Id = Guid.NewGuid();
Label = label.Trim();
Content = content;
CreatedAt = DateTimeOffset.Now;
UpdatedAt = CreatedAt;
}
public ListDocument(Guid id, string label, string content, DateTimeOffset createdAt, DateTimeOffset updatedAt)
{
if (id == Guid.Empty)
throw new ArgumentException("Id is required", nameof(id));
Validate(label);
Id = id;
Label = label.Trim();
Content = content;
CreatedAt = createdAt;
UpdatedAt = updatedAt;
}
private static void Validate(string label)
{
if (string.IsNullOrWhiteSpace(label))
throw new ArgumentException("Label is required", nameof(label));
}
}

View File

@ -0,0 +1,21 @@
namespace Journal.Core.Models;
public class ParsedSection
{
public string Title { get; set; }
public List<string> Content { get; set; }
public Dictionary<string, bool> Checkboxes { get; set; }
public ParsedSection(
string title,
IEnumerable<string>? content = null,
IDictionary<string, bool>? checkboxes = null)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Section title is required", nameof(title));
Title = title.Trim();
Content = content is null ? [] : [.. content];
Checkboxes = checkboxes is null ? [] : new Dictionary<string, bool>(checkboxes);
}
}

View File

@ -0,0 +1,20 @@
namespace Journal.Core.Models;
public static class SectionTitles
{
public static readonly IReadOnlyList<string> Canonical =
[
"Summary",
"Cognitive State",
"Mental / Emotional Snapshot",
"Memory / Mind Failures",
"Events / Triggers",
"Communication / Expression Log",
"Coping / Tools Used",
"Reflection",
"Core Events or Memories",
"Autism/ADHD-Related Elements",
"Emotional & Bodily Reactions",
"Truth to Anchor Myself To",
];
}

View File

@ -0,0 +1,44 @@
namespace Journal.Core.Models;
public class TodoItem
{
public Guid Id { get; }
public Guid ListId { get; }
public string Text { get; set; }
public bool Done { get; set; }
public int SortOrder { get; set; }
public TodoItem(Guid listId, string text, int sortOrder = 0)
{
Validate(text);
if (listId == Guid.Empty)
throw new ArgumentException("ListId is required", nameof(listId));
Id = Guid.NewGuid();
ListId = listId;
Text = text.Trim();
Done = false;
SortOrder = sortOrder;
}
public TodoItem(Guid id, Guid listId, string text, bool done, int sortOrder)
{
if (id == Guid.Empty)
throw new ArgumentException("Id is required", nameof(id));
if (listId == Guid.Empty)
throw new ArgumentException("ListId is required", nameof(listId));
Validate(text);
Id = id;
ListId = listId;
Text = text.Trim();
Done = done;
SortOrder = sortOrder;
}
private static void Validate(string text)
{
if (string.IsNullOrWhiteSpace(text))
throw new ArgumentException("Text is required", nameof(text));
}
}

View File

@ -0,0 +1,34 @@
namespace Journal.Core.Models;
public class TodoList
{
public Guid Id { get; }
public string Label { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public TodoList(string label)
{
Validate(label);
Id = Guid.NewGuid();
Label = label.Trim();
CreatedAt = DateTimeOffset.Now;
}
public TodoList(Guid id, string label, DateTimeOffset createdAt)
{
if (id == Guid.Empty)
throw new ArgumentException("Id is required", nameof(id));
Validate(label);
Id = id;
Label = label.Trim();
CreatedAt = createdAt;
}
private static void Validate(string label)
{
if (string.IsNullOrWhiteSpace(label))
throw new ArgumentException("Label is required", nameof(label));
}
}

View File

@ -0,0 +1,35 @@
namespace Journal.Core.Repositories;
public sealed class DiskEntryFileRepository : IEntryFileRepository
{
public IReadOnlyList<string> ListMarkdownFiles(string dataDirectory)
{
if (!Directory.Exists(dataDirectory))
return [];
return [.. Directory.GetFiles(dataDirectory, "*.md").OrderBy(Path.GetFileName, StringComparer.Ordinal)];
}
public string ReadFile(string filePath) => File.ReadAllText(filePath);
public void WriteFile(string filePath, string content) => File.WriteAllText(filePath, content);
public void AppendFile(string filePath, string content) => File.AppendAllText(filePath, content);
public bool FileExists(string filePath) => File.Exists(filePath);
public string GetFullPath(string filePath) => Path.GetFullPath(filePath);
public string GetFileName(string filePath) => Path.GetFileName(filePath);
public string GetFileNameWithoutExtension(string filePath) => Path.GetFileNameWithoutExtension(filePath);
public void EnsureDirectory(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir))
Directory.CreateDirectory(dir);
}
public void DeleteFile(string filePath) => File.Delete(filePath);
}

View File

@ -0,0 +1,15 @@
namespace Journal.Core.Repositories;
public interface IEntryFileRepository
{
IReadOnlyList<string> ListMarkdownFiles(string dataDirectory);
string ReadFile(string filePath);
void WriteFile(string filePath, string content);
void AppendFile(string filePath, string content);
bool FileExists(string filePath);
string GetFullPath(string filePath);
string GetFileName(string filePath);
string GetFileNameWithoutExtension(string filePath);
void EnsureDirectory(string path);
void DeleteFile(string filePath);
}

View File

@ -0,0 +1,15 @@
using Journal.Core.Models;
namespace Journal.Core.Repositories;
public interface IFragmentRepository
{
List<Fragment> GetAll();
Fragment? GetById(Guid id);
void Add(Fragment fragment);
bool Remove(Guid id);
bool Update(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null);
List<Fragment> GetByTag(string tag);
List<Fragment> GetByType(string type);
List<Fragment> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null);
}

View File

@ -0,0 +1,12 @@
using Journal.Core.Models;
namespace Journal.Core.Repositories;
public interface IListRepository
{
List<ListDocument> GetAll();
ListDocument? GetById(Guid id);
void Add(ListDocument list);
bool Update(Guid id, string? label = null, string? content = null);
bool Remove(Guid id);
}

View File

@ -0,0 +1,18 @@
using Journal.Core.Models;
namespace Journal.Core.Repositories;
public interface ITodoRepository
{
List<TodoList> GetAllLists();
TodoList? GetListById(Guid id);
void AddList(TodoList list);
bool UpdateList(Guid id, string? label = null);
bool RemoveList(Guid id);
List<TodoItem> GetItemsByListId(Guid listId);
TodoItem? GetItemById(Guid id);
void AddItem(TodoItem item);
bool UpdateItem(Guid id, string? text = null, bool? done = null, int? sortOrder = null);
bool RemoveItem(Guid id);
}

View File

@ -0,0 +1,125 @@
using Journal.Core.Models;
namespace Journal.Core.Repositories;
public class InMemoryFragmentRepository : IFragmentRepository
{
private readonly List<Fragment> _store = [];
private readonly Lock _lock = new();
public List<Fragment> GetAll()
{
lock (_lock)
{
return [.. _store];
}
}
public Fragment? GetById(Guid id)
{
lock (_lock)
{
return _store.FirstOrDefault(f => f.Id == id);
}
}
public void Add(Fragment fragment)
{
if (fragment is null) throw new ArgumentNullException(nameof(fragment));
lock (_lock)
{
if (fragment.Tags != null)
{
fragment.Tags = [.. fragment.Tags
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t!.Trim())];
}
if (!string.IsNullOrWhiteSpace(fragment.Type)) fragment.Type = fragment.Type.Trim();
if (!string.IsNullOrWhiteSpace(fragment.Description)) fragment.Description = fragment.Description.Trim();
_store.Add(fragment);
}
}
public bool Remove(Guid id)
{
lock (_lock)
{
var item = _store.FirstOrDefault(f => f.Id == id);
if (item is null) return false;
return _store.Remove(item);
}
}
public bool Update(Guid id, string? type = null, string? description = null, IEnumerable<string>? tags = null, DateTimeOffset? time = null)
{
lock (_lock)
{
var item = _store.FirstOrDefault(f => f.Id == id);
if (item is null) return false;
if (type != null)
{
if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Type cannot be empty", nameof(type));
item.Type = type.Trim();
}
if (description != null)
{
if (string.IsNullOrWhiteSpace(description)) throw new ArgumentException("Description cannot be empty", nameof(description));
item.Description = description.Trim();
}
if (tags != null)
{
item.Tags = [.. tags
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t!.Trim())];
}
if (time.HasValue)
item.Time = time.Value;
return true;
}
}
public List<Fragment> GetByTag(string tag)
{
var q = tag?.Trim();
if (string.IsNullOrWhiteSpace(q)) return [];
lock (_lock)
{
return [.. _store.Where(f => f.Tags?.Any(t => !string.IsNullOrWhiteSpace(t) && t.Contains(q, StringComparison.OrdinalIgnoreCase)) == true)];
}
}
public List<Fragment> GetByType(string type)
{
var q = type?.Trim();
if (string.IsNullOrWhiteSpace(q)) return [];
lock (_lock)
{
return [.. _store.Where(f => !string.IsNullOrWhiteSpace(f.Type) && f.Type.Trim().Contains(q, StringComparison.OrdinalIgnoreCase))];
}
}
public List<Fragment> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
{
var results = _store.AsEnumerable();
var qType = type?.Trim();
var qTag = tag?.Trim();
lock (_lock)
{
if (!string.IsNullOrWhiteSpace(qType))
results = results.Where(f => !string.IsNullOrWhiteSpace(f.Type) && f.Type.Trim().Contains(qType, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(qTag))
results = results.Where(f => f.Tags?.Any(t => !string.IsNullOrWhiteSpace(t) && t.Contains(qTag, StringComparison.OrdinalIgnoreCase)) == true);
if (timeAfter.HasValue)
results = results.Where(f => f.Time > timeAfter.Value);
return [.. results];
}
}
}

View File

@ -0,0 +1,310 @@
using Journal.Core.Models;
using Journal.Core.Services.Database;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Repositories;
public sealed class SqliteFragmentRepository(IDatabaseSessionService session) : IFragmentRepository
{
private readonly IDatabaseSessionService _session = session;
public List<Fragment> GetAll()
{
var conn = _session.GetConnection();
return ReadAllFragments(conn);
}
public Fragment? GetById(Guid id)
{
var conn = _session.GetConnection();
return ReadFragment(conn, id);
}
public void Add(Fragment fragment)
{
ArgumentNullException.ThrowIfNull(fragment);
Normalize(fragment);
var conn = _session.GetConnection();
InsertFragment(conn, fragment);
}
public bool Remove(Guid id)
{
var conn = _session.GetConnection();
return DeleteFragment(conn, id);
}
public bool Update(
Guid id,
string? type = null,
string? description = null,
IEnumerable<string>? tags = null,
DateTimeOffset? time = null)
{
var conn = _session.GetConnection();
var existing = ReadFragment(conn, id);
if (existing is null)
return false;
if (type != null)
{
if (string.IsNullOrWhiteSpace(type))
throw new ArgumentException("Type cannot be empty", nameof(type));
existing.Type = type.Trim();
}
if (description != null)
{
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentException("Description cannot be empty", nameof(description));
existing.Description = description.Trim();
}
if (tags != null)
{
existing.Tags = [..
tags.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim())];
}
if (time.HasValue)
existing.Time = time.Value;
UpdateFragmentRow(conn, existing);
return true;
}
public List<Fragment> GetByTag(string tag)
{
var q = tag?.Trim();
if (string.IsNullOrWhiteSpace(q))
return [];
var conn = _session.GetConnection();
var all = ReadAllFragments(conn);
return [.. all.Where(f => f.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)))];
}
public List<Fragment> GetByType(string type)
{
var q = type?.Trim();
if (string.IsNullOrWhiteSpace(q))
return [];
var conn = _session.GetConnection();
var all = ReadAllFragments(conn);
return [.. all.Where(f => f.Type.Contains(q, StringComparison.OrdinalIgnoreCase))];
}
public List<Fragment> Search(string? type = null, string? tag = null, DateTimeOffset? timeAfter = null)
{
var conn = _session.GetConnection();
IEnumerable<Fragment> results = ReadAllFragments(conn);
var qType = type?.Trim();
var qTag = tag?.Trim();
if (!string.IsNullOrWhiteSpace(qType))
results = results.Where(f => f.Type.Contains(qType, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(qTag))
results = results.Where(f => f.Tags.Any(t => t.Contains(qTag, StringComparison.OrdinalIgnoreCase)));
if (timeAfter.HasValue)
results = results.Where(f => f.Time > timeAfter.Value);
return [.. results];
}
// ── Private helpers ──────────────────────────────────────────────
private static void InsertFragment(SqliteConnection conn, Fragment f)
{
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO fragments (guid, entry_id, type, description, time)
VALUES (@guid, NULL, @type, @description, @time);
""";
cmd.Parameters.AddWithValue("@guid", f.Id.ToString("D"));
cmd.Parameters.AddWithValue("@type", f.Type);
cmd.Parameters.AddWithValue("@description", f.Description);
cmd.Parameters.AddWithValue("@time", f.Time.ToString("O"));
cmd.ExecuteNonQuery();
var fragmentRowId = GetFragmentRowId(conn, f.Id);
if (fragmentRowId.HasValue)
InsertTags(conn, fragmentRowId.Value, f.Tags);
tx.Commit();
}
private static void UpdateFragmentRow(SqliteConnection conn, Fragment f)
{
using var tx = conn.BeginTransaction();
using var upd = conn.CreateCommand();
upd.CommandText = """
UPDATE fragments SET type = @type, description = @description, time = @time
WHERE guid = @guid AND entry_id IS NULL;
""";
upd.Parameters.AddWithValue("@guid", f.Id.ToString("D"));
upd.Parameters.AddWithValue("@type", f.Type);
upd.Parameters.AddWithValue("@description", f.Description);
upd.Parameters.AddWithValue("@time", f.Time.ToString("O"));
upd.ExecuteNonQuery();
var fragmentRowId = GetFragmentRowId(conn, f.Id);
if (fragmentRowId.HasValue)
{
using var del = conn.CreateCommand();
del.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;";
del.Parameters.AddWithValue("@id", fragmentRowId.Value);
del.ExecuteNonQuery();
InsertTags(conn, fragmentRowId.Value, f.Tags);
}
tx.Commit();
}
private static bool DeleteFragment(SqliteConnection conn, Guid id)
{
using var tx = conn.BeginTransaction();
var fragmentRowId = GetFragmentRowId(conn, id);
if (fragmentRowId.HasValue)
{
using var delTags = conn.CreateCommand();
delTags.CommandText = "DELETE FROM fragment_tags WHERE fragment_id = @id;";
delTags.Parameters.AddWithValue("@id", fragmentRowId.Value);
delTags.ExecuteNonQuery();
}
using var delFrag = conn.CreateCommand();
delFrag.CommandText = "DELETE FROM fragments WHERE guid = @guid AND entry_id IS NULL;";
delFrag.Parameters.AddWithValue("@guid", id.ToString("D"));
var rows = delFrag.ExecuteNonQuery();
tx.Commit();
return rows > 0;
}
private static Fragment? ReadFragment(SqliteConnection conn, Guid id)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT id, guid, type, description, time
FROM fragments WHERE guid = @guid AND entry_id IS NULL;
""";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
using var reader = cmd.ExecuteReader();
if (!reader.Read())
return null;
var fragment = MapRow(reader);
fragment.Tags = ReadTags(conn, reader.GetInt64(0));
return fragment;
}
private static List<Fragment> ReadAllFragments(SqliteConnection conn)
{
var fragments = new List<Fragment>();
var rowIds = new List<long>();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT id, guid, type, description, time
FROM fragments WHERE guid IS NOT NULL AND entry_id IS NULL
ORDER BY time;
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
fragments.Add(MapRow(reader));
rowIds.Add(reader.GetInt64(0));
}
for (var i = 0; i < fragments.Count; i++)
fragments[i].Tags = ReadTags(conn, rowIds[i]);
return fragments;
}
private static List<string> ReadTags(SqliteConnection conn, long fragmentRowId)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.name FROM tags t
INNER JOIN fragment_tags ft ON ft.tag_id = t.id
WHERE ft.fragment_id = @id
ORDER BY t.name;
""";
cmd.Parameters.AddWithValue("@id", fragmentRowId);
var tags = new List<string>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
tags.Add(reader.GetString(0));
return tags;
}
private static long? GetFragmentRowId(SqliteConnection conn, Guid guid)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id FROM fragments WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", guid.ToString("D"));
var result = cmd.ExecuteScalar();
return result is long id ? id : null;
}
private static void InsertTags(SqliteConnection conn, long fragmentRowId, List<string> tags)
{
if (tags.Count == 0) return;
foreach (var tag in tags)
{
// Upsert into tags table
using var upsert = conn.CreateCommand();
upsert.CommandText = "INSERT OR IGNORE INTO tags (name) VALUES (@name);";
upsert.Parameters.AddWithValue("@name", tag);
upsert.ExecuteNonQuery();
// Get tag id
using var getTagId = conn.CreateCommand();
getTagId.CommandText = "SELECT id FROM tags WHERE name = @name;";
getTagId.Parameters.AddWithValue("@name", tag);
var tagId = (long)getTagId.ExecuteScalar()!;
// Link fragment to tag
using var link = conn.CreateCommand();
link.CommandText = "INSERT OR IGNORE INTO fragment_tags (fragment_id, tag_id) VALUES (@fid, @tid);";
link.Parameters.AddWithValue("@fid", fragmentRowId);
link.Parameters.AddWithValue("@tid", tagId);
link.ExecuteNonQuery();
}
}
private static Fragment MapRow(SqliteDataReader reader)
{
// columns: id (int), guid (text), type (text), description (text), time (text)
var guid = Guid.Parse(reader.GetString(1));
var type = reader.GetString(2);
var description = reader.IsDBNull(3) ? "" : reader.GetString(3);
var time = reader.IsDBNull(4)
? DateTimeOffset.MinValue
: DateTimeOffset.Parse(reader.GetString(4));
return new Fragment(guid, type, description, time);
}
private static void Normalize(Fragment fragment)
{
fragment.Type = fragment.Type.Trim();
fragment.Description = fragment.Description.Trim();
fragment.Tags = [..
fragment.Tags.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim())];
}
}

View File

@ -0,0 +1,129 @@
using Journal.Core.Models;
using Journal.Core.Services.Database;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Repositories;
public sealed class SqliteListRepository(IDatabaseSessionService session) : IListRepository
{
private readonly IDatabaseSessionService _session = session;
public List<ListDocument> GetAll()
{
var conn = _session.GetConnection();
return ReadAll(conn);
}
public ListDocument? GetById(Guid id)
{
var conn = _session.GetConnection();
return ReadById(conn, id);
}
public void Add(ListDocument list)
{
ArgumentNullException.ThrowIfNull(list);
var conn = _session.GetConnection();
Insert(conn, list);
}
public bool Update(Guid id, string? label = null, string? content = null)
{
var conn = _session.GetConnection();
var existing = ReadById(conn, id);
if (existing is null)
return false;
if (label is not null)
{
if (string.IsNullOrWhiteSpace(label))
throw new ArgumentException("Label cannot be empty", nameof(label));
existing.Label = label.Trim();
}
if (content is not null)
existing.Content = content;
existing.UpdatedAt = DateTimeOffset.Now;
UpdateRow(conn, existing);
return true;
}
public bool Remove(Guid id)
{
var conn = _session.GetConnection();
return Delete(conn, id);
}
// ── Private helpers ──────────────────────────────────────────────
private static void Insert(SqliteConnection conn, ListDocument list)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO lists (guid, label, content, created_at, updated_at)
VALUES (@guid, @label, @content, @createdAt, @updatedAt);
""";
cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D"));
cmd.Parameters.AddWithValue("@label", list.Label);
cmd.Parameters.AddWithValue("@content", list.Content);
cmd.Parameters.AddWithValue("@createdAt", list.CreatedAt.ToString("O"));
cmd.Parameters.AddWithValue("@updatedAt", list.UpdatedAt.ToString("O"));
cmd.ExecuteNonQuery();
}
private static void UpdateRow(SqliteConnection conn, ListDocument list)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE lists SET label = @label, content = @content, updated_at = @updatedAt
WHERE guid = @guid;
""";
cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D"));
cmd.Parameters.AddWithValue("@label", list.Label);
cmd.Parameters.AddWithValue("@content", list.Content);
cmd.Parameters.AddWithValue("@updatedAt", list.UpdatedAt.ToString("O"));
cmd.ExecuteNonQuery();
}
private static bool Delete(SqliteConnection conn, Guid id)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM lists WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
return cmd.ExecuteNonQuery() > 0;
}
private static ListDocument? ReadById(SqliteConnection conn, Guid id)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT guid, label, content, created_at, updated_at FROM lists WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
using var reader = cmd.ExecuteReader();
return reader.Read() ? MapRow(reader) : null;
}
private static List<ListDocument> ReadAll(SqliteConnection conn)
{
var results = new List<ListDocument>();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT guid, label, content, created_at, updated_at FROM lists ORDER BY created_at;";
using var reader = cmd.ExecuteReader();
while (reader.Read())
results.Add(MapRow(reader));
return results;
}
private static ListDocument MapRow(SqliteDataReader reader)
{
var guid = Guid.Parse(reader.GetString(0));
var label = reader.GetString(1);
var content = reader.IsDBNull(2) ? "" : reader.GetString(2);
var createdAt = reader.IsDBNull(3) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(3));
var updatedAt = reader.IsDBNull(4) ? createdAt : DateTimeOffset.Parse(reader.GetString(4));
return new ListDocument(guid, label, content, createdAt, updatedAt);
}
}

View File

@ -0,0 +1,279 @@
using Journal.Core.Models;
using Journal.Core.Services.Database;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Repositories;
public sealed class SqliteTodoRepository(IDatabaseSessionService session) : ITodoRepository
{
private readonly IDatabaseSessionService _session = session;
// ── Lists ────────────────────────────────────────────────────────
public List<TodoList> GetAllLists()
{
var conn = _session.GetConnection();
return ReadAllLists(conn);
}
public TodoList? GetListById(Guid id)
{
var conn = _session.GetConnection();
return ReadListById(conn, id);
}
public void AddList(TodoList list)
{
ArgumentNullException.ThrowIfNull(list);
var conn = _session.GetConnection();
InsertList(conn, list);
}
public bool UpdateList(Guid id, string? label = null)
{
var conn = _session.GetConnection();
var existing = ReadListById(conn, id);
if (existing is null)
return false;
if (label is not null)
{
if (string.IsNullOrWhiteSpace(label))
throw new ArgumentException("Label cannot be empty", nameof(label));
existing.Label = label.Trim();
}
UpdateListRow(conn, existing);
return true;
}
public bool RemoveList(Guid id)
{
var conn = _session.GetConnection();
return DeleteList(conn, id);
}
// ── Items ────────────────────────────────────────────────────────
public List<TodoItem> GetItemsByListId(Guid listId)
{
var conn = _session.GetConnection();
return ReadItemsByListId(conn, listId);
}
public TodoItem? GetItemById(Guid id)
{
var conn = _session.GetConnection();
return ReadItemById(conn, id);
}
public void AddItem(TodoItem item)
{
ArgumentNullException.ThrowIfNull(item);
var conn = _session.GetConnection();
InsertItem(conn, item);
}
public bool UpdateItem(Guid id, string? text = null, bool? done = null, int? sortOrder = null)
{
var conn = _session.GetConnection();
var existing = ReadItemById(conn, id);
if (existing is null)
return false;
if (text is not null)
{
if (string.IsNullOrWhiteSpace(text))
throw new ArgumentException("Text cannot be empty", nameof(text));
existing.Text = text.Trim();
}
if (done.HasValue)
existing.Done = done.Value;
if (sortOrder.HasValue)
existing.SortOrder = sortOrder.Value;
UpdateItemRow(conn, existing);
return true;
}
public bool RemoveItem(Guid id)
{
var conn = _session.GetConnection();
return DeleteItem(conn, id);
}
// ── Private list helpers ─────────────────────────────────────────
private static void InsertList(SqliteConnection conn, TodoList list)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO todo_lists (guid, label, created_at)
VALUES (@guid, @label, @createdAt);
""";
cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D"));
cmd.Parameters.AddWithValue("@label", list.Label);
cmd.Parameters.AddWithValue("@createdAt", list.CreatedAt.ToString("O"));
cmd.ExecuteNonQuery();
}
private static void UpdateListRow(SqliteConnection conn, TodoList list)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE todo_lists SET label = @label WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", list.Id.ToString("D"));
cmd.Parameters.AddWithValue("@label", list.Label);
cmd.ExecuteNonQuery();
}
private static bool DeleteList(SqliteConnection conn, Guid id)
{
using var tx = conn.BeginTransaction();
var rowId = GetListRowId(conn, id);
if (rowId.HasValue)
{
using var delItems = conn.CreateCommand();
delItems.CommandText = "DELETE FROM todo_items WHERE list_id = @listId;";
delItems.Parameters.AddWithValue("@listId", rowId.Value);
delItems.ExecuteNonQuery();
}
using var delList = conn.CreateCommand();
delList.CommandText = "DELETE FROM todo_lists WHERE guid = @guid;";
delList.Parameters.AddWithValue("@guid", id.ToString("D"));
var rows = delList.ExecuteNonQuery();
tx.Commit();
return rows > 0;
}
private static TodoList? ReadListById(SqliteConnection conn, Guid id)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT guid, label, created_at FROM todo_lists WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
using var reader = cmd.ExecuteReader();
return reader.Read() ? MapListRow(reader) : null;
}
private static List<TodoList> ReadAllLists(SqliteConnection conn)
{
var results = new List<TodoList>();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT guid, label, created_at FROM todo_lists ORDER BY created_at;";
using var reader = cmd.ExecuteReader();
while (reader.Read())
results.Add(MapListRow(reader));
return results;
}
private static long? GetListRowId(SqliteConnection conn, Guid guid)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id FROM todo_lists WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", guid.ToString("D"));
var result = cmd.ExecuteScalar();
return result is long id ? id : null;
}
private static TodoList MapListRow(SqliteDataReader reader)
{
var guid = Guid.Parse(reader.GetString(0));
var label = reader.GetString(1);
var createdAt = reader.IsDBNull(2) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(2));
return new TodoList(guid, label, createdAt);
}
// ── Private item helpers ─────────────────────────────────────────
private static void InsertItem(SqliteConnection conn, TodoItem item)
{
var listRowId = GetListRowId(conn, item.ListId)
?? throw new InvalidOperationException($"Todo list {item.ListId} not found");
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO todo_items (guid, list_id, text, done, sort_order)
VALUES (@guid, @listId, @text, @done, @sortOrder);
""";
cmd.Parameters.AddWithValue("@guid", item.Id.ToString("D"));
cmd.Parameters.AddWithValue("@listId", listRowId);
cmd.Parameters.AddWithValue("@text", item.Text);
cmd.Parameters.AddWithValue("@done", item.Done ? 1 : 0);
cmd.Parameters.AddWithValue("@sortOrder", item.SortOrder);
cmd.ExecuteNonQuery();
}
private static void UpdateItemRow(SqliteConnection conn, TodoItem item)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE todo_items SET text = @text, done = @done, sort_order = @sortOrder
WHERE guid = @guid;
""";
cmd.Parameters.AddWithValue("@guid", item.Id.ToString("D"));
cmd.Parameters.AddWithValue("@text", item.Text);
cmd.Parameters.AddWithValue("@done", item.Done ? 1 : 0);
cmd.Parameters.AddWithValue("@sortOrder", item.SortOrder);
cmd.ExecuteNonQuery();
}
private static bool DeleteItem(SqliteConnection conn, Guid id)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM todo_items WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
return cmd.ExecuteNonQuery() > 0;
}
private static TodoItem? ReadItemById(SqliteConnection conn, Guid id)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT ti.guid, tl.guid, ti.text, ti.done, ti.sort_order
FROM todo_items ti
INNER JOIN todo_lists tl ON tl.id = ti.list_id
WHERE ti.guid = @guid;
""";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
using var reader = cmd.ExecuteReader();
return reader.Read() ? MapItemRow(reader) : null;
}
private static List<TodoItem> ReadItemsByListId(SqliteConnection conn, Guid listId)
{
var results = new List<TodoItem>();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT ti.guid, tl.guid, ti.text, ti.done, ti.sort_order
FROM todo_items ti
INNER JOIN todo_lists tl ON tl.id = ti.list_id
WHERE tl.guid = @listGuid
ORDER BY ti.sort_order, ti.guid;
""";
cmd.Parameters.AddWithValue("@listGuid", listId.ToString("D"));
using var reader = cmd.ExecuteReader();
while (reader.Read())
results.Add(MapItemRow(reader));
return results;
}
private static TodoItem MapItemRow(SqliteDataReader reader)
{
var guid = Guid.Parse(reader.GetString(0));
var listGuid = Guid.Parse(reader.GetString(1));
var text = reader.GetString(2);
var done = !reader.IsDBNull(3) && reader.GetInt64(3) != 0;
var sortOrder = reader.IsDBNull(4) ? 0 : (int)reader.GetInt64(4);
return new TodoItem(guid, listGuid, text, done, sortOrder);
}
}

View File

@ -0,0 +1,71 @@
using Microsoft.Extensions.DependencyInjection;
using Journal.Core.Repositories;
using Journal.Core.Services.Ai;
using Journal.Core.Services.Config;
using Journal.Core.Services.Database;
using Journal.Core.Services.Entries;
using Journal.Core.Services.Fragments;
using Journal.Core.Services.Lists;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Sidecar;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Todos;
using Journal.Core.Services.Vault;
namespace Journal.Core;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddFragmentServices(this IServiceCollection services)
{
services.AddSingleton<IDatabaseSessionService, DatabaseSessionService>();
services.AddSingleton<IFragmentRepository, SqliteFragmentRepository>();
services.AddSingleton<IJournalConfigService, JournalConfigService>();
services.AddSingleton<IFragmentService, FragmentService>();
services.AddTransient<IEntrySearchService, EntrySearchService>();
services.AddSingleton<IVaultCryptoService, VaultCryptoService>();
services.AddSingleton<IVaultStorageService, VaultStorageService>();
services.AddSingleton<IJournalDatabaseService, JournalDatabaseService>();
services.AddSingleton<IAiService>(provider =>
{
var config = provider.GetRequiredService<IJournalConfigService>().Current;
if (!string.Equals(config.AiProvider, "python-sidecar", StringComparison.OrdinalIgnoreCase))
return new DisabledAiService(config.AiProvider);
try
{
return new PythonSidecarAiService(config);
}
catch (Exception ex)
{
return new DisabledAiService(
provider: "python-sidecar",
message: $"Python AI sidecar unavailable: {ex.Message}",
healthy: false);
}
});
services.AddSingleton<ISpeechBridgeService>(provider =>
{
var config = provider.GetRequiredService<IJournalConfigService>().Current;
try
{
return new PythonSidecarSpeechService(config);
}
catch (Exception ex)
{
return new DisabledSpeechBridgeService(
provider: "python-sidecar",
message: $"Python speech sidecar unavailable: {ex.Message}");
}
});
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
services.AddSingleton<IEntryFileService, EntryFileService>();
services.AddSingleton<IListRepository, SqliteListRepository>();
services.AddSingleton<IListService, ListService>();
services.AddSingleton<ITodoRepository, SqliteTodoRepository>();
services.AddSingleton<ITodoService, TodoService>();
services.AddSingleton<CommandLogger>();
services.AddSingleton<SidecarCli>();
return services;
}
}

Some files were not shown because too many files have changed in this diff Show More