Trilium als Archiv: verwaltete Instanz pro Projekt, MD-Archiv und Dokument-Tab entfernt
- domain/trilium.rs: Runtime-Download (gepinnt 0.104.0), Instanz pro Projekt auf 127.0.0.1 mit FNV-Port, ETAPI (archive_note, search_notes), Token im Keyring - Setup bis aufs Passwort automatisiert: trilium_connect macht new-document (skipDemoDb), set-password und ETAPI-Login; Modul-Flag erst nach Token-Erfolg - write_panel legt direkt eine Trilium-Notiz an (text|path, folder/description/tags); noteopen-Kanal, das Terminal-Fenster öffnet die Notiz (ersetzt archive_panel) - MD-Altbestand raus: archive*/wiki*, archiveHome samt Permissions, panel_set/panel_load/ wiki_open, panel_archive*/archive_docs/*_archive_home, reveal_path, spellcheck - Dokument-Tab raus: Panel = ToDo/Befehle/Suche + Archiv-Knopf (trilium_open); panel-view/archive-form gelöscht, Markup/CSS/Messages bereinigt - Lösch-Stufe Archiv: Instanz stoppen, Trilium-Datadir und Keyring-Token löschen
This commit is contained in:
+5
-17
@@ -286,14 +286,11 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
||||
commands::oauth_login,
|
||||
commands::keychain_status,
|
||||
commands::set_apikey,
|
||||
commands::panel_archive_dir_cmd,
|
||||
commands::panel_archive_cmd,
|
||||
commands::set_archive_home_cmd,
|
||||
commands::change_archive_home_cmd,
|
||||
commands::clear_archive_home_cmd,
|
||||
commands::archive_docs_cmd,
|
||||
commands::reveal_path_cmd,
|
||||
commands::spellcheck_lang,
|
||||
commands::trilium_status,
|
||||
commands::trilium_setup,
|
||||
commands::trilium_connect,
|
||||
commands::trilium_open,
|
||||
commands::trilium_disable,
|
||||
commands::enabled_modules,
|
||||
commands::module_registry,
|
||||
commands::set_module,
|
||||
@@ -307,16 +304,7 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
||||
terminal::todos_delete,
|
||||
terminal::todos_add,
|
||||
terminal::todos_update,
|
||||
terminal::panel_set,
|
||||
terminal::search_run,
|
||||
terminal::panel_load,
|
||||
terminal::wiki_open,
|
||||
terminal::archive_move,
|
||||
terminal::archive_rename,
|
||||
terminal::archive_delete,
|
||||
terminal::archive_move_folder,
|
||||
terminal::archive_create_folder,
|
||||
terminal::archive_create_doc,
|
||||
terminal::open_panel_window
|
||||
]
|
||||
}
|
||||
|
||||
+104
-72
@@ -3,9 +3,6 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::domain::archive::{
|
||||
archive_panel_content, project_archive_home, require_archive_home, ArchiveMeta,
|
||||
};
|
||||
use crate::domain::credentials::keychain_service;
|
||||
use crate::domain::paths::Paths;
|
||||
use crate::domain::pool::{
|
||||
@@ -350,11 +347,6 @@ pub(crate) fn set_terminal_font_size(size: u32) -> Result<(), String> {
|
||||
settings::set_terminal_font_size_in(&Paths::real(), size)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn spellcheck_lang() -> String {
|
||||
settings::spellcheck_lang(&Paths::real())
|
||||
}
|
||||
|
||||
// ---------- Verbrauch ----------
|
||||
|
||||
#[tauri::command]
|
||||
@@ -362,15 +354,6 @@ pub(crate) fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
|
||||
usage_stats_in(&Paths::real(), days)
|
||||
}
|
||||
|
||||
// ---------- Panel-Archiv ----------
|
||||
|
||||
/// Konfiguriertes Archiv-Home des Projekts (für den Panel-Button: entscheidet,
|
||||
/// ob der Ordner-Dialog nötig ist).
|
||||
#[tauri::command]
|
||||
pub(crate) fn panel_archive_dir_cmd(project: String) -> Option<String> {
|
||||
project_archive_home(&project).map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Modul-Registry für den Settings-Dialog (Checkbox-Gruppe „Module").
|
||||
#[tauri::command]
|
||||
pub(crate) fn module_registry(project: String) -> Result<Vec<crate::domain::modules::ModuleInfo>, String> {
|
||||
@@ -383,78 +366,127 @@ pub(crate) fn set_module(project: String, module: String, enabled: bool) -> Resu
|
||||
crate::domain::modules::set_module_in(&Paths::real(), &project, &module, enabled)
|
||||
}
|
||||
|
||||
/// Aktive Module fürs Frontend: Registry-Enablement der Projekt-Config plus
|
||||
/// `requires_archive` — ohne konfiguriertes Archiv-Home fallen Archiv-Module
|
||||
/// weg, ihre Tabs erscheinen nicht.
|
||||
/// Aktive Module fürs Frontend (Registry-Enablement der Projekt-Config).
|
||||
#[tauri::command]
|
||||
pub(crate) fn enabled_modules(project: String) -> Result<Vec<String>, String> {
|
||||
let paths = Paths::real();
|
||||
let has_archive = read_project_config_in(&paths, &project)?.archive_home.is_some();
|
||||
Ok(
|
||||
crate::domain::modules::active_in(&paths, &project)?
|
||||
crate::domain::modules::active_in(&Paths::real(), &project)?
|
||||
.iter()
|
||||
.filter(|m| !m.requires_archive || has_archive)
|
||||
.map(|m| m.id.to_string())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Setzt das Archiv-Home eines Projekts (Einstellungsdialog) — inklusive
|
||||
/// Permissions-Eintrag in der Projekt-settings.json.
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_archive_home_cmd(project: String, dir: String) -> Result<(), String> {
|
||||
crate::domain::archive::set_project_archive_home(&project, &dir)
|
||||
// ---------- Trilium ----------
|
||||
|
||||
/// Wizard-Status: Runtime da, Instanz erreichbar, Token gültig. Reine
|
||||
/// Checks, keine Schreiber.
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct TriliumStatus {
|
||||
version: &'static str,
|
||||
port: u16,
|
||||
data_dir: String,
|
||||
runtime_ready: bool,
|
||||
instance_running: bool,
|
||||
token_ok: bool,
|
||||
}
|
||||
|
||||
fn trilium_status_for(project: &str) -> TriliumStatus {
|
||||
let paths = Paths::real();
|
||||
let instance_running = crate::domain::trilium::instance_running(project);
|
||||
TriliumStatus {
|
||||
version: crate::domain::trilium::VERSION,
|
||||
port: crate::domain::trilium::port_for(project),
|
||||
data_dir: crate::domain::paths::contract_home(
|
||||
&paths,
|
||||
&crate::domain::trilium::data_dir(&paths, project),
|
||||
),
|
||||
runtime_ready: crate::domain::trilium::runtime_ready(&paths),
|
||||
instance_running,
|
||||
token_ok: instance_running && crate::domain::trilium::token_ok(project),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wechselt das Archiv-Home (Einstellungsdialog, wenn schon eins gesetzt
|
||||
/// ist): optional ziehen die Dokumente mit um; die Rechte des alten Ordners
|
||||
/// werden zurückgenommen.
|
||||
#[tauri::command]
|
||||
pub(crate) fn change_archive_home_cmd(
|
||||
pub(crate) async fn trilium_status(project: String) -> TriliumStatus {
|
||||
trilium_status_for(&project)
|
||||
}
|
||||
|
||||
/// Richtet die Projekt-Instanz ein: Runtime laden (gepinnt, Checksum),
|
||||
/// Instanz starten. Schreibt KEINE Config — das Modul-Flag setzt erst
|
||||
/// trilium_connect nach erfolgreichem Token-Login.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn trilium_setup(project: String) -> Result<TriliumStatus, String> {
|
||||
crate::domain::trilium::ensure_instance(&Paths::real(), &project)?;
|
||||
Ok(trilium_status_for(&project))
|
||||
}
|
||||
|
||||
/// Richtet die Instanz mit dem Passwort aus dem Wizard vollständig ein:
|
||||
/// Erstlauf legt das Dokument an und setzt das Passwort, dann ETAPI-Token
|
||||
/// holen, im Keyring ablegen und ERST DANN das Modul in der Projekt-Config
|
||||
/// freischalten. Das Passwort wird nicht gespeichert.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn trilium_connect(
|
||||
project: String,
|
||||
dir: String,
|
||||
migrate: bool,
|
||||
password: String,
|
||||
) -> Result<TriliumStatus, String> {
|
||||
let paths = Paths::real();
|
||||
crate::domain::trilium::ensure_instance(&paths, &project)?;
|
||||
if !crate::domain::trilium::initialized(&project)? {
|
||||
crate::domain::trilium::setup_new_document(&project)?;
|
||||
crate::domain::trilium::set_password(&project, &password)?;
|
||||
}
|
||||
let token = crate::domain::trilium::login(&project, &password)?;
|
||||
crate::domain::trilium::store_token(&project, &token)?;
|
||||
if !crate::domain::trilium::token_ok(&project) {
|
||||
crate::domain::trilium::delete_token(&project);
|
||||
return Err("Token angenommen, aber ETAPI-Zugriff scheitert".into());
|
||||
}
|
||||
crate::domain::modules::set_module_in(&paths, &project, "trilium", true)?;
|
||||
Ok(trilium_status_for(&project))
|
||||
}
|
||||
|
||||
/// Öffnet das Trilium-Fenster des Projekts (startet die Instanz bei Bedarf);
|
||||
/// `note_id` springt zur Notiz. Async wie open_panel_window (Fenster-Erzeugung
|
||||
/// aus synchronen Commands klemmt auf dem GTK-Mainloop).
|
||||
#[tauri::command]
|
||||
pub(crate) async fn trilium_open(
|
||||
app: tauri::AppHandle,
|
||||
project: String,
|
||||
note_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
crate::domain::archive::change_project_archive_home(&project, &dir, migrate)
|
||||
use tauri::Manager;
|
||||
crate::domain::trilium::ensure_instance(&Paths::real(), &project)?;
|
||||
let mut url = crate::domain::trilium::base_url(&project);
|
||||
if let Some(id) = ¬e_id {
|
||||
url.push_str(&format!("/#root/{id}"));
|
||||
}
|
||||
let label = format!("trilium-{project}");
|
||||
if let Some(win) = app.get_webview_window(&label) {
|
||||
if note_id.is_some() {
|
||||
win
|
||||
.eval(&format!("window.location.href = {}", serde_json::json!(url)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
win.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
let parsed: tauri::Url = url.parse().map_err(|e| format!("{url}: {e}"))?;
|
||||
tauri::WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::External(parsed))
|
||||
.title(format!("Trilium — {}", crate::domain::project::display_name_in(&Paths::real(), &project)?))
|
||||
.inner_size(1100.0, 780.0)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wählt das Archiv ab (Einstellungsdialog): Config-Eintrag und Permissions
|
||||
/// weg, der Ordner bleibt liegen.
|
||||
/// Wählt das Modul ab und stoppt die Projekt-Instanz. Daten und Token
|
||||
/// bleiben — erneutes Aktivieren verbindet sich wieder.
|
||||
#[tauri::command]
|
||||
pub(crate) fn clear_archive_home_cmd(project: String) -> Result<(), String> {
|
||||
crate::domain::archive::clear_project_archive_home(&project)
|
||||
pub(crate) async fn trilium_disable(project: String) -> Result<(), String> {
|
||||
crate::domain::modules::set_module_in(&Paths::real(), &project, "trilium", false)?;
|
||||
crate::domain::trilium::stop_instance(&project);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt
|
||||
/// das Archiv-Home zugleich; ohne `dir` muss es konfiguriert sein. Ordner,
|
||||
/// Beschreibung und Schlagwörter kommen aus dem Archiv-Formular im Panel.
|
||||
#[tauri::command]
|
||||
pub(crate) fn panel_archive_cmd(
|
||||
project: String,
|
||||
dir: Option<String>,
|
||||
folder: Option<String>,
|
||||
description: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<String, String> {
|
||||
let meta = ArchiveMeta {
|
||||
folder: folder.filter(|f| !f.trim().is_empty()),
|
||||
description: description.filter(|d| !d.trim().is_empty()),
|
||||
tags: tags.unwrap_or_default(),
|
||||
};
|
||||
archive_panel_content(&project, dir.as_deref(), &meta).map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Dokumentliste des Archiv-Index (frisch gescannt) fürs Panel.
|
||||
#[tauri::command]
|
||||
pub(crate) fn archive_docs_cmd(
|
||||
project: String,
|
||||
) -> Result<Vec<crate::domain::archive_index::Doc>, String> {
|
||||
let home = require_archive_home(&project)?;
|
||||
crate::domain::archive_index::scan_archive(&home)
|
||||
}
|
||||
|
||||
/// Zeigt einen Pfad im Dateimanager (nach dem Archivieren „im Finder zeigen").
|
||||
#[tauri::command]
|
||||
pub(crate) fn reveal_path_cmd(path: String) {
|
||||
crate::platform::reveal_path(std::path::Path::new(&path));
|
||||
}
|
||||
|
||||
@@ -1,565 +0,0 @@
|
||||
//! Archiv: Panel-Entwürfe als Markdown-Dateien mit Frontmatter im
|
||||
//! konfigurierten Archiv-Ordner des Projekts persistieren.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::domain::paths::{contract_home, expand_home, panel_file, Paths};
|
||||
use crate::domain::project::{
|
||||
read_project_config_in, settings_path, write_project_config_in,
|
||||
};
|
||||
use crate::domain::registry::project_dir;
|
||||
|
||||
/// Konfiguriertes Archiv-Home des Projekts (config.json: archiveHome),
|
||||
/// Home-expandiert; None, wenn nicht gesetzt.
|
||||
pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
|
||||
let paths = Paths::real();
|
||||
let dir = read_project_config_in(&paths, project).ok()?.archive_home?;
|
||||
Some(expand_home(&paths, &dir))
|
||||
}
|
||||
|
||||
/// Archiv-Home als Result — die gemeinsame Vorbedingung aller Archiv-Tools.
|
||||
/// Legt den konfigurierten Ordner an, falls er (noch) nicht existiert.
|
||||
pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
|
||||
let home = project_archive_home(project).ok_or("kein Archiv-Ordner gesetzt")?;
|
||||
fs::create_dir_all(&home).map_err(|e| format!("{}: {e}", home.display()))?;
|
||||
Ok(home)
|
||||
}
|
||||
|
||||
/// Setzt das Archiv-Home: config.json (~-relativ) und ein Eintrag in
|
||||
/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
|
||||
/// claude das Archiv später lesen/scannen darf.
|
||||
pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), String> {
|
||||
let paths = Paths::real();
|
||||
let expanded = expand_home(&paths, dir);
|
||||
if !expanded.is_absolute() {
|
||||
return Err(format!("Archiv-Ordner muss ein absoluter Pfad sein: {}", expanded.display()));
|
||||
}
|
||||
// Zu weit gefasst? Ein Vorfahre des Home (inkl. Home selbst und Root) würde
|
||||
// claude über additionalDirectories weiten Zugriff geben — das lehnen wir ab.
|
||||
if paths.home.starts_with(&expanded) {
|
||||
return Err(format!(
|
||||
"Archiv-Ordner zu weit gefasst: {}. Bitte einen spezifischen Unterordner wählen.",
|
||||
expanded.display()
|
||||
));
|
||||
}
|
||||
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
|
||||
let contracted = contract_home(&paths, &expanded);
|
||||
let mut cfg = read_project_config_in(&paths, project)?;
|
||||
cfg.archive_home = Some(contracted.clone());
|
||||
write_project_config_in(&paths, project, &cfg)?;
|
||||
add_archive_permission(&paths, project, &contracted)
|
||||
}
|
||||
|
||||
/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein.
|
||||
/// Auch beim Projekt-Import im Einsatz (mitgebrachtes archiveHome).
|
||||
pub(crate) fn add_archive_permission(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
dir: &str,
|
||||
) -> Result<(), String> {
|
||||
let sp = settings_path(&project_dir(paths, project)?);
|
||||
crate::domain::update_settings_permissions(&sp, true, |perms| {
|
||||
let dirs = crate::domain::perm_array(perms, "additionalDirectories")?;
|
||||
if !dirs.iter().any(|d| d.as_str() == Some(dir)) {
|
||||
dirs.push(serde_json::json!(dir));
|
||||
}
|
||||
let edit = format!("Edit({dir}/**)");
|
||||
let allow = crate::domain::perm_array(perms, "allow")?;
|
||||
if !allow.iter().any(|p| p.as_str() == Some(&edit)) {
|
||||
allow.push(serde_json::json!(edit));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Wechselt das Archiv-Home: neues Home setzen (validieren, anlegen, Rechte,
|
||||
/// Config), auf Wunsch die Dokumente aus dem alten Home hinüberziehen, dann
|
||||
/// die Rechte des alten zurücknehmen. Ohne `migrate` bleibt das alte Archiv
|
||||
/// unverändert liegen — nichts wird implizit verschoben; der Umzug ist eine
|
||||
/// bewusste Option im Dialog.
|
||||
pub(crate) fn change_project_archive_home(
|
||||
project: &str,
|
||||
dir: &str,
|
||||
migrate: bool,
|
||||
) -> Result<(), String> {
|
||||
let paths = Paths::real();
|
||||
let old = read_project_config_in(&paths, project)?.archive_home;
|
||||
set_project_archive_home(project, dir)?;
|
||||
let new_home = expand_home(&paths, dir);
|
||||
if let Some(old_c) = old {
|
||||
let old_home = expand_home(&paths, &old_c);
|
||||
if old_home == new_home {
|
||||
return Ok(());
|
||||
}
|
||||
if migrate && old_home.is_dir() {
|
||||
move_dir_contents(&old_home, &new_home)?;
|
||||
}
|
||||
remove_archive_permission(&paths, project, &old_c)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Zieht alle Einträge von `from` nach `to` um. Gleiche Platte per rename,
|
||||
/// über Dateisystemgrenzen als Kopie + Löschen. Ein im Ziel schon vorhandener
|
||||
/// Name bricht laut ab — nichts wird überschrieben.
|
||||
fn move_dir_contents(from: &std::path::Path, to: &std::path::Path) -> Result<(), String> {
|
||||
for entry in fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))? {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", from.display()))?;
|
||||
let src = entry.path();
|
||||
let dest = to.join(entry.file_name());
|
||||
if dest.exists() {
|
||||
return Err(format!("existiert schon im neuen Archiv: {}", dest.display()));
|
||||
}
|
||||
match fs::rename(&src, &dest) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
|
||||
copy_recursive(&src, &dest)?;
|
||||
if src.is_dir() {
|
||||
fs::remove_dir_all(&src).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
} else {
|
||||
fs::remove_file(&src).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("{}: {e}", src.display())),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_recursive(src: &std::path::Path, dest: &std::path::Path) -> Result<(), String> {
|
||||
if src.is_dir() {
|
||||
fs::create_dir_all(dest).map_err(|e| format!("{}: {e}", dest.display()))?;
|
||||
for entry in fs::read_dir(src).map_err(|e| format!("{}: {e}", src.display()))? {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
copy_recursive(&entry.path(), &dest.join(entry.file_name()))?;
|
||||
}
|
||||
} else {
|
||||
fs::copy(src, dest).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wählt das Archiv ab: archiveHome aus der config.json entfernen und die
|
||||
/// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus
|
||||
/// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen.
|
||||
pub(crate) fn clear_project_archive_home(project: &str) -> Result<(), String> {
|
||||
let paths = Paths::real();
|
||||
let mut cfg = read_project_config_in(&paths, project)?;
|
||||
let Some(dir) = cfg.archive_home.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
write_project_config_in(&paths, project, &cfg)?;
|
||||
remove_archive_permission(&paths, project, &dir)
|
||||
}
|
||||
|
||||
/// Gegenstück zu add_archive_permission; auch der Lösch-Dialog (Stufe „nur
|
||||
/// Integration") nimmt darüber die Archiv-Rechte zurück.
|
||||
pub(crate) fn remove_archive_permission(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
dir: &str,
|
||||
) -> Result<(), String> {
|
||||
let sp = settings_path(&project_dir(paths, project)?);
|
||||
if !sp.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
crate::domain::update_settings_permissions(&sp, false, |perms| {
|
||||
if let Some(dirs) = perms.get_mut("additionalDirectories").and_then(|d| d.as_array_mut()) {
|
||||
dirs.retain(|d| d.as_str() != Some(dir));
|
||||
}
|
||||
let edit = format!("Edit({dir}/**)");
|
||||
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
|
||||
allow.retain(|p| p.as_str() != Some(&edit));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ArchiveMeta {
|
||||
/// Unterordner relativ zum Archiv-Home (wird angelegt).
|
||||
pub(crate) folder: Option<String>,
|
||||
/// Einzeiler fürs Frontmatter.
|
||||
pub(crate) description: Option<String>,
|
||||
/// Schlagwörter fürs Frontmatter.
|
||||
pub(crate) tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit
|
||||
/// Frontmatter im Archiv-Home. `dir_override` setzt das Home zugleich
|
||||
/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad.
|
||||
pub(crate) fn archive_panel_content(
|
||||
project: &str,
|
||||
dir_override: Option<&str>,
|
||||
meta: &ArchiveMeta,
|
||||
) -> Result<PathBuf, String> {
|
||||
// Erst prüfen, dann konfigurieren: `set_project_archive_home` legt Ordner an
|
||||
// und trägt eine Berechtigung in die settings.json des Projekts ein. Käme das
|
||||
// vor der Leer-Prüfung, hinterließe ein Archivieren mit leerem Panel die
|
||||
// Meldung „nicht archiviert“ — und ein dauerhaft umgestelltes Archiv-Home.
|
||||
let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
return Err("Panel ist leer — nichts zu archivieren".into());
|
||||
}
|
||||
let folder = meta.folder.as_deref().map(check_folder).transpose()?;
|
||||
if let Some(d) = dir_override {
|
||||
set_project_archive_home(project, d)?;
|
||||
}
|
||||
let home = require_archive_home(project)?;
|
||||
let dir = match folder {
|
||||
Some(f) => home.join(f),
|
||||
None => home,
|
||||
};
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_secs();
|
||||
let (stamp, iso) = utc_stamp(secs);
|
||||
let title = first_line(&text);
|
||||
let (path, mut file) = create_unique(&dir, &stamp, &slugify(&title))?;
|
||||
// Frontmatter trägt den Anzeigenamen, nicht die Projekt-ID.
|
||||
let name = crate::domain::project::display_name_in(&Paths::real(), project)?;
|
||||
let doc = format!("{}{}\n", frontmatter(&title, &name, &iso, meta), text.trim_end());
|
||||
std::io::Write::write_all(&mut file, doc.as_bytes())
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Archiv-Dokument kollisionsfrei anlegen.
|
||||
///
|
||||
/// Der Zeitstempel hat Minutenauflösung; zweimal Archivieren innerhalb einer
|
||||
/// Minute mit derselben Titelzeile ergäbe denselben Namen — bei Kollision
|
||||
/// wird `-2`, `-3`, … angehängt. Die Garantie kommt vom Dateisystem
|
||||
/// (`create_new`), nicht von einem `exists()`-Vorabblick: Beim Archiv-Sync
|
||||
/// über zwei Maschinen wäre der Vorabblick ein TOCTOU-Fenster, und stiller
|
||||
/// Datenverlust träfe ausgerechnet die dauerhafte Ablage.
|
||||
fn create_unique(
|
||||
dir: &std::path::Path,
|
||||
stamp: &str,
|
||||
slug: &str,
|
||||
) -> Result<(PathBuf, fs::File), String> {
|
||||
for n in 1.. {
|
||||
let name = if n == 1 {
|
||||
format!("{stamp}-{slug}.md")
|
||||
} else {
|
||||
format!("{stamp}-{slug}-{n}.md")
|
||||
};
|
||||
let path = dir.join(name);
|
||||
match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(file) => return Ok((path, file)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root).
|
||||
fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
|
||||
let p = std::path::Path::new(folder);
|
||||
let normal = p
|
||||
.components()
|
||||
.all(|c| matches!(c, std::path::Component::Normal(_)));
|
||||
if p.components().next().is_some() && normal {
|
||||
Ok(p)
|
||||
} else {
|
||||
Err(format!("Unterordner muss ein relativer Pfad ohne '..' sein: {folder}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler
|
||||
/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten —
|
||||
/// Schreiber und Leser des Formats leben bewusst im selben Modul.
|
||||
pub(crate) fn frontmatter(
|
||||
title: &str,
|
||||
project: &str,
|
||||
iso: &str,
|
||||
meta: &ArchiveMeta,
|
||||
) -> String {
|
||||
let mut fm = format!(
|
||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
|
||||
title.replace('"', "'"),
|
||||
);
|
||||
if let Some(d) = &meta.description {
|
||||
fm.push_str(&format!("description: \"{}\"\n", d.replace('"', "'")));
|
||||
}
|
||||
if !meta.tags.is_empty() {
|
||||
let quoted: Vec<String> =
|
||||
meta.tags.iter().map(|t| format!("\"{}\"", t.replace('"', "'"))).collect();
|
||||
fm.push_str(&format!("tags: [{}]\n", quoted.join(", ")));
|
||||
}
|
||||
fm.push_str("---\n\n");
|
||||
fm
|
||||
}
|
||||
|
||||
/// Minimaler Frontmatter-Parser für die selbst geschriebenen Dokumente:
|
||||
/// `key: value`-Zeilen zwischen den beiden `---`-Markern, Anführungszeichen
|
||||
/// um Werte werden entfernt.
|
||||
pub(crate) fn parse_frontmatter(text: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let Some(rest) = text.strip_prefix("---\n") else {
|
||||
return map;
|
||||
};
|
||||
let Some(end) = rest.find("\n---") else {
|
||||
return map;
|
||||
};
|
||||
for line in rest[..end].lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
map.insert(key.trim().to_string(), unquote(value.trim()).to_string());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Dokument-Rumpf ohne den Frontmatter-Block; führende Leerzeilen entfernt.
|
||||
pub(crate) fn strip_frontmatter(text: &str) -> &str {
|
||||
let Some(rest) = text.strip_prefix("---\n") else {
|
||||
return text;
|
||||
};
|
||||
match rest.find("\n---\n") {
|
||||
Some(end) => rest[end + 5..].trim_start_matches('\n'),
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
fn unquote(s: &str) -> &str {
|
||||
s.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Inline-Liste `["a", "b"]` bzw. `[a, b]` in Einzel-Tags zerlegen.
|
||||
pub(crate) fn parse_tag_list(raw: &str) -> Vec<String> {
|
||||
raw
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.split(',')
|
||||
.map(|t| unquote(t.trim()).to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Datei-Stem ohne führenden `YYYY-MM-DD_HHMM-`-Zeitstempel — das Gegenstück
|
||||
/// zum Dateinamen aus `utc_stamp` + `slugify`.
|
||||
pub(crate) fn strip_stamp(stem: &str) -> &str {
|
||||
let bytes = stem.as_bytes();
|
||||
let stamped = bytes.len() > 16
|
||||
&& bytes[..16].iter().enumerate().all(|(i, b)| match i {
|
||||
4 | 7 => *b == b'-',
|
||||
10 => *b == b'_',
|
||||
15 => *b == b'-',
|
||||
_ => b.is_ascii_digit(),
|
||||
});
|
||||
if stamped {
|
||||
&stem[16..]
|
||||
} else {
|
||||
stem
|
||||
}
|
||||
}
|
||||
|
||||
/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile.
|
||||
fn first_line(text: &str) -> String {
|
||||
let mut fallback: Option<&str> = None;
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let h = t.trim_start_matches('#').trim();
|
||||
if t.starts_with('#') && !h.is_empty() {
|
||||
return h.to_string();
|
||||
}
|
||||
fallback.get_or_insert(t);
|
||||
}
|
||||
fallback.unwrap_or("entwurf").to_string()
|
||||
}
|
||||
|
||||
/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).
|
||||
pub(crate) fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
// Umlaute/ß transliterieren statt verschlucken („für's" → fuer-s).
|
||||
match c {
|
||||
'ä' | 'Ä' => out.push_str("ae"),
|
||||
'ö' | 'Ö' => out.push_str("oe"),
|
||||
'ü' | 'Ü' => out.push_str("ue"),
|
||||
'ß' => out.push_str("ss"),
|
||||
_ if c.is_ascii_alphanumeric() => out.push(c.to_ascii_lowercase()),
|
||||
_ if !out.ends_with('-') => out.push('-'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let out: String = out.trim_matches('-').chars().take(60).collect();
|
||||
if out.is_empty() {
|
||||
"entwurf".into()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktuelle UTC-Zeit als ISO-String — Frontmatter-`created` neu angelegter
|
||||
/// Dokumente.
|
||||
pub(crate) fn utc_now_iso() -> Result<String, String> {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_secs();
|
||||
Ok(utc_stamp(secs).1)
|
||||
}
|
||||
|
||||
/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
|
||||
fn utc_stamp(secs: u64) -> (String, String) {
|
||||
let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
|
||||
let (y, m, d) = civil_from_days((secs / 86400) as i64);
|
||||
(
|
||||
format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"),
|
||||
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utc_stamp_bekannte_zeit() {
|
||||
// 2026-07-11 (20645 Tage seit Epoch) um 11:14:15 UTC.
|
||||
let secs = 20_645u64 * 86400 + 11 * 3600 + 14 * 60 + 15;
|
||||
let (stamp, iso) = utc_stamp(secs);
|
||||
assert_eq!(stamp, "2026-07-11_1114");
|
||||
assert_eq!(iso, "2026-07-11T11:14:15Z");
|
||||
}
|
||||
|
||||
/// Zweimal Archivieren in derselben Minute mit gleichem Titel darf die erste
|
||||
/// Datei nicht überschreiben — der Stempel hat nur Minutenauflösung, und
|
||||
/// die Datei entsteht bereits beim Anlegen (create_new), nicht erst beim
|
||||
/// Schreiben.
|
||||
#[test]
|
||||
fn gleicher_stempel_und_titel_kollidiert_nicht() {
|
||||
let dir = crate::domain::testutil::tmp_paths().home.join("archiv");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let (erste, mut f1) = create_unique(&dir, "2026-07-19_2118", "notiz").unwrap();
|
||||
assert_eq!(erste.file_name().unwrap(), "2026-07-19_2118-notiz.md");
|
||||
std::io::Write::write_all(&mut f1, b"alt").unwrap();
|
||||
|
||||
let (zweite, _f2) = create_unique(&dir, "2026-07-19_2118", "notiz").unwrap();
|
||||
assert_eq!(zweite.file_name().unwrap(), "2026-07-19_2118-notiz-2.md");
|
||||
|
||||
assert_eq!(fs::read_to_string(&erste).unwrap(), "alt");
|
||||
let (dritte, _f3) = create_unique(&dir, "2026-07-19_2118", "notiz").unwrap();
|
||||
assert_eq!(dritte.file_name().unwrap(), "2026-07-19_2118-notiz-3.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_from_days_referenz() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(20_645), (2026, 7, 11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_grenzen() {
|
||||
assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen");
|
||||
assert_eq!(slugify(" "), "entwurf");
|
||||
assert_eq!(slugify("###"), "entwurf");
|
||||
assert_eq!(slugify("Ein Test für's Archiv"), "ein-test-fuer-s-archiv");
|
||||
assert_eq!(slugify("Größe ÄÖÜ"), "groesse-aeoeue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_und_strip_roundtrip() {
|
||||
let (stamp, _) = utc_stamp(20_645u64 * 86400);
|
||||
assert_eq!(strip_stamp(&format!("{stamp}-adr-logging")), "adr-logging");
|
||||
assert_eq!(strip_stamp("adr-logging"), "adr-logging");
|
||||
assert_eq!(strip_stamp("2026-07-19-adr"), "2026-07-19-adr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_und_parser_roundtrip() {
|
||||
let meta = ArchiveMeta {
|
||||
folder: None,
|
||||
description: Some("Kurz".into()),
|
||||
tags: vec!["adr".into(), "infra".into()],
|
||||
};
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &meta);
|
||||
let map = parse_frontmatter(&fm);
|
||||
assert_eq!(map.get("title").map(String::as_str), Some("Titel"));
|
||||
assert_eq!(map.get("description").map(String::as_str), Some("Kurz"));
|
||||
assert_eq!(parse_tag_list(&map["tags"]), vec!["adr", "infra"]);
|
||||
}
|
||||
|
||||
/// Umzug: Einträge wandern komplett, Namenskollision bricht laut ab.
|
||||
#[test]
|
||||
fn archiv_umzug_verschiebt_und_kollidiert_laut() {
|
||||
let home = crate::domain::testutil::tmp_paths().home;
|
||||
let (alt, neu) = (home.join("alt"), home.join("neu"));
|
||||
fs::create_dir_all(alt.join("ordner")).unwrap();
|
||||
fs::write(alt.join("doc.md"), "inhalt").unwrap();
|
||||
fs::write(alt.join("ordner/tief.md"), "tief").unwrap();
|
||||
fs::create_dir_all(&neu).unwrap();
|
||||
|
||||
move_dir_contents(&alt, &neu).unwrap();
|
||||
assert_eq!(fs::read_to_string(neu.join("doc.md")).unwrap(), "inhalt");
|
||||
assert_eq!(fs::read_to_string(neu.join("ordner/tief.md")).unwrap(), "tief");
|
||||
assert!(fs::read_dir(&alt).unwrap().next().is_none());
|
||||
|
||||
// Kollision: gleicher Name im Ziel → Fehler, nichts überschrieben
|
||||
fs::write(alt.join("doc.md"), "neuer").unwrap();
|
||||
let err = move_dir_contents(&alt, &neu).unwrap_err();
|
||||
assert!(err.contains("existiert schon"));
|
||||
assert_eq!(fs::read_to_string(neu.join("doc.md")).unwrap(), "inhalt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_folder_relativ_ohne_punktpunkt() {
|
||||
assert!(check_folder("konzepte/panel").is_ok());
|
||||
assert!(check_folder("../raus").is_err());
|
||||
assert!(check_folder("a/../b").is_err());
|
||||
assert!(check_folder("/absolut").is_err());
|
||||
assert!(check_folder("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_mit_und_ohne_meta() {
|
||||
let leer = ArchiveMeta::default();
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &leer);
|
||||
assert!(fm.starts_with("---\ntitle: \"Titel\"\n"));
|
||||
assert!(!fm.contains("description:"));
|
||||
assert!(!fm.contains("tags:"));
|
||||
|
||||
let voll = ArchiveMeta {
|
||||
folder: None,
|
||||
description: Some("Kurz \"zitiert\"".into()),
|
||||
tags: vec!["archiv".into(), "wiki".into()],
|
||||
};
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &voll);
|
||||
assert!(fm.contains("description: \"Kurz 'zitiert'\"\n"));
|
||||
assert!(fm.contains("tags: [\"archiv\", \"wiki\"]\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_ueberschrift_oder_erste_zeile() {
|
||||
assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel");
|
||||
assert_eq!(first_line("Kein Heading\nzweite"), "Kein Heading");
|
||||
assert_eq!(first_line(" \n"), "entwurf");
|
||||
}
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
//! Archiv-Index: reproduzierbare Sicht über den Archiv-Baum eines Projekts —
|
||||
//! pro Markdown-Dokument Name, Frontmatter-Metadaten und Wikilinks; Backlinks
|
||||
//! fallen beim Scan als Nebenprodukt ab. Der Index ist abgeleitete Information
|
||||
//! und wird bei Bedarf frisch aus dem Baum gebaut (nichts davon wird gesynct).
|
||||
//! Das Dateiformat (Frontmatter, Zeitstempel-Stem) definiert archive.rs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_stamp};
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct Doc {
|
||||
/// Pfad relativ zum Archiv-Home.
|
||||
pub(crate) relpath: String,
|
||||
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
|
||||
pub(crate) name: String,
|
||||
/// Frontmatter-Titel, sonst der Name.
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) tags: Vec<String>,
|
||||
/// Frontmatter-`created` (ISO) — Datumsquelle für Dokumente ohne
|
||||
/// Zeitstempel im Dateinamen.
|
||||
pub(crate) created: Option<String>,
|
||||
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
|
||||
pub(crate) links: Vec<String>,
|
||||
/// Namen der Dokumente, die per Wikilink hierher zeigen.
|
||||
pub(crate) backlinks: Vec<String>,
|
||||
}
|
||||
|
||||
/// Scannt den Archiv-Baum rekursiv über alle Markdown-Dateien; versteckte
|
||||
/// Einträge (Punkt-Präfix) bleiben außen vor. Reihenfolge: relpath sortiert;
|
||||
/// Backlinks sind nach dem Scan gefüllt.
|
||||
pub(crate) fn scan_archive(home: &Path) -> Result<Vec<Doc>, String> {
|
||||
Ok(scan_with_bodies(home)?.into_iter().map(|(doc, _)| doc).collect())
|
||||
}
|
||||
|
||||
/// Wie `scan_archive`, liefert zu jedem Dokument den bereits gelesenen
|
||||
/// Volltext mit — die Suche indexiert damit ohne zweite Lesung pro Datei.
|
||||
pub(crate) fn scan_with_bodies(home: &Path) -> Result<Vec<(Doc, String)>, String> {
|
||||
let mut docs = Vec::new();
|
||||
walk(home, home, &mut docs)?;
|
||||
docs.sort_by(|a, b| a.0.relpath.cmp(&b.0.relpath));
|
||||
fill_backlinks(&mut docs);
|
||||
Ok(docs)
|
||||
}
|
||||
|
||||
/// Backlinks in einem Durchlauf: Slug → Doc-Index einmal aufbauen, jeden Link
|
||||
/// genau einmal auflösen und zu Backlinks invertieren.
|
||||
fn fill_backlinks(docs: &mut [(Doc, String)]) {
|
||||
let mut lookup: HashMap<String, usize> = HashMap::new();
|
||||
for (i, (doc, _)) in docs.iter().enumerate() {
|
||||
let stem = Path::new(&doc.relpath).file_stem().unwrap_or_default().to_string_lossy();
|
||||
for key in [slugify(&doc.name), slugify(&doc.title), slugify(&stem)] {
|
||||
lookup.entry(key).or_insert(i);
|
||||
}
|
||||
}
|
||||
let mut back: Vec<Vec<String>> = vec![Vec::new(); docs.len()];
|
||||
for (i, (doc, _)) in docs.iter().enumerate() {
|
||||
let mut targets: std::collections::BTreeSet<usize> = docs[i]
|
||||
.0
|
||||
.links
|
||||
.iter()
|
||||
.filter_map(|l| lookup.get(&slugify(l)).copied())
|
||||
.collect();
|
||||
targets.remove(&i);
|
||||
for t in targets {
|
||||
back[t].push(doc.name.clone());
|
||||
}
|
||||
}
|
||||
for ((doc, _), b) in docs.iter_mut().zip(back) {
|
||||
doc.backlinks = b;
|
||||
}
|
||||
}
|
||||
|
||||
fn walk(home: &Path, dir: &Path, docs: &mut Vec<(Doc, String)>) -> Result<(), String> {
|
||||
let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let fname = file_name.to_string_lossy();
|
||||
if fname.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
walk(home, &path, docs)?;
|
||||
} else if fname.ends_with(".md") {
|
||||
docs.push(read_doc(home, &path)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
|
||||
let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let name = strip_stamp(&stem).to_string();
|
||||
let fm = parse_frontmatter(&text);
|
||||
let relpath = path
|
||||
.strip_prefix(home)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.display()
|
||||
.to_string();
|
||||
let doc = Doc {
|
||||
relpath,
|
||||
title: fm.get("title").unwrap_or(&name).clone(),
|
||||
description: fm.get("description").cloned(),
|
||||
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
|
||||
created: fm.get("created").cloned(),
|
||||
links: wikilinks(&text),
|
||||
backlinks: Vec::new(),
|
||||
name,
|
||||
};
|
||||
Ok((doc, text))
|
||||
}
|
||||
|
||||
/// Slug-Vergleich eines Wikilink-Ziels gegen Name, Titel und Datei-Stem.
|
||||
fn matches(doc: &Doc, want: &str) -> bool {
|
||||
slugify(&doc.name) == want
|
||||
|| slugify(&doc.title) == want
|
||||
|| Path::new(&doc.relpath)
|
||||
.file_stem()
|
||||
.is_some_and(|s| slugify(&s.to_string_lossy()) == want)
|
||||
}
|
||||
|
||||
/// Übersichts- bzw. Schlagwort-Seite als strukturierte Wiki-Daten: Dokumente
|
||||
/// nach Ordnern gruppiert (neueste zuerst), Schlagwort-Leiste mit Zählern.
|
||||
/// Das Panel rendert daraus die Wiki-Ansicht; `kind` unterscheidet im
|
||||
/// Wiki-Puffer Seite und Dokument.
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiPage {
|
||||
pub(crate) kind: &'static str,
|
||||
pub(crate) home: String,
|
||||
pub(crate) tag: Option<String>,
|
||||
pub(crate) total: usize,
|
||||
pub(crate) tags: Vec<TagCount>,
|
||||
pub(crate) folders: Vec<WikiFolder>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct TagCount {
|
||||
pub(crate) name: String,
|
||||
pub(crate) count: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiFolder {
|
||||
/// Ordner relativ zum Archiv-Home; leer für die Wurzel.
|
||||
pub(crate) name: String,
|
||||
pub(crate) docs: Vec<WikiDocEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiDocEntry {
|
||||
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
|
||||
/// Zeilen-Aktionen (umbenennen, löschen).
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) tags: Vec<String>,
|
||||
/// Archivierungsdatum aus dem Zeitstempel-Stem (`YYYY-MM-DD`).
|
||||
pub(crate) date: Option<String>,
|
||||
/// Zahl der Dokumente, die per Wikilink hierher zeigen.
|
||||
pub(crate) backlinks: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, String> {
|
||||
let docs = scan_archive(home)?;
|
||||
let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
|
||||
for doc in &docs {
|
||||
for t in &doc.tags {
|
||||
*counts.entry(t).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let selected: Vec<&Doc> = match tag {
|
||||
Some(t) => docs.iter().filter(|d| d.tags.iter().any(|x| x == t)).collect(),
|
||||
None => docs.iter().collect(),
|
||||
};
|
||||
let mut folders: std::collections::BTreeMap<String, Vec<&Doc>> =
|
||||
std::collections::BTreeMap::new();
|
||||
// Die Übersicht führt alle Ordner, auch dokumentlose — der Navigator zeigt
|
||||
// frisch angelegte Ordner sonst nicht. Schlagwort-Seiten bleiben auf die
|
||||
// Treffer-Ordner beschränkt.
|
||||
if tag.is_none() {
|
||||
let mut dirs = Vec::new();
|
||||
folder_paths(home, home, &mut dirs)?;
|
||||
for d in dirs {
|
||||
folders.entry(d).or_default();
|
||||
}
|
||||
}
|
||||
for doc in &selected {
|
||||
let folder = Path::new(&doc.relpath)
|
||||
.parent()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
folders.entry(folder).or_default().push(doc);
|
||||
}
|
||||
Ok(WikiPage {
|
||||
kind: "page",
|
||||
home: home.display().to_string(),
|
||||
tag: tag.map(str::to_string),
|
||||
total: selected.len(),
|
||||
tags: counts
|
||||
.into_iter()
|
||||
.map(|(name, count)| TagCount { name: name.to_string(), count })
|
||||
.collect(),
|
||||
folders: folders
|
||||
.into_iter()
|
||||
.map(|(name, mut list)| {
|
||||
// Zeitstempel-Stems sortieren chronologisch — absteigend = neueste oben.
|
||||
list.sort_by(|a, b| b.relpath.cmp(&a.relpath));
|
||||
WikiFolder { name, docs: list.into_iter().map(doc_entry).collect() }
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Alle Ordner-Relpaths unterhalb von `dir`, rekursiv; versteckte Einträge
|
||||
/// (Punkt-Präfix) bleiben außen vor — wie beim Dokument-Scan.
|
||||
fn folder_paths(home: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
|
||||
let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = entry.path();
|
||||
if entry.file_name().to_string_lossy().starts_with('.') || !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
out.push(path.strip_prefix(home).unwrap().display().to_string());
|
||||
folder_paths(home, &path, out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||
let stem = Path::new(&doc.relpath)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
// Zeitstempel im Dateinamen, sonst der Frontmatter-`created`-Tag —
|
||||
// im Navigator angelegte Dokumente tragen das Datum nur dort.
|
||||
let date = (stem != doc.name)
|
||||
.then(|| stem[..10].to_string())
|
||||
.or_else(|| {
|
||||
doc
|
||||
.created
|
||||
.as_ref()
|
||||
.filter(|c| c.len() >= 10)
|
||||
.map(|c| c[..10].to_string())
|
||||
});
|
||||
WikiDocEntry {
|
||||
relpath: doc.relpath.clone(),
|
||||
name: doc.name.clone(),
|
||||
title: doc.title.clone(),
|
||||
description: doc.description.clone(),
|
||||
tags: doc.tags.clone(),
|
||||
date,
|
||||
backlinks: doc.backlinks.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Löst ein Wikilink-Ziel (Name, Titel oder Datei-Stem) gegen das Archiv auf
|
||||
/// und liefert den relpath des Dokuments.
|
||||
pub(crate) fn resolve_doc(home: &Path, target: &str) -> Result<String, String> {
|
||||
let docs = scan_archive(home)?;
|
||||
let want = slugify(target);
|
||||
docs
|
||||
.iter()
|
||||
.find(|d| matches(d, &want))
|
||||
.map(|d| d.relpath.clone())
|
||||
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))
|
||||
}
|
||||
|
||||
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
|
||||
/// `[[ziel|label]]` zählt nur das Ziel.
|
||||
fn wikilinks(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find("[[") {
|
||||
rest = &rest[start + 2..];
|
||||
let Some(end) = rest.find("]]") else {
|
||||
break;
|
||||
};
|
||||
let inner = &rest[..end];
|
||||
let target = inner.split('|').next().unwrap_or(inner).trim();
|
||||
if !target.is_empty() {
|
||||
out.push(target.to_string());
|
||||
}
|
||||
rest = &rest[end + 2..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::tmp_paths;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn write(home: &Path, rel: &str, content: &str) {
|
||||
let path = home.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
fn archiv() -> PathBuf {
|
||||
let home = tmp_paths().home.join("archiv");
|
||||
fs::create_dir_all(&home).unwrap();
|
||||
write(
|
||||
&home,
|
||||
"2026-07-19_1000-adr-logging.md",
|
||||
"---\ntitle: \"ADR Logging\"\ndescription: \"Logging vereinheitlichen\"\ntags: [\"adr\", \"infra\"]\n---\n\nSiehe [[notiz-deploy|die Deploy-Notiz]].\n",
|
||||
);
|
||||
write(
|
||||
&home,
|
||||
"konzepte/2026-07-19_1005-notiz-deploy.md",
|
||||
"---\ntitle: \"Notiz Deploy\"\n---\n\nText ohne Links.\n",
|
||||
);
|
||||
write(&home, ".versteckt/ignoriert.md", "unsichtbar");
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_liest_baum_frontmatter_und_links() {
|
||||
let home = archiv();
|
||||
let docs = scan_archive(&home).unwrap();
|
||||
assert_eq!(docs.len(), 2);
|
||||
let adr = &docs[0];
|
||||
assert_eq!(adr.name, "adr-logging");
|
||||
assert_eq!(adr.title, "ADR Logging");
|
||||
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
|
||||
assert_eq!(adr.tags, vec!["adr", "infra"]);
|
||||
assert_eq!(adr.links, vec!["notiz-deploy"]);
|
||||
assert_eq!(docs[1].relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backlinks_aus_wikilinks() {
|
||||
let home = archiv();
|
||||
let docs = scan_archive(&home).unwrap();
|
||||
let deploy = docs.iter().find(|d| d.name == "notiz-deploy").unwrap();
|
||||
assert_eq!(deploy.backlinks, vec!["adr-logging"]);
|
||||
let adr = docs.iter().find(|d| d.name == "adr-logging").unwrap();
|
||||
assert!(adr.backlinks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startseite_gruppiert_und_zaehlt() {
|
||||
let home = archiv();
|
||||
let page = archive_page(&home, None).unwrap();
|
||||
assert_eq!(page.kind, "page");
|
||||
assert_eq!(page.tag, None);
|
||||
assert_eq!(page.total, 2);
|
||||
let tags: Vec<(&str, usize)> =
|
||||
page.tags.iter().map(|t| (t.name.as_str(), t.count)).collect();
|
||||
assert_eq!(tags, vec![("adr", 1), ("infra", 1)]);
|
||||
assert_eq!(page.folders.len(), 2);
|
||||
assert_eq!(page.folders[0].name, "");
|
||||
let adr = &page.folders[0].docs[0];
|
||||
assert_eq!(adr.name, "adr-logging");
|
||||
assert_eq!(adr.title, "ADR Logging");
|
||||
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
|
||||
assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
|
||||
assert_eq!(adr.relpath, "2026-07-19_1000-adr-logging.md");
|
||||
assert_eq!(page.folders[1].name, "konzepte");
|
||||
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
|
||||
assert_eq!(page.folders[1].docs[0].backlinks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leere_ordner_und_created_datum() {
|
||||
let home = archiv();
|
||||
fs::create_dir_all(home.join("leer/unter")).unwrap();
|
||||
write(
|
||||
&home,
|
||||
"notiz-plain.md",
|
||||
"---\ntitle: \"Plain\"\ncreated: 2026-07-23T10:00:00Z\n---\n\nText.\n",
|
||||
);
|
||||
let page = archive_page(&home, None).unwrap();
|
||||
let names: Vec<&str> = page.folders.iter().map(|f| f.name.as_str()).collect();
|
||||
// Dokumentlose Ordner erscheinen (Navigator), versteckte weiterhin nicht.
|
||||
assert!(names.contains(&"leer"));
|
||||
assert!(names.contains(&"leer/unter"));
|
||||
assert!(!names.iter().any(|n| n.starts_with(".versteckt")));
|
||||
// Ohne Zeitstempel im Namen kommt das Datum aus dem Frontmatter-created.
|
||||
let plain = page
|
||||
.folders
|
||||
.iter()
|
||||
.find(|f| f.name.is_empty())
|
||||
.unwrap()
|
||||
.docs
|
||||
.iter()
|
||||
.find(|d| d.name == "notiz-plain")
|
||||
.unwrap();
|
||||
assert_eq!(plain.date.as_deref(), Some("2026-07-23"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_seite_filtert() {
|
||||
let home = archiv();
|
||||
let page = archive_page(&home, Some("adr")).unwrap();
|
||||
assert_eq!(page.tag.as_deref(), Some("adr"));
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.folders.len(), 1);
|
||||
assert_eq!(page.folders[0].docs[0].name, "adr-logging");
|
||||
// Die Schlagwort-Leiste bleibt vollständig — sie ist die Navigation.
|
||||
assert_eq!(page.tags.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ueber_name_titel_und_stem() {
|
||||
let home = archiv();
|
||||
// Auflösung über Titel; Name und Stem gehen über dieselben Slug-Vergleiche.
|
||||
assert_eq!(
|
||||
resolve_doc(&home, "Notiz Deploy").unwrap(),
|
||||
"konzepte/2026-07-19_1005-notiz-deploy.md"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_doc(&home, "2026-07-19_1000-adr-logging").unwrap(),
|
||||
"2026-07-19_1000-adr-logging.md"
|
||||
);
|
||||
assert!(resolve_doc(&home, "fehlt").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
//! Dokument- und Ordner-Operationen im Archiv-Baum (Baum-Navigation im
|
||||
//! Panel): verschieben, umbenennen, löschen. Alle Pfade sind relativ zum
|
||||
//! Archiv-Home und strikt darauf begrenzt — die relpaths kommen aus dem
|
||||
//! Webview, ein Traversal-Bestandteil bricht laut ab.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::domain::archive::{
|
||||
frontmatter, slugify, strip_frontmatter, strip_stamp, utc_now_iso, ArchiveMeta,
|
||||
};
|
||||
|
||||
/// Relativer Pfad ohne Traversal: nur normale Komponenten, nichts Absolutes.
|
||||
fn checked_rel(rel: &str) -> Result<(), String> {
|
||||
if rel.is_empty() {
|
||||
return Err("leerer Pfad".into());
|
||||
}
|
||||
for c in Path::new(rel).components() {
|
||||
match c {
|
||||
Component::Normal(_) => {}
|
||||
_ => return Err(format!("unzulässiger Pfad: {rel}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Vorhandenes Archiv-Dokument zum relpath.
|
||||
fn doc_path(home: &Path, relpath: &str) -> Result<PathBuf, String> {
|
||||
checked_rel(relpath)?;
|
||||
if !relpath.ends_with(".md") {
|
||||
return Err(format!("kein Markdown-Dokument: {relpath}"));
|
||||
}
|
||||
let path = home.join(relpath);
|
||||
if !path.is_file() {
|
||||
return Err(format!("Dokument nicht gefunden: {relpath}"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Ziel für rename: darf noch nicht existieren — stilles Überschreiben wäre
|
||||
/// Datenverlust.
|
||||
fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
|
||||
if target.exists() {
|
||||
return Err(format!(
|
||||
"Ziel existiert bereits: {}",
|
||||
target.strip_prefix(home).unwrap_or(target).display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verschiebt ein Dokument in einen anderen Ordner (leer = Wurzel); der
|
||||
/// Zielordner entsteht bei Bedarf. Liefert den neuen relpath.
|
||||
pub(crate) fn move_doc(home: &Path, relpath: &str, folder: &str) -> Result<String, String> {
|
||||
let src = doc_path(home, relpath)?;
|
||||
if !folder.is_empty() {
|
||||
checked_rel(folder)?;
|
||||
}
|
||||
let file_name = src.file_name().unwrap();
|
||||
let dir = home.join(folder);
|
||||
let target = dir.join(file_name);
|
||||
if target == src {
|
||||
return Ok(relpath.to_string());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
/// Benennt ein Dokument um: neuer Name als Slug hinter dem (erhaltenen)
|
||||
/// Zeitstempel. Liefert den neuen relpath.
|
||||
pub(crate) fn rename_doc(home: &Path, relpath: &str, name: &str) -> Result<String, String> {
|
||||
let src = doc_path(home, relpath)?;
|
||||
let stem = src.file_stem().unwrap().to_string_lossy().to_string();
|
||||
let stamp = &stem[..stem.len() - strip_stamp(&stem).len()];
|
||||
let target = src.with_file_name(format!("{stamp}{}.md", slugify(name)));
|
||||
if target == src {
|
||||
return Ok(relpath.to_string());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn delete_doc(home: &Path, relpath: &str) -> Result<(), String> {
|
||||
let path = doc_path(home, relpath)?;
|
||||
fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Legt einen (auch leeren) Ordner unterhalb des Archiv-Home an.
|
||||
pub(crate) fn create_folder(home: &Path, folder: &str) -> Result<(), String> {
|
||||
checked_rel(folder)?;
|
||||
let dir = home.join(folder);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
/// Legt ein leeres Dokument an: `<slug>.md` im Ordner (leer = Wurzel), das
|
||||
/// Datum steht in der Frontmatter (`created`), nicht im Dateinamen. Liefert
|
||||
/// den relpath.
|
||||
pub(crate) fn create_doc(
|
||||
home: &Path,
|
||||
folder: &str,
|
||||
name: &str,
|
||||
project_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Dokumentname fehlt".into());
|
||||
}
|
||||
if !folder.is_empty() {
|
||||
checked_rel(folder)?;
|
||||
}
|
||||
let dir = home.join(folder);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let target = dir.join(format!("{}.md", slugify(name)));
|
||||
let fm = frontmatter(name, project_name, &utc_now_iso()?, &ArchiveMeta::default());
|
||||
let mut file = match fs::OpenOptions::new().write(true).create_new(true).open(&target) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(format!(
|
||||
"Ziel existiert bereits: {}",
|
||||
target.strip_prefix(home).unwrap_or(&target).display()
|
||||
))
|
||||
}
|
||||
Err(e) => return Err(format!("{}: {e}", target.display())),
|
||||
};
|
||||
std::io::Write::write_all(&mut file, fm.as_bytes())
|
||||
.map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
/// Schreibt den Panel-Body zurück in ein Archiv-Dokument (Quell-Verknüpfung
|
||||
/// des Dokument-Tabs): der Frontmatter-Block der Datei bleibt, der Rumpf wird
|
||||
/// ersetzt.
|
||||
pub(crate) fn write_body(path: &Path, text: &str) -> Result<(), String> {
|
||||
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let head = doc.len() - strip_frontmatter(&doc).len();
|
||||
let out = format!("{}{}\n", &doc[..head], text.trim_end());
|
||||
fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Verschiebt bzw. benennt einen ganzen Ordner um (fs::rename des
|
||||
/// Verzeichnisses); Eltern des Ziels entstehen bei Bedarf.
|
||||
pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), String> {
|
||||
checked_rel(folder)?;
|
||||
checked_rel(to)?;
|
||||
let src = home.join(folder);
|
||||
if !src.is_dir() {
|
||||
return Err(format!("Ordner nicht gefunden: {folder}"));
|
||||
}
|
||||
let target = home.join(to);
|
||||
if target == src {
|
||||
return Ok(());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::tmp_paths;
|
||||
|
||||
fn archiv() -> PathBuf {
|
||||
let home = tmp_paths().home.join("archiv");
|
||||
fs::create_dir_all(home.join("konzepte")).unwrap();
|
||||
fs::write(home.join("2026-07-19_1000-adr-logging.md"), "a").unwrap();
|
||||
fs::write(home.join("konzepte/2026-07-19_1005-notiz-deploy.md"), "b").unwrap();
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verschieben_legt_ordner_an_und_liefert_relpath() {
|
||||
let home = archiv();
|
||||
let rel = move_doc(&home, "2026-07-19_1000-adr-logging.md", "adr/2026").unwrap();
|
||||
assert_eq!(rel, "adr/2026/2026-07-19_1000-adr-logging.md");
|
||||
assert!(home.join(&rel).is_file());
|
||||
|
||||
// In die Wurzel zurück: leerer Zielordner.
|
||||
let rel = move_doc(&home, &rel, "").unwrap();
|
||||
assert_eq!(rel, "2026-07-19_1000-adr-logging.md");
|
||||
|
||||
// Gleiches Ziel ist ein No-Op, kein Fehler.
|
||||
assert_eq!(move_doc(&home, &rel, "").unwrap(), rel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verschieben_ueberschreibt_nicht() {
|
||||
let home = archiv();
|
||||
fs::write(home.join("konzepte/2026-07-19_1000-adr-logging.md"), "alt").unwrap();
|
||||
let err = move_doc(&home, "2026-07-19_1000-adr-logging.md", "konzepte").unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_und_fremde_pfade_brechen_ab() {
|
||||
let home = archiv();
|
||||
assert!(move_doc(&home, "../2026-07-19_1000-adr-logging.md", "x").is_err());
|
||||
assert!(move_doc(&home, "2026-07-19_1000-adr-logging.md", "../raus").is_err());
|
||||
assert!(move_doc(&home, "/etc/passwd.md", "x").is_err());
|
||||
assert!(delete_doc(&home, "konzepte").is_err()); // kein .md
|
||||
assert!(move_folder(&home, "konzepte", "../raus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn umbenennen_erhaelt_stempel_und_sluggt() {
|
||||
let home = archiv();
|
||||
let rel =
|
||||
rename_doc(&home, "konzepte/2026-07-19_1005-notiz-deploy.md", "Deploy Nötiz!").unwrap();
|
||||
assert_eq!(rel, "konzepte/2026-07-19_1005-deploy-noetiz.md");
|
||||
assert!(home.join(&rel).is_file());
|
||||
|
||||
// Ohne Stempel bleibt der Name pur.
|
||||
fs::write(home.join("plain.md"), "c").unwrap();
|
||||
assert_eq!(rename_doc(&home, "plain.md", "Neu").unwrap(), "neu.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anlegen_ordner_und_dokument() {
|
||||
let home = archiv();
|
||||
create_folder(&home, "notizen/2026").unwrap();
|
||||
assert!(home.join("notizen/2026").is_dir());
|
||||
assert!(create_folder(&home, "../raus").is_err());
|
||||
|
||||
let rel = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap();
|
||||
assert_eq!(rel, "notizen/2026/deploy-noetiz.md");
|
||||
let text = fs::read_to_string(home.join(&rel)).unwrap();
|
||||
assert!(text.starts_with("---\ntitle: \"Deploy Nötiz!\"\n"));
|
||||
assert!(text.contains("created: "));
|
||||
|
||||
// In der Wurzel, gleicher Name kollidiert laut, leerer Name bricht ab.
|
||||
assert_eq!(create_doc(&home, "", "Plain", "proj").unwrap(), "plain.md");
|
||||
let err = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
assert!(create_doc(&home, "", " ", "proj").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_body_ersetzt_rumpf_und_erhaelt_frontmatter() {
|
||||
let home = archiv();
|
||||
let path = home.join("mit-fm.md");
|
||||
fs::write(&path, "---\ntitle: \"T\"\n---\n\nalter Text\n").unwrap();
|
||||
write_body(&path, "neuer Text").unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).unwrap(),
|
||||
"---\ntitle: \"T\"\n---\n\nneuer Text\n"
|
||||
);
|
||||
|
||||
// Ohne Frontmatter wird die Datei komplett ersetzt.
|
||||
let plain = home.join("ohne-fm.md");
|
||||
fs::write(&plain, "alt").unwrap();
|
||||
write_body(&plain, "neu").unwrap();
|
||||
assert_eq!(fs::read_to_string(&plain).unwrap(), "neu\n");
|
||||
|
||||
assert!(write_body(&home.join("fehlt.md"), "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loeschen_und_ordner_verschieben() {
|
||||
let home = archiv();
|
||||
delete_doc(&home, "2026-07-19_1000-adr-logging.md").unwrap();
|
||||
assert!(!home.join("2026-07-19_1000-adr-logging.md").exists());
|
||||
|
||||
move_folder(&home, "konzepte", "notizen/2026").unwrap();
|
||||
assert!(home.join("notizen/2026/2026-07-19_1005-notiz-deploy.md").is_file());
|
||||
assert!(!home.join("konzepte").exists());
|
||||
|
||||
let err = move_folder(&home, "fehlt", "x").unwrap_err();
|
||||
assert!(err.contains("nicht gefunden"));
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
//! Volltext-Suche übers Panel-Archiv: SQLite-FTS5-Index, bei jeder Anfrage
|
||||
//! frisch in-memory aus dem Archiv-Baum gebaut. Bei den Archiv-Größen dieser
|
||||
//! App ist der Aufbau Millisekundensache; damit gibt es keinen persistierten
|
||||
//! Index, keine Staleness und nichts, was gesynct werden könnte. Die
|
||||
//! Tool-Schnittstelle bleibt engine-unabhängig.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::domain::archive_index::scan_with_bodies;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct Hit {
|
||||
/// Pfad relativ zum Archiv-Home.
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) title: String,
|
||||
/// Textausschnitt um die Fundstelle, Treffer in `**…**`.
|
||||
pub(crate) snippet: String,
|
||||
}
|
||||
|
||||
/// Durchsucht das Archiv unter `home`. `query` ist FTS5-Syntax (Wörter,
|
||||
/// "Phrasen", Präfix*); `tag` engt auf ein Schlagwort ein. Treffer nach
|
||||
/// BM25-Rang, höchstens `limit`.
|
||||
pub(crate) fn search(
|
||||
home: &Path,
|
||||
query: &str,
|
||||
tag: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Hit>, String> {
|
||||
let q = sanitize_query(query);
|
||||
let t = tag.and_then(|t| quote_phrase(t, false)).map(|t| format!("tags:{t}"));
|
||||
let expr = match (q.is_empty(), t) {
|
||||
(false, Some(t)) => format!("({q}) AND {t}"),
|
||||
(false, None) => q,
|
||||
(true, Some(t)) => t,
|
||||
// Von vornherein leere Anfrage ist ein Bedienfehler. Eine Eingabe, die nur
|
||||
// aus Satzzeichen bestand (`!?`, ein einzelnes `"`, das angetippte `#`),
|
||||
// findet dagegen schlicht nichts — und spart sich den Index-Aufbau.
|
||||
(true, None) if query.trim().is_empty() && tag.is_none() => {
|
||||
return Err("leere Suchanfrage".into())
|
||||
}
|
||||
(true, None) => return Ok(Vec::new()),
|
||||
};
|
||||
let conn = build_index(home)?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \
|
||||
FROM docs WHERE docs MATCH ?1 ORDER BY rank LIMIT ?2",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![expr, limit as i64], |row| {
|
||||
Ok(Hit { relpath: row.get(0)?, title: row.get(1)?, snippet: row.get(2)? })
|
||||
})
|
||||
.map_err(|e| format!("Suchausdruck „{query}“: {e}"))?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Übersetzt die Nutzereingabe in einen FTS5-Ausdruck, der nicht scheitern kann.
|
||||
///
|
||||
/// Roh durchgereicht ist fast jede natürliche Eingabe ein Syntaxfehler: `ai-control`
|
||||
/// liest FTS5 als Spaltenfilter (`no such column: control`), `C++` und eine offene
|
||||
/// Klammer brechen den Parser. Die Live-Suche schickt zudem jeden Zwischenstand beim
|
||||
/// Tippen ab, also auch das halbe `"Phrase`. Darum wird jedes Wort als Phrase
|
||||
/// gequotet — die Tokenizer-Regeln bleiben dieselben, nur die Operatorzeichen
|
||||
/// verlieren ihre Sonderbedeutung. Erhalten bleiben die zwei Formen, die Nutzer
|
||||
/// bewusst tippen: "Phrasen in Anführungszeichen" und Präfix*.
|
||||
fn sanitize_query(query: &str) -> String {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
// An `"` aufteilen: ungerade Segmente standen in Anführungszeichen und
|
||||
// bleiben als Ganzes eine Phrase, gerade zerfallen in Wörter. Ein fehlendes
|
||||
// schließendes Anführungszeichen (Tippzwischenstand) fällt damit von selbst
|
||||
// richtig heraus — der Rest der Eingabe ist das letzte ungerade Segment.
|
||||
for (i, teil) in query.split('"').enumerate() {
|
||||
if i % 2 == 1 {
|
||||
out.extend(quote_phrase(teil, false));
|
||||
} else {
|
||||
out.extend(
|
||||
teil
|
||||
.split_whitespace()
|
||||
.filter_map(|w| quote_phrase(w.trim_end_matches('*'), w.ends_with('*'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
out.join(" ")
|
||||
}
|
||||
|
||||
/// `term` als gequotete FTS5-Phrase, sofern überhaupt etwas Indexierbares darin
|
||||
/// steht. Reine Satzzeichen ergäben die leere Phrase `""` — für FTS5 ein
|
||||
/// Syntaxfehler. Gilt auch für den Tag-Filter: Das angetippte `#` liefert über
|
||||
/// `panel-wiring.ts` den leeren Tag.
|
||||
fn quote_phrase(term: &str, prefix: bool) -> Option<String> {
|
||||
if !term.chars().any(char::is_alphanumeric) {
|
||||
return None;
|
||||
}
|
||||
let quoted = term.replace('"', "");
|
||||
Some(if prefix { format!("\"{quoted}\"*") } else { format!("\"{quoted}\"") })
|
||||
}
|
||||
|
||||
/// Baut den FTS5-Index in-memory aus dem Archiv-Baum.
|
||||
fn build_index(home: &Path) -> Result<Connection, String> {
|
||||
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
conn
|
||||
.execute_batch(
|
||||
"CREATE VIRTUAL TABLE docs USING fts5(relpath UNINDEXED, name, title, description, tags, body)",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let docs = scan_with_bodies(home)?;
|
||||
let mut insert = conn
|
||||
.prepare("INSERT INTO docs (relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
for (doc, body) in &docs {
|
||||
insert
|
||||
.execute(rusqlite::params![
|
||||
doc.relpath,
|
||||
doc.name,
|
||||
doc.title,
|
||||
doc.description.as_deref().unwrap_or(""),
|
||||
doc.tags.join(" "),
|
||||
body,
|
||||
])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
drop(insert);
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::tmp_paths;
|
||||
use std::fs;
|
||||
|
||||
fn archiv() -> std::path::PathBuf {
|
||||
let home = tmp_paths().home.join("archiv");
|
||||
fs::create_dir_all(home.join("konzepte")).unwrap();
|
||||
fs::write(
|
||||
home.join("2026-07-19_1000-adr-logging.md"),
|
||||
"---\ntitle: \"ADR Logging\"\ntags: [\"adr\", \"infra\"]\n---\n\nStrukturiertes Logging mit tracing vereinheitlichen.\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
home.join("konzepte/2026-07-19_1005-notiz-deploy.md"),
|
||||
"---\ntitle: \"Notiz Deploy\"\ntags: [\"infra\"]\n---\n\nDeploy braucht lsregister auf macOS.\n",
|
||||
)
|
||||
.unwrap();
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn findet_nach_inhaltswort() {
|
||||
let home = archiv();
|
||||
let hits = search(&home, "tracing", None, 10).unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].title, "ADR Logging");
|
||||
assert!(hits[0].snippet.contains("**tracing**"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_filter_engt_ein() {
|
||||
let home = archiv();
|
||||
assert_eq!(search(&home, "", Some("infra"), 10).unwrap().len(), 2);
|
||||
assert_eq!(search(&home, "", Some("adr"), 10).unwrap().len(), 1);
|
||||
let hits = search(&home, "deploy", Some("adr"), 10).unwrap();
|
||||
assert!(hits.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phrase_und_praefix() {
|
||||
let home = archiv();
|
||||
assert_eq!(search(&home, "\"Strukturiertes Logging\"", None, 10).unwrap().len(), 1);
|
||||
assert_eq!(search(&home, "lsregist*", None, 10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leere_anfrage_scheitert() {
|
||||
let home = archiv();
|
||||
assert!(search(&home, " ", None, 10).is_err());
|
||||
}
|
||||
|
||||
/// Eingaben, die roh durchgereicht einen FTS5-Syntaxfehler warfen. Die
|
||||
/// Bindestrich-Fälle sind die wichtigsten: Archiv-Dokumente heißen selbst so.
|
||||
/// Die angefangenen Phrasen stehen für die Tippzwischenstände, die die
|
||||
/// Live-Suche abschickt.
|
||||
#[test]
|
||||
fn sonderzeichen_werfen_keinen_syntaxfehler() {
|
||||
let home = archiv();
|
||||
let eingaben = [
|
||||
"ai-control",
|
||||
"adr-log",
|
||||
"TODO: fix",
|
||||
"C++",
|
||||
"wiki (",
|
||||
"\"",
|
||||
"\"Strukturiertes",
|
||||
"\"Strukturiertes Logging",
|
||||
"!?",
|
||||
];
|
||||
for q in eingaben {
|
||||
assert!(search(&home, q, None, 10).is_ok(), "Suche scheiterte an „{q}“");
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Tag-Filter lief früher an der Quoting-Regel vorbei. `#` allein ist der
|
||||
/// erste Tastendruck jeder Tag-Suche und kommt als leerer Tag an.
|
||||
#[test]
|
||||
fn leerer_tag_wirft_keinen_syntaxfehler() {
|
||||
let home = archiv();
|
||||
// Das angetippte `#`: kein Fehler-Toast, sondern schlicht kein Treffer.
|
||||
assert_eq!(search(&home, "", Some(""), 10).unwrap().len(), 0);
|
||||
assert_eq!(search(&home, "", Some("!?"), 10).unwrap().len(), 0);
|
||||
// Mit Volltext daneben zählt nur dieser, der leere Tag engt nichts ein.
|
||||
assert_eq!(search(&home, "tracing", Some(""), 10).unwrap().len(), 1);
|
||||
// Fehler bleibt allein die komplett leere Anfrage ohne jeden Tag.
|
||||
assert!(search(&home, "", None, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindestrich_wort_findet_dokument() {
|
||||
let home = archiv();
|
||||
let hits = search(&home, "adr-logging", None, 10).unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].title, "ADR Logging");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,13 +2,10 @@
|
||||
//! (injizierbares Home) und den `ApikeyStore`-Trait testbar; OS-Aufrufe
|
||||
//! laufen ausschließlich über crate::platform.
|
||||
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod archive_index;
|
||||
pub(crate) mod archive_ops;
|
||||
pub(crate) mod archive_search;
|
||||
pub(crate) mod credentials;
|
||||
pub(crate) mod modules;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod trilium;
|
||||
pub(crate) mod pool;
|
||||
pub(crate) mod project;
|
||||
pub(crate) mod registry;
|
||||
|
||||
@@ -36,36 +36,15 @@ pub(crate) struct ModuleDesc {
|
||||
pub(crate) core: bool,
|
||||
/// Aktiv ohne Eintrag in der Projekt-Config.
|
||||
pub(crate) default_enabled: bool,
|
||||
/// Modul setzt ein konfiguriertes Archiv-Home voraus — ohne Home fallen
|
||||
/// seine Tabs im Frontend weg. Seine MCP-Tools bleiben absichtlich
|
||||
/// gelistet: Das Home kann mitten in der Session gesetzt werden, und die
|
||||
/// Tools melden das fehlende Home selbst verständlich zurück.
|
||||
pub(crate) requires_archive: bool,
|
||||
pub(crate) mcp_tools: &'static [&'static str],
|
||||
pub(crate) buffers: &'static [BufferDesc],
|
||||
}
|
||||
|
||||
pub(crate) const MODULES: &[ModuleDesc] = &[
|
||||
ModuleDesc {
|
||||
id: "draft",
|
||||
core: true,
|
||||
default_enabled: true,
|
||||
requires_archive: false,
|
||||
mcp_tools: &["write_panel"],
|
||||
buffers: &[BufferDesc {
|
||||
id: "panel",
|
||||
env: "AI_CONTROL_PANEL",
|
||||
event: "panel-update",
|
||||
file: crate::domain::paths::panel_file,
|
||||
suffix: "md",
|
||||
persistent: false,
|
||||
}],
|
||||
},
|
||||
ModuleDesc {
|
||||
id: "commands",
|
||||
core: false,
|
||||
default_enabled: true,
|
||||
requires_archive: false,
|
||||
mcp_tools: &["write_commands", "show_commands"],
|
||||
buffers: &[BufferDesc {
|
||||
id: "commands",
|
||||
@@ -81,7 +60,6 @@ pub(crate) const MODULES: &[ModuleDesc] = &[
|
||||
core: false,
|
||||
// Opt-in: der Tab erscheint erst, wenn das Modul im Projekt gewählt ist.
|
||||
default_enabled: false,
|
||||
requires_archive: false,
|
||||
mcp_tools: &["write_todos", "show_todos"],
|
||||
buffers: &[BufferDesc {
|
||||
id: "todos",
|
||||
@@ -93,11 +71,14 @@ pub(crate) const MODULES: &[ModuleDesc] = &[
|
||||
}],
|
||||
},
|
||||
ModuleDesc {
|
||||
id: "archive",
|
||||
id: "trilium",
|
||||
core: false,
|
||||
default_enabled: true,
|
||||
requires_archive: true,
|
||||
mcp_tools: &["archive_panel", "show_archive", "search_archive"],
|
||||
// Opt-in: das Anwählen öffnet im Settings-Dialog den Einricht-Wizard;
|
||||
// das Modul-Flag schreibt erst der erfolgreiche Setup (Erst-nach-Erfolg).
|
||||
// Das Modul IST das Archiv: Archivieren und Volltextsuche laufen über
|
||||
// die ETAPI der projekteigenen Trilium-Instanz.
|
||||
default_enabled: false,
|
||||
mcp_tools: &["write_panel", "search_archive"],
|
||||
buffers: &[
|
||||
BufferDesc {
|
||||
id: "search",
|
||||
@@ -107,12 +88,14 @@ pub(crate) const MODULES: &[ModuleDesc] = &[
|
||||
suffix: "search.json",
|
||||
persistent: false,
|
||||
},
|
||||
// Signal-Kanal „Notiz öffnen": write_panel legt die Notiz per ETAPI an
|
||||
// und schreibt hier die noteId; die Fenster reagieren mit trilium_open.
|
||||
BufferDesc {
|
||||
id: "wiki",
|
||||
env: "AI_CONTROL_WIKI",
|
||||
event: "wiki-update",
|
||||
file: crate::domain::paths::wiki_file,
|
||||
suffix: "wiki.json",
|
||||
id: "noteopen",
|
||||
env: "AI_CONTROL_NOTEOPEN",
|
||||
event: "noteopen-update",
|
||||
file: crate::domain::paths::noteopen_file,
|
||||
suffix: "noteopen.json",
|
||||
persistent: false,
|
||||
},
|
||||
],
|
||||
@@ -142,8 +125,7 @@ pub(crate) fn by_tool(tool: &str) -> Option<&'static ModuleDesc> {
|
||||
}
|
||||
|
||||
/// Registry-Zeile für den Settings-Dialog: `enabled` ist der effektive
|
||||
/// Config-Schalter (ohne `requires_archive` — die Pfadfrage zeigt der
|
||||
/// Dialog daneben an).
|
||||
/// Config-Schalter.
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct ModuleInfo {
|
||||
pub(crate) id: &'static str,
|
||||
@@ -207,7 +189,7 @@ mod tests {
|
||||
create_project(&p, "proj").unwrap();
|
||||
assert_eq!(
|
||||
ids(&active_in(&p, "proj").unwrap()),
|
||||
vec!["draft", "commands", "archive"]
|
||||
vec!["commands"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,11 +204,10 @@ mod tests {
|
||||
.join("config.json");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
r#"{"id": "proj", "name": "proj", "modules": {"commands": false, "draft": false}}"#,
|
||||
r#"{"id": "proj", "name": "proj", "modules": {"commands": false}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// commands ist ab; draft ignoriert den Eintrag (Kern-Modul).
|
||||
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
|
||||
assert!(active_in(&p, "proj").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -242,7 +223,7 @@ mod tests {
|
||||
set_module_in(&p, "proj", "todo", true).unwrap();
|
||||
assert_eq!(
|
||||
ids(&active_in(&p, "proj").unwrap()),
|
||||
vec!["draft", "commands", "todo", "archive"]
|
||||
vec!["commands", "todo"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,21 +241,20 @@ mod tests {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
set_module_in(&p, "proj", "commands", false).unwrap();
|
||||
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
|
||||
assert!(!module_infos_in(&p, "proj").unwrap()[1].enabled);
|
||||
assert!(active_in(&p, "proj").unwrap().is_empty());
|
||||
assert!(!module_infos_in(&p, "proj").unwrap()[0].enabled);
|
||||
// Zurück auf den Default löscht den Eintrag statt `true` zu speichern.
|
||||
set_module_in(&p, "proj", "commands", true).unwrap();
|
||||
let cfg = crate::domain::project::read_project_config_in(&p, "proj").unwrap();
|
||||
assert!(cfg.modules.is_empty());
|
||||
// Kern-Modul und unbekannte ID scheitern laut.
|
||||
assert!(set_module_in(&p, "proj", "draft", false).is_err());
|
||||
// Unbekannte ID scheitert laut.
|
||||
assert!(set_module_in(&p, "proj", "gibtsnicht", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_zuordnung() {
|
||||
assert_eq!(by_tool("write_panel").unwrap().id, "draft");
|
||||
assert_eq!(by_tool("search_archive").unwrap().id, "archive");
|
||||
assert_eq!(by_tool("write_panel").unwrap().id, "trilium");
|
||||
assert_eq!(by_tool("search_archive").unwrap().id, "trilium");
|
||||
assert!(by_tool("unbekannt").is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -65,22 +65,13 @@ fn checked(project: &str) -> &str {
|
||||
project
|
||||
}
|
||||
|
||||
/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
|
||||
/// AI_CONTROL_PANEL in der PTY-Umgebung.
|
||||
pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||
Paths::real().panels_dir().join(format!("{}.md", checked(project)))
|
||||
}
|
||||
|
||||
/// Quell-Verknüpfung des Panel-Inhalts: absoluter Pfad des Archiv-Dokuments,
|
||||
/// aus dem der Dokument-Tab gerade geladen ist. Solange sie existiert,
|
||||
/// schreibt jeder Editor-Commit den Body dorthin zurück; ein frischer Entwurf
|
||||
/// (write_panel, Session-Start) entfernt sie. Liegt neben der Panel-Datei als
|
||||
/// `<panel>.source`, damit der MCP-Server sie aus AI_CONTROL_PANEL ableiten
|
||||
/// kann.
|
||||
pub(crate) fn panel_source_file(project: &str) -> PathBuf {
|
||||
/// Signal-Datei „Notiz öffnen": write_panel (MCP) schreibt die noteId der
|
||||
/// frisch angelegten Trilium-Notiz, die Fenster öffnen sie daraufhin. Der
|
||||
/// Pfad landet als AI_CONTROL_NOTEOPEN in der PTY-Umgebung.
|
||||
pub(crate) fn noteopen_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{}.md.source", checked(project)))
|
||||
.join(format!("{}.noteopen.json", checked(project)))
|
||||
}
|
||||
|
||||
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
|
||||
@@ -101,15 +92,6 @@ pub(crate) fn search_file(project: &str) -> PathBuf {
|
||||
.join(format!("{}.search.json", checked(project)))
|
||||
}
|
||||
|
||||
/// Wiki-Puffer eines Projekts (JSON, jeweils letzte Wiki-Seite bzw. letztes
|
||||
/// geöffnetes Dokument — flüchtig, wird beim Session-Start geleert). Der Pfad
|
||||
/// landet als AI_CONTROL_WIKI in der PTY-Umgebung.
|
||||
pub(crate) fn wiki_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{}.wiki.json", checked(project)))
|
||||
}
|
||||
|
||||
/// Persistente ToDo-Liste eines Projekts (JSONL, anhängend — überlebt
|
||||
/// Sessions; write_todos hängt an, Kachel-Löschen entfernt Zeilen). Der Pfad
|
||||
/// landet als AI_CONTROL_TODOS in der PTY-Umgebung.
|
||||
|
||||
@@ -38,6 +38,8 @@ pub(crate) struct Project {
|
||||
pub(crate) pool: Option<String>,
|
||||
pub(crate) running: bool,
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
/// Trilium-Modul aktiv — die Projektliste zeigt dann den Archiv-Knopf.
|
||||
pub(crate) archive: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
@@ -51,16 +53,6 @@ pub(crate) struct ProjectConfig {
|
||||
pub(crate) name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
/// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen
|
||||
/// (~-relativ gespeichert). Liest den früheren Key `archiveDir` weiterhin ein,
|
||||
/// geschrieben wird nur noch `archiveHome`.
|
||||
#[serde(
|
||||
default,
|
||||
rename = "archiveHome",
|
||||
alias = "archiveDir",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub(crate) archive_home: Option<String>,
|
||||
/// Modul-Abweichungen vom Default (`"commands": false`); fehlender Key =
|
||||
/// Default aus der Registry (domain/modules.rs). Kern-Module ignorieren
|
||||
/// den Eintrag.
|
||||
@@ -174,6 +166,7 @@ pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||||
path: contract_home(paths, &entry.dir),
|
||||
pool: entry.pool,
|
||||
name: cfg.name.unwrap_or_else(|| id.clone()),
|
||||
archive: cfg.modules.get("trilium").copied().unwrap_or(false),
|
||||
terminal: cfg.terminal,
|
||||
id,
|
||||
});
|
||||
@@ -314,7 +307,6 @@ pub(crate) fn create_project_full_in(
|
||||
id: Some(id.clone()),
|
||||
name: Some(name.to_string()),
|
||||
terminal,
|
||||
archive_home: None,
|
||||
modules: Default::default(),
|
||||
rest: Default::default(),
|
||||
};
|
||||
@@ -403,11 +395,6 @@ pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
|
||||
let wd_path = expand_home(paths, &wd);
|
||||
fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
|
||||
}
|
||||
if let Some(archive) = cfg.archive_home.as_deref() {
|
||||
let expanded = expand_home(paths, archive);
|
||||
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
|
||||
crate::domain::archive::add_archive_permission(paths, &id, archive)?;
|
||||
}
|
||||
crate::platform::write_terminal_desktop(paths, &id, &cfg);
|
||||
Ok(())
|
||||
}
|
||||
@@ -484,10 +471,10 @@ pub(crate) struct DeletePreview {
|
||||
pub(crate) name: String,
|
||||
pub(crate) project_dir: String,
|
||||
pub(crate) ai_control_dir: bool,
|
||||
pub(crate) archive_permission: bool,
|
||||
pub(crate) panel_files: usize,
|
||||
pub(crate) archive_home: Option<String>,
|
||||
pub(crate) archive_docs: usize,
|
||||
/// Trilium-Datenverzeichnis der Projekt-Instanz (~-relativ), sofern es
|
||||
/// existiert — das Archiv der Lösch-Stufe "archive".
|
||||
pub(crate) trilium_data: Option<String>,
|
||||
pub(crate) work_dirs: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -495,25 +482,13 @@ pub(crate) fn delete_preview_in(paths: &Paths, project: &str) -> Result<DeletePr
|
||||
check_name(project)?;
|
||||
let dir = project_dir(paths, project)?;
|
||||
let cfg = read_project_config_in(paths, project)?;
|
||||
let archive_docs = match cfg.archive_home.as_deref() {
|
||||
Some(a) => {
|
||||
let home = expand_home(paths, a);
|
||||
if home.is_dir() {
|
||||
crate::domain::archive_index::scan_archive(&home)?.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
let tri = crate::domain::trilium::data_dir(paths, project);
|
||||
Ok(DeletePreview {
|
||||
name: cfg.name.clone().unwrap_or_else(|| project.to_string()),
|
||||
project_dir: contract_home(paths, &dir),
|
||||
ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_dir(),
|
||||
archive_permission: cfg.archive_home.is_some(),
|
||||
panel_files: session_files(paths, project).iter().filter(|f| f.is_file()).count(),
|
||||
archive_home: cfg.archive_home.clone(),
|
||||
archive_docs,
|
||||
trilium_data: tri.is_dir().then(|| contract_home(paths, &tri)),
|
||||
work_dirs: project_work_dirs_in(paths, project)?,
|
||||
})
|
||||
}
|
||||
@@ -533,9 +508,10 @@ fn session_files(paths: &Paths, project: &str) -> Vec<PathBuf> {
|
||||
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
|
||||
/// vorige ein):
|
||||
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
|
||||
/// Archiv-Rechte in der settings.json, Panel-Dateien, .desktop. Ordner,
|
||||
/// memory/ und das Claude-Code-Grundgerüst der settings.json bleiben.
|
||||
/// - "archive": zusätzlich der Archiv-Ordner samt Dokumenten.
|
||||
/// Panel-Dateien, .desktop. Ordner, memory/ und das Claude-Code-Grundgerüst
|
||||
/// der settings.json bleiben.
|
||||
/// - "archive": zusätzlich das Trilium-Archiv der Projekt-Instanz
|
||||
/// (Datenverzeichnis + Keyring-Token; die Instanz wird vorher gestoppt).
|
||||
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
|
||||
pub(crate) fn delete_project_scoped_in(
|
||||
paths: &Paths,
|
||||
@@ -548,24 +524,17 @@ pub(crate) fn delete_project_scoped_in(
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Projektordner nicht gefunden: {}", dir.display()));
|
||||
}
|
||||
let cfg = read_project_config_in(paths, project)?;
|
||||
|
||||
// Archiv vor der Integration räumen — Stufe 1 löscht die Config, die den
|
||||
// Pfad kennt.
|
||||
if scope == "archive" || scope == "full" {
|
||||
if let Some(a) = cfg.archive_home.as_deref() {
|
||||
let home = expand_home(paths, a);
|
||||
if home.is_dir() {
|
||||
fs::remove_dir_all(&home).map_err(|e| format!("{}: {e}", home.display()))?;
|
||||
}
|
||||
crate::domain::trilium::stop_instance(project);
|
||||
let tri = crate::domain::trilium::data_dir(paths, project);
|
||||
if tri.is_dir() {
|
||||
fs::remove_dir_all(&tri).map_err(|e| format!("{}: {e}", tri.display()))?;
|
||||
}
|
||||
crate::domain::trilium::delete_token(project);
|
||||
}
|
||||
|
||||
match scope {
|
||||
"integration" | "archive" => {
|
||||
if let Some(a) = cfg.archive_home.as_deref() {
|
||||
crate::domain::archive::remove_archive_permission(paths, project, a)?;
|
||||
}
|
||||
let ac = dir.join(PROJECT_CONFIG_DIR);
|
||||
if ac.is_dir() {
|
||||
fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?;
|
||||
@@ -664,7 +633,7 @@ pub(crate) fn resolve_icon_path(
|
||||
|
||||
/// Liest eine Datei für `write_panel(path)` — den promptfreien MCP-Weg.
|
||||
/// Erlaubt ist genau, was die Session über ihre Permissions ohnehin promptfrei
|
||||
/// liest: Projektordner, Arbeitsordner und Archiv-Home. Geprüft wird nach
|
||||
/// liest: Projektordner und Arbeitsordner. Geprüft wird nach
|
||||
/// `canonicalize` (symlink-fest), nur reguläre Dateien bis 2 MB — sonst
|
||||
/// umginge das Tool das Read-Permission-Modell von Claude Code, und ein
|
||||
/// Device wie /dev/zero hinge den Prozess.
|
||||
@@ -679,16 +648,13 @@ pub(crate) fn read_for_panel_in(
|
||||
for d in project_work_dirs_in(paths, project)? {
|
||||
roots.push(expand_home(paths, &d));
|
||||
}
|
||||
if let Some(a) = read_project_config_in(paths, project)?.archive_home {
|
||||
roots.push(expand_home(paths, &a));
|
||||
}
|
||||
let allowed = roots
|
||||
.iter()
|
||||
.filter_map(|r| fs::canonicalize(r).ok())
|
||||
.any(|r| canon.starts_with(&r));
|
||||
if !allowed {
|
||||
return Err(format!(
|
||||
"{src}: liegt außerhalb von Projekt-, Arbeits- und Archiv-Ordnern"
|
||||
"{src}: liegt außerhalb von Projekt- und Arbeitsordnern"
|
||||
));
|
||||
}
|
||||
let f = fs::File::open(&canon).map_err(|e| format!("{src}: {e}"))?;
|
||||
@@ -1088,7 +1054,8 @@ mod tests {
|
||||
let cfg = read_project_config_in(&p, &id).unwrap();
|
||||
assert_eq!(cfg.id.as_deref(), Some(id.as_str()));
|
||||
assert_eq!(cfg.name.as_deref(), Some("proj"));
|
||||
assert_eq!(cfg.archive_home.as_deref(), Some("~/archiv"));
|
||||
// Unbekannte Alt-Keys (archiveHome) wandern unverändert mit.
|
||||
assert_eq!(cfg.rest["archiveHome"], "~/archiv");
|
||||
// Icon liegt jetzt im Projekt, das zentrale Verzeichnis ist geräumt.
|
||||
assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png"));
|
||||
assert!(dir.join(PROJECT_CONFIG_DIR).join("icon.png").is_file());
|
||||
@@ -1143,8 +1110,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Import eines Ordners mit mitgebrachter Config: die ID wird übernommen
|
||||
/// (Sync-Fall), Arbeitsordner und Archiv-Home werden angelegt, die
|
||||
/// Archiv-Permission nachgezogen.
|
||||
/// (Sync-Fall), Arbeitsordner werden angelegt.
|
||||
#[test]
|
||||
fn projekt_import_uebernimmt_id_und_legt_pfade_an() {
|
||||
let p = tmp_paths();
|
||||
@@ -1152,7 +1118,7 @@ mod tests {
|
||||
fs::create_dir_all(dir.join(PROJECT_CONFIG_DIR)).unwrap();
|
||||
fs::write(
|
||||
dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE),
|
||||
r#"{"id": "sync-id", "name": "Mein Projekt", "archiveHome": "~/archiv/proj"}"#,
|
||||
r#"{"id": "sync-id", "name": "Mein Projekt"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(dir.join(".claude")).unwrap();
|
||||
@@ -1167,10 +1133,6 @@ mod tests {
|
||||
assert_eq!(project_dir(&p, "sync-id").unwrap(), dir);
|
||||
assert_eq!(display_name_in(&p, "sync-id").unwrap(), "Mein Projekt");
|
||||
assert!(p.home.join("work/proj").is_dir());
|
||||
assert!(p.home.join("archiv/proj").is_dir());
|
||||
let raw = fs::read_to_string(settings_path(&dir)).unwrap();
|
||||
assert!(raw.contains("~/archiv/proj"));
|
||||
assert!(raw.contains("Edit(~/archiv/proj/**)"));
|
||||
|
||||
// Doppelt aufnehmen scheitert an der ID.
|
||||
let err = add_project_in(&p, &dir.display().to_string()).unwrap_err();
|
||||
@@ -1300,7 +1262,7 @@ mod tests {
|
||||
// -- write_panel(path): promptfreier Lese-Weg --
|
||||
|
||||
#[test]
|
||||
fn panel_lesen_nur_aus_projekt_arbeits_und_archivordnern() {
|
||||
fn panel_lesen_nur_aus_projekt_und_arbeitsordnern() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
let dir = p.projects_dir().join("proj");
|
||||
@@ -1339,49 +1301,45 @@ mod tests {
|
||||
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
|
||||
.unwrap();
|
||||
let dir = p.projects_dir().join("proj");
|
||||
// Archiv-Home samt Permission wie über die UI gesetzt
|
||||
let mut cfg = read_project_config_in(&p, &id).unwrap();
|
||||
cfg.archive_home = Some("~/archiv/proj".into());
|
||||
write_project_config_in(&p, &id, &cfg).unwrap();
|
||||
crate::domain::archive::add_archive_permission(&p, &id, "~/archiv/proj").unwrap();
|
||||
fs::create_dir_all(p.home.join("archiv/proj")).unwrap();
|
||||
fs::write(p.home.join("archiv/proj/doc.md"), "inhalt").unwrap();
|
||||
// flüchtige Panel-Datei
|
||||
// flüchtige Panel-Datei + Trilium-Datenverzeichnis
|
||||
fs::create_dir_all(p.config_dir().join("panels")).unwrap();
|
||||
fs::write(p.config_dir().join("panels").join(format!("{id}.md")), "x").unwrap();
|
||||
fs::write(
|
||||
p.config_dir().join("panels").join(format!("{id}.commands.jsonl")),
|
||||
"x",
|
||||
)
|
||||
.unwrap();
|
||||
let tri = crate::domain::trilium::data_dir(&p, &id);
|
||||
fs::create_dir_all(&tri).unwrap();
|
||||
|
||||
let preview = delete_preview_in(&p, &id).unwrap();
|
||||
assert!(preview.ai_control_dir && preview.archive_permission);
|
||||
assert!(preview.ai_control_dir);
|
||||
assert_eq!(preview.panel_files, 1);
|
||||
assert_eq!(preview.archive_docs, 1);
|
||||
assert!(preview.trilium_data.is_some());
|
||||
|
||||
delete_project_scoped_in(&p, &id, "integration", false).unwrap();
|
||||
|
||||
assert!(dir.is_dir());
|
||||
assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
|
||||
let sp = fs::read_to_string(settings_path(&dir)).unwrap();
|
||||
assert!(!sp.contains("archiv/proj"));
|
||||
assert!(sp.contains("autoMemoryDirectory")); // Claude-Code-Bestand bleibt
|
||||
assert!(p.home.join("archiv/proj/doc.md").is_file()); // Archiv bleibt
|
||||
assert!(!p.config_dir().join("panels").join(format!("{id}.md")).exists());
|
||||
assert!(tri.is_dir()); // Archiv bleibt
|
||||
assert!(!p.config_dir().join("panels").join(format!("{id}.commands.jsonl")).exists());
|
||||
assert!(load_registry(&p).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Stufe „Integration & Archiv": zusätzlich stirbt der Archiv-Ordner.
|
||||
/// Stufe „Integration & Archiv": zusätzlich stirbt das Trilium-Datadir.
|
||||
#[test]
|
||||
fn loeschen_integration_und_archiv() {
|
||||
let p = tmp_paths();
|
||||
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
|
||||
.unwrap();
|
||||
let mut cfg = read_project_config_in(&p, &id).unwrap();
|
||||
cfg.archive_home = Some("~/archiv/proj".into());
|
||||
write_project_config_in(&p, &id, &cfg).unwrap();
|
||||
fs::create_dir_all(p.home.join("archiv/proj")).unwrap();
|
||||
let tri = crate::domain::trilium::data_dir(&p, &id);
|
||||
fs::create_dir_all(&tri).unwrap();
|
||||
|
||||
delete_project_scoped_in(&p, &id, "archive", false).unwrap();
|
||||
|
||||
assert!(p.projects_dir().join("proj").is_dir());
|
||||
assert!(!p.home.join("archiv/proj").exists());
|
||||
assert!(!tri.exists());
|
||||
assert!(load_registry(&p).unwrap().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen):
|
||||
//! claudeCommand, syncOnSessionEnd, poolSyncDir, terminalFontSize, spellcheckLang.
|
||||
//! claudeCommand, syncOnSessionEnd, poolSyncDir, terminalFontSize.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -53,14 +53,6 @@ pub(crate) fn set_terminal_font_size_in(paths: &Paths, size: u32) -> Result<(),
|
||||
write_app_setting(paths, "terminalFontSize", serde_json::json!(size))
|
||||
}
|
||||
|
||||
/// Standard-Sprache der Rechtschreibprüfung (settings.json: spellcheckLang),
|
||||
/// Default „de". Pro Text im Panel überschreibbar.
|
||||
pub(crate) fn spellcheck_lang(paths: &Paths) -> String {
|
||||
read_app_settings(paths)
|
||||
.and_then(|v| v["spellcheckLang"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "de".to_string())
|
||||
}
|
||||
|
||||
/// Hat der Nutzer das Angebot, die App nach ~/Applications zu holen, schon
|
||||
/// abgelehnt? (settings.json: moveOfferDismissed, nur macOS)
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
//! Trilium-Integration: das Archiv eines Projekts ist eine eigene, von
|
||||
//! ai-control verwaltete Trilium-Instanz — Archivieren und Volltextsuche
|
||||
//! laufen über die dokumentierte ETAPI, die Oberfläche kommt aus dem lokalen
|
||||
//! Server-Prozess und wird in einem eigenen Tauri-Fenster angezeigt.
|
||||
//!
|
||||
//! Verwaltung komplett bei uns: versionsgepinnter Runtime-Download (Checksum),
|
||||
//! ein Server-Prozess pro Projekt mit eigenem Datenverzeichnis und
|
||||
//! deterministischem Port (nur 127.0.0.1) — parallele Projekte und ein
|
||||
//! privates Trilium des Nutzers kollidieren nicht. Es werden ausschließlich
|
||||
//! dokumentierte Schnittstellen benutzt: TRILIUM_DATA_DIR, TRILIUM_PORT,
|
||||
//! ETAPI (Token über /etapi/auth/login).
|
||||
//!
|
||||
//! Erst-nach-Erfolg-Muster: Config-Einträge (Modul-Flag) schreibt der Wizard
|
||||
//! erst, wenn Setup und Token-Login durch sind; Datei-Arbeit rollt bei
|
||||
//! Fehlern zurück.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::domain::paths::Paths;
|
||||
|
||||
/// Gepinntes Release; Update = Konstanten anheben (der Update-Check beim
|
||||
/// App-Start vergleicht gegen diese Version, nicht gegen GitHub).
|
||||
pub(crate) const VERSION: &str = "0.104.0";
|
||||
|
||||
/// Download-URL und SHA-256 des Server-Builds je Plattform. macOS/Windows
|
||||
/// folgen, sobald unsere Pipeline eigene Server-Bundles baut (offizielle
|
||||
/// Server-Builds gibt es nur für Linux).
|
||||
#[cfg(target_os = "linux")]
|
||||
const ASSET: (&str, &str) = (
|
||||
"https://github.com/TriliumNext/Trilium/releases/download/v0.104.0/TriliumNotes-Server-v0.104.0-linux-x64.tar.xz",
|
||||
"96b64bd1cf628ef8d851094de813750611d1d8a69c146ea1e8acc896420a2c69",
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
const ASSET: (&str, &str) = ("", "");
|
||||
|
||||
/// Wurzel der Trilium-Verwaltung: Runtime und Projekt-Datenverzeichnisse.
|
||||
pub(crate) fn trilium_root(paths: &Paths) -> PathBuf {
|
||||
paths.config_dir().join("trilium")
|
||||
}
|
||||
|
||||
/// Entpackte Runtime der gepinnten Version.
|
||||
pub(crate) fn runtime_dir(paths: &Paths) -> PathBuf {
|
||||
trilium_root(paths).join(format!("runtime-{VERSION}"))
|
||||
}
|
||||
|
||||
fn runtime_binary(paths: &Paths) -> PathBuf {
|
||||
runtime_dir(paths).join("node").join("bin").join("node")
|
||||
}
|
||||
|
||||
fn runtime_main(paths: &Paths) -> PathBuf {
|
||||
runtime_dir(paths).join("main.cjs")
|
||||
}
|
||||
|
||||
/// Datenverzeichnis der Projekt-Instanz (TRILIUM_DATA_DIR) — maschinenlokal,
|
||||
/// unter der Projekt-UUID.
|
||||
pub(crate) fn data_dir(paths: &Paths, project: &str) -> PathBuf {
|
||||
// Zweite Linie wie bei den Panel-Kanälen: eine Projekt-ID mit
|
||||
// Pfad-Bestandteilen bricht laut ab, statt einen Pfad zu bilden.
|
||||
crate::domain::check_name(project).unwrap();
|
||||
trilium_root(paths).join("data").join(project)
|
||||
}
|
||||
|
||||
/// Deterministischer Port pro Projekt (FNV-1a über die Projekt-ID) im
|
||||
/// Bereich 27200–27981 — dasselbe Schema wie die übrigen Projekt-Ports.
|
||||
pub(crate) fn port_for(project: &str) -> u16 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in project.bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
27200 + (h % 782) as u16
|
||||
}
|
||||
|
||||
pub(crate) fn base_url(project: &str) -> String {
|
||||
format!("http://127.0.0.1:{}", port_for(project))
|
||||
}
|
||||
|
||||
/// Lädt und entpackt die gepinnte Runtime, falls sie fehlt. Download in eine
|
||||
/// Temp-Datei, SHA-256-Prüfung, Entpacken neben das Ziel, atomares Umbenennen
|
||||
/// — ein Abbruch lässt keinen halben Runtime-Ordner zurück.
|
||||
pub(crate) fn ensure_runtime(paths: &Paths) -> Result<(), String> {
|
||||
if runtime_binary(paths).is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let (url, sha) = ASSET;
|
||||
if url.is_empty() {
|
||||
return Err("Trilium-Runtime für dieses Betriebssystem noch nicht verfügbar".into());
|
||||
}
|
||||
let root = trilium_root(paths);
|
||||
fs::create_dir_all(&root).map_err(|e| format!("{}: {e}", root.display()))?;
|
||||
let tarball = root.join(format!("download-{VERSION}.tar.xz"));
|
||||
|
||||
let status = Command::new("curl")
|
||||
.args(["-fsSL", "--retry", "2", "-o"])
|
||||
.arg(&tarball)
|
||||
.arg(url)
|
||||
.status()
|
||||
.map_err(|e| format!("curl: {e}"))?;
|
||||
if !status.success() {
|
||||
let _ = fs::remove_file(&tarball);
|
||||
return Err(format!("Download fehlgeschlagen ({status}): {url}"));
|
||||
}
|
||||
|
||||
let bytes = fs::read(&tarball).map_err(|e| format!("{}: {e}", tarball.display()))?;
|
||||
let digest = {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(&bytes);
|
||||
format!("{:x}", h.finalize())
|
||||
};
|
||||
if digest != sha {
|
||||
let _ = fs::remove_file(&tarball);
|
||||
return Err(format!("Checksum-Abweichung beim Trilium-Download: {digest} statt {sha}"));
|
||||
}
|
||||
|
||||
// Ins Temp-Verzeichnis entpacken, dann den inneren Ordner atomar an den
|
||||
// Zielnamen heben.
|
||||
let unpack = root.join(format!("unpack-{VERSION}"));
|
||||
let _ = fs::remove_dir_all(&unpack);
|
||||
fs::create_dir_all(&unpack).map_err(|e| format!("{}: {e}", unpack.display()))?;
|
||||
let status = Command::new("tar")
|
||||
.arg("-xJf")
|
||||
.arg(&tarball)
|
||||
.arg("-C")
|
||||
.arg(&unpack)
|
||||
.arg("--strip-components=1")
|
||||
.status()
|
||||
.map_err(|e| format!("tar: {e}"))?;
|
||||
let _ = fs::remove_file(&tarball);
|
||||
if !status.success() {
|
||||
let _ = fs::remove_dir_all(&unpack);
|
||||
return Err(format!("Entpacken fehlgeschlagen ({status})"));
|
||||
}
|
||||
fs::rename(&unpack, runtime_dir(paths))
|
||||
.map_err(|e| format!("{}: {e}", runtime_dir(paths).display()))
|
||||
}
|
||||
|
||||
/// Laufende Projekt-Instanzen dieses App-Prozesses.
|
||||
static INSTANCES: Mutex<Option<HashMap<String, Child>>> = Mutex::new(None);
|
||||
|
||||
/// Startet die Projekt-Instanz, falls sie nicht läuft, und wartet bis der
|
||||
/// Port antwortet. Läuft der Port schon (z. B. Instanz aus einem früheren
|
||||
/// App-Lauf), wird nichts gestartet.
|
||||
pub(crate) fn ensure_instance(paths: &Paths, project: &str) -> Result<(), String> {
|
||||
if http_ok(project, "/", 1) {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_runtime(paths)?;
|
||||
let dd = data_dir(paths, project);
|
||||
fs::create_dir_all(&dd).map_err(|e| format!("{}: {e}", dd.display()))?;
|
||||
|
||||
let mut guard = INSTANCES.lock().unwrap();
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
// Toter Eintrag (Prozess beendet) fliegt raus, bevor neu gestartet wird.
|
||||
if let Some(child) = map.get_mut(project) {
|
||||
match child.try_wait() {
|
||||
Ok(None) => {}
|
||||
_ => {
|
||||
map.remove(project);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !map.contains_key(project) {
|
||||
let child = Command::new(runtime_binary(paths))
|
||||
.arg(runtime_main(paths))
|
||||
.current_dir(runtime_dir(paths))
|
||||
.env("TRILIUM_DATA_DIR", &dd)
|
||||
.env("TRILIUM_PORT", port_for(project).to_string())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Trilium-Start: {e}"))?;
|
||||
map.insert(project.to_string(), child);
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
if http_ok(project, "/", 20) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Trilium antwortet nicht auf Port {}", port_for(project)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Beendet die Projekt-Instanz dieses App-Prozesses (falls von uns gestartet).
|
||||
pub(crate) fn stop_instance(project: &str) {
|
||||
let mut guard = INSTANCES.lock().unwrap();
|
||||
if let Some(map) = guard.as_mut() {
|
||||
if let Some(mut child) = map.remove(project) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Beendet alle von uns gestarteten Instanzen (App-Ende).
|
||||
pub(crate) fn stop_all() {
|
||||
let mut guard = INSTANCES.lock().unwrap();
|
||||
if let Some(map) = guard.as_mut() {
|
||||
for (_, mut child) in map.drain() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wartet bis zu `tries` Sekunden darauf, dass GET auf `path` antwortet.
|
||||
fn http_ok(project: &str, path: &str, tries: u32) -> bool {
|
||||
for i in 0..tries {
|
||||
if i > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
let ok = Command::new("curl")
|
||||
.args(["-fsS", "-m", "2", "-o", "/dev/null"])
|
||||
.arg(format!("{}{}", base_url(project), path))
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Ist die Datenbank der Instanz schon initialisiert? (Vor dem ersten Setup
|
||||
/// liefert Trilium die Setup-Seite aus; der dokumentierte ETAPI-Login
|
||||
/// existiert erst danach sinnvoll.) Erkennung über die App-Info der ETAPI:
|
||||
/// 401 = initialisiert (Auth nötig), Verbindung da; die Setup-Seite selbst
|
||||
/// beantwortet /etapi/… nicht mit 401.
|
||||
pub(crate) fn etapi(
|
||||
project: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<&Value>,
|
||||
) -> Result<(u16, Value), String> {
|
||||
let mut cmd = Command::new("curl");
|
||||
cmd.args(["-sS", "-m", "10", "-X", method, "-w", "\n%{http_code}"]);
|
||||
if let Some(token) = token {
|
||||
cmd.arg("-H").arg(format!("Authorization: {token}"));
|
||||
}
|
||||
if let Some(body) = body {
|
||||
cmd.args(["-H", "Content-Type: application/json", "--data-binary", &body.to_string()]);
|
||||
}
|
||||
cmd.arg(format!("{}{}", base_url(project), path));
|
||||
let out = cmd.output().map_err(|e| format!("curl: {e}"))?;
|
||||
if !out.status.success() && out.stdout.is_empty() {
|
||||
return Err(format!("ETAPI {path}: {}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let (payload, code) = text.rsplit_once('\n').ok_or_else(|| format!("ETAPI {path}: leere Antwort"))?;
|
||||
let code: u16 = code.trim().parse().map_err(|_| format!("ETAPI {path}: Statuszeile fehlt"))?;
|
||||
let value = if payload.trim().is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_str(payload).unwrap_or(Value::String(payload.to_string()))
|
||||
};
|
||||
Ok((code, value))
|
||||
}
|
||||
|
||||
/// Ist die Datenbank der Instanz schon initialisiert? (GET /api/setup/status,
|
||||
/// erreichbar ohne Auth.)
|
||||
pub(crate) fn initialized(project: &str) -> Result<bool, String> {
|
||||
let (code, value) = etapi(project, "GET", "/api/setup/status", None, None)?;
|
||||
if code != 200 {
|
||||
return Err(format!("Setup-Status fehlgeschlagen (HTTP {code})"));
|
||||
}
|
||||
Ok(value["isInitialized"].as_bool() == Some(true))
|
||||
}
|
||||
|
||||
/// Legt das Initial-Dokument an (ohne Demo-Notizen). Die Route ist nur
|
||||
/// erreichbar, solange die Instanz uninitialisiert ist, und antwortet erst
|
||||
/// nach abgeschlossener Datenbank-Anlage.
|
||||
pub(crate) fn setup_new_document(project: &str) -> Result<(), String> {
|
||||
let code = http_post(project, "/api/setup/new-document?skipDemoDb", None, 120)?;
|
||||
if code >= 300 {
|
||||
return Err(format!("Dokument-Anlage fehlgeschlagen (HTTP {code})"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt das Passwort der frischen Instanz (Route nur erreichbar, solange
|
||||
/// keins gesetzt ist); Trilium antwortet mit Redirect auf die Login-Seite.
|
||||
pub(crate) fn set_password(project: &str, password: &str) -> Result<(), String> {
|
||||
let form = format!("password1={0}&password2={0}", uri_encode(password));
|
||||
let code = http_post(project, "/set-password", Some(&form), 30)?;
|
||||
if code != 302 {
|
||||
return Err(format!("Passwort setzen fehlgeschlagen (HTTP {code})"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Formular-/Setup-POST außerhalb der ETAPI; liefert den Statuscode.
|
||||
fn http_post(
|
||||
project: &str,
|
||||
path: &str,
|
||||
form: Option<&str>,
|
||||
timeout_s: u32,
|
||||
) -> Result<u16, String> {
|
||||
let mut cmd = Command::new("curl");
|
||||
cmd.args(["-sS", "-m", &timeout_s.to_string(), "-o", "/dev/null", "-w", "%{http_code}", "-X", "POST"]);
|
||||
if let Some(form) = form {
|
||||
cmd.args(["--data", form]);
|
||||
}
|
||||
cmd.arg(format!("{}{}", base_url(project), path));
|
||||
let out = cmd.output().map_err(|e| format!("curl: {e}"))?;
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("POST {path}: {}", String::from_utf8_lossy(&out.stderr)))
|
||||
}
|
||||
|
||||
/// Holt per dokumentiertem Login einen ETAPI-Token (Passwort kommt aus dem
|
||||
/// Wizard und wird nicht gespeichert).
|
||||
pub(crate) fn login(project: &str, password: &str) -> Result<String, String> {
|
||||
let (code, value) = etapi(
|
||||
project,
|
||||
"POST",
|
||||
"/etapi/auth/login",
|
||||
None,
|
||||
Some(&json!({ "password": password })),
|
||||
)?;
|
||||
if code != 201 {
|
||||
return Err(format!("ETAPI-Login fehlgeschlagen (HTTP {code})"));
|
||||
}
|
||||
value["authToken"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| "ETAPI-Login ohne authToken".into())
|
||||
}
|
||||
|
||||
/// Keyring-Ablage des ETAPI-Tokens (Service wie die übrigen ai-control-Keys).
|
||||
const TOKEN_SERVICE: &str = "ai-control-trilium";
|
||||
|
||||
pub(crate) fn store_token(project: &str, token: &str) -> Result<(), String> {
|
||||
keyring::Entry::new(TOKEN_SERVICE, project)
|
||||
.and_then(|e| e.set_password(token))
|
||||
.map_err(|e| format!("Keyring: {e}"))
|
||||
}
|
||||
|
||||
pub(crate) fn read_token(project: &str) -> Result<String, String> {
|
||||
keyring::Entry::new(TOKEN_SERVICE, project)
|
||||
.and_then(|e| e.get_password())
|
||||
.map_err(|e| format!("Keyring: {e}"))
|
||||
}
|
||||
|
||||
pub(crate) fn delete_token(project: &str) {
|
||||
if let Ok(entry) = keyring::Entry::new(TOKEN_SERVICE, project) {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-Encoding für Query-Parameter (ETAPI-Suche).
|
||||
pub(crate) fn uri_encode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Runtime der gepinnten Version liegt entpackt da.
|
||||
pub(crate) fn runtime_ready(paths: &Paths) -> bool {
|
||||
runtime_binary(paths).is_file()
|
||||
}
|
||||
|
||||
/// Antwortet die Projekt-Instanz gerade? (Ein Versuch, ohne Warten.)
|
||||
pub(crate) fn instance_running(project: &str) -> bool {
|
||||
http_ok(project, "/", 1)
|
||||
}
|
||||
|
||||
/// Prüft den gespeicherten Token gegen die Instanz (GET /etapi/app-info).
|
||||
pub(crate) fn token_ok(project: &str) -> bool {
|
||||
let Ok(token) = read_token(project) else { return false };
|
||||
matches!(etapi(project, "GET", "/etapi/app-info", Some(&token), None), Ok((200, _)))
|
||||
}
|
||||
|
||||
/// Findet unter `parent` das Kind mit diesem Titel oder legt es als
|
||||
/// Buch-Notiz an (Triliums Ordner-Äquivalent: Notiz mit Kindern).
|
||||
fn find_or_create_child(
|
||||
project: &str,
|
||||
token: &str,
|
||||
parent: &str,
|
||||
title: &str,
|
||||
) -> Result<String, String> {
|
||||
let (code, parent_note) =
|
||||
etapi(project, "GET", &format!("/etapi/notes/{parent}"), Some(token), None)?;
|
||||
if code != 200 {
|
||||
return Err(format!("ETAPI: Notiz {parent} nicht lesbar (HTTP {code})"));
|
||||
}
|
||||
for child in parent_note["childNoteIds"].as_array().into_iter().flatten() {
|
||||
let Some(id) = child.as_str() else { continue };
|
||||
let (code, note) =
|
||||
etapi(project, "GET", &format!("/etapi/notes/{id}"), Some(token), None)?;
|
||||
if code == 200 && note["title"].as_str() == Some(title) {
|
||||
return Ok(id.to_string());
|
||||
}
|
||||
}
|
||||
let (code, created) = etapi(
|
||||
project,
|
||||
"POST",
|
||||
"/etapi/create-note",
|
||||
Some(token),
|
||||
Some(&json!({ "parentNoteId": parent, "title": title, "type": "book", "content": "" })),
|
||||
)?;
|
||||
if code != 201 {
|
||||
return Err(format!("ETAPI: Ordner-Notiz „{title}“ nicht anlegbar (HTTP {code})"));
|
||||
}
|
||||
created["note"]["noteId"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| "ETAPI: create-note ohne noteId".into())
|
||||
}
|
||||
|
||||
/// Archiviert einen Markdown-Text als Trilium-Notiz: HTML-Konvertierung,
|
||||
/// Ordner-Pfad als Notiz-Hierarchie unter root, Beschreibung und Schlagwörter
|
||||
/// als Labels. Liefert die noteId.
|
||||
pub(crate) fn archive_note(
|
||||
project: &str,
|
||||
title: &str,
|
||||
markdown: &str,
|
||||
folder: Option<&str>,
|
||||
description: Option<&str>,
|
||||
tags: &[String],
|
||||
) -> Result<String, String> {
|
||||
let token = read_token(project)?;
|
||||
let html = {
|
||||
let parser = pulldown_cmark::Parser::new_ext(markdown, pulldown_cmark::Options::all());
|
||||
let mut html = String::new();
|
||||
pulldown_cmark::html::push_html(&mut html, parser);
|
||||
html
|
||||
};
|
||||
let mut parent = "root".to_string();
|
||||
if let Some(folder) = folder {
|
||||
for segment in folder.split('/').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
parent = find_or_create_child(project, &token, &parent, segment)?;
|
||||
}
|
||||
}
|
||||
let (code, created) = etapi(
|
||||
project,
|
||||
"POST",
|
||||
"/etapi/create-note",
|
||||
Some(&token),
|
||||
Some(&json!({
|
||||
"parentNoteId": parent,
|
||||
"title": title,
|
||||
"type": "text",
|
||||
"content": html,
|
||||
})),
|
||||
)?;
|
||||
if code != 201 {
|
||||
return Err(format!("ETAPI: Notiz nicht anlegbar (HTTP {code})"));
|
||||
}
|
||||
let note_id = created["note"]["noteId"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| String::from("ETAPI: create-note ohne noteId"))?;
|
||||
|
||||
let mut labels: Vec<(&str, String)> = Vec::new();
|
||||
if let Some(d) = description {
|
||||
labels.push(("description", d.to_string()));
|
||||
}
|
||||
for tag in tags {
|
||||
labels.push(("tag", tag.clone()));
|
||||
}
|
||||
for (name, value) in labels {
|
||||
let (code, _) = etapi(
|
||||
project,
|
||||
"POST",
|
||||
"/etapi/attributes",
|
||||
Some(&token),
|
||||
Some(&json!({ "noteId": note_id, "type": "label", "name": name, "value": value })),
|
||||
)?;
|
||||
if code != 201 {
|
||||
return Err(format!("ETAPI: Label {name} nicht setzbar (HTTP {code})"));
|
||||
}
|
||||
}
|
||||
Ok(note_id)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct Hit {
|
||||
pub(crate) note_id: String,
|
||||
pub(crate) title: String,
|
||||
}
|
||||
|
||||
/// Volltextsuche über die Projekt-Instanz; `tag` engt auf das Label
|
||||
/// `#tag=<wert>` ein (so setzt archive_note Schlagwörter).
|
||||
pub(crate) fn search_notes(
|
||||
project: &str,
|
||||
query: &str,
|
||||
tag: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Hit>, String> {
|
||||
let token = read_token(project)?;
|
||||
let mut q = query.trim().to_string();
|
||||
if let Some(tag) = tag {
|
||||
q = format!("{q} #tag=\"{}\"", tag.replace('"', "")).trim().to_string();
|
||||
}
|
||||
let (code, value) = etapi(
|
||||
project,
|
||||
"GET",
|
||||
&format!("/etapi/notes?search={}&limit={limit}", uri_encode(&q)),
|
||||
Some(&token),
|
||||
None,
|
||||
)?;
|
||||
if code != 200 {
|
||||
return Err(format!("ETAPI-Suche fehlgeschlagen (HTTP {code})"));
|
||||
}
|
||||
Ok(
|
||||
value["results"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|n| {
|
||||
Some(Hit {
|
||||
note_id: n["noteId"].as_str()?.to_string(),
|
||||
title: n["title"].as_str().unwrap_or("(ohne Titel)").to_string(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn port_stabil_und_im_bereich() {
|
||||
let a = port_for("projekt-a");
|
||||
assert_eq!(a, port_for("projekt-a"));
|
||||
assert!((27200..27982).contains(&a));
|
||||
assert_ne!(a, port_for("projekt-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pfade_unter_config_dir() {
|
||||
let p = crate::domain::testutil::tmp_paths();
|
||||
assert!(runtime_dir(&p).starts_with(p.config_dir()));
|
||||
assert!(data_dir(&p, "proj").starts_with(trilium_root(&p)));
|
||||
}
|
||||
}
|
||||
+17
-6
@@ -95,11 +95,16 @@ pub fn run() {
|
||||
// Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt
|
||||
// überschreibt macOS es beim Anlegen des Dock-Tiles wieder.
|
||||
.run(move |app, event| {
|
||||
if let tauri::RunEvent::Ready = event {
|
||||
if let Some(icon) = icon.as_deref() {
|
||||
platform::set_dock_icon(icon);
|
||||
match event {
|
||||
tauri::RunEvent::Ready => {
|
||||
if let Some(icon) = icon.as_deref() {
|
||||
platform::set_dock_icon(icon);
|
||||
}
|
||||
platform::activate_self(app);
|
||||
}
|
||||
platform::activate_self(app);
|
||||
// Von diesem Prozess gestartete Trilium-Instanzen enden mit ihm.
|
||||
tauri::RunEvent::Exit => domain::trilium::stop_all(),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -108,8 +113,14 @@ pub fn run() {
|
||||
// dem offenen Fenster über ai-control.desktop das App-Icon zu.
|
||||
platform::set_app_id("ai-control");
|
||||
app::main_builder()
|
||||
.run(context)
|
||||
.expect("error while running tauri application");
|
||||
.build(context)
|
||||
.expect("error while building tauri application")
|
||||
.run(|_, event| {
|
||||
// Von diesem Prozess gestartete Trilium-Instanzen enden mit ihm.
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
domain::trilium::stop_all();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+94
-154
@@ -1,8 +1,9 @@
|
||||
//! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server
|
||||
//! startet. Er stellt genau ein Tool bereit: `write_panel(text)` schreibt den
|
||||
//! Text in die Panel-Datei aus `AI_CONTROL_PANEL` (dieselbe Env, die der
|
||||
//! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder vererbt).
|
||||
//! Der bestehende Datei-Watcher im Terminal-Prozess zieht den Inhalt ins Panel.
|
||||
//! startet. Die Tools kommen aus der Modul-Registry (domain/modules.rs);
|
||||
//! Puffer-Kanäle laufen über die AI_CONTROL_*-Dateipfade, die der
|
||||
//! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder
|
||||
//! vererbt. Der Datei-Watcher im Terminal-Prozess zieht Änderungen als
|
||||
//! Events in die Panel-Fenster.
|
||||
//!
|
||||
//! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile).
|
||||
|
||||
@@ -97,14 +98,16 @@ fn tool_def(name: &str) -> Value {
|
||||
"name": "write_panel",
|
||||
"description":
|
||||
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
|
||||
Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
|
||||
ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
|
||||
ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
|
||||
Commit-Message, Textbaustein) als Notiz im Trilium-Archiv des \
|
||||
Projekts an und öffnet sie im Trilium-Fenster neben dem Terminal. \
|
||||
Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
|
||||
aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \
|
||||
IMMER `path` statt `text` übergeben — der Server liest die Datei \
|
||||
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss. \
|
||||
`path` muss im Projekt-, Arbeits- oder Archiv-Ordner liegen \
|
||||
(reguläre Datei, max. 2 MB).",
|
||||
`path` muss im Projekt- oder Arbeitsordner liegen (reguläre Datei, \
|
||||
max. 2 MB). `folder`, `description` und `tags` kuratieren die \
|
||||
Notiz: Ordner werden zur Notiz-Hierarchie, Beschreibung und \
|
||||
Schlagwörter zu Labels.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -115,8 +118,23 @@ fn tool_def(name: &str) -> Value {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description":
|
||||
"Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt ins \
|
||||
Panel geladen wird. Genau eines von beiden angeben.",
|
||||
"Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt \
|
||||
die Notiz wird. Genau eines von beiden angeben.",
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description":
|
||||
"Optionaler Ordner-Pfad im Archiv (z. B. `konzepte/panel`). \
|
||||
Wird als Notiz-Hierarchie angelegt.",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Einzeiler zum Inhalt (Label `description`).",
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Schlagwörter (Labels `tag`, kurze Slugs).",
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -210,69 +228,20 @@ fn tool_def(name: &str) -> Value {
|
||||
(„zeig die ToDos“).",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
}),
|
||||
"archive_panel" => json!({
|
||||
"name": "archive_panel",
|
||||
"description":
|
||||
"Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
|
||||
Markdown-Datei im Archiv-Home des Projekts. Auf Wunsch nutzen \
|
||||
(Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \
|
||||
`description` und `tags` mitgeben — einmalige Kuratierung im Moment \
|
||||
des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \
|
||||
konfiguriert, meldet das Tool das zurück; gesetzt wird es in den \
|
||||
Projekt-Einstellungen oder im Panel.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description":
|
||||
"Optionaler Unterordner im Archiv-Home, relativ (z. B. \
|
||||
`konzepte/panel`). Wird angelegt.",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Einzeiler zum Inhalt fürs Frontmatter.",
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Schlagwörter fürs Frontmatter (kurze Slugs).",
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
"show_archive" => json!({
|
||||
"name": "show_archive",
|
||||
"description":
|
||||
"Zeigt die Archiv-Übersicht des Projekts als Wiki-Seite im Panel: \
|
||||
Dokumente nach Ordnern gruppiert, mit Beschreibungen und \
|
||||
klickbaren Schlagwort-Links. Mit `tag` stattdessen die Seite eines \
|
||||
Schlagworts. Nutzen, wenn der Nutzer das Archiv sehen will \
|
||||
(„zeig das Archiv“).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Optional: Seite dieses Schlagworts statt der Übersicht.",
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
"search_archive" => json!({
|
||||
"name": "search_archive",
|
||||
"description":
|
||||
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \
|
||||
Wörter, \"Phrasen\", Präfix*). Die Treffer erscheinen als Kacheln \
|
||||
im Panel; das Tool liefert sie zusätzlich mit Pfad und Snippet \
|
||||
zurück. Nutzen, wenn der Nutzer im Archiv suchen will („such im \
|
||||
Archiv nach …“).",
|
||||
"Volltext-Suche über das Trilium-Archiv des Projekts (Triliums \
|
||||
Suchsyntax: Wörter, \"Phrasen\", Operatoren). Die Treffer \
|
||||
erscheinen als Kacheln im Panel; das Tool liefert sie zusätzlich \
|
||||
mit Titel und noteId zurück. Nutzen, wenn der Nutzer im Archiv \
|
||||
suchen will („such im Archiv nach …“).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Suchanfrage (FTS5-Syntax).",
|
||||
"description": "Suchanfrage (Trilium-Suchsyntax).",
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
@@ -300,9 +269,7 @@ fn call_tool(req: &Value) -> Value {
|
||||
"show_commands" => call_show_commands(),
|
||||
"write_todos" => call_write_todos(req),
|
||||
"show_todos" => call_show_todos(),
|
||||
"archive_panel" => call_archive(req),
|
||||
"search_archive" => call_search(req),
|
||||
"show_archive" => call_show_archive(req),
|
||||
other => err(format!("Unbekanntes Tool: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -324,44 +291,75 @@ fn env_path(var: &str) -> Result<String, Value> {
|
||||
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
|
||||
}
|
||||
|
||||
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL. Ein frischer
|
||||
/// Entwurf löst die Quell-Verknüpfung des Dokument-Tabs (`<panel>.source`,
|
||||
/// gesetzt beim Öffnen eines Archiv-Dokuments) — Edits gehören danach wieder
|
||||
/// nur dem Panel, nicht der zuletzt geöffneten Archiv-Datei.
|
||||
fn write_panel_text(text: &str) -> Result<(), Value> {
|
||||
let path = env_path("AI_CONTROL_PANEL")?;
|
||||
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))?;
|
||||
match std::fs::remove_file(format!("{path}.source")) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(err(format!("Quell-Verknüpfung nicht gelöst: {e}"))),
|
||||
/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile —
|
||||
/// wird der Notiz-Titel von write_panel.
|
||||
fn first_line(text: &str) -> String {
|
||||
let mut fallback: Option<&str> = None;
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let h = t.trim_start_matches('#').trim();
|
||||
if t.starts_with('#') && !h.is_empty() {
|
||||
return h.to_string();
|
||||
}
|
||||
fallback.get_or_insert(t);
|
||||
}
|
||||
fallback.unwrap_or("entwurf").to_string()
|
||||
}
|
||||
|
||||
/// Legt den Entwurf als Trilium-Notiz an (ETAPI der Projekt-Instanz; Token
|
||||
/// aus dem Keyring) und meldet die noteId über den noteopen-Kanal — die
|
||||
/// Fenster öffnen die Notiz daraufhin im Trilium-Fenster.
|
||||
fn call_write(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let args = &req["params"]["arguments"];
|
||||
// `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne
|
||||
// dass das Modell den Inhalt Token für Token als `text` generieren muss.
|
||||
// Der Zugriff ist auf Projekt-, Arbeits- und Archiv-Ordner begrenzt
|
||||
// Der Zugriff ist auf Projekt- und Arbeitsordner begrenzt
|
||||
// (read_for_panel_in): das Tool ist promptfrei freigegeben und darf das
|
||||
// Read-Permission-Modell von Claude Code nicht umgehen.
|
||||
let (text, ok_msg) = match args["path"].as_str() {
|
||||
let text = match args["path"].as_str() {
|
||||
Some(src) => {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let paths = crate::domain::paths::Paths::real();
|
||||
match crate::domain::project::read_for_panel_in(&paths, &project, src) {
|
||||
Ok(content) => (content, "Datei ins Panel geladen."),
|
||||
Ok(content) => content,
|
||||
Err(e) => return err(format!("Datei nicht geladen: {e}")),
|
||||
}
|
||||
}
|
||||
None => (
|
||||
args["text"].as_str().unwrap_or("").to_string(),
|
||||
"Entwurf ins Panel geschrieben.",
|
||||
),
|
||||
None => args["text"].as_str().unwrap_or("").to_string(),
|
||||
};
|
||||
match write_panel_text(&text) {
|
||||
Ok(()) => ok(ok_msg.into()),
|
||||
Err(e) => e,
|
||||
if text.trim().is_empty() {
|
||||
return err("Kein Inhalt — `text` oder `path` angeben.".into());
|
||||
}
|
||||
let title = first_line(&text);
|
||||
let tags: Vec<String> = args["tags"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
let note_id = match crate::domain::trilium::archive_note(
|
||||
&project,
|
||||
&title,
|
||||
&text,
|
||||
args["folder"].as_str().filter(|f| !f.trim().is_empty()),
|
||||
args["description"].as_str().filter(|d| !d.trim().is_empty()),
|
||||
&tags,
|
||||
) {
|
||||
Ok(note_id) => note_id,
|
||||
Err(e) => {
|
||||
return err(format!(
|
||||
"Notiz nicht angelegt: {e}. Läuft die Trilium-Instanz? (Trilium-Modul in den Projekt-Einstellungen)"
|
||||
))
|
||||
}
|
||||
};
|
||||
let noteopen = match env_path("AI_CONTROL_NOTEOPEN") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
match std::fs::write(¬eopen, json!({ "noteId": note_id }).to_string()) {
|
||||
Ok(()) => ok(format!("Notiz {note_id} („{title}“) im Archiv angelegt.")),
|
||||
Err(e) => err(format!("Notiz {note_id} angelegt, aber Fenster nicht benachrichtigt: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,71 +506,15 @@ fn call_write_todos(req: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// `dir` gibt es hier bewusst nicht mehr: Das Setzen des Archiv-Homes vergibt
|
||||
/// über `add_archive_permission` dauerhafte Rechte in der settings.json des
|
||||
/// Projekts (additionalDirectories + Edit-Allow) — das bleibt der UI mit
|
||||
/// Nutzer-Dialog vorbehalten, nicht einem Tool-Argument des Modells.
|
||||
fn call_archive(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let args = &req["params"]["arguments"];
|
||||
let meta = crate::domain::archive::ArchiveMeta {
|
||||
folder: args["folder"].as_str().map(str::to_string),
|
||||
description: args["description"].as_str().map(str::to_string),
|
||||
tags: args["tags"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
match crate::domain::archive::archive_panel_content(&project, None, &meta) {
|
||||
Ok(path) => ok(format!("Archiviert: {}", path.display())),
|
||||
Err(e) => err(format!(
|
||||
"Nicht archiviert: {e}. Archiv-Ordner im Panel oder in den Projekt-Einstellungen wählen."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Archiv-Übersicht bzw. Schlagwort-Seite generieren und in den Wiki-Puffer
|
||||
/// schreiben — der Watcher zieht sie als Wiki-Ansicht ins Panel.
|
||||
fn call_show_archive(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let home = match crate::domain::archive::require_archive_home(&project) {
|
||||
Ok(home) => home,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let tag = req["params"]["arguments"]["tag"].as_str();
|
||||
let page = match crate::domain::archive_index::archive_page(&home, tag) {
|
||||
Ok(page) => page,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let path = match env_path("AI_CONTROL_WIKI") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let json = match serde_json::to_string(&page) {
|
||||
Ok(json) => json,
|
||||
Err(e) => return err(e.to_string()),
|
||||
};
|
||||
match std::fs::write(&path, json) {
|
||||
Ok(()) => ok(match tag {
|
||||
Some(t) => format!("Schlagwort-Seite #{t} im Panel."),
|
||||
None => "Archiv-Übersicht im Panel.".to_string(),
|
||||
}),
|
||||
Err(e) => err(format!("Wiki-Datei nicht schreibbar: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Volltext-Suche übers Archiv: Treffer als JSON in die Suchtreffer-Datei
|
||||
/// (der Watcher zieht sie als Kacheln ins Panel) und als Text zurück an claude.
|
||||
/// Volltext-Suche übers Trilium-Archiv: Treffer als JSON in die
|
||||
/// Suchtreffer-Datei (der Watcher zieht sie als Kacheln ins Panel) und als
|
||||
/// Text zurück an claude.
|
||||
fn call_search(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let home = match crate::domain::archive::require_archive_home(&project) {
|
||||
Ok(home) => home,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let args = &req["params"]["arguments"];
|
||||
let query = args["query"].as_str().unwrap_or("");
|
||||
let tag = args["tag"].as_str();
|
||||
let hits = match crate::domain::archive_search::search(&home, query, tag, 20) {
|
||||
let hits = match crate::domain::trilium::search_notes(&project, query, tag, 20) {
|
||||
Ok(hits) => hits,
|
||||
Err(e) => return err(format!("Suche fehlgeschlagen: {e}")),
|
||||
};
|
||||
@@ -583,7 +525,6 @@ fn call_search(req: &Value) -> Value {
|
||||
let payload = json!({
|
||||
"query": query,
|
||||
"tag": tag,
|
||||
"home": home.display().to_string(),
|
||||
"hits": hits,
|
||||
});
|
||||
if let Err(e) = std::fs::write(&search_path, payload.to_string()) {
|
||||
@@ -591,12 +532,11 @@ fn call_search(req: &Value) -> Value {
|
||||
}
|
||||
let list: Vec<String> = hits
|
||||
.iter()
|
||||
.map(|h| format!("- {} — {} ({})", h.relpath, h.title, h.snippet))
|
||||
.map(|h| format!("- {} ({})", h.title, h.note_id))
|
||||
.collect();
|
||||
ok(format!(
|
||||
"{} Treffer, als Kacheln im Panel. Archiv: {}\n{}",
|
||||
"{} Treffer, als Kacheln im Panel.\n{}",
|
||||
hits.len(),
|
||||
home.display(),
|
||||
list.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
@@ -44,11 +44,6 @@ pub(crate) fn set_app_id(name: &str) {
|
||||
glib::set_prgname(Some(name));
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_path(path: &Path) {
|
||||
let target = path.parent().unwrap_or(path);
|
||||
let _ = Command::new("xdg-open").arg(target).spawn();
|
||||
}
|
||||
|
||||
/// Selbstinstallation gibt es nur unter macOS — hier installieren deb/rpm.
|
||||
pub(crate) fn offer_move_to_applications(_paths: &crate::domain::paths::Paths) {}
|
||||
|
||||
|
||||
@@ -78,10 +78,6 @@ pub(crate) fn focus_terminal(pid: u32) {
|
||||
/// Wayland-app_id gibt es nur unter Linux.
|
||||
pub(crate) fn set_app_id(_name: &str) {}
|
||||
|
||||
pub(crate) fn reveal_path(path: &Path) {
|
||||
let _ = Command::new("open").arg("-R").arg(path).spawn();
|
||||
}
|
||||
|
||||
// ---------- Selbstinstallation ----------
|
||||
|
||||
/// App-Bundle zum Programmpfad: …/ai-control.app/Contents/MacOS/ai-control.
|
||||
@@ -322,3 +318,4 @@ pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(),
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! Prozesse terminal_pids, kill_terminal
|
||||
//! Fokus/Aktivierung focus_terminal, activate_self, yield_activation
|
||||
//! Fenster/Icons set_dock_icon, set_app_id
|
||||
//! Dateisystem home_dir, write_secret_file, symlink, reveal_path
|
||||
//! Dateisystem home_dir, write_secret_file, symlink
|
||||
//! Shell shell_command
|
||||
//! Secrets KeychainStore (impl ApikeyStore), apikey_helper_command,
|
||||
//! oauth_keychain_exists, oauth_keychain_delete
|
||||
|
||||
@@ -47,12 +47,6 @@ pub(crate) fn set_app_id(_name: &str) {}
|
||||
/// Selbstinstallation gibt es nur unter macOS — hier installiert der NSIS-Setup.
|
||||
pub(crate) fn offer_move_to_applications(_paths: &crate::domain::paths::Paths) {}
|
||||
|
||||
pub(crate) fn reveal_path(path: &Path) {
|
||||
let _ = Command::new("explorer")
|
||||
.arg(format!("/select,{}", path.display()))
|
||||
.spawn();
|
||||
}
|
||||
|
||||
// ---------- Dateisystem / Shell ----------
|
||||
|
||||
pub(crate) fn home_dir() -> PathBuf {
|
||||
@@ -125,3 +119,4 @@ pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(),
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+23
-185
@@ -5,9 +5,7 @@ use std::io::{Read, Write};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
use crate::domain::paths::{
|
||||
commands_file, panel_file, panel_source_file, search_file, wiki_file, Paths,
|
||||
};
|
||||
use crate::domain::paths::{commands_file, search_file, Paths};
|
||||
use crate::domain::project::{
|
||||
project_config, project_pool_dir, verify_project_dir_in, ProjectConfig,
|
||||
};
|
||||
@@ -147,12 +145,19 @@ pub fn term_start(
|
||||
let _ = std::fs::write(path, "");
|
||||
}
|
||||
}
|
||||
// Quell-Verknüpfung der Vorsession lösen — der Dokument-Puffer startet leer,
|
||||
// Edits dürfen nicht in die zuletzt geöffnete Archiv-Datei laufen.
|
||||
match std::fs::remove_file(panel_source_file(&project)) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
// Trilium-Modul an: Projekt-Instanz zur Session hochfahren (im Thread —
|
||||
// der Start wartet bis zu 20 s auf den Port). Fehler landen im Dev-Log;
|
||||
// die Tools melden eine fehlende Instanz ohnehin laut.
|
||||
if crate::domain::modules::active_in(&paths, &project)?
|
||||
.iter()
|
||||
.any(|m| m.id == "trilium")
|
||||
{
|
||||
let proj = project.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = crate::domain::trilium::ensure_instance(&Paths::real(), &proj) {
|
||||
log::warn!("Trilium-Start ({proj}): {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
|
||||
@@ -429,191 +434,24 @@ pub fn todos_update(
|
||||
}
|
||||
|
||||
/// Quell-Verknüpfung des Dokument-Tabs setzen: ab jetzt schreibt jeder
|
||||
/// Editor-Commit den Body in diese Archiv-Datei zurück.
|
||||
fn link_panel_source(project: &str, path: &std::path::Path) -> Result<(), String> {
|
||||
std::fs::write(panel_source_file(project), path.display().to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Aktuelle Quell-Verknüpfung, falls gesetzt.
|
||||
fn panel_source(project: &str) -> Option<std::path::PathBuf> {
|
||||
std::fs::read_to_string(panel_source_file(project))
|
||||
.ok()
|
||||
.map(|s| std::path::PathBuf::from(s.trim()))
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
}
|
||||
|
||||
/// Verknüpfung nach einer Dokument-Operation nachziehen: zeigt sie auf den
|
||||
/// alten Pfad, wandert sie mit; `None` löst sie (gelöschtes Dokument).
|
||||
fn relink(
|
||||
project: &str,
|
||||
old: &std::path::Path,
|
||||
new: Option<&std::path::Path>,
|
||||
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive über
|
||||
/// die ETAPI der Projekt-Instanz und schreibt die Treffer-Datei; der Watcher
|
||||
/// zieht sie in die Ansicht (beide Fenster).
|
||||
#[tauri::command]
|
||||
pub async fn search_run(
|
||||
project: String,
|
||||
query: String,
|
||||
tag: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
if panel_source(project).as_deref() != Some(old) {
|
||||
return Ok(());
|
||||
}
|
||||
match new {
|
||||
Some(p) => link_panel_source(project, p),
|
||||
None => std::fs::remove_file(panel_source_file(project)).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie `relink`, für einen verschobenen Ordner: eine Verknüpfung auf ein
|
||||
/// Dokument darin wandert mit.
|
||||
fn relink_folder(
|
||||
project: &str,
|
||||
old: &std::path::Path,
|
||||
new: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
let Some(cur) = panel_source(project) else {
|
||||
return Ok(());
|
||||
};
|
||||
match cur.strip_prefix(old) {
|
||||
Ok(rest) => link_panel_source(project, &new.join(rest)),
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die
|
||||
/// Änderung als `panel-update` an alle Fenster. Zeigt der Dokument-Tab ein
|
||||
/// Archiv-Dokument (Quell-Verknüpfung), wandert der Body implizit in die
|
||||
/// Archiv-Datei zurück; ihre Frontmatter bleibt.
|
||||
#[tauri::command]
|
||||
pub fn panel_set(project: String, text: String) -> Result<(), String> {
|
||||
std::fs::write(panel_file(&project), &text).map_err(|e| e.to_string())?;
|
||||
match panel_source(&project) {
|
||||
Some(src) => crate::domain::archive_ops::write_body(&src, &text),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt ein Archiv-Dokument in den Dokument-Puffer (Treffer-Klick in der
|
||||
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf — und setzt die
|
||||
/// Quell-Verknüpfung fürs Zurückschreiben. Der Watcher meldet den neuen
|
||||
/// Inhalt als `panel-update`.
|
||||
#[tauri::command]
|
||||
pub fn panel_load(project: String, path: String) -> Result<(), String> {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let body = crate::domain::archive::strip_frontmatter(&text);
|
||||
std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())?;
|
||||
link_panel_source(&project, std::path::Path::new(&path))
|
||||
}
|
||||
|
||||
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive und
|
||||
/// schreibt die Treffer-Datei; der Watcher zieht sie in die Ansicht (beide
|
||||
/// Fenster).
|
||||
#[tauri::command]
|
||||
pub fn search_run(project: String, query: String, tag: Option<String>) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let hits = crate::domain::archive_search::search(&home, &query, tag.as_deref(), 20)?;
|
||||
let hits = crate::domain::trilium::search_notes(&project, &query, tag.as_deref(), 20)?;
|
||||
let payload = serde_json::json!({
|
||||
"query": query,
|
||||
"tag": tag,
|
||||
"home": home.display().to_string(),
|
||||
"hits": hits,
|
||||
});
|
||||
std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Öffnet ein Wiki-Ziel: `tag:x` → Schlagwort-Seite, `tag:` →
|
||||
/// Archiv-Übersicht in den Wiki-Puffer (`wiki-update`); ein Dokumentname
|
||||
/// (Wikilink, Backlink) wird über den Index aufgelöst und wie ein
|
||||
/// Suchtreffer in den Dokument-Puffer geladen (`panel-update`).
|
||||
#[tauri::command]
|
||||
pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
match name.strip_prefix("tag:") {
|
||||
Some(tag) => {
|
||||
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(
|
||||
&home,
|
||||
(!tag.is_empty()).then_some(tag),
|
||||
)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string())
|
||||
}
|
||||
None => {
|
||||
let rel = crate::domain::archive_index::resolve_doc(&home, &name)?;
|
||||
let path = home.join(rel);
|
||||
let text =
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let body = crate::domain::archive::strip_frontmatter(&text);
|
||||
std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())?;
|
||||
link_panel_source(&project, &path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frische Archiv-Übersicht in den Wiki-Puffer — Abschluss der
|
||||
/// Dokument-/Ordner-Operationen; der Watcher meldet `wiki-update`.
|
||||
fn wiki_refresh_page(project: &str, home: &std::path::Path) -> Result<(), String> {
|
||||
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(home, None)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(wiki_file(project), json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Verschiebt ein Archiv-Dokument in einen anderen Ordner (leer = Wurzel).
|
||||
#[tauri::command]
|
||||
pub fn archive_move(project: String, relpath: String, folder: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let old = home.join(&relpath);
|
||||
let new_rel = crate::domain::archive_ops::move_doc(&home, &relpath, &folder)?;
|
||||
relink(&project, &old, Some(&home.join(new_rel)))?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Benennt ein Archiv-Dokument um (Slug hinter erhaltenem Zeitstempel).
|
||||
#[tauri::command]
|
||||
pub fn archive_rename(project: String, relpath: String, name: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let old = home.join(&relpath);
|
||||
let new_rel = crate::domain::archive_ops::rename_doc(&home, &relpath, &name)?;
|
||||
relink(&project, &old, Some(&home.join(new_rel)))?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Löscht ein Archiv-Dokument; danach zeigt das Wiki die Übersicht.
|
||||
#[tauri::command]
|
||||
pub fn archive_delete(project: String, relpath: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
crate::domain::archive_ops::delete_doc(&home, &relpath)?;
|
||||
relink(&project, &home.join(&relpath), None)?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Verschiebt/benennt einen Archiv-Ordner um; danach Übersicht.
|
||||
#[tauri::command]
|
||||
pub fn archive_move_folder(project: String, folder: String, to: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
crate::domain::archive_ops::move_folder(&home, &folder, &to)?;
|
||||
relink_folder(&project, &home.join(&folder), &home.join(&to))?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Legt einen Ordner im Archiv an (Plus im Baum); danach Übersicht.
|
||||
#[tauri::command]
|
||||
pub fn archive_create_folder(project: String, folder: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
crate::domain::archive_ops::create_folder(&home, &folder)?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Legt ein leeres Dokument an (Plus im Listenkopf) und öffnet es im
|
||||
/// Dokument-Tab: leerer Dokument-Puffer plus Quell-Verknüpfung — das
|
||||
/// Getippte landet über `panel_set` in der Archiv-Datei. Die Übersicht
|
||||
/// bleibt hier unangetastet; der Wiki-Tab lädt sie beim nächsten Aktivieren
|
||||
/// frisch (zwei konkurrierende Puffer-Events würden sonst um den aktiven Tab
|
||||
/// rennen).
|
||||
#[tauri::command]
|
||||
pub fn archive_create_doc(project: String, folder: String, name: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
||||
let rel = crate::domain::archive_ops::create_doc(&home, &folder, &name, &display)?;
|
||||
let path = home.join(rel);
|
||||
std::fs::write(panel_file(&project), "").map_err(|e| e.to_string())?;
|
||||
link_panel_source(&project, &path)
|
||||
}
|
||||
|
||||
/// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach
|
||||
/// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus.
|
||||
/// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem
|
||||
|
||||
Reference in New Issue
Block a user