Archiv als eigenes Fenster: Trilium raus, Notizbaum mit In-Place-Bearbeitung
Trilium-Integration komplett zurückgenommen (Revert von 47951a6): keine
verwaltete Instanz, kein Runtime-Download, kein ETAPI, kein Keyring-Token,
kein Setup-Wizard. Das Archiv sind wieder Markdown-Dateien unter archiveHome
samt FTS5-Suche und Wikilinks.
Notizmodell (an Trilium orientiert, ohne dessen Überbau):
- Jeder Knoten besitzt eine Textdatei — ensure_node_texts legt fehlende beim
Seitenaufbau an (Ordner: gleichnamiges .md daneben, Wurzel: index.md);
Ordner und Zwillingsdatei sind EINE Notiz mit Inhalt und Kindern.
- Der angezeigte Titel steht im Frontmatter der Datei, der Datei-/Ordnername
ist technischer Verwaltungsname; move_folder nimmt den Knotentext mit.
Archiv-Ansicht (wiki-view):
- Baum links: Knoten- und Blatt-Symbole, Führungslinien pro Ebene, Dokumente
näher an der Linie als Ordner; ganze Zeile wählt aus, nur der Pfeil klappt.
- Notiz-Ansicht rechts: großer Titel (Klick bearbeitet ihn → archive_set_title),
Zurück-Knopf mit Auswahl-Verlauf, Details als Popup am Info-Knopf, Kindliste
als abgesetzter Karten-Block mit Anlage-/Änderungsdatum.
- Bearbeiten in place: Split-View aus Rohtext und Live-Vorschau, Speichern per
Knopf oder Ctrl+S (archive_read/archive_write; Frontmatter bleibt).
- Rechte Maustaste im Baum als einziger Aktionsweg (eigenes Kontextmenü,
Standard-Menü des Webviews aus); Anlegen/Umbenennen über modale Dialoge.
Feste Flächenaufteilung statt Ablösen/Andocken:
- Angedocktes Panel = Session-Begleiter (ToDo, Befehle, flüchtiger Entwurf);
der Dokument-Tab ist aus der Leiste raus.
- Archiv-Fenster = Archiv + Suche, Größe und Position einmalig beim Öffnen
aus dem Monitor des rufenden Terminal-Fensters (85 % / 92 %); Öffner-Klick
bei stehendem Fenster fokussiert und schaltet den Tab (panel-mode).
- Terminal-Fenster zu → Archiv-Fenster geht mit.
Neue Commands: archive_read, archive_write, archive_set_title.
This commit is contained in:
+29
-3
@@ -2,7 +2,7 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title data-i18n="panel.windowPanel">Panel</title>
|
<title data-i18n="panel.windowDocument">Dokument</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="panel-topbar" data-tauri-drag-region>
|
<div class="panel-topbar" data-tauri-drag-region>
|
||||||
@@ -21,12 +21,38 @@
|
|||||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg>
|
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Modul-Container (<mode>-content) reihen sich hinter der Kopfzeile ein. -->
|
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-titlerow" data-tauri-drag-region>
|
<div class="panel-titlerow" data-tauri-drag-region>
|
||||||
<span class="panel-title"></span>
|
<span class="panel-title" data-i18n="panel.tabDraft">Dokument</span>
|
||||||
|
<button class="panel-btn panel-edit draft-only" id="panel-title-edit" data-i18n-title="panel.editTitle">✎</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<select class="panel-lang draft-only" id="panel-lang" data-i18n-title="panel.spellcheckLang">
|
||||||
|
<option value="de">DE</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="en-GB">EN-GB</option>
|
||||||
|
<option value="fr">FR</option>
|
||||||
|
<option value="es">ES</option>
|
||||||
|
<option value="it">IT</option>
|
||||||
|
<option value="nl">NL</option>
|
||||||
|
</select>
|
||||||
|
<button class="panel-btn draft-only" id="panel-wiki-jump" data-i18n-title="panel.openInWiki">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M8 3.5C6.6 2.4 4.6 2 2.5 2v11c2.1 0 4.1.4 5.5 1.5 1.4-1.1 3.4-1.5 5.5-1.5V2c-2.1 0-4.1.4-5.5 1.5z" /><path d="M8 3.5v11" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="panel-btn draft-only" id="panel-mode" data-i18n-title="panel.toggleRaw" data-i18n="panel.rendered">MD</button>
|
||||||
|
<button class="panel-btn draft-only" id="panel-content-edit" data-i18n-title="panel.editDraft">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M10.8 2.6 13.4 5.2 6 12.6l-3.1.5.5-3.1z" /><path d="M9.7 3.7 12.3 6.3" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="panel-btn draft-only" id="panel-copy" data-i18n-title="panel.copy">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="panel-btn draft-only" id="panel-archive" data-i18n-title="panel.archive">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="3.5" rx="1" /><path d="M2.8 6v6.5a1 1 0 0 0 1 1h8.4a1 1 0 0 0 1-1V6" /><path d="M6.3 9h3.4" /></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Modul-Container (<mode>-content) reihen sich hier ein. -->
|
||||||
|
<div id="panel-content" class="md"></div>
|
||||||
<div id="resize-grips" aria-hidden="true">
|
<div id="resize-grips" aria-hidden="true">
|
||||||
<div class="grip n" data-dir="North"></div>
|
<div class="grip n" data-dir="North"></div>
|
||||||
<div class="grip s" data-dir="South"></div>
|
<div class="grip s" data-dir="South"></div>
|
||||||
|
|||||||
Generated
+75
-26
@@ -19,6 +19,18 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -42,8 +54,8 @@ dependencies = [
|
|||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"pulldown-cmark",
|
|
||||||
"rfd 0.15.4",
|
"rfd 0.15.4",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -1244,6 +1256,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -1779,7 +1803,16 @@ version = "0.12.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash 0.7.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash 0.8.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1797,6 +1830,15 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2325,6 +2367,17 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -3119,24 +3172,6 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pulldown-cmark"
|
|
||||||
version = "0.12.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.0",
|
|
||||||
"memchr",
|
|
||||||
"pulldown-cmark-escape",
|
|
||||||
"unicase",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pulldown-cmark-escape"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pxfm"
|
name = "pxfm"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@@ -3430,6 +3465,20 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust_decimal"
|
name = "rust_decimal"
|
||||||
version = "1.42.1"
|
version = "1.42.1"
|
||||||
@@ -4881,12 +4930,6 @@ dependencies = [
|
|||||||
"unic-common",
|
"unic-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicase"
|
|
||||||
version = "2.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -4966,6 +5009,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vec_map"
|
name = "vec_map"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ base64 = "0.22"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
portable-pty = "0.9"
|
portable-pty = "0.9"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
# Markdown→HTML beim Archivieren in Trilium (Notizen sind HTML).
|
# Volltext-Suche übers Panel-Archiv (SQLite FTS5, in-memory aus dem Baum gebaut).
|
||||||
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] }
|
keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
|||||||
+20
-5
@@ -41,11 +41,14 @@ fn main() {
|
|||||||
"oauth_login",
|
"oauth_login",
|
||||||
"keychain_status",
|
"keychain_status",
|
||||||
"set_apikey",
|
"set_apikey",
|
||||||
"trilium_status",
|
"panel_archive_dir_cmd",
|
||||||
"trilium_setup",
|
"panel_archive_cmd",
|
||||||
"trilium_connect",
|
"set_archive_home_cmd",
|
||||||
"trilium_open",
|
"change_archive_home_cmd",
|
||||||
"trilium_disable",
|
"clear_archive_home_cmd",
|
||||||
|
"archive_docs_cmd",
|
||||||
|
"reveal_path_cmd",
|
||||||
|
"spellcheck_lang",
|
||||||
"open_terminal",
|
"open_terminal",
|
||||||
"term_start",
|
"term_start",
|
||||||
"term_log",
|
"term_log",
|
||||||
@@ -59,7 +62,19 @@ fn main() {
|
|||||||
"todos_delete",
|
"todos_delete",
|
||||||
"todos_add",
|
"todos_add",
|
||||||
"todos_update",
|
"todos_update",
|
||||||
|
"panel_set",
|
||||||
"search_run",
|
"search_run",
|
||||||
|
"panel_load",
|
||||||
|
"wiki_open",
|
||||||
|
"archive_move",
|
||||||
|
"archive_rename",
|
||||||
|
"archive_read",
|
||||||
|
"archive_write",
|
||||||
|
"archive_set_title",
|
||||||
|
"archive_delete",
|
||||||
|
"archive_move_folder",
|
||||||
|
"archive_create_folder",
|
||||||
|
"archive_create_doc",
|
||||||
"open_panel_window",
|
"open_panel_window",
|
||||||
])),
|
])),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,14 +48,14 @@
|
|||||||
"allow-oauth-login",
|
"allow-oauth-login",
|
||||||
"allow-keychain-status",
|
"allow-keychain-status",
|
||||||
"allow-set-apikey",
|
"allow-set-apikey",
|
||||||
|
"allow-panel-archive-dir-cmd",
|
||||||
|
"allow-set-archive-home-cmd",
|
||||||
|
"allow-clear-archive-home-cmd",
|
||||||
|
"allow-archive-docs-cmd",
|
||||||
"allow-delete-preview",
|
"allow-delete-preview",
|
||||||
"allow-delete-project-scoped",
|
"allow-delete-project-scoped",
|
||||||
|
"allow-change-archive-home-cmd",
|
||||||
"allow-module-registry",
|
"allow-module-registry",
|
||||||
"allow-set-module",
|
"allow-set-module"
|
||||||
"allow-trilium-status",
|
|
||||||
"allow-trilium-setup",
|
|
||||||
"allow-trilium-connect",
|
|
||||||
"allow-trilium-open",
|
|
||||||
"allow-trilium-disable"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-set-size",
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-center",
|
||||||
"core:window:allow-set-position",
|
"core:window:allow-set-position",
|
||||||
"core:window:allow-outer-position",
|
"core:window:allow-outer-position",
|
||||||
"core:window:allow-inner-size",
|
"core:window:allow-inner-size",
|
||||||
@@ -20,15 +21,30 @@
|
|||||||
"autostart:default",
|
"autostart:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
|
"allow-archive-create-doc",
|
||||||
|
"allow-archive-create-folder",
|
||||||
|
"allow-archive-delete",
|
||||||
|
"allow-archive-move",
|
||||||
|
"allow-archive-move-folder",
|
||||||
|
"allow-archive-rename",
|
||||||
|
"allow-archive-read",
|
||||||
|
"allow-archive-write",
|
||||||
|
"allow-archive-set-title",
|
||||||
"allow-buffer-read",
|
"allow-buffer-read",
|
||||||
"allow-commands-delete",
|
"allow-commands-delete",
|
||||||
"allow-enabled-modules",
|
"allow-enabled-modules",
|
||||||
"allow-list-projects",
|
"allow-list-projects",
|
||||||
"allow-trilium-open",
|
"allow-panel-archive-cmd",
|
||||||
|
"allow-panel-archive-dir-cmd",
|
||||||
|
"allow-panel-load",
|
||||||
|
"allow-panel-set",
|
||||||
|
"allow-reveal-path-cmd",
|
||||||
"allow-search-run",
|
"allow-search-run",
|
||||||
|
"allow-spellcheck-lang",
|
||||||
"allow-term-log",
|
"allow-term-log",
|
||||||
"allow-todos-add",
|
"allow-todos-add",
|
||||||
"allow-todos-delete",
|
"allow-todos-delete",
|
||||||
"allow-todos-update"
|
"allow-todos-update",
|
||||||
|
"allow-wiki-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,30 @@
|
|||||||
"autostart:default",
|
"autostart:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
|
"allow-archive-create-doc",
|
||||||
|
"allow-archive-create-folder",
|
||||||
|
"allow-archive-delete",
|
||||||
|
"allow-archive-move",
|
||||||
|
"allow-archive-move-folder",
|
||||||
|
"allow-archive-rename",
|
||||||
|
"allow-archive-read",
|
||||||
|
"allow-archive-write",
|
||||||
|
"allow-archive-set-title",
|
||||||
"allow-buffer-read",
|
"allow-buffer-read",
|
||||||
"allow-commands-delete",
|
"allow-commands-delete",
|
||||||
"allow-enabled-modules",
|
"allow-enabled-modules",
|
||||||
"allow-list-projects",
|
"allow-list-projects",
|
||||||
"allow-open-panel-window",
|
"allow-open-panel-window",
|
||||||
"allow-trilium-open",
|
"allow-panel-archive-cmd",
|
||||||
|
"allow-panel-archive-dir-cmd",
|
||||||
|
"allow-panel-load",
|
||||||
|
"allow-panel-set",
|
||||||
"allow-pool-label",
|
"allow-pool-label",
|
||||||
"allow-project-icon",
|
"allow-project-icon",
|
||||||
|
"allow-reveal-path-cmd",
|
||||||
"allow-search-run",
|
"allow-search-run",
|
||||||
"allow-set-terminal-font-size",
|
"allow-set-terminal-font-size",
|
||||||
|
"allow-spellcheck-lang",
|
||||||
"allow-term-log",
|
"allow-term-log",
|
||||||
"allow-term-resize",
|
"allow-term-resize",
|
||||||
"allow-term-start",
|
"allow-term-start",
|
||||||
@@ -37,6 +51,7 @@
|
|||||||
"allow-terminal-font-size",
|
"allow-terminal-font-size",
|
||||||
"allow-todos-add",
|
"allow-todos-add",
|
||||||
"allow-todos-delete",
|
"allow-todos-delete",
|
||||||
"allow-todos-update"
|
"allow-todos-update",
|
||||||
|
"allow-wiki-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-archive-docs-cmd"
|
||||||
|
description = "Enables the archive_docs_cmd command without any pre-configured scope."
|
||||||
|
commands.allow = ["archive_docs_cmd"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-archive-docs-cmd"
|
||||||
|
description = "Denies the archive_docs_cmd command without any pre-configured scope."
|
||||||
|
commands.deny = ["archive_docs_cmd"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-archive-read"
|
||||||
|
description = "Enables the archive_read command without any pre-configured scope."
|
||||||
|
commands.allow = ["archive_read"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-archive-read"
|
||||||
|
description = "Denies the archive_read command without any pre-configured scope."
|
||||||
|
commands.deny = ["archive_read"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-archive-set-title"
|
||||||
|
description = "Enables the archive_set_title command without any pre-configured scope."
|
||||||
|
commands.allow = ["archive_set_title"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-archive-set-title"
|
||||||
|
description = "Denies the archive_set_title command without any pre-configured scope."
|
||||||
|
commands.deny = ["archive_set_title"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-archive-write"
|
||||||
|
description = "Enables the archive_write command without any pre-configured scope."
|
||||||
|
commands.allow = ["archive_write"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-archive-write"
|
||||||
|
description = "Denies the archive_write command without any pre-configured scope."
|
||||||
|
commands.deny = ["archive_write"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-change-archive-home-cmd"
|
||||||
|
description = "Enables the change_archive_home_cmd command without any pre-configured scope."
|
||||||
|
commands.allow = ["change_archive_home_cmd"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-change-archive-home-cmd"
|
||||||
|
description = "Denies the change_archive_home_cmd command without any pre-configured scope."
|
||||||
|
commands.deny = ["change_archive_home_cmd"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-clear-archive-home-cmd"
|
||||||
|
description = "Enables the clear_archive_home_cmd command without any pre-configured scope."
|
||||||
|
commands.allow = ["clear_archive_home_cmd"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-clear-archive-home-cmd"
|
||||||
|
description = "Denies the clear_archive_home_cmd command without any pre-configured scope."
|
||||||
|
commands.deny = ["clear_archive_home_cmd"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-panel-archive-cmd"
|
||||||
|
description = "Enables the panel_archive_cmd command without any pre-configured scope."
|
||||||
|
commands.allow = ["panel_archive_cmd"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-panel-archive-cmd"
|
||||||
|
description = "Denies the panel_archive_cmd command without any pre-configured scope."
|
||||||
|
commands.deny = ["panel_archive_cmd"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-panel-archive-dir-cmd"
|
||||||
|
description = "Enables the panel_archive_dir_cmd command without any pre-configured scope."
|
||||||
|
commands.allow = ["panel_archive_dir_cmd"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-panel-archive-dir-cmd"
|
||||||
|
description = "Denies the panel_archive_dir_cmd command without any pre-configured scope."
|
||||||
|
commands.deny = ["panel_archive_dir_cmd"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-panel-load"
|
||||||
|
description = "Enables the panel_load command without any pre-configured scope."
|
||||||
|
commands.allow = ["panel_load"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-panel-load"
|
||||||
|
description = "Denies the panel_load command without any pre-configured scope."
|
||||||
|
commands.deny = ["panel_load"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-panel-set"
|
||||||
|
description = "Enables the panel_set command without any pre-configured scope."
|
||||||
|
commands.allow = ["panel_set"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-panel-set"
|
||||||
|
description = "Denies the panel_set command without any pre-configured scope."
|
||||||
|
commands.deny = ["panel_set"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-reveal-path-cmd"
|
||||||
|
description = "Enables the reveal_path_cmd command without any pre-configured scope."
|
||||||
|
commands.allow = ["reveal_path_cmd"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-reveal-path-cmd"
|
||||||
|
description = "Denies the reveal_path_cmd command without any pre-configured scope."
|
||||||
|
commands.deny = ["reveal_path_cmd"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-set-archive-home-cmd"
|
||||||
|
description = "Enables the set_archive_home_cmd command without any pre-configured scope."
|
||||||
|
commands.allow = ["set_archive_home_cmd"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-set-archive-home-cmd"
|
||||||
|
description = "Denies the set_archive_home_cmd command without any pre-configured scope."
|
||||||
|
commands.deny = ["set_archive_home_cmd"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-spellcheck-lang"
|
||||||
|
description = "Enables the spellcheck_lang command without any pre-configured scope."
|
||||||
|
commands.allow = ["spellcheck_lang"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-spellcheck-lang"
|
||||||
|
description = "Denies the spellcheck_lang command without any pre-configured scope."
|
||||||
|
commands.deny = ["spellcheck_lang"]
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Automatically generated - DO NOT EDIT!
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "allow-trilium-connect"
|
|
||||||
description = "Enables the trilium_connect command without any pre-configured scope."
|
|
||||||
commands.allow = ["trilium_connect"]
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "deny-trilium-connect"
|
|
||||||
description = "Denies the trilium_connect command without any pre-configured scope."
|
|
||||||
commands.deny = ["trilium_connect"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Automatically generated - DO NOT EDIT!
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "allow-trilium-disable"
|
|
||||||
description = "Enables the trilium_disable command without any pre-configured scope."
|
|
||||||
commands.allow = ["trilium_disable"]
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "deny-trilium-disable"
|
|
||||||
description = "Denies the trilium_disable command without any pre-configured scope."
|
|
||||||
commands.deny = ["trilium_disable"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Automatically generated - DO NOT EDIT!
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "allow-trilium-open"
|
|
||||||
description = "Enables the trilium_open command without any pre-configured scope."
|
|
||||||
commands.allow = ["trilium_open"]
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "deny-trilium-open"
|
|
||||||
description = "Denies the trilium_open command without any pre-configured scope."
|
|
||||||
commands.deny = ["trilium_open"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Automatically generated - DO NOT EDIT!
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "allow-trilium-setup"
|
|
||||||
description = "Enables the trilium_setup command without any pre-configured scope."
|
|
||||||
commands.allow = ["trilium_setup"]
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "deny-trilium-setup"
|
|
||||||
description = "Denies the trilium_setup command without any pre-configured scope."
|
|
||||||
commands.deny = ["trilium_setup"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Automatically generated - DO NOT EDIT!
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "allow-trilium-status"
|
|
||||||
description = "Enables the trilium_status command without any pre-configured scope."
|
|
||||||
commands.allow = ["trilium_status"]
|
|
||||||
|
|
||||||
[[permission]]
|
|
||||||
identifier = "deny-trilium-status"
|
|
||||||
description = "Denies the trilium_status command without any pre-configured scope."
|
|
||||||
commands.deny = ["trilium_status"]
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-wiki-open"
|
||||||
|
description = "Enables the wiki_open command without any pre-configured scope."
|
||||||
|
commands.allow = ["wiki_open"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-wiki-open"
|
||||||
|
description = "Denies the wiki_open command without any pre-configured scope."
|
||||||
|
commands.deny = ["wiki_open"]
|
||||||
+28
-5
@@ -286,11 +286,14 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
|||||||
commands::oauth_login,
|
commands::oauth_login,
|
||||||
commands::keychain_status,
|
commands::keychain_status,
|
||||||
commands::set_apikey,
|
commands::set_apikey,
|
||||||
commands::trilium_status,
|
commands::panel_archive_dir_cmd,
|
||||||
commands::trilium_setup,
|
commands::panel_archive_cmd,
|
||||||
commands::trilium_connect,
|
commands::set_archive_home_cmd,
|
||||||
commands::trilium_open,
|
commands::change_archive_home_cmd,
|
||||||
commands::trilium_disable,
|
commands::clear_archive_home_cmd,
|
||||||
|
commands::archive_docs_cmd,
|
||||||
|
commands::reveal_path_cmd,
|
||||||
|
commands::spellcheck_lang,
|
||||||
commands::enabled_modules,
|
commands::enabled_modules,
|
||||||
commands::module_registry,
|
commands::module_registry,
|
||||||
commands::set_module,
|
commands::set_module,
|
||||||
@@ -304,7 +307,19 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
|||||||
terminal::todos_delete,
|
terminal::todos_delete,
|
||||||
terminal::todos_add,
|
terminal::todos_add,
|
||||||
terminal::todos_update,
|
terminal::todos_update,
|
||||||
|
terminal::panel_set,
|
||||||
terminal::search_run,
|
terminal::search_run,
|
||||||
|
terminal::panel_load,
|
||||||
|
terminal::wiki_open,
|
||||||
|
terminal::archive_move,
|
||||||
|
terminal::archive_rename,
|
||||||
|
terminal::archive_read,
|
||||||
|
terminal::archive_write,
|
||||||
|
terminal::archive_set_title,
|
||||||
|
terminal::archive_delete,
|
||||||
|
terminal::archive_move_folder,
|
||||||
|
terminal::archive_create_folder,
|
||||||
|
terminal::archive_create_doc,
|
||||||
terminal::open_panel_window
|
terminal::open_panel_window
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -330,6 +345,14 @@ pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
|
|||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
let _ = window.app_handle().emit("panel-window-closed", ());
|
let _ = window.app_handle().emit("panel-window-closed", ());
|
||||||
} else {
|
} else {
|
||||||
|
// Hauptfenster des Projekts zu: das Archiv-Fenster geht mit —
|
||||||
|
// sonst hielte es den Terminal-Prozess allein am Leben.
|
||||||
|
use tauri::Manager;
|
||||||
|
for (label, w) in window.app_handle().webview_windows() {
|
||||||
|
if label.starts_with("panel-") {
|
||||||
|
let _ = w.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
terminal::close(window);
|
terminal::close(window);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-103
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
use std::time::{Duration, Instant};
|
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::credentials::keychain_service;
|
||||||
use crate::domain::paths::Paths;
|
use crate::domain::paths::Paths;
|
||||||
use crate::domain::pool::{
|
use crate::domain::pool::{
|
||||||
@@ -347,6 +350,11 @@ pub(crate) fn set_terminal_font_size(size: u32) -> Result<(), String> {
|
|||||||
settings::set_terminal_font_size_in(&Paths::real(), size)
|
settings::set_terminal_font_size_in(&Paths::real(), size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn spellcheck_lang() -> String {
|
||||||
|
settings::spellcheck_lang(&Paths::real())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Verbrauch ----------
|
// ---------- Verbrauch ----------
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -354,6 +362,15 @@ pub(crate) fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
|
|||||||
usage_stats_in(&Paths::real(), days)
|
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").
|
/// Modul-Registry für den Settings-Dialog (Checkbox-Gruppe „Module").
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn module_registry(project: String) -> Result<Vec<crate::domain::modules::ModuleInfo>, String> {
|
pub(crate) fn module_registry(project: String) -> Result<Vec<crate::domain::modules::ModuleInfo>, String> {
|
||||||
@@ -366,127 +383,78 @@ pub(crate) fn set_module(project: String, module: String, enabled: bool) -> Resu
|
|||||||
crate::domain::modules::set_module_in(&Paths::real(), &project, &module, enabled)
|
crate::domain::modules::set_module_in(&Paths::real(), &project, &module, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aktive Module fürs Frontend (Registry-Enablement der Projekt-Config).
|
/// 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.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn enabled_modules(project: String) -> Result<Vec<String>, String> {
|
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(
|
Ok(
|
||||||
crate::domain::modules::active_in(&Paths::real(), &project)?
|
crate::domain::modules::active_in(&paths, &project)?
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|m| !m.requires_archive || has_archive)
|
||||||
.map(|m| m.id.to_string())
|
.map(|m| m.id.to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Trilium ----------
|
/// Setzt das Archiv-Home eines Projekts (Einstellungsdialog) — inklusive
|
||||||
|
/// Permissions-Eintrag in der Projekt-settings.json.
|
||||||
/// 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn trilium_status(project: String) -> TriliumStatus {
|
pub(crate) fn set_archive_home_cmd(project: String, dir: String) -> Result<(), String> {
|
||||||
trilium_status_for(&project)
|
crate::domain::archive::set_project_archive_home(&project, &dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Richtet die Projekt-Instanz ein: Runtime laden (gepinnt, Checksum),
|
/// Wechselt das Archiv-Home (Einstellungsdialog, wenn schon eins gesetzt
|
||||||
/// Instanz starten. Schreibt KEINE Config — das Modul-Flag setzt erst
|
/// ist): optional ziehen die Dokumente mit um; die Rechte des alten Ordners
|
||||||
/// trilium_connect nach erfolgreichem Token-Login.
|
/// werden zurückgenommen.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn trilium_setup(project: String) -> Result<TriliumStatus, String> {
|
pub(crate) fn change_archive_home_cmd(
|
||||||
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,
|
project: String,
|
||||||
password: String,
|
dir: String,
|
||||||
) -> Result<TriliumStatus, String> {
|
migrate: bool,
|
||||||
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> {
|
) -> Result<(), String> {
|
||||||
use tauri::Manager;
|
crate::domain::archive::change_project_archive_home(&project, &dir, migrate)
|
||||||
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 Modul ab und stoppt die Projekt-Instanz. Daten und Token
|
/// Wählt das Archiv ab (Einstellungsdialog): Config-Eintrag und Permissions
|
||||||
/// bleiben — erneutes Aktivieren verbindet sich wieder.
|
/// weg, der Ordner bleibt liegen.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn trilium_disable(project: String) -> Result<(), String> {
|
pub(crate) fn clear_archive_home_cmd(project: String) -> Result<(), String> {
|
||||||
crate::domain::modules::set_module_in(&Paths::real(), &project, "trilium", false)?;
|
crate::domain::archive::clear_project_archive_home(&project)
|
||||||
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,565 @@
|
|||||||
|
//! 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`).
|
||||||
|
pub(crate) 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
//! 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>,
|
||||||
|
/// Letzte Änderung (Datei-mtime, `YYYY-MM-DD`).
|
||||||
|
pub(crate) modified: 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 mtime = fs::metadata(path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.as_secs();
|
||||||
|
let modified = crate::domain::archive::utc_stamp(mtime).1[..10].to_string();
|
||||||
|
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(),
|
||||||
|
modified,
|
||||||
|
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,
|
||||||
|
/// Letzte Änderung (Datei-mtime, `YYYY-MM-DD`).
|
||||||
|
pub(crate) modified: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
modified: doc.modified.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
//! 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.
|
||||||
|
pub(crate) 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt den Anzeige-Titel einer Notiz: `title:` im Frontmatter der Datei.
|
||||||
|
/// Der Dateiname bleibt — er ist der technische Name (Modellvorgabe: der
|
||||||
|
/// angezeigte Titel steht im Text). Ohne Frontmatter-Block bekommt die Datei
|
||||||
|
/// keinen Titel angeheftet, sondern der Aufruf scheitert laut.
|
||||||
|
pub(crate) fn set_title(path: &Path, title: &str) -> Result<(), String> {
|
||||||
|
let title = title.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err("Titel fehlt".into());
|
||||||
|
}
|
||||||
|
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
|
let rest = doc
|
||||||
|
.strip_prefix("---\n")
|
||||||
|
.ok_or_else(|| format!("kein Frontmatter-Block: {}", path.display()))?;
|
||||||
|
let end = rest
|
||||||
|
.find("\n---\n")
|
||||||
|
.ok_or_else(|| format!("kein Frontmatter-Block: {}", path.display()))?;
|
||||||
|
let line = format!("title: \"{}\"", title.replace('"', "'"));
|
||||||
|
let mut lines: Vec<String> = rest[..end].lines().map(str::to_string).collect();
|
||||||
|
match lines.iter().position(|l| l.trim_start().starts_with("title:")) {
|
||||||
|
Some(i) => lines[i] = line,
|
||||||
|
None => lines.insert(0, line),
|
||||||
|
}
|
||||||
|
let out = format!("---\n{}\n{}", lines.join("\n"), &rest[end + 1..]);
|
||||||
|
fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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. Die Textdatei des
|
||||||
|
/// Knotens (gleichnamiges Dokument daneben) wandert mit.
|
||||||
|
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()))?;
|
||||||
|
let src_text = src.with_extension("md");
|
||||||
|
if src_text.is_file() {
|
||||||
|
let target_text = target.with_extension("md");
|
||||||
|
fresh_target(&target_text, home)?;
|
||||||
|
fs::rename(&src_text, &target_text).map_err(|e| format!("{}: {e}", src_text.display()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invariante des Notizmodells: JEDER Knoten besitzt eine Textdatei — der
|
||||||
|
/// Ordnername ist nur technische Verwaltung, Titel und Inhalt stehen im
|
||||||
|
/// gleichnamigen Dokument daneben (Wurzel: `index.md`). Läuft vor jedem
|
||||||
|
/// Seitenaufbau und ergänzt fehlende Texte (Titel = Ordnername) — auch für
|
||||||
|
/// von Hand oder per Tool angelegte Ordner.
|
||||||
|
pub(crate) fn ensure_node_texts(
|
||||||
|
home: &Path,
|
||||||
|
project_name: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
ensure_dir_texts(home, project_name)?;
|
||||||
|
let stems = md_stems(home)?;
|
||||||
|
if !stems.contains("index") {
|
||||||
|
write_node_text(&home.join("index.md"), "Archiv", project_name)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_dir_texts(dir: &Path, project_name: &str) -> Result<(), String> {
|
||||||
|
let stems = md_stems(dir)?;
|
||||||
|
for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
|
||||||
|
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.starts_with('.') || !entry.path().is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Auch gestempelte Dokumente zählen als Knotentext (Name = Stem ohne
|
||||||
|
// Stempel) — kein Duplikat anlegen.
|
||||||
|
if !stems.contains(name.as_str()) {
|
||||||
|
write_node_text(&dir.join(format!("{name}.md")), &name, project_name)?;
|
||||||
|
}
|
||||||
|
ensure_dir_texts(&entry.path(), project_name)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Namen (Stem ohne Zeitstempel) aller Markdown-Dateien direkt im Ordner.
|
||||||
|
fn md_stems(dir: &Path) -> Result<std::collections::HashSet<String>, String> {
|
||||||
|
let mut out = std::collections::HashSet::new();
|
||||||
|
for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
|
||||||
|
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if let Some(stem) = name.strip_suffix(".md") {
|
||||||
|
out.insert(strip_stamp(stem).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_node_text(path: &Path, title: &str, project_name: &str) -> Result<(), String> {
|
||||||
|
let fm = frontmatter(title, project_name, &utc_now_iso()?, &ArchiveMeta::default());
|
||||||
|
fs::write(path, fm).map_err(|e| format!("{}: {e}", path.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 titel_setzen_ersetzt_frontmatter_zeile() {
|
||||||
|
let home = archiv();
|
||||||
|
let path = home.join("t.md");
|
||||||
|
fs::write(&path, "---\ntitle: \"Alt\"\nproject: p\n---\n\nRumpf\n").unwrap();
|
||||||
|
set_title(&path, "Neuer \"Titel\"").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(&path).unwrap(),
|
||||||
|
"---\ntitle: \"Neuer 'Titel'\"\nproject: p\n---\n\nRumpf\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ohne title-Zeile kommt sie oben dazu; der Rumpf bleibt.
|
||||||
|
let plain = home.join("ohne.md");
|
||||||
|
fs::write(&plain, "---\nproject: p\n---\n\nRumpf\n").unwrap();
|
||||||
|
set_title(&plain, "T").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(&plain).unwrap(),
|
||||||
|
"---\ntitle: \"T\"\nproject: p\n---\n\nRumpf\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Leerer Titel und Datei ohne Frontmatter scheitern laut.
|
||||||
|
assert!(set_title(&path, " ").is_err());
|
||||||
|
let bare = home.join("bare.md");
|
||||||
|
fs::write(&bare, "nur Text\n").unwrap();
|
||||||
|
assert!(set_title(&bare, "T").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 jeder_knoten_bekommt_text() {
|
||||||
|
let home = archiv();
|
||||||
|
fs::create_dir_all(home.join("leer/unter")).unwrap();
|
||||||
|
ensure_node_texts(&home, "proj").unwrap();
|
||||||
|
// Wurzel und alle Ordner haben danach ihre Textdatei.
|
||||||
|
assert!(home.join("index.md").is_file());
|
||||||
|
assert!(home.join("leer.md").is_file());
|
||||||
|
assert!(home.join("leer/unter.md").is_file());
|
||||||
|
let text = fs::read_to_string(home.join("leer.md")).unwrap();
|
||||||
|
assert!(text.starts_with("---\ntitle: \"leer\"\n"));
|
||||||
|
// Gestempelte Zwillinge zählen als Knotentext — kein Duplikat: konzepte/
|
||||||
|
// hat keins, bekommt eines; ein zweiter Lauf legt nichts Neues an.
|
||||||
|
assert!(home.join("konzepte.md").is_file());
|
||||||
|
let before = fs::read_to_string(home.join("konzepte.md")).unwrap();
|
||||||
|
ensure_node_texts(&home, "proj").unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(home.join("konzepte.md")).unwrap(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordner_verschieben_nimmt_knotentext_mit() {
|
||||||
|
let home = archiv();
|
||||||
|
fs::write(home.join("konzepte.md"), "k").unwrap();
|
||||||
|
move_folder(&home, "konzepte", "notizen").unwrap();
|
||||||
|
assert!(home.join("notizen").is_dir());
|
||||||
|
assert_eq!(fs::read_to_string(home.join("notizen.md")).unwrap(), "k");
|
||||||
|
assert!(!home.join("konzepte.md").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! 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,10 +2,13 @@
|
|||||||
//! (injizierbares Home) und den `ApikeyStore`-Trait testbar; OS-Aufrufe
|
//! (injizierbares Home) und den `ApikeyStore`-Trait testbar; OS-Aufrufe
|
||||||
//! laufen ausschließlich über crate::platform.
|
//! 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 credentials;
|
||||||
pub(crate) mod modules;
|
pub(crate) mod modules;
|
||||||
pub(crate) mod paths;
|
pub(crate) mod paths;
|
||||||
pub(crate) mod trilium;
|
|
||||||
pub(crate) mod pool;
|
pub(crate) mod pool;
|
||||||
pub(crate) mod project;
|
pub(crate) mod project;
|
||||||
pub(crate) mod registry;
|
pub(crate) mod registry;
|
||||||
|
|||||||
@@ -36,15 +36,36 @@ pub(crate) struct ModuleDesc {
|
|||||||
pub(crate) core: bool,
|
pub(crate) core: bool,
|
||||||
/// Aktiv ohne Eintrag in der Projekt-Config.
|
/// Aktiv ohne Eintrag in der Projekt-Config.
|
||||||
pub(crate) default_enabled: bool,
|
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) mcp_tools: &'static [&'static str],
|
||||||
pub(crate) buffers: &'static [BufferDesc],
|
pub(crate) buffers: &'static [BufferDesc],
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) const MODULES: &[ModuleDesc] = &[
|
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 {
|
ModuleDesc {
|
||||||
id: "commands",
|
id: "commands",
|
||||||
core: false,
|
core: false,
|
||||||
default_enabled: true,
|
default_enabled: true,
|
||||||
|
requires_archive: false,
|
||||||
mcp_tools: &["write_commands", "show_commands"],
|
mcp_tools: &["write_commands", "show_commands"],
|
||||||
buffers: &[BufferDesc {
|
buffers: &[BufferDesc {
|
||||||
id: "commands",
|
id: "commands",
|
||||||
@@ -60,6 +81,7 @@ pub(crate) const MODULES: &[ModuleDesc] = &[
|
|||||||
core: false,
|
core: false,
|
||||||
// Opt-in: der Tab erscheint erst, wenn das Modul im Projekt gewählt ist.
|
// Opt-in: der Tab erscheint erst, wenn das Modul im Projekt gewählt ist.
|
||||||
default_enabled: false,
|
default_enabled: false,
|
||||||
|
requires_archive: false,
|
||||||
mcp_tools: &["write_todos", "show_todos"],
|
mcp_tools: &["write_todos", "show_todos"],
|
||||||
buffers: &[BufferDesc {
|
buffers: &[BufferDesc {
|
||||||
id: "todos",
|
id: "todos",
|
||||||
@@ -71,14 +93,11 @@ pub(crate) const MODULES: &[ModuleDesc] = &[
|
|||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
ModuleDesc {
|
ModuleDesc {
|
||||||
id: "trilium",
|
id: "archive",
|
||||||
core: false,
|
core: false,
|
||||||
// Opt-in: das Anwählen öffnet im Settings-Dialog den Einricht-Wizard;
|
default_enabled: true,
|
||||||
// das Modul-Flag schreibt erst der erfolgreiche Setup (Erst-nach-Erfolg).
|
requires_archive: true,
|
||||||
// Das Modul IST das Archiv: Archivieren und Volltextsuche laufen über
|
mcp_tools: &["archive_panel", "show_archive", "search_archive"],
|
||||||
// die ETAPI der projekteigenen Trilium-Instanz.
|
|
||||||
default_enabled: false,
|
|
||||||
mcp_tools: &["write_panel", "search_archive"],
|
|
||||||
buffers: &[
|
buffers: &[
|
||||||
BufferDesc {
|
BufferDesc {
|
||||||
id: "search",
|
id: "search",
|
||||||
@@ -88,14 +107,12 @@ pub(crate) const MODULES: &[ModuleDesc] = &[
|
|||||||
suffix: "search.json",
|
suffix: "search.json",
|
||||||
persistent: false,
|
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 {
|
BufferDesc {
|
||||||
id: "noteopen",
|
id: "wiki",
|
||||||
env: "AI_CONTROL_NOTEOPEN",
|
env: "AI_CONTROL_WIKI",
|
||||||
event: "noteopen-update",
|
event: "wiki-update",
|
||||||
file: crate::domain::paths::noteopen_file,
|
file: crate::domain::paths::wiki_file,
|
||||||
suffix: "noteopen.json",
|
suffix: "wiki.json",
|
||||||
persistent: false,
|
persistent: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -125,7 +142,8 @@ pub(crate) fn by_tool(tool: &str) -> Option<&'static ModuleDesc> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Registry-Zeile für den Settings-Dialog: `enabled` ist der effektive
|
/// Registry-Zeile für den Settings-Dialog: `enabled` ist der effektive
|
||||||
/// Config-Schalter.
|
/// Config-Schalter (ohne `requires_archive` — die Pfadfrage zeigt der
|
||||||
|
/// Dialog daneben an).
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub(crate) struct ModuleInfo {
|
pub(crate) struct ModuleInfo {
|
||||||
pub(crate) id: &'static str,
|
pub(crate) id: &'static str,
|
||||||
@@ -189,7 +207,7 @@ mod tests {
|
|||||||
create_project(&p, "proj").unwrap();
|
create_project(&p, "proj").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ids(&active_in(&p, "proj").unwrap()),
|
ids(&active_in(&p, "proj").unwrap()),
|
||||||
vec!["commands"]
|
vec!["draft", "commands", "archive"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,10 +222,11 @@ mod tests {
|
|||||||
.join("config.json");
|
.join("config.json");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&cfg,
|
&cfg,
|
||||||
r#"{"id": "proj", "name": "proj", "modules": {"commands": false}}"#,
|
r#"{"id": "proj", "name": "proj", "modules": {"commands": false, "draft": false}}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(active_in(&p, "proj").unwrap().is_empty());
|
// commands ist ab; draft ignoriert den Eintrag (Kern-Modul).
|
||||||
|
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -223,7 +242,7 @@ mod tests {
|
|||||||
set_module_in(&p, "proj", "todo", true).unwrap();
|
set_module_in(&p, "proj", "todo", true).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ids(&active_in(&p, "proj").unwrap()),
|
ids(&active_in(&p, "proj").unwrap()),
|
||||||
vec!["commands", "todo"]
|
vec!["draft", "commands", "todo", "archive"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,20 +260,21 @@ mod tests {
|
|||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
create_project(&p, "proj").unwrap();
|
create_project(&p, "proj").unwrap();
|
||||||
set_module_in(&p, "proj", "commands", false).unwrap();
|
set_module_in(&p, "proj", "commands", false).unwrap();
|
||||||
assert!(active_in(&p, "proj").unwrap().is_empty());
|
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
|
||||||
assert!(!module_infos_in(&p, "proj").unwrap()[0].enabled);
|
assert!(!module_infos_in(&p, "proj").unwrap()[1].enabled);
|
||||||
// Zurück auf den Default löscht den Eintrag statt `true` zu speichern.
|
// Zurück auf den Default löscht den Eintrag statt `true` zu speichern.
|
||||||
set_module_in(&p, "proj", "commands", true).unwrap();
|
set_module_in(&p, "proj", "commands", true).unwrap();
|
||||||
let cfg = crate::domain::project::read_project_config_in(&p, "proj").unwrap();
|
let cfg = crate::domain::project::read_project_config_in(&p, "proj").unwrap();
|
||||||
assert!(cfg.modules.is_empty());
|
assert!(cfg.modules.is_empty());
|
||||||
// Unbekannte ID scheitert laut.
|
// Kern-Modul und unbekannte ID scheitern laut.
|
||||||
|
assert!(set_module_in(&p, "proj", "draft", false).is_err());
|
||||||
assert!(set_module_in(&p, "proj", "gibtsnicht", true).is_err());
|
assert!(set_module_in(&p, "proj", "gibtsnicht", true).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_zuordnung() {
|
fn tool_zuordnung() {
|
||||||
assert_eq!(by_tool("write_panel").unwrap().id, "trilium");
|
assert_eq!(by_tool("write_panel").unwrap().id, "draft");
|
||||||
assert_eq!(by_tool("search_archive").unwrap().id, "trilium");
|
assert_eq!(by_tool("search_archive").unwrap().id, "archive");
|
||||||
assert!(by_tool("unbekannt").is_none());
|
assert!(by_tool("unbekannt").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,13 +65,22 @@ fn checked(project: &str) -> &str {
|
|||||||
project
|
project
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signal-Datei „Notiz öffnen": write_panel (MCP) schreibt die noteId der
|
/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
|
||||||
/// frisch angelegten Trilium-Notiz, die Fenster öffnen sie daraufhin. Der
|
/// AI_CONTROL_PANEL in der PTY-Umgebung.
|
||||||
/// Pfad landet als AI_CONTROL_NOTEOPEN in der PTY-Umgebung.
|
pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||||
pub(crate) fn noteopen_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 {
|
||||||
Paths::real()
|
Paths::real()
|
||||||
.panels_dir()
|
.panels_dir()
|
||||||
.join(format!("{}.noteopen.json", checked(project)))
|
.join(format!("{}.md.source", checked(project)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
|
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
|
||||||
@@ -92,6 +101,15 @@ pub(crate) fn search_file(project: &str) -> PathBuf {
|
|||||||
.join(format!("{}.search.json", checked(project)))
|
.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
|
/// Persistente ToDo-Liste eines Projekts (JSONL, anhängend — überlebt
|
||||||
/// Sessions; write_todos hängt an, Kachel-Löschen entfernt Zeilen). Der Pfad
|
/// Sessions; write_todos hängt an, Kachel-Löschen entfernt Zeilen). Der Pfad
|
||||||
/// landet als AI_CONTROL_TODOS in der PTY-Umgebung.
|
/// landet als AI_CONTROL_TODOS in der PTY-Umgebung.
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ pub(crate) struct Project {
|
|||||||
pub(crate) pool: Option<String>,
|
pub(crate) pool: Option<String>,
|
||||||
pub(crate) running: bool,
|
pub(crate) running: bool,
|
||||||
pub(crate) terminal: TerminalConfig,
|
pub(crate) terminal: TerminalConfig,
|
||||||
/// Trilium-Modul aktiv — die Projektliste zeigt dann den Archiv-Knopf.
|
|
||||||
pub(crate) archive: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
@@ -53,6 +51,16 @@ pub(crate) struct ProjectConfig {
|
|||||||
pub(crate) name: Option<String>,
|
pub(crate) name: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
||||||
pub(crate) terminal: TerminalConfig,
|
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 =
|
/// Modul-Abweichungen vom Default (`"commands": false`); fehlender Key =
|
||||||
/// Default aus der Registry (domain/modules.rs). Kern-Module ignorieren
|
/// Default aus der Registry (domain/modules.rs). Kern-Module ignorieren
|
||||||
/// den Eintrag.
|
/// den Eintrag.
|
||||||
@@ -166,7 +174,6 @@ pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
|||||||
path: contract_home(paths, &entry.dir),
|
path: contract_home(paths, &entry.dir),
|
||||||
pool: entry.pool,
|
pool: entry.pool,
|
||||||
name: cfg.name.unwrap_or_else(|| id.clone()),
|
name: cfg.name.unwrap_or_else(|| id.clone()),
|
||||||
archive: cfg.modules.get("trilium").copied().unwrap_or(false),
|
|
||||||
terminal: cfg.terminal,
|
terminal: cfg.terminal,
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
@@ -307,6 +314,7 @@ pub(crate) fn create_project_full_in(
|
|||||||
id: Some(id.clone()),
|
id: Some(id.clone()),
|
||||||
name: Some(name.to_string()),
|
name: Some(name.to_string()),
|
||||||
terminal,
|
terminal,
|
||||||
|
archive_home: None,
|
||||||
modules: Default::default(),
|
modules: Default::default(),
|
||||||
rest: Default::default(),
|
rest: Default::default(),
|
||||||
};
|
};
|
||||||
@@ -395,6 +403,11 @@ pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
|
|||||||
let wd_path = expand_home(paths, &wd);
|
let wd_path = expand_home(paths, &wd);
|
||||||
fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
|
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);
|
crate::platform::write_terminal_desktop(paths, &id, &cfg);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -471,10 +484,10 @@ pub(crate) struct DeletePreview {
|
|||||||
pub(crate) name: String,
|
pub(crate) name: String,
|
||||||
pub(crate) project_dir: String,
|
pub(crate) project_dir: String,
|
||||||
pub(crate) ai_control_dir: bool,
|
pub(crate) ai_control_dir: bool,
|
||||||
|
pub(crate) archive_permission: bool,
|
||||||
pub(crate) panel_files: usize,
|
pub(crate) panel_files: usize,
|
||||||
/// Trilium-Datenverzeichnis der Projekt-Instanz (~-relativ), sofern es
|
pub(crate) archive_home: Option<String>,
|
||||||
/// existiert — das Archiv der Lösch-Stufe "archive".
|
pub(crate) archive_docs: usize,
|
||||||
pub(crate) trilium_data: Option<String>,
|
|
||||||
pub(crate) work_dirs: Vec<String>,
|
pub(crate) work_dirs: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,13 +495,25 @@ pub(crate) fn delete_preview_in(paths: &Paths, project: &str) -> Result<DeletePr
|
|||||||
check_name(project)?;
|
check_name(project)?;
|
||||||
let dir = project_dir(paths, project)?;
|
let dir = project_dir(paths, project)?;
|
||||||
let cfg = read_project_config_in(paths, project)?;
|
let cfg = read_project_config_in(paths, project)?;
|
||||||
let tri = crate::domain::trilium::data_dir(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,
|
||||||
|
};
|
||||||
Ok(DeletePreview {
|
Ok(DeletePreview {
|
||||||
name: cfg.name.clone().unwrap_or_else(|| project.to_string()),
|
name: cfg.name.clone().unwrap_or_else(|| project.to_string()),
|
||||||
project_dir: contract_home(paths, &dir),
|
project_dir: contract_home(paths, &dir),
|
||||||
ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_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(),
|
panel_files: session_files(paths, project).iter().filter(|f| f.is_file()).count(),
|
||||||
trilium_data: tri.is_dir().then(|| contract_home(paths, &tri)),
|
archive_home: cfg.archive_home.clone(),
|
||||||
|
archive_docs,
|
||||||
work_dirs: project_work_dirs_in(paths, project)?,
|
work_dirs: project_work_dirs_in(paths, project)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -508,10 +533,9 @@ fn session_files(paths: &Paths, project: &str) -> Vec<PathBuf> {
|
|||||||
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
|
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
|
||||||
/// vorige ein):
|
/// vorige ein):
|
||||||
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
|
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
|
||||||
/// Panel-Dateien, .desktop. Ordner, memory/ und das Claude-Code-Grundgerüst
|
/// Archiv-Rechte in der settings.json, Panel-Dateien, .desktop. Ordner,
|
||||||
/// der settings.json bleiben.
|
/// memory/ und das Claude-Code-Grundgerüst der settings.json bleiben.
|
||||||
/// - "archive": zusätzlich das Trilium-Archiv der Projekt-Instanz
|
/// - "archive": zusätzlich der Archiv-Ordner samt Dokumenten.
|
||||||
/// (Datenverzeichnis + Keyring-Token; die Instanz wird vorher gestoppt).
|
|
||||||
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
|
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
|
||||||
pub(crate) fn delete_project_scoped_in(
|
pub(crate) fn delete_project_scoped_in(
|
||||||
paths: &Paths,
|
paths: &Paths,
|
||||||
@@ -524,17 +548,24 @@ pub(crate) fn delete_project_scoped_in(
|
|||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Err(format!("Projektordner nicht gefunden: {}", dir.display()));
|
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 scope == "archive" || scope == "full" {
|
||||||
crate::domain::trilium::stop_instance(project);
|
if let Some(a) = cfg.archive_home.as_deref() {
|
||||||
let tri = crate::domain::trilium::data_dir(paths, project);
|
let home = expand_home(paths, a);
|
||||||
if tri.is_dir() {
|
if home.is_dir() {
|
||||||
fs::remove_dir_all(&tri).map_err(|e| format!("{}: {e}", tri.display()))?;
|
fs::remove_dir_all(&home).map_err(|e| format!("{}: {e}", home.display()))?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
crate::domain::trilium::delete_token(project);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match scope {
|
match scope {
|
||||||
"integration" | "archive" => {
|
"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);
|
let ac = dir.join(PROJECT_CONFIG_DIR);
|
||||||
if ac.is_dir() {
|
if ac.is_dir() {
|
||||||
fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?;
|
fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?;
|
||||||
@@ -633,7 +664,7 @@ pub(crate) fn resolve_icon_path(
|
|||||||
|
|
||||||
/// Liest eine Datei für `write_panel(path)` — den promptfreien MCP-Weg.
|
/// Liest eine Datei für `write_panel(path)` — den promptfreien MCP-Weg.
|
||||||
/// Erlaubt ist genau, was die Session über ihre Permissions ohnehin promptfrei
|
/// Erlaubt ist genau, was die Session über ihre Permissions ohnehin promptfrei
|
||||||
/// liest: Projektordner und Arbeitsordner. Geprüft wird nach
|
/// liest: Projektordner, Arbeitsordner und Archiv-Home. Geprüft wird nach
|
||||||
/// `canonicalize` (symlink-fest), nur reguläre Dateien bis 2 MB — sonst
|
/// `canonicalize` (symlink-fest), nur reguläre Dateien bis 2 MB — sonst
|
||||||
/// umginge das Tool das Read-Permission-Modell von Claude Code, und ein
|
/// umginge das Tool das Read-Permission-Modell von Claude Code, und ein
|
||||||
/// Device wie /dev/zero hinge den Prozess.
|
/// Device wie /dev/zero hinge den Prozess.
|
||||||
@@ -648,13 +679,16 @@ pub(crate) fn read_for_panel_in(
|
|||||||
for d in project_work_dirs_in(paths, project)? {
|
for d in project_work_dirs_in(paths, project)? {
|
||||||
roots.push(expand_home(paths, &d));
|
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
|
let allowed = roots
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|r| fs::canonicalize(r).ok())
|
.filter_map(|r| fs::canonicalize(r).ok())
|
||||||
.any(|r| canon.starts_with(&r));
|
.any(|r| canon.starts_with(&r));
|
||||||
if !allowed {
|
if !allowed {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{src}: liegt außerhalb von Projekt- und Arbeitsordnern"
|
"{src}: liegt außerhalb von Projekt-, Arbeits- und Archiv-Ordnern"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let f = fs::File::open(&canon).map_err(|e| format!("{src}: {e}"))?;
|
let f = fs::File::open(&canon).map_err(|e| format!("{src}: {e}"))?;
|
||||||
@@ -1054,8 +1088,7 @@ mod tests {
|
|||||||
let cfg = read_project_config_in(&p, &id).unwrap();
|
let cfg = read_project_config_in(&p, &id).unwrap();
|
||||||
assert_eq!(cfg.id.as_deref(), Some(id.as_str()));
|
assert_eq!(cfg.id.as_deref(), Some(id.as_str()));
|
||||||
assert_eq!(cfg.name.as_deref(), Some("proj"));
|
assert_eq!(cfg.name.as_deref(), Some("proj"));
|
||||||
// Unbekannte Alt-Keys (archiveHome) wandern unverändert mit.
|
assert_eq!(cfg.archive_home.as_deref(), Some("~/archiv"));
|
||||||
assert_eq!(cfg.rest["archiveHome"], "~/archiv");
|
|
||||||
// Icon liegt jetzt im Projekt, das zentrale Verzeichnis ist geräumt.
|
// Icon liegt jetzt im Projekt, das zentrale Verzeichnis ist geräumt.
|
||||||
assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png"));
|
assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png"));
|
||||||
assert!(dir.join(PROJECT_CONFIG_DIR).join("icon.png").is_file());
|
assert!(dir.join(PROJECT_CONFIG_DIR).join("icon.png").is_file());
|
||||||
@@ -1110,7 +1143,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Import eines Ordners mit mitgebrachter Config: die ID wird übernommen
|
/// Import eines Ordners mit mitgebrachter Config: die ID wird übernommen
|
||||||
/// (Sync-Fall), Arbeitsordner werden angelegt.
|
/// (Sync-Fall), Arbeitsordner und Archiv-Home werden angelegt, die
|
||||||
|
/// Archiv-Permission nachgezogen.
|
||||||
#[test]
|
#[test]
|
||||||
fn projekt_import_uebernimmt_id_und_legt_pfade_an() {
|
fn projekt_import_uebernimmt_id_und_legt_pfade_an() {
|
||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
@@ -1118,7 +1152,7 @@ mod tests {
|
|||||||
fs::create_dir_all(dir.join(PROJECT_CONFIG_DIR)).unwrap();
|
fs::create_dir_all(dir.join(PROJECT_CONFIG_DIR)).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE),
|
dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE),
|
||||||
r#"{"id": "sync-id", "name": "Mein Projekt"}"#,
|
r#"{"id": "sync-id", "name": "Mein Projekt", "archiveHome": "~/archiv/proj"}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::create_dir_all(dir.join(".claude")).unwrap();
|
fs::create_dir_all(dir.join(".claude")).unwrap();
|
||||||
@@ -1133,6 +1167,10 @@ mod tests {
|
|||||||
assert_eq!(project_dir(&p, "sync-id").unwrap(), dir);
|
assert_eq!(project_dir(&p, "sync-id").unwrap(), dir);
|
||||||
assert_eq!(display_name_in(&p, "sync-id").unwrap(), "Mein Projekt");
|
assert_eq!(display_name_in(&p, "sync-id").unwrap(), "Mein Projekt");
|
||||||
assert!(p.home.join("work/proj").is_dir());
|
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.
|
// Doppelt aufnehmen scheitert an der ID.
|
||||||
let err = add_project_in(&p, &dir.display().to_string()).unwrap_err();
|
let err = add_project_in(&p, &dir.display().to_string()).unwrap_err();
|
||||||
@@ -1262,7 +1300,7 @@ mod tests {
|
|||||||
// -- write_panel(path): promptfreier Lese-Weg --
|
// -- write_panel(path): promptfreier Lese-Weg --
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn panel_lesen_nur_aus_projekt_und_arbeitsordnern() {
|
fn panel_lesen_nur_aus_projekt_arbeits_und_archivordnern() {
|
||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
create_project(&p, "proj").unwrap();
|
create_project(&p, "proj").unwrap();
|
||||||
let dir = p.projects_dir().join("proj");
|
let dir = p.projects_dir().join("proj");
|
||||||
@@ -1301,45 +1339,49 @@ mod tests {
|
|||||||
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
|
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let dir = p.projects_dir().join("proj");
|
let dir = p.projects_dir().join("proj");
|
||||||
// flüchtige Panel-Datei + Trilium-Datenverzeichnis
|
// 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
|
||||||
fs::create_dir_all(p.config_dir().join("panels")).unwrap();
|
fs::create_dir_all(p.config_dir().join("panels")).unwrap();
|
||||||
fs::write(
|
fs::write(p.config_dir().join("panels").join(format!("{id}.md")), "x").unwrap();
|
||||||
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();
|
let preview = delete_preview_in(&p, &id).unwrap();
|
||||||
assert!(preview.ai_control_dir);
|
assert!(preview.ai_control_dir && preview.archive_permission);
|
||||||
assert_eq!(preview.panel_files, 1);
|
assert_eq!(preview.panel_files, 1);
|
||||||
assert!(preview.trilium_data.is_some());
|
assert_eq!(preview.archive_docs, 1);
|
||||||
|
|
||||||
delete_project_scoped_in(&p, &id, "integration", false).unwrap();
|
delete_project_scoped_in(&p, &id, "integration", false).unwrap();
|
||||||
|
|
||||||
assert!(dir.is_dir());
|
assert!(dir.is_dir());
|
||||||
assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
|
assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
|
||||||
let sp = fs::read_to_string(settings_path(&dir)).unwrap();
|
let sp = fs::read_to_string(settings_path(&dir)).unwrap();
|
||||||
|
assert!(!sp.contains("archiv/proj"));
|
||||||
assert!(sp.contains("autoMemoryDirectory")); // Claude-Code-Bestand bleibt
|
assert!(sp.contains("autoMemoryDirectory")); // Claude-Code-Bestand bleibt
|
||||||
assert!(tri.is_dir()); // Archiv bleibt
|
assert!(p.home.join("archiv/proj/doc.md").is_file()); // Archiv bleibt
|
||||||
assert!(!p.config_dir().join("panels").join(format!("{id}.commands.jsonl")).exists());
|
assert!(!p.config_dir().join("panels").join(format!("{id}.md")).exists());
|
||||||
assert!(load_registry(&p).unwrap().is_empty());
|
assert!(load_registry(&p).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stufe „Integration & Archiv": zusätzlich stirbt das Trilium-Datadir.
|
/// Stufe „Integration & Archiv": zusätzlich stirbt der Archiv-Ordner.
|
||||||
#[test]
|
#[test]
|
||||||
fn loeschen_integration_und_archiv() {
|
fn loeschen_integration_und_archiv() {
|
||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
|
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let tri = crate::domain::trilium::data_dir(&p, &id);
|
let mut cfg = read_project_config_in(&p, &id).unwrap();
|
||||||
fs::create_dir_all(&tri).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();
|
||||||
|
|
||||||
delete_project_scoped_in(&p, &id, "archive", false).unwrap();
|
delete_project_scoped_in(&p, &id, "archive", false).unwrap();
|
||||||
|
|
||||||
assert!(p.projects_dir().join("proj").is_dir());
|
assert!(p.projects_dir().join("proj").is_dir());
|
||||||
assert!(!tri.exists());
|
assert!(!p.home.join("archiv/proj").exists());
|
||||||
assert!(load_registry(&p).unwrap().is_empty());
|
assert!(load_registry(&p).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen):
|
//! App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen):
|
||||||
//! claudeCommand, syncOnSessionEnd, poolSyncDir, terminalFontSize.
|
//! claudeCommand, syncOnSessionEnd, poolSyncDir, terminalFontSize, spellcheckLang.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -53,6 +53,14 @@ pub(crate) fn set_terminal_font_size_in(paths: &Paths, size: u32) -> Result<(),
|
|||||||
write_app_setting(paths, "terminalFontSize", serde_json::json!(size))
|
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
|
/// Hat der Nutzer das Angebot, die App nach ~/Applications zu holen, schon
|
||||||
/// abgelehnt? (settings.json: moveOfferDismissed, nur macOS)
|
/// abgelehnt? (settings.json: moveOfferDismissed, nur macOS)
|
||||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||||
|
|||||||
@@ -1,555 +0,0 @@
|
|||||||
//! 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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-14
@@ -95,17 +95,12 @@ pub fn run() {
|
|||||||
// Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt
|
// Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt
|
||||||
// überschreibt macOS es beim Anlegen des Dock-Tiles wieder.
|
// überschreibt macOS es beim Anlegen des Dock-Tiles wieder.
|
||||||
.run(move |app, event| {
|
.run(move |app, event| {
|
||||||
match event {
|
if let tauri::RunEvent::Ready = event {
|
||||||
tauri::RunEvent::Ready => {
|
|
||||||
if let Some(icon) = icon.as_deref() {
|
if let Some(icon) = icon.as_deref() {
|
||||||
platform::set_dock_icon(icon);
|
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(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -113,14 +108,8 @@ pub fn run() {
|
|||||||
// dem offenen Fenster über ai-control.desktop das App-Icon zu.
|
// dem offenen Fenster über ai-control.desktop das App-Icon zu.
|
||||||
platform::set_app_id("ai-control");
|
platform::set_app_id("ai-control");
|
||||||
app::main_builder()
|
app::main_builder()
|
||||||
.build(context)
|
.run(context)
|
||||||
.expect("error while building tauri application")
|
.expect("error while running tauri application");
|
||||||
.run(|_, event| {
|
|
||||||
// Von diesem Prozess gestartete Trilium-Instanzen enden mit ihm.
|
|
||||||
if let tauri::RunEvent::Exit = event {
|
|
||||||
domain::trilium::stop_all();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-94
@@ -1,9 +1,8 @@
|
|||||||
//! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server
|
//! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server
|
||||||
//! startet. Die Tools kommen aus der Modul-Registry (domain/modules.rs);
|
//! startet. Er stellt genau ein Tool bereit: `write_panel(text)` schreibt den
|
||||||
//! Puffer-Kanäle laufen über die AI_CONTROL_*-Dateipfade, die der
|
//! 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
|
//! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder vererbt).
|
||||||
//! vererbt. Der Datei-Watcher im Terminal-Prozess zieht Änderungen als
|
//! Der bestehende Datei-Watcher im Terminal-Prozess zieht den Inhalt ins Panel.
|
||||||
//! Events in die Panel-Fenster.
|
|
||||||
//!
|
//!
|
||||||
//! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile).
|
//! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile).
|
||||||
|
|
||||||
@@ -98,16 +97,14 @@ fn tool_def(name: &str) -> Value {
|
|||||||
"name": "write_panel",
|
"name": "write_panel",
|
||||||
"description":
|
"description":
|
||||||
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
|
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
|
||||||
Commit-Message, Textbaustein) als Notiz im Trilium-Archiv des \
|
Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
|
||||||
Projekts an und öffnet sie im Trilium-Fenster neben dem Terminal. \
|
ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
|
||||||
Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
|
ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
|
||||||
aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \
|
aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \
|
||||||
IMMER `path` statt `text` übergeben — der Server liest die Datei \
|
IMMER `path` statt `text` übergeben — der Server liest die Datei \
|
||||||
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss. \
|
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss. \
|
||||||
`path` muss im Projekt- oder Arbeitsordner liegen (reguläre Datei, \
|
`path` muss im Projekt-, Arbeits- oder Archiv-Ordner liegen \
|
||||||
max. 2 MB). `folder`, `description` und `tags` kuratieren die \
|
(reguläre Datei, max. 2 MB).",
|
||||||
Notiz: Ordner werden zur Notiz-Hierarchie, Beschreibung und \
|
|
||||||
Schlagwörter zu Labels.",
|
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -118,23 +115,8 @@ fn tool_def(name: &str) -> Value {
|
|||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description":
|
"description":
|
||||||
"Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt \
|
"Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt ins \
|
||||||
die Notiz wird. Genau eines von beiden angeben.",
|
Panel geladen 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).",
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -228,20 +210,69 @@ fn tool_def(name: &str) -> Value {
|
|||||||
(„zeig die ToDos“).",
|
(„zeig die ToDos“).",
|
||||||
"inputSchema": { "type": "object", "properties": {} },
|
"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!({
|
"search_archive" => json!({
|
||||||
"name": "search_archive",
|
"name": "search_archive",
|
||||||
"description":
|
"description":
|
||||||
"Volltext-Suche über das Trilium-Archiv des Projekts (Triliums \
|
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \
|
||||||
Suchsyntax: Wörter, \"Phrasen\", Operatoren). Die Treffer \
|
Wörter, \"Phrasen\", Präfix*). Die Treffer erscheinen als Kacheln \
|
||||||
erscheinen als Kacheln im Panel; das Tool liefert sie zusätzlich \
|
im Panel; das Tool liefert sie zusätzlich mit Pfad und Snippet \
|
||||||
mit Titel und noteId zurück. Nutzen, wenn der Nutzer im Archiv \
|
zurück. Nutzen, wenn der Nutzer im Archiv suchen will („such im \
|
||||||
suchen will („such im Archiv nach …“).",
|
Archiv nach …“).",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"query": {
|
"query": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Suchanfrage (Trilium-Suchsyntax).",
|
"description": "Suchanfrage (FTS5-Syntax).",
|
||||||
},
|
},
|
||||||
"tag": {
|
"tag": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -269,7 +300,9 @@ fn call_tool(req: &Value) -> Value {
|
|||||||
"show_commands" => call_show_commands(),
|
"show_commands" => call_show_commands(),
|
||||||
"write_todos" => call_write_todos(req),
|
"write_todos" => call_write_todos(req),
|
||||||
"show_todos" => call_show_todos(),
|
"show_todos" => call_show_todos(),
|
||||||
|
"archive_panel" => call_archive(req),
|
||||||
"search_archive" => call_search(req),
|
"search_archive" => call_search(req),
|
||||||
|
"show_archive" => call_show_archive(req),
|
||||||
other => err(format!("Unbekanntes Tool: {other}")),
|
other => err(format!("Unbekanntes Tool: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,75 +324,44 @@ fn env_path(var: &str) -> Result<String, Value> {
|
|||||||
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
|
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile —
|
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL. Ein frischer
|
||||||
/// wird der Notiz-Titel von write_panel.
|
/// Entwurf löst die Quell-Verknüpfung des Dokument-Tabs (`<panel>.source`,
|
||||||
fn first_line(text: &str) -> String {
|
/// gesetzt beim Öffnen eines Archiv-Dokuments) — Edits gehören danach wieder
|
||||||
let mut fallback: Option<&str> = None;
|
/// nur dem Panel, nicht der zuletzt geöffneten Archiv-Datei.
|
||||||
for line in text.lines() {
|
fn write_panel_text(text: &str) -> Result<(), Value> {
|
||||||
let t = line.trim();
|
let path = env_path("AI_CONTROL_PANEL")?;
|
||||||
if t.is_empty() {
|
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))?;
|
||||||
continue;
|
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}"))),
|
||||||
}
|
}
|
||||||
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 {
|
fn call_write(req: &Value) -> Value {
|
||||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
|
||||||
let args = &req["params"]["arguments"];
|
let args = &req["params"]["arguments"];
|
||||||
// `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne
|
// `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne
|
||||||
// dass das Modell den Inhalt Token für Token als `text` generieren muss.
|
// dass das Modell den Inhalt Token für Token als `text` generieren muss.
|
||||||
// Der Zugriff ist auf Projekt- und Arbeitsordner begrenzt
|
// Der Zugriff ist auf Projekt-, Arbeits- und Archiv-Ordner begrenzt
|
||||||
// (read_for_panel_in): das Tool ist promptfrei freigegeben und darf das
|
// (read_for_panel_in): das Tool ist promptfrei freigegeben und darf das
|
||||||
// Read-Permission-Modell von Claude Code nicht umgehen.
|
// Read-Permission-Modell von Claude Code nicht umgehen.
|
||||||
let text = match args["path"].as_str() {
|
let (text, ok_msg) = match args["path"].as_str() {
|
||||||
Some(src) => {
|
Some(src) => {
|
||||||
|
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||||
let paths = crate::domain::paths::Paths::real();
|
let paths = crate::domain::paths::Paths::real();
|
||||||
match crate::domain::project::read_for_panel_in(&paths, &project, src) {
|
match crate::domain::project::read_for_panel_in(&paths, &project, src) {
|
||||||
Ok(content) => content,
|
Ok(content) => (content, "Datei ins Panel geladen."),
|
||||||
Err(e) => return err(format!("Datei nicht geladen: {e}")),
|
Err(e) => return err(format!("Datei nicht geladen: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => args["text"].as_str().unwrap_or("").to_string(),
|
None => (
|
||||||
|
args["text"].as_str().unwrap_or("").to_string(),
|
||||||
|
"Entwurf ins Panel geschrieben.",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
if text.trim().is_empty() {
|
match write_panel_text(&text) {
|
||||||
return err("Kein Inhalt — `text` oder `path` angeben.".into());
|
Ok(()) => ok(ok_msg.into()),
|
||||||
}
|
Err(e) => e,
|
||||||
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}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,15 +508,71 @@ fn call_write_todos(req: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Volltext-Suche übers Trilium-Archiv: Treffer als JSON in die
|
/// `dir` gibt es hier bewusst nicht mehr: Das Setzen des Archiv-Homes vergibt
|
||||||
/// Suchtreffer-Datei (der Watcher zieht sie als Kacheln ins Panel) und als
|
/// über `add_archive_permission` dauerhafte Rechte in der settings.json des
|
||||||
/// Text zurück an claude.
|
/// 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.
|
||||||
fn call_search(req: &Value) -> Value {
|
fn call_search(req: &Value) -> Value {
|
||||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
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 args = &req["params"]["arguments"];
|
||||||
let query = args["query"].as_str().unwrap_or("");
|
let query = args["query"].as_str().unwrap_or("");
|
||||||
let tag = args["tag"].as_str();
|
let tag = args["tag"].as_str();
|
||||||
let hits = match crate::domain::trilium::search_notes(&project, query, tag, 20) {
|
let hits = match crate::domain::archive_search::search(&home, query, tag, 20) {
|
||||||
Ok(hits) => hits,
|
Ok(hits) => hits,
|
||||||
Err(e) => return err(format!("Suche fehlgeschlagen: {e}")),
|
Err(e) => return err(format!("Suche fehlgeschlagen: {e}")),
|
||||||
};
|
};
|
||||||
@@ -525,6 +583,7 @@ fn call_search(req: &Value) -> Value {
|
|||||||
let payload = json!({
|
let payload = json!({
|
||||||
"query": query,
|
"query": query,
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
|
"home": home.display().to_string(),
|
||||||
"hits": hits,
|
"hits": hits,
|
||||||
});
|
});
|
||||||
if let Err(e) = std::fs::write(&search_path, payload.to_string()) {
|
if let Err(e) = std::fs::write(&search_path, payload.to_string()) {
|
||||||
@@ -532,11 +591,12 @@ fn call_search(req: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
let list: Vec<String> = hits
|
let list: Vec<String> = hits
|
||||||
.iter()
|
.iter()
|
||||||
.map(|h| format!("- {} ({})", h.title, h.note_id))
|
.map(|h| format!("- {} — {} ({})", h.relpath, h.title, h.snippet))
|
||||||
.collect();
|
.collect();
|
||||||
ok(format!(
|
ok(format!(
|
||||||
"{} Treffer, als Kacheln im Panel.\n{}",
|
"{} Treffer, als Kacheln im Panel. Archiv: {}\n{}",
|
||||||
hits.len(),
|
hits.len(),
|
||||||
|
home.display(),
|
||||||
list.join("\n")
|
list.join("\n")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ pub(crate) fn set_app_id(name: &str) {
|
|||||||
glib::set_prgname(Some(name));
|
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.
|
/// Selbstinstallation gibt es nur unter macOS — hier installieren deb/rpm.
|
||||||
pub(crate) fn offer_move_to_applications(_paths: &crate::domain::paths::Paths) {}
|
pub(crate) fn offer_move_to_applications(_paths: &crate::domain::paths::Paths) {}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ pub(crate) fn focus_terminal(pid: u32) {
|
|||||||
/// Wayland-app_id gibt es nur unter Linux.
|
/// Wayland-app_id gibt es nur unter Linux.
|
||||||
pub(crate) fn set_app_id(_name: &str) {}
|
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 ----------
|
// ---------- Selbstinstallation ----------
|
||||||
|
|
||||||
/// App-Bundle zum Programmpfad: …/ai-control.app/Contents/MacOS/ai-control.
|
/// App-Bundle zum Programmpfad: …/ai-control.app/Contents/MacOS/ai-control.
|
||||||
@@ -318,4 +322,3 @@ pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(),
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! Prozesse terminal_pids, kill_terminal
|
//! Prozesse terminal_pids, kill_terminal
|
||||||
//! Fokus/Aktivierung focus_terminal, activate_self, yield_activation
|
//! Fokus/Aktivierung focus_terminal, activate_self, yield_activation
|
||||||
//! Fenster/Icons set_dock_icon, set_app_id
|
//! Fenster/Icons set_dock_icon, set_app_id
|
||||||
//! Dateisystem home_dir, write_secret_file, symlink
|
//! Dateisystem home_dir, write_secret_file, symlink, reveal_path
|
||||||
//! Shell shell_command
|
//! Shell shell_command
|
||||||
//! Secrets KeychainStore (impl ApikeyStore), apikey_helper_command,
|
//! Secrets KeychainStore (impl ApikeyStore), apikey_helper_command,
|
||||||
//! oauth_keychain_exists, oauth_keychain_delete
|
//! oauth_keychain_exists, oauth_keychain_delete
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ pub(crate) fn set_app_id(_name: &str) {}
|
|||||||
/// Selbstinstallation gibt es nur unter macOS — hier installiert der NSIS-Setup.
|
/// 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 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 ----------
|
// ---------- Dateisystem / Shell ----------
|
||||||
|
|
||||||
pub(crate) fn home_dir() -> PathBuf {
|
pub(crate) fn home_dir() -> PathBuf {
|
||||||
@@ -119,4 +125,3 @@ pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(),
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+259
-25
@@ -5,7 +5,9 @@ use std::io::{Read, Write};
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
use crate::domain::paths::{commands_file, search_file, Paths};
|
use crate::domain::paths::{
|
||||||
|
commands_file, panel_file, panel_source_file, search_file, wiki_file, Paths,
|
||||||
|
};
|
||||||
use crate::domain::project::{
|
use crate::domain::project::{
|
||||||
project_config, project_pool_dir, verify_project_dir_in, ProjectConfig,
|
project_config, project_pool_dir, verify_project_dir_in, ProjectConfig,
|
||||||
};
|
};
|
||||||
@@ -145,19 +147,12 @@ pub fn term_start(
|
|||||||
let _ = std::fs::write(path, "");
|
let _ = std::fs::write(path, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Trilium-Modul an: Projekt-Instanz zur Session hochfahren (im Thread —
|
// Quell-Verknüpfung der Vorsession lösen — der Dokument-Puffer startet leer,
|
||||||
// der Start wartet bis zu 20 s auf den Port). Fehler landen im Dev-Log;
|
// Edits dürfen nicht in die zuletzt geöffnete Archiv-Datei laufen.
|
||||||
// die Tools melden eine fehlende Instanz ohnehin laut.
|
match std::fs::remove_file(panel_source_file(&project)) {
|
||||||
if crate::domain::modules::active_in(&paths, &project)?
|
Ok(()) => {}
|
||||||
.iter()
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
.any(|m| m.id == "trilium")
|
Err(e) => return Err(e.to_string()),
|
||||||
{
|
|
||||||
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));
|
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
|
||||||
@@ -434,24 +429,224 @@ pub fn todos_update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Quell-Verknüpfung des Dokument-Tabs setzen: ab jetzt schreibt jeder
|
/// Quell-Verknüpfung des Dokument-Tabs setzen: ab jetzt schreibt jeder
|
||||||
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive über
|
/// Editor-Commit den Body in diese Archiv-Datei zurück.
|
||||||
/// die ETAPI der Projekt-Instanz und schreibt die Treffer-Datei; der Watcher
|
fn link_panel_source(project: &str, path: &std::path::Path) -> Result<(), String> {
|
||||||
/// zieht sie in die Ansicht (beide Fenster).
|
std::fs::write(panel_source_file(project), path.display().to_string())
|
||||||
#[tauri::command]
|
.map_err(|e| e.to_string())
|
||||||
pub async fn search_run(
|
}
|
||||||
project: String,
|
|
||||||
query: String,
|
/// Aktuelle Quell-Verknüpfung, falls gesetzt.
|
||||||
tag: Option<String>,
|
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>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let hits = crate::domain::trilium::search_notes(&project, &query, tag.as_deref(), 20)?;
|
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 payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"query": query,
|
"query": query,
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
|
"home": home.display().to_string(),
|
||||||
"hits": hits,
|
"hits": hits,
|
||||||
});
|
});
|
||||||
std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string())
|
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 display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
||||||
|
crate::domain::archive_ops::ensure_node_texts(&home, &display)?;
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest den Body eines Archiv-Dokuments (ohne Frontmatter) — Inhalt der
|
||||||
|
/// Notiz-Ansicht im Archiv-Tab.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn archive_read(project: String, relpath: String) -> Result<String, String> {
|
||||||
|
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||||
|
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||||
|
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
|
Ok(crate::domain::archive::strip_frontmatter(&text).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schreibt den Body einer Archiv-Notiz zurück (Bearbeiten im
|
||||||
|
/// Archiv-Fenster); die Frontmatter der Datei bleibt.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn archive_write(project: String, relpath: String, text: String) -> Result<(), String> {
|
||||||
|
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||||
|
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||||
|
crate::domain::archive_ops::write_body(&path, &text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt den Anzeige-Titel einer Notiz (Klick auf den Titel im Archiv);
|
||||||
|
/// danach die frische Übersicht, damit Baum und Karten den neuen Titel zeigen.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn archive_set_title(project: String, relpath: String, title: String) -> Result<(), String> {
|
||||||
|
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||||
|
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||||
|
crate::domain::archive_ops::set_title(&path, &title)?;
|
||||||
|
wiki_refresh_page(&project, &home)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 display = crate::domain::project::display_name_in(&Paths::real(), project)?;
|
||||||
|
crate::domain::archive_ops::ensure_node_texts(home, &display)?;
|
||||||
|
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
|
/// 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.
|
/// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus.
|
||||||
/// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem
|
/// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem
|
||||||
@@ -464,7 +659,14 @@ pub async fn open_panel_window(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let label = format!("panel-{project}");
|
let label = format!("panel-{project}");
|
||||||
if let Some(w) = app.get_webview_window(&label) {
|
if let Some(w) = app.get_webview_window(&label) {
|
||||||
|
// Fenster steht schon: nach vorn holen und auf den gewünschten Tab
|
||||||
|
// schalten (Archiv-/Such-Öffner im Terminal-Header).
|
||||||
let _ = w.set_focus();
|
let _ = w.set_focus();
|
||||||
|
if let Some(m) = &mode {
|
||||||
|
app
|
||||||
|
.emit_to(tauri::EventTarget::webview_window(&label), "panel-mode", m)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let cfg = project_config(&project)?;
|
let cfg = project_config(&project)?;
|
||||||
@@ -481,18 +683,50 @@ pub async fn open_panel_window(
|
|||||||
Some(m) => format!("panel.html?project={project}&mode={m}"),
|
Some(m) => format!("panel.html?project={project}&mode={m}"),
|
||||||
None => format!("panel.html?project={project}"),
|
None => format!("panel.html?project={project}"),
|
||||||
};
|
};
|
||||||
|
// Zentrale Popup-Größe: EINMAL beim Öffnen gesetzt, für alle Tabs gleich —
|
||||||
|
// 85 % der Bildschirmbreite, 92 % der Höhe (Vorbild Trilium, kein
|
||||||
|
// Vollbild). Danach fasst niemand mehr die Größe an; was der Nutzer zieht,
|
||||||
|
// bleibt. Maßgeblich ist der Monitor des Terminal-Fensters, das den Öffner
|
||||||
|
// geklickt hat — bei mehreren Monitoren zählt der, vor dem der Nutzer
|
||||||
|
// sitzt (Wayland kennt zudem keinen „primären" Monitor).
|
||||||
|
let monitor = app
|
||||||
|
.get_webview_window(&format!("term-{project}"))
|
||||||
|
.and_then(|w| w.current_monitor().ok().flatten())
|
||||||
|
.or_else(|| app.primary_monitor().ok().flatten())
|
||||||
|
.or_else(|| app.available_monitors().ok().and_then(|m| m.into_iter().next()));
|
||||||
|
let (w, h) = match &monitor {
|
||||||
|
Some(m) => {
|
||||||
|
let s = m.size().to_logical::<f64>(m.scale_factor());
|
||||||
|
(s.width * 0.85, s.height * 0.92)
|
||||||
|
}
|
||||||
|
// Kein Monitor abfragbar: Standard-Bildschirm 1920×1080 annehmen,
|
||||||
|
// gleiche Faktoren.
|
||||||
|
None => (1920.0 * 0.85, 1080.0 * 0.92),
|
||||||
|
};
|
||||||
let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into()))
|
let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into()))
|
||||||
.title(format!("{title} — Dokument"))
|
.title(format!("{title} — Dokument"))
|
||||||
.inner_size(480.0, 640.0)
|
.inner_size(w, h)
|
||||||
.background_color(tauri::window::Color(r, g, b, 0xff));
|
.background_color(tauri::window::Color(r, g, b, 0xff));
|
||||||
|
// Zentriert auf DEM Monitor, dessen Größe oben zählte — .center() nähme
|
||||||
|
// sonst einen anderen.
|
||||||
|
let builder = match &monitor {
|
||||||
|
Some(m) => {
|
||||||
|
let scale = m.scale_factor();
|
||||||
|
let ms = m.size().to_logical::<f64>(scale);
|
||||||
|
let mp = m.position().to_logical::<f64>(scale);
|
||||||
|
builder.position(mp.x + (ms.width - w) / 2.0, mp.y + (ms.height - h) / 2.0)
|
||||||
|
}
|
||||||
|
None => builder.center(),
|
||||||
|
};
|
||||||
|
|
||||||
// Linux/GNOME: keine GTK-Deko — eigene Kopfleiste in panel.html, wie beim
|
// Linux/GNOME: keine GTK-Deko — eigene Kopfleiste in panel.html, wie beim
|
||||||
// Terminal-Fenster.
|
// Terminal-Fenster.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let builder = builder.decorations(false);
|
let builder = builder.decorations(false);
|
||||||
|
|
||||||
|
// Kein panel-detached mehr: das Archiv-Fenster ist eine eigene Fläche,
|
||||||
|
// das angedockte Panel (ToDo/Befehle/Entwurf) bleibt unberührt.
|
||||||
builder.build().map_err(|e| e.to_string())?;
|
builder.build().map_err(|e| e.to_string())?;
|
||||||
app.emit("panel-detached", ()).map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Archiv-Formular: Aufklappen, Meta-Aufbereitung, Tastatur.
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { initArchiveForm } from "./archive-form";
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
document.body.innerHTML = `<button id="btn">Archiv</button>`;
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const form = initArchiveForm(document.getElementById("btn")!, onSubmit);
|
||||||
|
const root = document.querySelector<HTMLElement>(".archive-form")!;
|
||||||
|
const inputs = [...root.querySelectorAll("input")];
|
||||||
|
return { form, onSubmit, root, inputs };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("initArchiveForm", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("klappt auf und zu", () => {
|
||||||
|
const { form, root } = setup();
|
||||||
|
expect(root.hidden).toBe(true);
|
||||||
|
form.toggle();
|
||||||
|
expect(root.hidden).toBe(false);
|
||||||
|
form.toggle();
|
||||||
|
expect(root.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", () => {
|
||||||
|
const { form, onSubmit, root, inputs } = setup();
|
||||||
|
form.toggle();
|
||||||
|
inputs[0].value = " konzepte/panel ";
|
||||||
|
inputs[1].value = "";
|
||||||
|
inputs[2].value = " adr, infra ,, ";
|
||||||
|
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
folder: "konzepte/panel",
|
||||||
|
description: undefined,
|
||||||
|
tags: ["adr", "infra"],
|
||||||
|
});
|
||||||
|
expect(root.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Sonst gelten Ordner und Schlagwörter des vorigen Dokuments unbemerkt für
|
||||||
|
/// das nächste — wer direkt Enter drückt, archiviert es falsch einsortiert.
|
||||||
|
it("startet nach dem Archivieren wieder leer", () => {
|
||||||
|
const { form, onSubmit, root, inputs } = setup();
|
||||||
|
form.toggle();
|
||||||
|
inputs[0].value = "konzepte/panel";
|
||||||
|
inputs[1].value = "Beschreibung A";
|
||||||
|
inputs[2].value = "panel, wiki";
|
||||||
|
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||||
|
|
||||||
|
form.toggle();
|
||||||
|
expect(inputs.map((i) => i.value)).toEqual(["", "", ""]);
|
||||||
|
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||||
|
expect(onSubmit).toHaveBeenLastCalledWith({
|
||||||
|
folder: undefined,
|
||||||
|
description: undefined,
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter schickt ab, Escape schließt ohne Abschicken", () => {
|
||||||
|
const { form, onSubmit, root } = setup();
|
||||||
|
form.toggle();
|
||||||
|
root.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
expect(root.hidden).toBe(true);
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
form.toggle();
|
||||||
|
root.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
expect(onSubmit).toHaveBeenCalledOnce();
|
||||||
|
expect(root.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/// Archiv-Formular: klappt unter dem Archiv-Button auf und fragt Ordner,
|
||||||
|
/// Beschreibung und Schlagwörter ab (alles optional); Enter oder der Button
|
||||||
|
/// archivieren, Escape schließt. Gemeinsam für das angedockte Panel und das
|
||||||
|
/// abgelöste Fenster.
|
||||||
|
|
||||||
|
import { t } from "./messages";
|
||||||
|
|
||||||
|
export interface ArchiveFormMeta {
|
||||||
|
folder?: string;
|
||||||
|
description?: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initArchiveForm(
|
||||||
|
anchor: HTMLElement,
|
||||||
|
onSubmit: (meta: ArchiveFormMeta) => void,
|
||||||
|
): { toggle(): void } {
|
||||||
|
function field(placeholder: string): HTMLInputElement {
|
||||||
|
const i = document.createElement("input");
|
||||||
|
i.type = "text";
|
||||||
|
i.placeholder = placeholder;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.createElement("div");
|
||||||
|
form.className = "archive-form";
|
||||||
|
form.hidden = true;
|
||||||
|
const folder = field(t("archiveForm.folder"));
|
||||||
|
const desc = field(t("archiveForm.description"));
|
||||||
|
const tags = field(t("archiveForm.tags"));
|
||||||
|
const submit = document.createElement("button");
|
||||||
|
submit.className = "archive-form-submit";
|
||||||
|
submit.textContent = t("archiveForm.submit");
|
||||||
|
form.append(folder, desc, tags, submit);
|
||||||
|
document.body.append(form);
|
||||||
|
|
||||||
|
const meta = (): ArchiveFormMeta => ({
|
||||||
|
folder: folder.value.trim() || undefined,
|
||||||
|
description: desc.value.trim() || undefined,
|
||||||
|
tags: tags.value
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
});
|
||||||
|
// Leeren, weil das Formular zum Fenster gehört und nicht zum Dokument.
|
||||||
|
const close = () => {
|
||||||
|
form.hidden = true;
|
||||||
|
folder.value = "";
|
||||||
|
desc.value = "";
|
||||||
|
tags.value = "";
|
||||||
|
};
|
||||||
|
const open = () => {
|
||||||
|
const r = anchor.getBoundingClientRect();
|
||||||
|
form.style.top = `${r.bottom + 6}px`;
|
||||||
|
form.style.right = `${Math.max(8, window.innerWidth - r.right - 4)}px`;
|
||||||
|
form.hidden = false;
|
||||||
|
folder.focus();
|
||||||
|
};
|
||||||
|
const fire = () => {
|
||||||
|
onSubmit(meta());
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
submit.addEventListener("click", fire);
|
||||||
|
form.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
fire();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { toggle: () => (form.hidden ? open() : close()) };
|
||||||
|
}
|
||||||
+28
-25
@@ -6,66 +6,69 @@ function modeSetup() {
|
|||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="tabs">
|
<div id="tabs">
|
||||||
<button data-mode="commands">Befehle</button>
|
<button data-mode="commands">Befehle</button>
|
||||||
<button data-mode="todo">ToDo</button>
|
<button data-mode="draft">Dokument</button>
|
||||||
|
<button data-mode="wiki">Wiki</button>
|
||||||
<button data-mode="search">Suche</button>
|
<button data-mode="search">Suche</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="title"></div>
|
<div id="title">Mein Titel</div>
|
||||||
|
<div id="draft-el"></div>
|
||||||
<div id="cc" hidden></div>
|
<div id="cc" hidden></div>
|
||||||
<div id="tc" hidden></div>
|
<div id="sc" hidden></div>
|
||||||
<div id="sc" hidden></div>`;
|
<div id="wc" hidden></div>`;
|
||||||
const el = (id: string) => document.getElementById(id)!;
|
const el = (id: string) => document.getElementById(id)!;
|
||||||
const tab = (m: string) =>
|
const tab = (m: string) =>
|
||||||
document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!;
|
document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!;
|
||||||
|
const flush = vi.fn();
|
||||||
const mode = initPanelMode({
|
const mode = initPanelMode({
|
||||||
tabs: [
|
tabs: [
|
||||||
{ mode: "commands", btn: tab("commands"), content: el("cc"), label: "Befehle" },
|
{ mode: "commands", btn: tab("commands"), content: el("cc"), label: "Befehle" },
|
||||||
{ mode: "todo", btn: tab("todo"), content: el("tc"), label: "ToDo" },
|
{ mode: "draft", btn: tab("draft"), content: null, label: "Dokument" },
|
||||||
|
{ mode: "wiki", btn: tab("wiki"), content: el("wc"), label: "Wiki" },
|
||||||
{ mode: "search", btn: tab("search"), content: el("sc"), label: "Suche" },
|
{ mode: "search", btn: tab("search"), content: el("sc"), label: "Suche" },
|
||||||
],
|
],
|
||||||
|
draftEls: [el("draft-el")],
|
||||||
titleEl: el("title"),
|
titleEl: el("title"),
|
||||||
|
flush,
|
||||||
});
|
});
|
||||||
return { mode, el, tab };
|
return { mode, flush, el, tab };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("initPanelMode", () => {
|
describe("initPanelMode", () => {
|
||||||
it("startet ohne aktiven Tab", () => {
|
it("startet im Dokument-Modus mit aktivem Tab", () => {
|
||||||
const { mode, el } = modeSetup();
|
const { el, tab } = modeSetup();
|
||||||
expect(mode.current()).toBe(null);
|
expect(el("draft-el").hidden).toBe(false);
|
||||||
expect(el("cc").hidden).toBe(true);
|
expect(el("cc").hidden).toBe(true);
|
||||||
expect(document.querySelectorAll(".active").length).toBe(0);
|
expect(tab("draft").classList.contains("active")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wechselt Sichtbarkeit, Titel und aktive Markierung", () => {
|
it("wechselt Sichtbarkeit, Titel und aktive Markierung", () => {
|
||||||
const { mode, el, tab } = modeSetup();
|
const { mode, flush, el, tab } = modeSetup();
|
||||||
mode.to("commands");
|
mode.to("commands");
|
||||||
|
expect(flush).toHaveBeenCalledOnce();
|
||||||
expect(el("cc").hidden).toBe(false);
|
expect(el("cc").hidden).toBe(false);
|
||||||
expect(el("tc").hidden).toBe(true);
|
expect(el("draft-el").hidden).toBe(true);
|
||||||
expect(el("title").textContent).toBe("Befehle");
|
expect(el("title").textContent).toBe("Befehle");
|
||||||
expect(tab("commands").classList.contains("active")).toBe(true);
|
expect(tab("commands").classList.contains("active")).toBe(true);
|
||||||
mode.to("todo");
|
expect(tab("draft").classList.contains("active")).toBe(false);
|
||||||
expect(el("cc").hidden).toBe(true);
|
mode.to("draft");
|
||||||
expect(el("tc").hidden).toBe(false);
|
expect(el("title").textContent).toBe("Mein Titel");
|
||||||
expect(el("title").textContent).toBe("ToDo");
|
|
||||||
expect(tab("commands").classList.contains("active")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Tab-Klick wechselt den Modus", () => {
|
it("Tab-Klick wechselt den Modus", () => {
|
||||||
const { el, tab } = modeSetup();
|
const { el, tab } = modeSetup();
|
||||||
tab("search").click();
|
tab("wiki").click();
|
||||||
expect(el("sc").hidden).toBe(false);
|
expect(el("wc").hidden).toBe(false);
|
||||||
expect(el("cc").hidden).toBe(true);
|
expect(el("draft-el").hidden).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clear hebt die Auswahl auf; danach wählt to() wieder aus", () => {
|
it("clear hebt die Auswahl auf; danach wählt to() wieder aus", () => {
|
||||||
const { mode, el, tab } = modeSetup();
|
const { mode, el, tab } = modeSetup();
|
||||||
mode.to("commands");
|
|
||||||
mode.clear();
|
mode.clear();
|
||||||
expect(document.querySelectorAll(".active").length).toBe(0);
|
expect(document.querySelectorAll(".active").length).toBe(0);
|
||||||
expect(el("cc").hidden).toBe(true);
|
|
||||||
expect(mode.current()).toBe(null);
|
expect(mode.current()).toBe(null);
|
||||||
mode.to("commands");
|
mode.to("draft");
|
||||||
expect(tab("commands").classList.contains("active")).toBe(true);
|
expect(tab("draft").classList.contains("active")).toBe(true);
|
||||||
expect(el("cc").hidden).toBe(false);
|
expect(el("title").textContent).toBe("Mein Titel");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+25
-13
@@ -123,42 +123,54 @@ export type PanelMode = string;
|
|||||||
export interface ModeTab {
|
export interface ModeTab {
|
||||||
mode: PanelMode;
|
mode: PanelMode;
|
||||||
btn: HTMLElement;
|
btn: HTMLElement;
|
||||||
/// Content-Container des Tabs.
|
/// Content-Container des Tabs; null für den Entwurfs-Tab, dessen
|
||||||
content: HTMLElement;
|
/// Sichtbarkeit über draftEls läuft.
|
||||||
/// Titelzeilen-Text, wenn der Tab aktiv ist.
|
content: HTMLElement | null;
|
||||||
|
/// Titelzeilen-Text, wenn der Tab aktiv ist (der Entwurfs-Tab behält
|
||||||
|
/// seinen Dokument-Titel).
|
||||||
label: string;
|
label: string;
|
||||||
onActivate?: () => void;
|
onActivate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modus-Umschalter über die Tabs der Registry: blendet die Ansichten
|
/// Modus-Umschalter über die Tabs der Registry: blendet Entwurfs-Inhalt samt
|
||||||
/// gegeneinander aus; die Titelzeile trägt das Label des aktiven Tabs.
|
/// zugehöriger Kopf-Controls gegen die jeweilige Ansicht aus. Ein Wechsel bei
|
||||||
export function initPanelMode(opts: { tabs: ModeTab[]; titleEl: HTMLElement }) {
|
/// offener Inhalts-Bearbeitung speichert den Entwurf (flush) statt zu
|
||||||
|
/// blockieren.
|
||||||
|
export function initPanelMode(opts: {
|
||||||
|
tabs: ModeTab[];
|
||||||
|
draftEls: HTMLElement[];
|
||||||
|
titleEl: HTMLElement;
|
||||||
|
flush: () => void;
|
||||||
|
}) {
|
||||||
// `null` = kein Tab aktiv (Panel zugeklappt).
|
// `null` = kein Tab aktiv (Panel zugeklappt).
|
||||||
let mode: PanelMode | null = null;
|
let mode: PanelMode | null = "draft";
|
||||||
|
let draftTitle = "";
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
for (const tab of opts.tabs) {
|
for (const tab of opts.tabs) {
|
||||||
tab.content.hidden = mode !== tab.mode;
|
if (tab.content) tab.content.hidden = mode !== tab.mode;
|
||||||
tab.btn.classList.toggle("active", tab.mode === mode);
|
tab.btn.classList.toggle("active", tab.mode === mode);
|
||||||
}
|
}
|
||||||
|
for (const el of opts.draftEls) el.hidden = mode !== "draft";
|
||||||
const active = opts.tabs.find((tab) => tab.mode === mode);
|
const active = opts.tabs.find((tab) => tab.mode === mode);
|
||||||
if (active) opts.titleEl.textContent = active.label;
|
if (mode && mode !== "draft" && active) opts.titleEl.textContent = active.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function to(m: PanelMode) {
|
function to(m: PanelMode) {
|
||||||
if (m === mode) return;
|
if (m === mode) return;
|
||||||
|
opts.flush();
|
||||||
|
if (mode === "draft") draftTitle = opts.titleEl.textContent || t("panel.tabDraft");
|
||||||
mode = m;
|
mode = m;
|
||||||
apply();
|
apply();
|
||||||
|
if (m === "draft") opts.titleEl.textContent = draftTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auswahl aufheben (Panel zugeklappt) — der nächste to()-Aufruf oder
|
/// Auswahl aufheben (Panel zugeklappt) — der nächste to()-Aufruf oder
|
||||||
/// Tab-Klick wählt wieder aus.
|
/// Tab-Klick wählt wieder aus.
|
||||||
function clear() {
|
function clear() {
|
||||||
|
if (mode === "draft") draftTitle = opts.titleEl.textContent || t("panel.tabDraft");
|
||||||
mode = null;
|
mode = null;
|
||||||
for (const tab of opts.tabs) {
|
for (const tab of opts.tabs) tab.btn.classList.remove("active");
|
||||||
tab.content.hidden = true;
|
|
||||||
tab.btn.classList.remove("active");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tab of opts.tabs) {
|
for (const tab of opts.tabs) {
|
||||||
|
|||||||
+107
-273
@@ -10,7 +10,6 @@ interface Project {
|
|||||||
pool: string | null;
|
pool: string | null;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
terminal: { theme: string | null; icon: string | null; title: string | null };
|
terminal: { theme: string | null; icon: string | null; title: string | null };
|
||||||
archive: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Pool {
|
interface Pool {
|
||||||
@@ -99,15 +98,6 @@ async function openTerminal(project: Project) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Archiv-Zugang aus der Projektliste: öffnet das Trilium-Fenster des Projekts.
|
|
||||||
async function openArchive(project: Project) {
|
|
||||||
try {
|
|
||||||
await invoke("trilium_open", { project: project.id, noteId: null });
|
|
||||||
} catch (e) {
|
|
||||||
error.value = String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingRestart {
|
interface PendingRestart {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -181,6 +171,7 @@ interface TerminalSettings {
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
workDirs: string[];
|
workDirs: string[];
|
||||||
|
archiveHome: string | null;
|
||||||
modules: ModuleState[];
|
modules: ModuleState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +182,9 @@ async function openSettings(p: Project) {
|
|||||||
const workDirs = await invoke<string[]>("project_work_dirs", {
|
const workDirs = await invoke<string[]>("project_work_dirs", {
|
||||||
project: p.id,
|
project: p.id,
|
||||||
});
|
});
|
||||||
|
const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||||
|
project: p.id,
|
||||||
|
});
|
||||||
const modules = await invoke<ModuleState[]>("module_registry", {
|
const modules = await invoke<ModuleState[]>("module_registry", {
|
||||||
project: p.id,
|
project: p.id,
|
||||||
});
|
});
|
||||||
@@ -202,6 +196,7 @@ async function openSettings(p: Project) {
|
|||||||
icon: p.terminal.icon,
|
icon: p.terminal.icon,
|
||||||
title: p.terminal.title ?? "",
|
title: p.terminal.title ?? "",
|
||||||
workDirs,
|
workDirs,
|
||||||
|
archiveHome,
|
||||||
modules,
|
modules,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -210,22 +205,10 @@ async function openSettings(p: Project) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Modul-Schalter schreiben sofort in die Projekt-Config (nur Abweichungen
|
// Modul-Schalter schreiben sofort in die Projekt-Config (nur Abweichungen
|
||||||
// vom Default); ein Fehler stellt die Checkbox zurück. Ausnahme Trilium:
|
// vom Default); ein Fehler stellt die Checkbox zurück.
|
||||||
// Anhaken öffnet nur den Wizard — das Modul-Flag schreibt erst der
|
|
||||||
// erfolgreiche Setup (trilium_connect im Backend); Abwählen stoppt die
|
|
||||||
// Instanz mit.
|
|
||||||
async function toggleModule(m: ModuleState) {
|
async function toggleModule(m: ModuleState) {
|
||||||
const s = settings.value!;
|
const s = settings.value!;
|
||||||
try {
|
try {
|
||||||
if (m.id === "trilium") {
|
|
||||||
if (m.enabled) {
|
|
||||||
m.enabled = false; // folgt erst dem Setup-Erfolg
|
|
||||||
await openTriliumWizard();
|
|
||||||
} else {
|
|
||||||
await invoke("trilium_disable", { project: s.id });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await invoke("set_module", { project: s.id, module: m.id, enabled: m.enabled });
|
await invoke("set_module", { project: s.id, module: m.id, enabled: m.enabled });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
m.enabled = !m.enabled;
|
m.enabled = !m.enabled;
|
||||||
@@ -233,83 +216,6 @@ async function toggleModule(m: ModuleState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trilium-Wizard: Runtime laden, Instanz starten, per Passwort den
|
|
||||||
// ETAPI-Token holen. Reine Checks beim Öffnen; geschrieben wird erst nach
|
|
||||||
// Erfolg (Backend-Muster), das Passwort wird nicht gespeichert.
|
|
||||||
interface TriliumStatus {
|
|
||||||
version: string;
|
|
||||||
port: number;
|
|
||||||
data_dir: string;
|
|
||||||
runtime_ready: boolean;
|
|
||||||
instance_running: boolean;
|
|
||||||
token_ok: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TriliumWizard {
|
|
||||||
status: TriliumStatus | null;
|
|
||||||
password: string;
|
|
||||||
busy: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const triWizard = ref<TriliumWizard | null>(null);
|
|
||||||
|
|
||||||
async function openTriliumWizard() {
|
|
||||||
const s = settings.value!;
|
|
||||||
triWizard.value = { status: null, password: "", busy: false, error: null };
|
|
||||||
try {
|
|
||||||
triWizard.value.status = await invoke<TriliumStatus>("trilium_status", {
|
|
||||||
project: s.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
triWizard.value.error = String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triliumSetup() {
|
|
||||||
const s = settings.value!;
|
|
||||||
const w = triWizard.value!;
|
|
||||||
w.busy = true;
|
|
||||||
w.error = null;
|
|
||||||
try {
|
|
||||||
w.status = await invoke<TriliumStatus>("trilium_setup", { project: s.id });
|
|
||||||
} catch (e) {
|
|
||||||
w.error = String(e);
|
|
||||||
}
|
|
||||||
w.busy = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triliumConnect() {
|
|
||||||
const s = settings.value!;
|
|
||||||
const w = triWizard.value!;
|
|
||||||
w.busy = true;
|
|
||||||
w.error = null;
|
|
||||||
try {
|
|
||||||
w.status = await invoke<TriliumStatus>("trilium_connect", {
|
|
||||||
project: s.id,
|
|
||||||
password: w.password,
|
|
||||||
});
|
|
||||||
w.password = "";
|
|
||||||
// Modul-Flag hat das Backend gesetzt — Checkbox nachziehen.
|
|
||||||
s.modules = await invoke<ModuleState[]>("module_registry", { project: s.id });
|
|
||||||
} catch (e) {
|
|
||||||
w.error = String(e);
|
|
||||||
}
|
|
||||||
w.busy = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triliumOpen() {
|
|
||||||
const s = settings.value!;
|
|
||||||
const w = triWizard.value!;
|
|
||||||
w.error = null;
|
|
||||||
try {
|
|
||||||
await invoke("trilium_open", { project: s.id, noteId: null });
|
|
||||||
w.status = await invoke<TriliumStatus>("trilium_status", { project: s.id });
|
|
||||||
} catch (e) {
|
|
||||||
w.error = String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Projektordner neu zuordnen (Root, in dem die Session startet); verschoben
|
// Projektordner neu zuordnen (Root, in dem die Session startet); verschoben
|
||||||
// wird nichts — der gewählte Ordner ist der neue Ort des Projekts.
|
// wird nichts — der gewählte Ordner ist der neue Ort des Projekts.
|
||||||
async function changeProjectDir() {
|
async function changeProjectDir() {
|
||||||
@@ -350,6 +256,59 @@ async function removeWorkDir(dir: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archiv-Home schreibt wie die Arbeitsordner direkt (Config + Permissions);
|
||||||
|
// ohne Archiv zeigt das Panel nur Befehle und Dokument. Greift ab dem
|
||||||
|
// nächsten Session-Start. Ist schon eins gesetzt, fragt ein Dialog nach —
|
||||||
|
// mit der Option, die Dokumente mitzunehmen (nichts wird implizit verschoben).
|
||||||
|
interface PendingArchiveChange {
|
||||||
|
dir: string;
|
||||||
|
migrate: boolean;
|
||||||
|
}
|
||||||
|
const pendingArchive = ref<PendingArchiveChange | null>(null);
|
||||||
|
|
||||||
|
async function chooseArchive() {
|
||||||
|
const s = settings.value!;
|
||||||
|
const dir = await open({ directory: true, multiple: false });
|
||||||
|
if (typeof dir !== "string") return;
|
||||||
|
if (s.archiveHome && s.archiveHome !== dir) {
|
||||||
|
pendingArchive.value = { dir, migrate: false };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await invoke("set_archive_home_cmd", { project: s.id, dir });
|
||||||
|
s.archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||||
|
project: s.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmArchiveChange() {
|
||||||
|
const s = settings.value!;
|
||||||
|
const a = pendingArchive.value!;
|
||||||
|
try {
|
||||||
|
await invoke("change_archive_home_cmd", { project: s.id, dir: a.dir, migrate: a.migrate });
|
||||||
|
s.archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||||
|
project: s.id,
|
||||||
|
});
|
||||||
|
pendingArchive.value = null;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
pendingArchive.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearArchive() {
|
||||||
|
const s = settings.value!;
|
||||||
|
try {
|
||||||
|
await invoke("clear_archive_home_cmd", { project: s.id });
|
||||||
|
s.archiveHome = null;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function pickIcon() {
|
async function pickIcon() {
|
||||||
const file = await open({
|
const file = await open({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -466,8 +425,10 @@ interface DeletePreview {
|
|||||||
name: string;
|
name: string;
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
aiControlDir: boolean;
|
aiControlDir: boolean;
|
||||||
|
archivePermission: boolean;
|
||||||
panelFiles: number;
|
panelFiles: number;
|
||||||
triliumData: string | null;
|
archiveHome: string | null;
|
||||||
|
archiveDocs: number;
|
||||||
workDirs: string[];
|
workDirs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,14 +527,6 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
</td>
|
</td>
|
||||||
<td class="cell-actions">
|
<td class="cell-actions">
|
||||||
<span class="row-actions">
|
<span class="row-actions">
|
||||||
<button
|
|
||||||
v-if="p.archive"
|
|
||||||
class="gear"
|
|
||||||
:title="$t('projects.openArchive')"
|
|
||||||
@click="openArchive(p)"
|
|
||||||
>
|
|
||||||
🗃
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="gear"
|
class="gear"
|
||||||
:title="$t('projects.terminalSettings')"
|
:title="$t('projects.terminalSettings')"
|
||||||
@@ -712,13 +665,20 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
<small>{{ $t("projects.scopeIntegrationDesc") }}</small>
|
<small>{{ $t("projects.scopeIntegrationDesc") }}</small>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="pendingDelete.preview.triliumData" class="scope">
|
<label class="scope">
|
||||||
<input v-model="pendingDelete.scope" type="radio" value="archive" />
|
<input
|
||||||
|
v-model="pendingDelete.scope"
|
||||||
|
type="radio"
|
||||||
|
value="archive"
|
||||||
|
:disabled="!pendingDelete.preview.archiveHome"
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>{{ $t("projects.scopeArchive") }}</strong>
|
<strong>{{ $t("projects.scopeArchive") }}</strong>
|
||||||
<small>
|
<small v-if="pendingDelete.preview.archiveHome">
|
||||||
{{ $t("projects.scopeArchiveDesc", { path: pendingDelete.preview.triliumData }) }}
|
{{ $t("projects.scopeArchiveDesc", { path: pendingDelete.preview.archiveHome }) }}
|
||||||
|
({{ $t("projects.docCount", pendingDelete.preview.archiveDocs) }})
|
||||||
</small>
|
</small>
|
||||||
|
<small v-else>{{ $t("projects.archiveNone") }}</small>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="scope">
|
<label class="scope">
|
||||||
@@ -740,12 +700,14 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
<ul class="affected">
|
<ul class="affected">
|
||||||
<li>{{ $t("projects.artRegistry") }}</li>
|
<li>{{ $t("projects.artRegistry") }}</li>
|
||||||
<li v-if="pendingDelete.preview.aiControlDir">{{ $t("projects.artAiControl") }}</li>
|
<li v-if="pendingDelete.preview.aiControlDir">{{ $t("projects.artAiControl") }}</li>
|
||||||
|
<li v-if="pendingDelete.preview.archivePermission">{{ $t("projects.artArchivePerm") }}</li>
|
||||||
<li v-if="pendingDelete.preview.panelFiles">
|
<li v-if="pendingDelete.preview.panelFiles">
|
||||||
{{ $t("projects.artPanelFiles", pendingDelete.preview.panelFiles) }}
|
{{ $t("projects.artPanelFiles", pendingDelete.preview.panelFiles) }}
|
||||||
</li>
|
</li>
|
||||||
<li>{{ $t("projects.artDesktop") }}</li>
|
<li>{{ $t("projects.artDesktop") }}</li>
|
||||||
<li v-if="pendingDelete.scope !== 'integration' && pendingDelete.preview.triliumData">
|
<li v-if="pendingDelete.scope !== 'integration' && pendingDelete.preview.archiveHome">
|
||||||
{{ $t("projects.artArchive", { path: pendingDelete.preview.triliumData }) }}
|
{{ $t("projects.artArchive", { path: pendingDelete.preview.archiveHome }) }}
|
||||||
|
({{ $t("projects.docCount", pendingDelete.preview.archiveDocs) }})
|
||||||
</li>
|
</li>
|
||||||
<li v-if="pendingDelete.scope === 'full'">
|
<li v-if="pendingDelete.scope === 'full'">
|
||||||
{{ $t("projects.artProjectDir", { path: pendingDelete.preview.projectDir }) }}
|
{{ $t("projects.artProjectDir", { path: pendingDelete.preview.projectDir }) }}
|
||||||
@@ -856,11 +818,20 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
<input v-model="m.enabled" type="checkbox" @change="toggleModule(m)" />
|
<input v-model="m.enabled" type="checkbox" @change="toggleModule(m)" />
|
||||||
<span>{{ $t(`modules.${m.id}`) }} — {{ $t(`modules.${m.id}Desc`) }}</span>
|
<span>{{ $t(`modules.${m.id}`) }} — {{ $t(`modules.${m.id}Desc`) }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div v-if="m.id === 'trilium'" class="srow">
|
<div v-if="m.id === 'archive'" class="srow">
|
||||||
<span class="slbl"></span>
|
<span class="slbl">{{ $t("projects.archive") }}</span>
|
||||||
<span class="sval"></span>
|
<span class="sval">
|
||||||
|
<span v-if="settings.archiveHome" class="spath hover-pop">
|
||||||
|
{{ contractHome(settings.archiveHome) }}
|
||||||
|
<span class="pop pop-path">{{ settings.archiveHome }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="spath muted">{{ $t("projects.archiveNone") }}</span>
|
||||||
|
</span>
|
||||||
<span class="sacts">
|
<span class="sacts">
|
||||||
<button @click="openTriliumWizard">{{ $t("trilium.wizard") }}</button>
|
<button @click="chooseArchive">{{ $t("projects.changeDir") }}</button>
|
||||||
|
<button v-if="settings.archiveHome" @click="clearArchive">
|
||||||
|
{{ $t("projects.remove") }}
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -878,63 +849,27 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="triWizard" class="overlay" @click.self="triWizard = null">
|
<div v-if="pendingArchive && settings" class="overlay" @click.self="pendingArchive = null">
|
||||||
<div class="dialog tri-dialog">
|
<div class="dialog">
|
||||||
<h3>{{ $t("trilium.wizardTitle") }}</h3>
|
<h3>{{ $t("projects.archiveChangeTitle") }}</h3>
|
||||||
<p class="hint">{{ $t("trilium.wizardIntro") }}</p>
|
<p>
|
||||||
<p v-if="triWizard.error" class="error">{{ triWizard.error }}</p>
|
{{ $t("projects.archiveChangeText", {
|
||||||
|
old: contractHome(settings.archiveHome ?? ""),
|
||||||
<ul v-if="triWizard.status" class="tri-steps">
|
neu: contractHome(pendingArchive.dir),
|
||||||
<li>
|
}) }}
|
||||||
<span class="mark" :class="{ ok: triWizard.status.runtime_ready }">{{ triWizard.status.runtime_ready ? "✓" : "✗" }}</span>
|
|
||||||
<span>
|
|
||||||
{{ $t("trilium.stepRuntime") }}
|
|
||||||
<small>Trilium {{ triWizard.status.version }}</small>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="mark" :class="{ ok: triWizard.status.instance_running }">{{ triWizard.status.instance_running ? "✓" : "✗" }}</span>
|
|
||||||
<span>
|
|
||||||
{{ $t("trilium.stepInstance") }}
|
|
||||||
<small>{{ $t("trilium.port", { port: triWizard.status.port }) }}</small>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="mark" :class="{ ok: triWizard.status.token_ok }">{{ triWizard.status.token_ok ? "✓" : "✗" }}</span>
|
|
||||||
<span>{{ $t("trilium.stepToken") }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p v-if="triWizard.status" class="tri-meta">
|
|
||||||
{{ $t("trilium.dataDir") }}: <code>{{ triWizard.status.data_dir }}</code>
|
|
||||||
· http://127.0.0.1:{{ triWizard.status.port }}
|
|
||||||
</p>
|
</p>
|
||||||
|
<label class="checkline">
|
||||||
<p class="hint">{{ $t("trilium.firstRunHint") }}</p>
|
<input v-model="pendingArchive.migrate" type="checkbox" />
|
||||||
|
{{ $t("projects.archiveMigrate") }}
|
||||||
<div v-if="triWizard.status && triWizard.status.instance_running && !triWizard.status.token_ok" class="tri-connect">
|
</label>
|
||||||
<span class="tri-lbl">{{ $t("trilium.password") }}</span>
|
<p class="hint">{{ $t("projects.archiveMigrateHint") }}</p>
|
||||||
<input
|
|
||||||
v-model="triWizard.password"
|
|
||||||
type="password"
|
|
||||||
@keydown.enter="triWizard.password && triliumConnect()"
|
|
||||||
/>
|
|
||||||
<button :disabled="triWizard.busy || !triWizard.password" @click="triliumConnect">
|
|
||||||
{{ $t("trilium.connect") }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button :disabled="triWizard.busy" @click="triliumSetup">
|
<button type="button" @click="pendingArchive = null">
|
||||||
{{ triWizard.busy ? $t("trilium.working") : $t("trilium.setup") }}
|
{{ $t("projects.cancel") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="primary" @click="confirmArchiveChange">
|
||||||
:disabled="triWizard.busy || !triWizard.status?.instance_running"
|
{{ $t("projects.archiveChangeConfirm") }}
|
||||||
@click="triliumOpen"
|
|
||||||
>
|
|
||||||
{{ $t("trilium.open") }}
|
|
||||||
</button>
|
</button>
|
||||||
<button class="primary" @click="triWizard = null">{{ $t("trilium.close") }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1115,105 +1050,4 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
.settings-dialog .actions {
|
.settings-dialog .actions {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trilium-Wizard. Maße nach UI-Guideline (px statt rem): 14 px Basis für
|
|
||||||
Interaktives, Meta-Zeilen 12 px, Zeilen mindestens 28 px, 4-px-Skala. */
|
|
||||||
.tri-dialog {
|
|
||||||
width: min(40rem, 94vw);
|
|
||||||
max-width: none;
|
|
||||||
padding: 24px;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-dialog h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-dialog .hint,
|
|
||||||
.tri-dialog .error {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-steps {
|
|
||||||
list-style: none;
|
|
||||||
margin: 4px 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-steps li {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
min-height: 28px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-steps li > span:last-child {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-steps small {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--subtext);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-steps .mark {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--surface0);
|
|
||||||
color: var(--overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-steps .mark.ok {
|
|
||||||
color: var(--green, #a6e3a1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-meta {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-meta code {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--subtext);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Verbinden-Zeile: Label | Passwort | Knopf. */
|
|
||||||
.tri-connect {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 8rem 1fr auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 32px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--subtext);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-connect input {
|
|
||||||
min-height: 28px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tri-dialog .actions button {
|
|
||||||
min-height: 28px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ describe("Nachrichtentabellen", () => {
|
|||||||
const erlaubt = new Set([
|
const erlaubt = new Set([
|
||||||
"Pool",
|
"Pool",
|
||||||
"Pools",
|
"Pools",
|
||||||
"Panel",
|
|
||||||
"Name",
|
"Name",
|
||||||
"Theme",
|
"Theme",
|
||||||
"Wiki",
|
"Wiki",
|
||||||
@@ -48,7 +47,6 @@ describe("Nachrichtentabellen", () => {
|
|||||||
"+ apiKey",
|
"+ apiKey",
|
||||||
"+ ToDo",
|
"+ ToDo",
|
||||||
"System",
|
"System",
|
||||||
"Trilium",
|
|
||||||
"Terminal",
|
"Terminal",
|
||||||
"Terminal — {name}",
|
"Terminal — {name}",
|
||||||
".desktop-Starter",
|
".desktop-Starter",
|
||||||
@@ -71,7 +69,7 @@ function pick(obj: object, key: string): string {
|
|||||||
|
|
||||||
describe("t()", () => {
|
describe("t()", () => {
|
||||||
it("löst Punkt-Pfade auf", () => {
|
it("löst Punkt-Pfade auf", () => {
|
||||||
expect(t("panel.tabSearch")).toBe("Suche");
|
expect(t("panel.tabDraft")).toBe("Dokument");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setzt Platzhalter ein", () => {
|
it("setzt Platzhalter ein", () => {
|
||||||
@@ -90,7 +88,7 @@ describe("t()", () => {
|
|||||||
it("folgt der gespeicherten Sprache", () => {
|
it("folgt der gespeicherten Sprache", () => {
|
||||||
window.localStorage.setItem("lang", "en");
|
window.localStorage.setItem("lang", "en");
|
||||||
expect(storedLocale()).toBe("en");
|
expect(storedLocale()).toBe("en");
|
||||||
expect(t("panel.tabSearch")).toBe("Search");
|
expect(t("panel.tabDraft")).toBe("Document");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fällt bei unbekannter Sprache auf die Browsersprache zurück", () => {
|
it("fällt bei unbekannter Sprache auf die Browsersprache zurück", () => {
|
||||||
@@ -102,13 +100,13 @@ describe("t()", () => {
|
|||||||
describe("applyI18n()", () => {
|
describe("applyI18n()", () => {
|
||||||
it("beschriftet Text und Attribute", () => {
|
it("beschriftet Text und Attribute", () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<button data-i18n="panel.tabSearch" data-i18n-title="panel.tabSearchTitle">alt</button>
|
<button data-i18n="panel.tabWiki" data-i18n-title="panel.tabWikiTitle">alt</button>
|
||||||
<button data-i18n-aria="panel.close"></button>
|
<button data-i18n-aria="panel.close"></button>
|
||||||
<input data-i18n-placeholder="search.placeholder" />`;
|
<input data-i18n-placeholder="search.placeholder" />`;
|
||||||
applyI18n();
|
applyI18n();
|
||||||
const btn = document.querySelector("button")!;
|
const btn = document.querySelector("button")!;
|
||||||
expect(btn.textContent).toBe("Suche");
|
expect(btn.textContent).toBe("Archiv");
|
||||||
expect(btn.title).toBe("Suchtreffer");
|
expect(btn.title).toBe("Archiv-Notizen");
|
||||||
expect(document.querySelectorAll("button")[1].getAttribute("aria-label")).toBe(
|
expect(document.querySelectorAll("button")[1].getAttribute("aria-label")).toBe(
|
||||||
"Schließen",
|
"Schließen",
|
||||||
);
|
);
|
||||||
|
|||||||
+129
-57
@@ -34,7 +34,6 @@ const de = {
|
|||||||
start: "Starten",
|
start: "Starten",
|
||||||
stop: "Beenden",
|
stop: "Beenden",
|
||||||
terminalSettings: "Terminal-Einstellungen",
|
terminalSettings: "Terminal-Einstellungen",
|
||||||
openArchive: "Archiv öffnen (Trilium)",
|
|
||||||
stillRunning: "{name} läuft noch",
|
stillRunning: "{name} läuft noch",
|
||||||
poolChangeSaved:
|
poolChangeSaved:
|
||||||
"Der Pool-Wechsel ist gespeichert. Die laufende Session arbeitet aber weiter im alten Pool — der neue gilt erst ab dem nächsten Start.",
|
"Der Pool-Wechsel ist gespeichert. Die laufende Session arbeitet aber weiter im alten Pool — der neue gilt erst ab dem nächsten Start.",
|
||||||
@@ -60,6 +59,8 @@ const de = {
|
|||||||
addFolder: "Projekt importieren …",
|
addFolder: "Projekt importieren …",
|
||||||
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
|
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
|
||||||
addWorkDir: "Ordner hinzufügen …",
|
addWorkDir: "Ordner hinzufügen …",
|
||||||
|
archive: "Archiv",
|
||||||
|
archiveNone: "kein Archiv — Panel ohne Wiki und Suche",
|
||||||
changeDir: "Ändern …",
|
changeDir: "Ändern …",
|
||||||
wizardTitle: "Neues Projekt",
|
wizardTitle: "Neues Projekt",
|
||||||
name: "Name",
|
name: "Name",
|
||||||
@@ -81,17 +82,25 @@ const de = {
|
|||||||
scopeIntegrationDesc:
|
scopeIntegrationDesc:
|
||||||
"ai-control-Spuren werden entfernt; Projektordner, memory/ und Todoliste bleiben.",
|
"ai-control-Spuren werden entfernt; Projektordner, memory/ und Todoliste bleiben.",
|
||||||
scopeArchive: "Integration & Archiv",
|
scopeArchive: "Integration & Archiv",
|
||||||
scopeArchiveDesc: "zusätzlich das Trilium-Archiv {path} — endgültig.",
|
scopeArchiveDesc: "zusätzlich {path} — endgültig.",
|
||||||
scopeFull: "Projekt komplett löschen",
|
scopeFull: "Projekt komplett löschen",
|
||||||
scopeFullDesc: "zusätzlich der Projektordner {path} — endgültig.",
|
scopeFullDesc: "zusätzlich der Projektordner {path} — endgültig.",
|
||||||
deletePreviewTitle: "Wird entfernt:",
|
deletePreviewTitle: "Wird entfernt:",
|
||||||
|
docCount: "kein Dokument | 1 Dokument | {count} Dokumente",
|
||||||
artRegistry: "Registry-Eintrag",
|
artRegistry: "Registry-Eintrag",
|
||||||
artAiControl: ".ai-control/ (Config + Icon)",
|
artAiControl: ".ai-control/ (Config + Icon)",
|
||||||
|
artArchivePerm: "Archiv-Berechtigung in .claude/settings.json",
|
||||||
artPanelFiles: "Panel-Kanaldatei | {count} Panel-Kanaldateien",
|
artPanelFiles: "Panel-Kanaldatei | {count} Panel-Kanaldateien",
|
||||||
artDesktop: ".desktop-Starter",
|
artDesktop: ".desktop-Starter",
|
||||||
artArchive: "Trilium-Archiv {path}",
|
artArchive: "Archiv {path}",
|
||||||
artProjectDir: "Projektordner {path}",
|
artProjectDir: "Projektordner {path}",
|
||||||
artWorkDir: "Arbeitsordner {path}",
|
artWorkDir: "Arbeitsordner {path}",
|
||||||
|
archiveChangeTitle: "Archiv wechseln",
|
||||||
|
archiveChangeText: "Das Archiv wechselt von {old} nach {neu}.",
|
||||||
|
archiveMigrate: "Dokumente ins neue Archiv verschieben",
|
||||||
|
archiveMigrateHint:
|
||||||
|
"Ohne Haken bleibt das bisherige Archiv unverändert liegen; die App verweist künftig nur noch auf das neue.",
|
||||||
|
archiveChangeConfirm: "Wechseln",
|
||||||
groupAppearance: "Darstellung",
|
groupAppearance: "Darstellung",
|
||||||
groupFolders: "Ordner",
|
groupFolders: "Ordner",
|
||||||
groupModules: "Module",
|
groupModules: "Module",
|
||||||
@@ -102,28 +111,8 @@ const de = {
|
|||||||
commandsDesc: "Befehls-Kacheln im Panel (write_commands)",
|
commandsDesc: "Befehls-Kacheln im Panel (write_commands)",
|
||||||
todo: "ToDo-Liste",
|
todo: "ToDo-Liste",
|
||||||
todoDesc: "persistente Aufgaben-Kacheln mit Fälligkeits-Ampel; öffnet beim Sessionstart",
|
todoDesc: "persistente Aufgaben-Kacheln mit Fälligkeits-Ampel; öffnet beim Sessionstart",
|
||||||
trilium: "Trilium",
|
archive: "Archiv",
|
||||||
triliumDesc:
|
archiveDesc: "Archivieren, Wiki und Suche",
|
||||||
"Archiv als eigene Trilium-Instanz — Archivieren, Suche und Notizen laufen über Trilium",
|
|
||||||
},
|
|
||||||
trilium: {
|
|
||||||
wizardTitle: "Trilium einrichten",
|
|
||||||
wizardIntro:
|
|
||||||
"Richtet das Projekt-Archiv als eigene Trilium-Instanz ein: Runtime versionsgepinnt geladen, Instanz nur auf 127.0.0.1 mit eigenem Datenverzeichnis und Port. Das Modul wird erst nach erfolgreicher Verbindung aktiviert.",
|
|
||||||
stepRuntime: "Runtime vorhanden",
|
|
||||||
stepInstance: "Instanz läuft",
|
|
||||||
stepToken: "Verbunden (ETAPI-Token)",
|
|
||||||
port: "Port {port}",
|
|
||||||
dataDir: "Daten",
|
|
||||||
firstRunHint:
|
|
||||||
"Beim ersten Verbinden legt die App das Dokument an und setzt das gewählte Passwort. Gespeichert wird nur der ETAPI-Token (Keyring), nie das Passwort.",
|
|
||||||
password: "Passwort",
|
|
||||||
connect: "Verbinden",
|
|
||||||
setup: "Einrichten / Starten",
|
|
||||||
working: "Läuft …",
|
|
||||||
open: "Trilium öffnen",
|
|
||||||
close: "Schließen",
|
|
||||||
wizard: "Einrichtung / Status",
|
|
||||||
},
|
},
|
||||||
todos: {
|
todos: {
|
||||||
copyOne: "Aufgabe kopieren",
|
copyOne: "Aufgabe kopieren",
|
||||||
@@ -189,15 +178,17 @@ const de = {
|
|||||||
},
|
},
|
||||||
panel: {
|
panel: {
|
||||||
windowTerminal: "Terminal",
|
windowTerminal: "Terminal",
|
||||||
windowPanel: "Panel",
|
windowDocument: "Dokument",
|
||||||
windowProjects: "Projekte",
|
windowProjects: "Projekte",
|
||||||
tabCommands: "Befehle",
|
tabCommands: "Befehle",
|
||||||
tabCommandsTitle: "Befehls-History",
|
tabCommandsTitle: "Befehls-History",
|
||||||
tabTodo: "ToDo",
|
tabTodo: "ToDo",
|
||||||
tabTodoTitle: "ToDo-Liste des Projekts",
|
tabTodoTitle: "ToDo-Liste des Projekts",
|
||||||
|
tabDraft: "Dokument",
|
||||||
|
tabDraftTitle: "Dokument",
|
||||||
|
tabWiki: "Archiv",
|
||||||
|
tabWikiTitle: "Archiv-Notizen",
|
||||||
tabSearch: "Suche",
|
tabSearch: "Suche",
|
||||||
archiveOpen: "Archiv",
|
|
||||||
archiveOpenTitle: "Archiv in Trilium öffnen",
|
|
||||||
tabSearchTitle: "Suchtreffer",
|
tabSearchTitle: "Suchtreffer",
|
||||||
minimize: "Minimieren",
|
minimize: "Minimieren",
|
||||||
maximize: "Maximieren",
|
maximize: "Maximieren",
|
||||||
@@ -206,6 +197,16 @@ const de = {
|
|||||||
hidePanel: "Panel ausblenden",
|
hidePanel: "Panel ausblenden",
|
||||||
detach: "In eigenes Fenster ablösen",
|
detach: "In eigenes Fenster ablösen",
|
||||||
dock: "Wieder andocken",
|
dock: "Wieder andocken",
|
||||||
|
editTitle: "Titel bearbeiten",
|
||||||
|
spellcheckLang: "Sprache der Rechtschreibprüfung",
|
||||||
|
openInWiki: "Im Wiki öffnen",
|
||||||
|
toggleRaw: "Rohtext / gerendert",
|
||||||
|
rendered: "MD",
|
||||||
|
raw: "Roh",
|
||||||
|
editDraft: "Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)",
|
||||||
|
copy: "In die Zwischenablage kopieren",
|
||||||
|
archive: "In den Archiv-Ordner speichern",
|
||||||
|
chooseArchiveDir: "Archiv-Ordner wählen",
|
||||||
processEnded: "[Prozess beendet]",
|
processEnded: "[Prozess beendet]",
|
||||||
},
|
},
|
||||||
popup: {
|
popup: {
|
||||||
@@ -218,12 +219,47 @@ const de = {
|
|||||||
copyAll: "Alle kopieren",
|
copyAll: "Alle kopieren",
|
||||||
session: "Session",
|
session: "Session",
|
||||||
},
|
},
|
||||||
|
archiveForm: {
|
||||||
|
folder: "Ordner — z. B. konzepte/panel",
|
||||||
|
description: "Beschreibung",
|
||||||
|
tags: "Schlagwörter, kommagetrennt",
|
||||||
|
submit: "Archivieren",
|
||||||
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: "Archiv durchsuchen — #tag filtert",
|
placeholder: "Archiv durchsuchen — #tag filtert",
|
||||||
minChars: "Mindestens 3 Zeichen — oder Enter.",
|
minChars: "Mindestens 3 Zeichen — oder Enter.",
|
||||||
hits: "{count} Treffer {scope}",
|
hits: "{count} Treffer {scope}",
|
||||||
noHits: "Keine Treffer für {scope}",
|
noHits: "Keine Treffer für {scope}",
|
||||||
},
|
},
|
||||||
|
wiki: {
|
||||||
|
archive: "Archiv",
|
||||||
|
docOne: "{count} Dokument",
|
||||||
|
docMany: "{count} Dokumente",
|
||||||
|
backlinks: "Verweise hierher",
|
||||||
|
emptyArchive: "Das Archiv ist leer.",
|
||||||
|
emptyFolder: "Keine Dokumente in diesem Ordner.",
|
||||||
|
emptyHint:
|
||||||
|
"Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.",
|
||||||
|
noPage: "Keine Wiki-Seite geladen.",
|
||||||
|
openOverview: "Archiv-Übersicht öffnen",
|
||||||
|
folderRename: "Ordnerpfad ändern",
|
||||||
|
renameDoc: "Dateiname ändern",
|
||||||
|
titleEdit: "Titel bearbeiten — klicken",
|
||||||
|
editDoc: "Bearbeiten",
|
||||||
|
save: "Speichern",
|
||||||
|
back: "Zurück",
|
||||||
|
info: "Details (Datum, Schlagwörter, Verweise)",
|
||||||
|
infoCaption: "Details",
|
||||||
|
createdAt: "angelegt {date}",
|
||||||
|
changedAt: "geändert {date}",
|
||||||
|
deleteDoc: "Dokument löschen",
|
||||||
|
newFolder: "Neuer Ordner",
|
||||||
|
newDoc: "Neues Dokument",
|
||||||
|
create: "Anlegen",
|
||||||
|
cancel: "Abbrechen",
|
||||||
|
folderPath: "ordner/unterordner",
|
||||||
|
docName: "Name des Dokuments",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: typeof de = {
|
const en: typeof de = {
|
||||||
@@ -255,7 +291,6 @@ const en: typeof de = {
|
|||||||
start: "Start",
|
start: "Start",
|
||||||
stop: "Stop",
|
stop: "Stop",
|
||||||
terminalSettings: "Terminal settings",
|
terminalSettings: "Terminal settings",
|
||||||
openArchive: "Open archive (Trilium)",
|
|
||||||
stillRunning: "{name} is still running",
|
stillRunning: "{name} is still running",
|
||||||
poolChangeSaved:
|
poolChangeSaved:
|
||||||
"The pool change is saved. The running session keeps working in the old pool — the new one applies from the next start.",
|
"The pool change is saved. The running session keeps working in the old pool — the new one applies from the next start.",
|
||||||
@@ -281,6 +316,8 @@ const en: typeof de = {
|
|||||||
addFolder: "Import project …",
|
addFolder: "Import project …",
|
||||||
addFolderTitle: "Add an existing folder as a project",
|
addFolderTitle: "Add an existing folder as a project",
|
||||||
addWorkDir: "Add folder …",
|
addWorkDir: "Add folder …",
|
||||||
|
archive: "Archive",
|
||||||
|
archiveNone: "no archive — panel without wiki and search",
|
||||||
changeDir: "Change …",
|
changeDir: "Change …",
|
||||||
wizardTitle: "New project",
|
wizardTitle: "New project",
|
||||||
name: "Name",
|
name: "Name",
|
||||||
@@ -302,17 +339,25 @@ const en: typeof de = {
|
|||||||
scopeIntegrationDesc:
|
scopeIntegrationDesc:
|
||||||
"ai-control traces are removed; project folder, memory/ and todo list stay.",
|
"ai-control traces are removed; project folder, memory/ and todo list stay.",
|
||||||
scopeArchive: "Integration & archive",
|
scopeArchive: "Integration & archive",
|
||||||
scopeArchiveDesc: "additionally the Trilium archive {path} — permanent.",
|
scopeArchiveDesc: "additionally {path} — permanent.",
|
||||||
scopeFull: "Delete project completely",
|
scopeFull: "Delete project completely",
|
||||||
scopeFullDesc: "additionally the project folder {path} — permanent.",
|
scopeFullDesc: "additionally the project folder {path} — permanent.",
|
||||||
deletePreviewTitle: "Will be removed:",
|
deletePreviewTitle: "Will be removed:",
|
||||||
|
docCount: "no documents | 1 document | {count} documents",
|
||||||
artRegistry: "registry entry",
|
artRegistry: "registry entry",
|
||||||
artAiControl: ".ai-control/ (config + icon)",
|
artAiControl: ".ai-control/ (config + icon)",
|
||||||
|
artArchivePerm: "archive permission in .claude/settings.json",
|
||||||
artPanelFiles: "panel channel file | {count} panel channel files",
|
artPanelFiles: "panel channel file | {count} panel channel files",
|
||||||
artDesktop: ".desktop launcher",
|
artDesktop: ".desktop launcher",
|
||||||
artArchive: "Trilium archive {path}",
|
artArchive: "archive {path}",
|
||||||
artProjectDir: "project folder {path}",
|
artProjectDir: "project folder {path}",
|
||||||
artWorkDir: "working folder {path}",
|
artWorkDir: "working folder {path}",
|
||||||
|
archiveChangeTitle: "Change archive",
|
||||||
|
archiveChangeText: "The archive changes from {old} to {neu}.",
|
||||||
|
archiveMigrate: "Move documents to the new archive",
|
||||||
|
archiveMigrateHint:
|
||||||
|
"Unchecked, the previous archive stays untouched; the app just points to the new one.",
|
||||||
|
archiveChangeConfirm: "Change",
|
||||||
groupAppearance: "Appearance",
|
groupAppearance: "Appearance",
|
||||||
groupFolders: "Folders",
|
groupFolders: "Folders",
|
||||||
groupModules: "Modules",
|
groupModules: "Modules",
|
||||||
@@ -323,28 +368,8 @@ const en: typeof de = {
|
|||||||
commandsDesc: "command tiles in the panel (write_commands)",
|
commandsDesc: "command tiles in the panel (write_commands)",
|
||||||
todo: "ToDo list",
|
todo: "ToDo list",
|
||||||
todoDesc: "persistent task tiles with due-date signal; opens at session start",
|
todoDesc: "persistent task tiles with due-date signal; opens at session start",
|
||||||
trilium: "Trilium",
|
archive: "Archive",
|
||||||
triliumDesc:
|
archiveDesc: "archiving, wiki and search",
|
||||||
"archive as a dedicated Trilium instance — archiving, search and notes run through Trilium",
|
|
||||||
},
|
|
||||||
trilium: {
|
|
||||||
wizardTitle: "Set up Trilium",
|
|
||||||
wizardIntro:
|
|
||||||
"Sets up the project archive as its own Trilium instance: version-pinned runtime download, instance on 127.0.0.1 only with its own data directory and port. The module is enabled only after a successful connection.",
|
|
||||||
stepRuntime: "Runtime present",
|
|
||||||
stepInstance: "Instance running",
|
|
||||||
stepToken: "Connected (ETAPI token)",
|
|
||||||
port: "port {port}",
|
|
||||||
dataDir: "Data",
|
|
||||||
firstRunHint:
|
|
||||||
"On first connect the app creates the document and sets the chosen password. Only the ETAPI token is stored (keyring), never the password.",
|
|
||||||
password: "Password",
|
|
||||||
connect: "Connect",
|
|
||||||
setup: "Set up / start",
|
|
||||||
working: "Working …",
|
|
||||||
open: "Open Trilium",
|
|
||||||
close: "Close",
|
|
||||||
wizard: "Setup / status",
|
|
||||||
},
|
},
|
||||||
todos: {
|
todos: {
|
||||||
copyOne: "Copy task",
|
copyOne: "Copy task",
|
||||||
@@ -410,15 +435,17 @@ const en: typeof de = {
|
|||||||
},
|
},
|
||||||
panel: {
|
panel: {
|
||||||
windowTerminal: "Terminal",
|
windowTerminal: "Terminal",
|
||||||
windowPanel: "Panel",
|
windowDocument: "Document",
|
||||||
windowProjects: "Projects",
|
windowProjects: "Projects",
|
||||||
tabCommands: "Commands",
|
tabCommands: "Commands",
|
||||||
tabCommandsTitle: "Command history",
|
tabCommandsTitle: "Command history",
|
||||||
tabTodo: "ToDo",
|
tabTodo: "ToDo",
|
||||||
tabTodoTitle: "Project to-do list",
|
tabTodoTitle: "Project to-do list",
|
||||||
|
tabDraft: "Document",
|
||||||
|
tabDraftTitle: "Document",
|
||||||
|
tabWiki: "Archive",
|
||||||
|
tabWikiTitle: "Archive notes",
|
||||||
tabSearch: "Search",
|
tabSearch: "Search",
|
||||||
archiveOpen: "Archive",
|
|
||||||
archiveOpenTitle: "Open the archive in Trilium",
|
|
||||||
tabSearchTitle: "Search hits",
|
tabSearchTitle: "Search hits",
|
||||||
minimize: "Minimize",
|
minimize: "Minimize",
|
||||||
maximize: "Maximize",
|
maximize: "Maximize",
|
||||||
@@ -427,6 +454,16 @@ const en: typeof de = {
|
|||||||
hidePanel: "Hide panel",
|
hidePanel: "Hide panel",
|
||||||
detach: "Detach into its own window",
|
detach: "Detach into its own window",
|
||||||
dock: "Dock again",
|
dock: "Dock again",
|
||||||
|
editTitle: "Edit title",
|
||||||
|
spellcheckLang: "Spell-check language",
|
||||||
|
openInWiki: "Open in wiki",
|
||||||
|
toggleRaw: "Raw text / rendered",
|
||||||
|
rendered: "MD",
|
||||||
|
raw: "Raw",
|
||||||
|
editDraft: "Edit draft (Cmd/Ctrl+Enter saves, Esc discards)",
|
||||||
|
copy: "Copy to clipboard",
|
||||||
|
archive: "Save to the archive folder",
|
||||||
|
chooseArchiveDir: "Choose archive folder",
|
||||||
processEnded: "[process ended]",
|
processEnded: "[process ended]",
|
||||||
},
|
},
|
||||||
popup: {
|
popup: {
|
||||||
@@ -439,12 +476,47 @@ const en: typeof de = {
|
|||||||
copyAll: "Copy all",
|
copyAll: "Copy all",
|
||||||
session: "Session",
|
session: "Session",
|
||||||
},
|
},
|
||||||
|
archiveForm: {
|
||||||
|
folder: "Folder — e.g. concepts/panel",
|
||||||
|
description: "Description",
|
||||||
|
tags: "Tags, comma-separated",
|
||||||
|
submit: "Archive",
|
||||||
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: "Search the archive — #tag filters",
|
placeholder: "Search the archive — #tag filters",
|
||||||
minChars: "At least 3 characters — or press Enter.",
|
minChars: "At least 3 characters — or press Enter.",
|
||||||
hits: "{count} hits {scope}",
|
hits: "{count} hits {scope}",
|
||||||
noHits: "No hits for {scope}",
|
noHits: "No hits for {scope}",
|
||||||
},
|
},
|
||||||
|
wiki: {
|
||||||
|
archive: "Archive",
|
||||||
|
docOne: "{count} document",
|
||||||
|
docMany: "{count} documents",
|
||||||
|
backlinks: "Links here",
|
||||||
|
emptyArchive: "The archive is empty.",
|
||||||
|
emptyFolder: "No documents in this folder.",
|
||||||
|
emptyHint:
|
||||||
|
"To archive: the archive button in the draft, or “archive this” in the chat — with folder, description and tags.",
|
||||||
|
noPage: "No wiki page loaded.",
|
||||||
|
openOverview: "Open archive overview",
|
||||||
|
folderRename: "Change folder path",
|
||||||
|
renameDoc: "Change file name",
|
||||||
|
titleEdit: "Edit title — click",
|
||||||
|
editDoc: "Edit",
|
||||||
|
save: "Save",
|
||||||
|
back: "Back",
|
||||||
|
info: "Details (date, tags, links)",
|
||||||
|
infoCaption: "Info",
|
||||||
|
createdAt: "created {date}",
|
||||||
|
changedAt: "changed {date}",
|
||||||
|
deleteDoc: "Delete document",
|
||||||
|
newFolder: "New folder",
|
||||||
|
newDoc: "New document",
|
||||||
|
create: "Create",
|
||||||
|
cancel: "Cancel",
|
||||||
|
folderPath: "folder/subfolder",
|
||||||
|
docName: "Document name",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const messages = { de, en };
|
export const messages = { de, en };
|
||||||
@@ -466,7 +538,7 @@ export function storedLocale(): Locale {
|
|||||||
return navigator.language.startsWith("de") ? "de" : "en";
|
return navigator.language.startsWith("de") ? "de" : "en";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Übersetzt einen Punkt-Pfad (`panel.tabSearch`) und setzt `{platzhalter}` ein.
|
/// Übersetzt einen Punkt-Pfad (`panel.tabDraft`) und setzt `{platzhalter}` ein.
|
||||||
/// Fehlt der Schlüssel, kommt der Pfad selbst zurück — im UI sichtbar statt
|
/// Fehlt der Schlüssel, kommt der Pfad selbst zurück — im UI sichtbar statt
|
||||||
/// still leer.
|
/// still leer.
|
||||||
export function t(key: string, params?: Record<string, string | number>): string {
|
export function t(key: string, params?: Record<string, string | number>): string {
|
||||||
|
|||||||
+60
-6
@@ -1,22 +1,76 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { initSearchView } from "../search-view";
|
import { initSearchView } from "../search-view";
|
||||||
|
import { initWikiView } from "../wiki-view";
|
||||||
import type { PanelTab } from "./index";
|
import type { PanelTab } from "./index";
|
||||||
|
|
||||||
|
/// Suchtreffer-Sprung ins Archiv: der Treffer merkt den relpath vor, lädt die
|
||||||
|
/// Übersicht neu (wiki-update wechselt den Tab), und die Archiv-Ansicht holt
|
||||||
|
/// die Vormerkung beim nächsten set() ab.
|
||||||
|
let pendingSelect: string | null = null;
|
||||||
|
|
||||||
|
export const wikiTab: PanelTab = {
|
||||||
|
mode: "wiki",
|
||||||
|
module: "archive",
|
||||||
|
buffer: "wiki",
|
||||||
|
popupOnly: true,
|
||||||
|
labelKey: "panel.tabWiki",
|
||||||
|
titleKey: "panel.tabWikiTitle",
|
||||||
|
// Dokument-/Ordner-Operationen laufen als Commands; der neue Stand kommt
|
||||||
|
// über den Wiki-Puffer (wiki-update) zurück, Fehler als Toast. Der
|
||||||
|
// Notiz-Inhalt kommt pro Auswahl über archive_read; Bearbeiten lädt wie
|
||||||
|
// bisher in den Dokument-Tab.
|
||||||
|
init: (container, ctx) => {
|
||||||
|
const run = (cmd: string, args: Record<string, string>) =>
|
||||||
|
void invoke(cmd, { project: ctx.project, ...args }).catch((e) =>
|
||||||
|
ctx.toast(String(e)),
|
||||||
|
);
|
||||||
|
return initWikiView(container, {
|
||||||
|
autoStart: ctx.standalone,
|
||||||
|
readDoc: (relpath) => invoke("archive_read", { project: ctx.project, relpath }),
|
||||||
|
writeDoc: (relpath, text) =>
|
||||||
|
invoke("archive_write", { project: ctx.project, relpath, text }),
|
||||||
|
setTitle: (relpath, title) => run("archive_set_title", { relpath, title }),
|
||||||
|
openWiki: ctx.openWiki,
|
||||||
|
takePending: () => {
|
||||||
|
const p = pendingSelect;
|
||||||
|
pendingSelect = null;
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
rename: (relpath, name) => run("archive_rename", { relpath, name }),
|
||||||
|
remove: (relpath) => run("archive_delete", { relpath }),
|
||||||
|
moveFolder: (folder, to) => run("archive_move_folder", { folder, to }),
|
||||||
|
createFolder: (folder) => run("archive_create_folder", { folder }),
|
||||||
|
createDoc: (folder, name) => run("archive_create_doc", { folder, name }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Beim Aktivieren die Übersicht frisch laden: das Anlegen eines Dokuments
|
||||||
|
// öffnet den Dokument-Tab, ohne den Wiki-Puffer anzufassen — und auch
|
||||||
|
// direkt archivierte oder von Hand abgelegte Dateien erscheinen so.
|
||||||
|
// Die Fenstergröße setzt allein open_panel_window beim Öffnen des Popups.
|
||||||
|
onActivate: (_view, ctx) => ctx.openWiki("tag:"),
|
||||||
|
};
|
||||||
|
|
||||||
export const searchTab: PanelTab = {
|
export const searchTab: PanelTab = {
|
||||||
mode: "search",
|
mode: "search",
|
||||||
module: "trilium",
|
module: "archive",
|
||||||
buffer: "search",
|
buffer: "search",
|
||||||
|
popupOnly: true,
|
||||||
labelKey: "panel.tabSearch",
|
labelKey: "panel.tabSearch",
|
||||||
titleKey: "panel.tabSearchTitle",
|
titleKey: "panel.tabSearchTitle",
|
||||||
|
// Trenner: Archiv-Fenster-Tabs (Archiv, Suche) links, Session-Tabs rechts.
|
||||||
sepAfter: true,
|
sepAfter: true,
|
||||||
// Treffer-Klick öffnet die Notiz im Trilium-Fenster des Projekts.
|
// Treffer-Klick öffnet die Notiz im Archiv-Tab: relpath vormerken, die
|
||||||
|
// Übersicht frisch laden — das wiki-update wechselt den Tab und die
|
||||||
|
// Ansicht wählt die vorgemerkte Notiz aus.
|
||||||
init: (container, ctx) =>
|
init: (container, ctx) =>
|
||||||
initSearchView(
|
initSearchView(
|
||||||
container,
|
container,
|
||||||
(noteId) =>
|
(_path, relpath) => {
|
||||||
void invoke("trilium_open", { project: ctx.project, noteId }).catch((e) =>
|
pendingSelect = relpath;
|
||||||
ctx.toast(String(e)),
|
ctx.openWiki("tag:");
|
||||||
),
|
},
|
||||||
(raw) => {
|
(raw) => {
|
||||||
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
|
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
|
||||||
const words = raw.split(/\s+/).filter(Boolean);
|
const words = raw.split(/\s+/).filter(Boolean);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export const commandsTab: PanelTab = {
|
|||||||
buffer: "commands",
|
buffer: "commands",
|
||||||
labelKey: "panel.tabCommands",
|
labelKey: "panel.tabCommands",
|
||||||
titleKey: "panel.tabCommandsTitle",
|
titleKey: "panel.tabCommandsTitle",
|
||||||
sepAfter: true,
|
|
||||||
init: (container, ctx) =>
|
init: (container, ctx) =>
|
||||||
initCommandsView(container, (id) =>
|
initCommandsView(container, (id) =>
|
||||||
invoke("commands_delete", { project: ctx.project, id }),
|
invoke("commands_delete", { project: ctx.project, id }),
|
||||||
|
|||||||
+18
-5
@@ -6,7 +6,7 @@
|
|||||||
/// `<buffer>-update`-Event.
|
/// `<buffer>-update`-Event.
|
||||||
|
|
||||||
import { commandsTab } from "./commands";
|
import { commandsTab } from "./commands";
|
||||||
import { searchTab } from "./archive";
|
import { searchTab, wikiTab } from "./archive";
|
||||||
import { todoTab } from "./todo";
|
import { todoTab } from "./todo";
|
||||||
|
|
||||||
export interface ModuleView {
|
export interface ModuleView {
|
||||||
@@ -18,7 +18,13 @@ export interface ModuleView {
|
|||||||
/// braucht; Modulspezifisches (eigene Invokes) macht der Deskriptor selbst.
|
/// braucht; Modulspezifisches (eigene Invokes) macht der Deskriptor selbst.
|
||||||
export interface ModuleCtx {
|
export interface ModuleCtx {
|
||||||
project: string;
|
project: string;
|
||||||
|
/// Läuft die Ansicht im eigenen Panel-Fenster (statt angedockt)?
|
||||||
|
standalone: boolean;
|
||||||
toast(msg: string): void;
|
toast(msg: string): void;
|
||||||
|
/// Dokument in den Entwurfs-Tab laden (Treffer-Klick).
|
||||||
|
openDoc(path: string): void;
|
||||||
|
/// Wiki-Ziel öffnen (Wikilink, Chip, `tag:`-Namensraum).
|
||||||
|
openWiki(name: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanelTab {
|
export interface PanelTab {
|
||||||
@@ -27,16 +33,23 @@ export interface PanelTab {
|
|||||||
mode: string;
|
mode: string;
|
||||||
/// Backend-Modul (enabled_modules) — bestimmt, ob der Tab erscheint.
|
/// Backend-Modul (enabled_modules) — bestimmt, ob der Tab erscheint.
|
||||||
module: string;
|
module: string;
|
||||||
|
/// Tab gehört ins eigene Panel-Fenster: angedockt zieht ein Puffer-Update
|
||||||
|
/// den Tab nicht in den Vordergrund (Klick öffnet dort das Fenster).
|
||||||
|
popupOnly?: boolean;
|
||||||
/// Puffer-ID für buffer_read; Updates als `<buffer>-update`.
|
/// Puffer-ID für buffer_read; Updates als `<buffer>-update`.
|
||||||
buffer: string;
|
buffer: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
/// Visueller Trenner hinter dem Tab.
|
/// Visueller Trenner hinter dem Tab.
|
||||||
sepAfter?: boolean;
|
sepAfter?: boolean;
|
||||||
/// Baut die Ansicht des Tabs.
|
/// Baut die Ansicht des Tabs. Ohne init verdrahtet panel-wiring die
|
||||||
init(container: HTMLElement, ctx: ModuleCtx): ModuleView;
|
/// Ansicht selbst (Entwurfs-Tab — Kern mit eigenem Markup).
|
||||||
/// Nach Tab-Klick.
|
init?(container: HTMLElement, ctx: ModuleCtx): ModuleView;
|
||||||
|
/// Nach Tab-Klick (z. B. Wiki: leere Ansicht lädt die Übersicht).
|
||||||
onActivate?(view: ModuleView, ctx: ModuleCtx): void;
|
onActivate?(view: ModuleView, ctx: ModuleCtx): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PANEL_TABS: PanelTab[] = [todoTab, commandsTab, searchTab];
|
// Der Entwurf ("draft") hat bewusst KEINEN Tab: Die Ansicht erscheint, wenn
|
||||||
|
// ein Entwurf hereinkommt (panel-update → mode "draft") oder eine Notiz zum
|
||||||
|
// Bearbeiten geladen wird — als flüchtige Fläche, nicht als Reiter.
|
||||||
|
export const PANEL_TABS: PanelTab[] = [wikiTab, searchTab, todoTab, commandsTab];
|
||||||
|
|||||||
+690
-1
@@ -139,7 +139,7 @@
|
|||||||
border: 1px solid var(--ok);
|
border: 1px solid var(--ok);
|
||||||
}
|
}
|
||||||
/* Kopfzeile mit Anlegen-Knopf und das ToDo-Formular (Anlegen/Bearbeiten),
|
/* Kopfzeile mit Anlegen-Knopf und das ToDo-Formular (Anlegen/Bearbeiten),
|
||||||
in-flow über der Liste. */
|
in-flow über der Liste; Input-Stil wie im Archiv-Formular. */
|
||||||
.todo-head {
|
.todo-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -311,6 +311,695 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wiki-Ansicht: Ordner-Baum links, flache Inhaltsliste rechts. */
|
||||||
|
#wiki-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
#wiki-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
#wiki-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
/* Baum links, Seite rechts; beide scrollen gemeinsam im Tab. */
|
||||||
|
.wiki-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.wiki-tree {
|
||||||
|
flex: 0 0 230px;
|
||||||
|
min-width: 0;
|
||||||
|
border-right: 1px solid var(--line-strong);
|
||||||
|
padding-right: 8px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.wiki-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.wiki-tree .wiki-tree-children {
|
||||||
|
padding-left: 0;
|
||||||
|
border-left: 2px solid var(--line-strong);
|
||||||
|
}
|
||||||
|
/* Wurzel-Ebene: Linie mittig unter dem Archiv-Knoten. */
|
||||||
|
.wiki-tree > .wiki-tree-children {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
/* Ordner-Ebenen: Linie genauso mittig unter dem Ordner-Knoten (0.7em
|
||||||
|
Zeilenbeginn + 1em). */
|
||||||
|
.wiki-tree details > .wiki-tree-children {
|
||||||
|
margin-left: 1.7em;
|
||||||
|
}
|
||||||
|
/* Absatz zwischen Dokumenten und Ordner-Gruppe derselben Ebene. */
|
||||||
|
.wiki-tree .wiki-tree-doc + details {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.wiki-tree summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 6px 5px 0.7em;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
500 16px/1.5 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
/* Notiz-Blatt im Baum (Dokument ohne Kinder). */
|
||||||
|
.wiki-tree-doc {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 6px 5px 0.4em;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
400 16px/1.5 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
/* Typ-Symbole (Ordner/Notiz) im Trilium-Stil vor dem Namen. */
|
||||||
|
.wiki-tree-icon {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.wiki-tree-icon svg {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--muted);
|
||||||
|
stroke-width: 1.3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.wiki-tree summary.active .wiki-tree-icon svg,
|
||||||
|
.wiki-tree-doc.active .wiki-tree-icon svg {
|
||||||
|
stroke: var(--accent);
|
||||||
|
}
|
||||||
|
.wiki-tree-doc:hover {
|
||||||
|
background: var(--tile);
|
||||||
|
}
|
||||||
|
.wiki-tree-doc.active,
|
||||||
|
.wiki-tree summary.active,
|
||||||
|
.wiki-tree-root.active {
|
||||||
|
background: var(--tile);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiki-tree summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wiki-tree-arrow {
|
||||||
|
flex: none;
|
||||||
|
width: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
.wiki-tree-arrow:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.wiki-tree details[open] > summary .wiki-tree-arrow {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.wiki-tree-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wiki-tree summary .panel-btn {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.wiki-tree summary:hover .panel-btn {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.wiki-tree summary:hover,
|
||||||
|
.wiki-tree-root:hover {
|
||||||
|
background: var(--tile);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.wiki-tree-root {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font:
|
||||||
|
600 16px/1.7 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-tree-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
400 15px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
/* Zeilen-Aktionen erst beim Überfahren der Zeile. */
|
||||||
|
.wiki-doc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.wiki-doc:hover .wiki-doc-actions {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
/* Kopfzeile des Baums: Wurzel-Knopf + Ordner anlegen. */
|
||||||
|
.wiki-tree-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.wiki-tree-head .wiki-tree-root {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.wiki-add {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font:
|
||||||
|
500 13px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wiki-add:hover {
|
||||||
|
background: var(--tile);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
/* Anlege-Dialog (Ordner/Dokument): modales Popup mit abgedunkeltem
|
||||||
|
Hintergrund. */
|
||||||
|
.wiki-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 300;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.wiki-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 380px;
|
||||||
|
background: var(--tile);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 16px 48px rgba(0, 0, 0, 0.6),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.wiki-form-title {
|
||||||
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
600 16px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-form-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.wiki-form .wiki-tree-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.wiki-form-submit,
|
||||||
|
.wiki-form-cancel {
|
||||||
|
border: none;
|
||||||
|
background: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font:
|
||||||
|
500 14px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-form-submit:hover {
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
.wiki-form-cancel {
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.wiki-form-cancel:hover {
|
||||||
|
background: var(--tile);
|
||||||
|
}
|
||||||
|
/* Notiz-Ansicht rechts: großer Titel wie in Trilium, Meta-Zeile, Inhalt. */
|
||||||
|
.wiki-note-head {
|
||||||
|
border-bottom: 1px solid var(--line-strong);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.wiki-note-titlerow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.wiki-note-back {
|
||||||
|
flex: none;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wiki-note-back:hover:not(:disabled) {
|
||||||
|
background: var(--tile);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.wiki-note-back:disabled {
|
||||||
|
color: var(--line);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.wiki-note-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
600 24px/1.3 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-note-title.editable {
|
||||||
|
cursor: text;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.wiki-note-title.editable:hover {
|
||||||
|
background: var(--tile);
|
||||||
|
}
|
||||||
|
.wiki-note-title-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
outline: none;
|
||||||
|
font:
|
||||||
|
600 24px/1.3 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-note-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.wiki-note-head:hover .wiki-note-title.editable {
|
||||||
|
cursor: text;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.wiki-note-title.editable:hover {
|
||||||
|
background: var(--tile);
|
||||||
|
}
|
||||||
|
.wiki-note-title-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
outline: none;
|
||||||
|
font:
|
||||||
|
600 24px/1.3 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-note-actions {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
/* Markdown-Editor der Notiz (Bearbeiten im Archiv): Rohtext links,
|
||||||
|
Live-Vorschau rechts. */
|
||||||
|
.wiki-edit-split {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.wiki-edit-split > * {
|
||||||
|
flex: 1 1 50%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.wiki-edit-preview {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
border-left: 1px solid var(--line-strong);
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
.wiki-note-editor {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60vh;
|
||||||
|
resize: vertical;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
outline: none;
|
||||||
|
font: 400 14px/1.6 "JetBrains Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
.wiki-note-editor:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiki-note-error {
|
||||||
|
color: #f38ba8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font:
|
||||||
|
500 13px/1.5 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
/* Kontextmenü im Baum (rechte Maustaste). */
|
||||||
|
.wiki-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--tile);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 40px rgba(0, 0, 0, 0.55),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.wiki-menu-item {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font:
|
||||||
|
400 14px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-menu-item:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
/* Info-Popup am ⓘ-Knopf der Titelzeile. */
|
||||||
|
.wiki-note-titlerow {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.wiki-note-info-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 240px;
|
||||||
|
background: var(--tile);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 40px rgba(0, 0, 0, 0.55),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.wiki-info-caption {
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font:
|
||||||
|
600 13px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-info-line {
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
font:
|
||||||
|
500 13px/1.7 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-note-body {
|
||||||
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
400 17px/1.6 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-note-body h1,
|
||||||
|
.wiki-note-body h2,
|
||||||
|
.wiki-note-body h3 {
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1em 0 0.4em;
|
||||||
|
}
|
||||||
|
.wiki-note-body h1 {
|
||||||
|
font-size: 1.35em;
|
||||||
|
}
|
||||||
|
.wiki-note-body h2 {
|
||||||
|
font-size: 1.15em;
|
||||||
|
}
|
||||||
|
.wiki-note-body h3 {
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
.wiki-note-body :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.wiki-note-body p,
|
||||||
|
.wiki-note-body ul,
|
||||||
|
.wiki-note-body ol,
|
||||||
|
.wiki-note-body pre,
|
||||||
|
.wiki-note-body blockquote {
|
||||||
|
margin: 0 0 0.7em;
|
||||||
|
}
|
||||||
|
.wiki-note-body code {
|
||||||
|
font-family: "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
.wiki-note-body pre {
|
||||||
|
background: var(--tile);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.wiki-note-body pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.wiki-note-body blockquote {
|
||||||
|
border-left: 3px solid var(--line);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.wiki-note-body a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiki-note-children {
|
||||||
|
margin-top: 26px;
|
||||||
|
background: var(--tile);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.wiki-children-caption {
|
||||||
|
color: var(--faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font:
|
||||||
|
600 12px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
/* Default-Text einer Ordner-Notiz ohne Inhaltsdatei. */
|
||||||
|
.wiki-note-body.default {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
/* Einstiegs-Knopf im Leerzustand. */
|
||||||
|
.wiki-chip {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font:
|
||||||
|
500 10.5px/1.5 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-chip:hover {
|
||||||
|
background: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.wiki-doc {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wiki-doc:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.wiki-doc:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiki-doc-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.wiki-doc-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font:
|
||||||
|
600 16px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-doc-date {
|
||||||
|
color: var(--faint);
|
||||||
|
font-family: "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wiki-doc-desc {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
font:
|
||||||
|
400 14px/1.5 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-empty {
|
||||||
|
margin-top: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font:
|
||||||
|
400 12px/1.6 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.wiki-empty strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
/* Backlink-Zähler in der Dokumentzeile. */
|
||||||
|
.wiki-doc-back {
|
||||||
|
color: var(--faint);
|
||||||
|
font:
|
||||||
|
500 10px/1 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Archiv-Formular: klappt unter dem Archiv-Button auf. */
|
||||||
|
.archive-form {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 260px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.archive-form[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.archive-form input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--tile);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
400 12px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.archive-form input:focus {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
.archive-form input::placeholder {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.archive-form-submit {
|
||||||
|
border: none;
|
||||||
|
background: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font:
|
||||||
|
500 12px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.archive-form-submit:hover {
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
/* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */
|
/* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */
|
||||||
.panel-toast {
|
.panel-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Dokument-Ansicht: Wikilink-Verlinkung im DOM und Editor-Flush.
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { initPanelView, linkWikiRefs } from "./panel-view";
|
||||||
|
|
||||||
|
describe("linkWikiRefs", () => {
|
||||||
|
it("verlinkt [[ziel]] und [[ziel|Label]], Code bleibt unangetastet", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML =
|
||||||
|
"<p>Siehe [[adr-logging]] und [[notiz|die Notiz]].</p>" +
|
||||||
|
"<pre><code>if [[ -f x ]]; then</code></pre>";
|
||||||
|
const onClick = vi.fn();
|
||||||
|
linkWikiRefs(root, onClick);
|
||||||
|
const links = [...root.querySelectorAll<HTMLElement>("a.wiki")];
|
||||||
|
expect(links.map((a) => a.textContent)).toEqual(["adr-logging", "die Notiz"]);
|
||||||
|
links[1].click();
|
||||||
|
expect(onClick).toHaveBeenCalledWith("notiz");
|
||||||
|
expect(root.querySelector("code")!.textContent).toBe("if [[ -f x ]]; then");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function viewSetup(onCommit: (text: string) => void | Promise<void>) {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="content"></div>
|
||||||
|
<button id="copy"></button>
|
||||||
|
<button id="mode"></button>
|
||||||
|
<button id="edit"></button>`;
|
||||||
|
const view = initPanelView({
|
||||||
|
content: document.getElementById("content")!,
|
||||||
|
copyBtn: document.getElementById("copy")!,
|
||||||
|
modeBtn: document.getElementById("mode")!,
|
||||||
|
editContentBtn: document.getElementById("edit")!,
|
||||||
|
onCommit,
|
||||||
|
});
|
||||||
|
const editor = document.querySelector<HTMLTextAreaElement>(".panel-editor")!;
|
||||||
|
return { view, editor, editBtn: document.getElementById("edit")! };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("initPanelView — Editor", () => {
|
||||||
|
it("flush speichert eine offene Bearbeitung und beendet sie", async () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const { view, editor, editBtn } = viewSetup(onCommit);
|
||||||
|
view.set("Alt");
|
||||||
|
editBtn.click();
|
||||||
|
expect(editor.hidden).toBe(false);
|
||||||
|
editor.value = "Neu";
|
||||||
|
await view.flush();
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("Neu");
|
||||||
|
expect(editor.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flush ohne offene Bearbeitung tut nichts", async () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const { view } = viewSetup(onCommit);
|
||||||
|
view.set("Alt");
|
||||||
|
await view.flush();
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puffert eingehende Updates während der Bearbeitung", () => {
|
||||||
|
const { view, editor, editBtn } = viewSetup(() => {});
|
||||||
|
view.set("Alt");
|
||||||
|
editBtn.click();
|
||||||
|
view.set("Update von außen");
|
||||||
|
expect(editBtn.classList.contains("changed")).toBe(true);
|
||||||
|
editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||||
|
expect(view.raw()).toBe("Update von außen");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { renderMarkdown } from "./markdown";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { flash } from "./tiles";
|
||||||
|
import { t } from "./messages";
|
||||||
|
|
||||||
|
export interface PanelView {
|
||||||
|
set(text: string): void;
|
||||||
|
raw(): string;
|
||||||
|
/// Schreibt eine offene Inhalts-Bearbeitung zurück (und beendet sie);
|
||||||
|
/// aufgelöst, sobald der Entwurf gespeichert ist.
|
||||||
|
flush(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verkabelt einen Panel-Inhaltsbereich: MD/Roh-Umschalter und Copy-Button.
|
||||||
|
/// Der Rohtext bleibt die Quelle für „Kopieren"; die Ansicht rendert Markdown.
|
||||||
|
/// Entfernt Inline-Markdown (Emphasis, Code, Links), damit der Plain-Text-Titel
|
||||||
|
/// nicht die rohen Marker zeigt.
|
||||||
|
function stripInlineMd(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/`([^`]*)`/g, "$1")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||||
|
.replace(/\*([^*]+)\*/g, "$1")
|
||||||
|
.replace(/__([^_]+)__/g, "$1")
|
||||||
|
.replace(/_([^_]+)_/g, "$1")
|
||||||
|
.replace(/~~([^~]+)~~/g, "$1")
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Titel für den Panel-Kopf: erste Überschrift (# …) oder sonst erste
|
||||||
|
/// nicht-leere Zeile, ohne Inline-Markdown; „Dokument" bei leerem Text.
|
||||||
|
function firstLine(text: string): string {
|
||||||
|
let fallback = "";
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
const h = t.replace(/^#+/, "").trim();
|
||||||
|
if (t.startsWith("#") && h) return stripInlineMd(h);
|
||||||
|
if (!fallback) fallback = t;
|
||||||
|
}
|
||||||
|
return fallback ? stripInlineMd(fallback) : t("panel.tabDraft");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ersetzt die erste Überschrift (bzw. legt eine an) im Rohtext durch `title`.
|
||||||
|
function setHeading(text: string, title: string): string {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const t = lines[i].trim();
|
||||||
|
if (!t) continue;
|
||||||
|
if (t.startsWith("#")) {
|
||||||
|
const hashes = (t.match(/^#+/) as RegExpMatchArray)[0];
|
||||||
|
lines[i] = `${hashes} ${title}`;
|
||||||
|
} else {
|
||||||
|
lines.splice(i, 0, `# ${title}`, "");
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
return `# ${title}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Macht `[[name]]`-Wikilinks im gerendertem Markdown klickbar. Läuft über die
|
||||||
|
/// Textknoten des DOM statt über den Rohtext, damit Vorkommen in Code-Spans
|
||||||
|
/// und Code-Blöcken (z. B. bash `[[ -f x ]]`) unangetastet bleiben.
|
||||||
|
export function linkWikiRefs(root: HTMLElement, onClick: (name: string) => void) {
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||||
|
const nodes: Text[] = [];
|
||||||
|
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||||
|
const t = n as Text;
|
||||||
|
if (!t.textContent?.includes("[[")) continue;
|
||||||
|
if (t.parentElement?.closest("code, pre, a")) continue;
|
||||||
|
nodes.push(t);
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
|
const parts = node.textContent!.split(/\[\[([^\]]+)\]\]/g);
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
parts.forEach((part, i) => {
|
||||||
|
if (i % 2) {
|
||||||
|
// `[[ziel]]` oder `[[ziel|label]]`.
|
||||||
|
const sep = part.indexOf("|");
|
||||||
|
const target = sep < 0 ? part : part.slice(0, sep);
|
||||||
|
const label = sep < 0 ? part : part.slice(sep + 1);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = "#";
|
||||||
|
a.className = "wiki";
|
||||||
|
a.textContent = label;
|
||||||
|
a.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick(target);
|
||||||
|
});
|
||||||
|
frag.append(a);
|
||||||
|
} else if (part) {
|
||||||
|
frag.append(part);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
node.replaceWith(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initPanelView(opts: {
|
||||||
|
content: HTMLElement;
|
||||||
|
copyBtn: HTMLElement;
|
||||||
|
modeBtn: HTMLElement;
|
||||||
|
titleEl?: HTMLElement;
|
||||||
|
editBtn?: HTMLElement;
|
||||||
|
editContentBtn?: HTMLElement;
|
||||||
|
langSelect?: HTMLSelectElement;
|
||||||
|
/// Standard-Sprache der Rechtschreibprüfung (aus den App-Settings).
|
||||||
|
defaultLang?: string;
|
||||||
|
/// Wird mit dem neuen Rohtext aufgerufen, wenn Titel oder Inhalt geändert
|
||||||
|
/// wurden.
|
||||||
|
onCommit?: (text: string) => void | Promise<void>;
|
||||||
|
/// Klick auf einen `[[name]]`-Wikilink im gerenderten Markdown.
|
||||||
|
onWikiLink?: (name: string) => void;
|
||||||
|
}): PanelView {
|
||||||
|
let rawText = "";
|
||||||
|
let rendered = true;
|
||||||
|
// Content-Edit-Zustand: während des Editierens werden eingehende Updates
|
||||||
|
// gepuffert statt angewandt.
|
||||||
|
let editing = false;
|
||||||
|
let pending: string | null = null;
|
||||||
|
let flushEditor: () => Promise<void> = async () => {};
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
if (rendered) {
|
||||||
|
opts.content.className = "md";
|
||||||
|
opts.content.innerHTML = renderMarkdown(rawText);
|
||||||
|
if (opts.onWikiLink) linkWikiRefs(opts.content, opts.onWikiLink);
|
||||||
|
} else {
|
||||||
|
opts.content.className = "raw";
|
||||||
|
opts.content.textContent = rawText;
|
||||||
|
}
|
||||||
|
opts.modeBtn.textContent = rendered ? t("panel.rendered") : t("panel.raw");
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.modeBtn.addEventListener("click", () => {
|
||||||
|
rendered = !rendered;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
opts.copyBtn.addEventListener("click", async () => {
|
||||||
|
await writeText(rawText);
|
||||||
|
flash(opts.copyBtn, "copied");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Titel-Edit: Edit-Button macht den Titel editierbar, Enter/Blur schreibt die
|
||||||
|
// geänderte Überschrift zurück, Escape verwirft.
|
||||||
|
const titleEl = opts.titleEl;
|
||||||
|
if (opts.editBtn && titleEl && opts.onCommit) {
|
||||||
|
const commit = () => {
|
||||||
|
if (titleEl.getAttribute("contenteditable") !== "true") return;
|
||||||
|
titleEl.removeAttribute("contenteditable");
|
||||||
|
const nt = (titleEl.textContent || "").trim();
|
||||||
|
if (nt && nt !== firstLine(rawText)) opts.onCommit!(setHeading(rawText, nt));
|
||||||
|
else titleEl.textContent = firstLine(rawText);
|
||||||
|
};
|
||||||
|
opts.editBtn.addEventListener("click", () => {
|
||||||
|
titleEl.setAttribute("contenteditable", "true");
|
||||||
|
titleEl.focus();
|
||||||
|
const r = document.createRange();
|
||||||
|
r.selectNodeContents(titleEl);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(r);
|
||||||
|
});
|
||||||
|
titleEl.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
titleEl.blur();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
titleEl.textContent = firstLine(rawText);
|
||||||
|
titleEl.removeAttribute("contenteditable");
|
||||||
|
titleEl.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
titleEl.addEventListener("blur", commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Edit: Button schaltet den Inhalt auf eine Rohtext-Textarea. Speichern
|
||||||
|
// (Button erneut oder Cmd/Ctrl+Enter) schreibt zurück, Escape verwirft.
|
||||||
|
// Eingehende Updates während des Editierens werden gepuffert.
|
||||||
|
if (opts.editContentBtn && opts.onCommit) {
|
||||||
|
const editBtn = opts.editContentBtn;
|
||||||
|
const editor = document.createElement("textarea");
|
||||||
|
editor.className = "panel-editor";
|
||||||
|
editor.spellcheck = true;
|
||||||
|
editor.hidden = true;
|
||||||
|
opts.content.after(editor);
|
||||||
|
|
||||||
|
// Sprache der Rechtschreibprüfung: Default aus den Settings, per Selector
|
||||||
|
// pro Text überschreibbar.
|
||||||
|
let lang = opts.defaultLang || "de";
|
||||||
|
const sel = opts.langSelect;
|
||||||
|
if (sel) {
|
||||||
|
if ([...sel.options].some((o) => o.value === lang)) sel.value = lang;
|
||||||
|
else lang = sel.value;
|
||||||
|
sel.addEventListener("change", () => {
|
||||||
|
lang = sel.value;
|
||||||
|
editor.lang = lang;
|
||||||
|
// Neuprüfung erzwingen.
|
||||||
|
editor.spellcheck = false;
|
||||||
|
editor.spellcheck = true;
|
||||||
|
if (!editor.hidden) editor.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
editor.lang = lang;
|
||||||
|
|
||||||
|
const leave = () => {
|
||||||
|
editing = false;
|
||||||
|
editor.hidden = true;
|
||||||
|
opts.content.hidden = false;
|
||||||
|
editBtn.classList.remove("active", "changed");
|
||||||
|
};
|
||||||
|
const enter = () => {
|
||||||
|
editing = true;
|
||||||
|
pending = null;
|
||||||
|
editor.value = rawText;
|
||||||
|
opts.content.hidden = true;
|
||||||
|
editor.hidden = false;
|
||||||
|
editBtn.classList.add("active");
|
||||||
|
editor.focus();
|
||||||
|
};
|
||||||
|
flushEditor = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const v = editor.value;
|
||||||
|
leave();
|
||||||
|
await opts.onCommit!(v); // schreibt Datei -> panel-update -> set() rendert
|
||||||
|
};
|
||||||
|
const save = () => void flushEditor();
|
||||||
|
const cancel = () => {
|
||||||
|
const p = pending;
|
||||||
|
leave();
|
||||||
|
if (p !== null) applyText(p);
|
||||||
|
else draw();
|
||||||
|
};
|
||||||
|
editBtn.addEventListener("click", () => (editing ? save() : enter()));
|
||||||
|
editor.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
save();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyText(text: string) {
|
||||||
|
rawText = text;
|
||||||
|
if (titleEl && titleEl.getAttribute("contenteditable") !== "true") {
|
||||||
|
titleEl.textContent = firstLine(text);
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
return {
|
||||||
|
set(text: string) {
|
||||||
|
// Während einer Inhalts-Bearbeitung nicht überschreiben — puffern und den
|
||||||
|
// Edit-Button als „geändert" markieren.
|
||||||
|
if (editing) {
|
||||||
|
pending = text;
|
||||||
|
opts.editContentBtn?.classList.add("changed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyText(text);
|
||||||
|
},
|
||||||
|
raw: () => rawText,
|
||||||
|
flush: () => flushEditor(),
|
||||||
|
};
|
||||||
|
}
|
||||||
+128
-5
@@ -179,12 +179,20 @@ body {
|
|||||||
z-index: 101;
|
z-index: 101;
|
||||||
}
|
}
|
||||||
.tab-sep {
|
.tab-sep {
|
||||||
width: 1px;
|
width: 2px;
|
||||||
height: 16px;
|
height: 22px;
|
||||||
background: var(--line-strong);
|
background: var(--accent);
|
||||||
margin: 0 6px;
|
opacity: 0.7;
|
||||||
|
border-radius: 1px;
|
||||||
|
margin: 0 12px;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
.panel-title {
|
.panel-title {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -193,6 +201,13 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
.panel-title[contenteditable="true"] {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
.panel-btn {
|
.panel-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -236,10 +251,118 @@ body {
|
|||||||
.panel-btn.changed {
|
.panel-btn.changed {
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
|
.panel-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-family: "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.panel-editor[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.panel-tabs {
|
.panel-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.panel-btn[hidden] {
|
.panel-lang {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin-right: auto;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.panel-lang:hover {
|
||||||
|
background: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
#panel-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
#panel-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
#panel-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
#panel-content.raw {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#panel-content.md h1,
|
||||||
|
#panel-content.md h2,
|
||||||
|
#panel-content.md h3 {
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1em 0 0.4em;
|
||||||
|
}
|
||||||
|
#panel-content.md h1 {
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
#panel-content.md h2 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
#panel-content.md h3 {
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
#panel-content.md :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
#panel-content.md p,
|
||||||
|
#panel-content.md ul,
|
||||||
|
#panel-content.md ol,
|
||||||
|
#panel-content.md pre,
|
||||||
|
#panel-content.md blockquote {
|
||||||
|
margin: 0 0 0.7em;
|
||||||
|
}
|
||||||
|
#panel-content.md code {
|
||||||
|
font-family: "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
#panel-content.md pre {
|
||||||
|
background: var(--tile);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
#panel-content.md pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#panel-content.md blockquote {
|
||||||
|
border-left: 3px solid var(--line);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
#panel-content.md a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.panel-btn[hidden],
|
||||||
|
.panel-lang[hidden],
|
||||||
|
#panel-content[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-27
@@ -1,11 +1,14 @@
|
|||||||
/// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und
|
/// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und
|
||||||
/// das abgelöste Fenster (panel.ts): alle Tabs kommen aus der Modul-Registry
|
/// das abgelöste Fenster (panel.ts): die Entwurfs-Ansicht als Kern, alle
|
||||||
/// (src/modules) — Tab-Knöpfe und Content-Container entstehen hier aus der
|
/// weiteren Tabs aus der Modul-Registry (src/modules) — Tab-Knöpfe und
|
||||||
/// Liste der aktiven Module, Erstbefüllung per buffer_read, Updates über
|
/// Content-Container entstehen hier aus der Liste der aktiven Module,
|
||||||
/// `<buffer>-update`-Events.
|
/// Erstbefüllung per buffer_read, Updates über `<buffer>-update`-Events.
|
||||||
|
/// Die Element-IDs des Entwurfs-Markups sind in beiden HTML-Dateien
|
||||||
|
/// identisch; die Draft-Controls trägt das Markup als `.draft-only`.
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { initPanelView, type PanelView } from "./panel-view";
|
||||||
import { initPanelMode, type ModeTab, type PanelMode } from "./commands-view";
|
import { initPanelMode, type ModeTab, type PanelMode } from "./commands-view";
|
||||||
import { panelToast } from "./tiles";
|
import { panelToast } from "./tiles";
|
||||||
import { PANEL_TABS, type ModuleCtx, type ModuleView } from "./modules";
|
import { PANEL_TABS, type ModuleCtx, type ModuleView } from "./modules";
|
||||||
@@ -13,28 +16,82 @@ import { t } from "./messages";
|
|||||||
import "./panel-tiles.css";
|
import "./panel-tiles.css";
|
||||||
|
|
||||||
export interface PanelWiring {
|
export interface PanelWiring {
|
||||||
|
view: PanelView;
|
||||||
/// Modul-Ansichten nach Tab-Modus; enthält nur Tabs aktiver Module.
|
/// Modul-Ansichten nach Tab-Modus; enthält nur Tabs aktiver Module.
|
||||||
views: Map<string, ModuleView>;
|
views: Map<string, ModuleView>;
|
||||||
mode: { to(m: PanelMode): void; clear(): void; current(): PanelMode | null };
|
mode: { to(m: PanelMode): void; clear(): void; current(): PanelMode | null };
|
||||||
|
/// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung).
|
||||||
|
draft: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function wirePanel(
|
export async function wirePanel(
|
||||||
project: string,
|
project: string,
|
||||||
onIncoming?: () => void,
|
onIncoming?: () => void,
|
||||||
|
standalone = false,
|
||||||
): Promise<PanelWiring> {
|
): Promise<PanelWiring> {
|
||||||
const titleEl = document.querySelector(".panel-title") as HTMLElement;
|
const titleEl = document.querySelector(".panel-title") as HTMLElement;
|
||||||
const enabled = await invoke<string[]>("enabled_modules", { project });
|
const [enabled, defaultLang, draft] = await Promise.all([
|
||||||
const tabs = PANEL_TABS.filter((tab) => enabled.includes(tab.module));
|
invoke<string[]>("enabled_modules", { project }),
|
||||||
|
invoke<string>("spellcheck_lang"),
|
||||||
|
invoke<string>("buffer_read", { project, buffer: "panel" }),
|
||||||
|
]);
|
||||||
|
// Feste Aufgabenteilung: Session-Tabs (ToDo, Befehle) leben im angedockten
|
||||||
|
// Panel, Archiv-Tabs (popupOnly: Archiv, Suche) im eigenen Panel-Fenster.
|
||||||
|
// Im Dock erscheinen die Archiv-Tabs nur als Öffner-Knöpfe (terminal.ts
|
||||||
|
// fängt den Klick und öffnet das Fenster).
|
||||||
|
const tabs = PANEL_TABS.filter((tab) => enabled.includes(tab.module)).filter(
|
||||||
|
(tab) => (standalone ? !!tab.popupOnly : true),
|
||||||
|
);
|
||||||
|
|
||||||
const ctx: ModuleCtx = { project, toast: panelToast };
|
// Ohne Archiv-Modul (abgewählt oder kein Archiv-Home) verschwinden auch
|
||||||
|
// die Archiv-Werkzeuge des Entwurfs-Tabs.
|
||||||
|
if (!enabled.includes("archive")) {
|
||||||
|
for (const sel of ["#panel-archive", "#panel-wiki-jump"]) {
|
||||||
|
const el = document.querySelector<HTMLElement>(sel)!;
|
||||||
|
el.hidden = true;
|
||||||
|
// Raus aus der draft-only-Menge, sonst blendet der Modus-Umschalter
|
||||||
|
// die Archiv-Werkzeuge im Dokument-Modus wieder ein.
|
||||||
|
el.classList.remove("draft-only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jeder Wiki-Sprung (Wikilink, Chip, Dokument-Sprung) geht als ein Invoke an
|
||||||
|
// den Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück.
|
||||||
|
// Fehler (z. B. Ziel nicht im Archiv) erscheinen als Toast.
|
||||||
|
const openWiki = (name: string) =>
|
||||||
|
void invoke("wiki_open", { project, name }).catch((e) => panelToast(String(e)));
|
||||||
|
|
||||||
|
const ctx: ModuleCtx = {
|
||||||
|
project,
|
||||||
|
standalone,
|
||||||
|
toast: panelToast,
|
||||||
|
openDoc: (path) =>
|
||||||
|
void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))),
|
||||||
|
openWiki,
|
||||||
|
};
|
||||||
|
|
||||||
|
const view = initPanelView({
|
||||||
|
content: document.getElementById("panel-content")!,
|
||||||
|
copyBtn: document.getElementById("panel-copy")!,
|
||||||
|
modeBtn: document.getElementById("panel-mode")!,
|
||||||
|
titleEl,
|
||||||
|
editBtn: document.getElementById("panel-title-edit")!,
|
||||||
|
editContentBtn: document.getElementById("panel-content-edit")!,
|
||||||
|
langSelect: document.getElementById("panel-lang") as HTMLSelectElement,
|
||||||
|
defaultLang,
|
||||||
|
onCommit: (text) => invoke("panel_set", { project, text }),
|
||||||
|
onWikiLink: openWiki,
|
||||||
|
});
|
||||||
|
|
||||||
// Tab-Leiste und Container aus der Registry. Die Container-IDs
|
// Tab-Leiste und Container aus der Registry. Die Container-IDs
|
||||||
// (`<mode>-content`) sind zugleich die CSS-Anker der Ansichten; die
|
// (`<mode>-content`) sind zugleich die CSS-Anker der Ansichten; die
|
||||||
// Container reihen sich hinter der Kopfzeile (.panel-head) ein.
|
// Container reihen sich hinter #panel-content ein.
|
||||||
const tabsEl = document.getElementById("panel-tabs")!;
|
const tabsEl = document.getElementById("panel-tabs")!;
|
||||||
const views = new Map<string, ModuleView>();
|
const views = new Map<string, ModuleView>();
|
||||||
const modeTabs: ModeTab[] = [];
|
const modeTabs: ModeTab[] = [];
|
||||||
let anchor = document.querySelector(".panel-head") as HTMLElement;
|
/// Tabs mit eigener Ansicht in DIESER Fläche (Öffner-Knöpfe zählen nicht).
|
||||||
|
const activeTabs: typeof tabs = [];
|
||||||
|
let anchor = document.getElementById("panel-content")!;
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.className = "panel-btn";
|
btn.className = "panel-btn";
|
||||||
@@ -47,12 +104,20 @@ export async function wirePanel(
|
|||||||
sep.className = "tab-sep";
|
sep.className = "tab-sep";
|
||||||
tabsEl.append(sep);
|
tabsEl.append(sep);
|
||||||
}
|
}
|
||||||
const content = document.createElement("div");
|
// Archiv-Tabs im Dock: nur der Öffner-Knopf, keine Ansicht.
|
||||||
|
if (tab.popupOnly && !standalone) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
activeTabs.push(tab);
|
||||||
|
let content: HTMLElement | null = null;
|
||||||
|
if (tab.init) {
|
||||||
|
content = document.createElement("div");
|
||||||
content.id = `${tab.mode}-content`;
|
content.id = `${tab.mode}-content`;
|
||||||
content.hidden = true;
|
content.hidden = true;
|
||||||
anchor.after(content);
|
anchor.after(content);
|
||||||
anchor = content;
|
anchor = content;
|
||||||
views.set(tab.mode, tab.init(content, ctx));
|
views.set(tab.mode, tab.init(content, ctx));
|
||||||
|
}
|
||||||
modeTabs.push({
|
modeTabs.push({
|
||||||
mode: tab.mode,
|
mode: tab.mode,
|
||||||
btn,
|
btn,
|
||||||
@@ -63,25 +128,34 @@ export async function wirePanel(
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Archiv-Zugang: fester Knopf hinter den Tabs — öffnet das Trilium-Fenster
|
const mode = initPanelMode({
|
||||||
// des Projekts (kein Modus, kein Content-Container).
|
tabs: modeTabs,
|
||||||
if (enabled.includes("trilium")) {
|
draftEls: [
|
||||||
const btn = document.createElement("button");
|
document.getElementById("panel-content")!,
|
||||||
btn.className = "panel-btn";
|
...document.querySelectorAll<HTMLElement>(".draft-only"),
|
||||||
btn.textContent = t("panel.archiveOpen");
|
],
|
||||||
btn.title = t("panel.archiveOpenTitle");
|
titleEl,
|
||||||
btn.addEventListener("click", () =>
|
flush: () => void view.flush(),
|
||||||
void invoke("trilium_open", { project, noteId: null }).catch((e) =>
|
});
|
||||||
panelToast(String(e)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
tabsEl.append(btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = initPanelMode({ tabs: modeTabs, titleEl });
|
// Sprung Dokument → Wiki: öffnet den Archiv-Navigator (Dokumentseiten im
|
||||||
|
// Wiki gibt es nicht mehr — Dokumente öffnen immer im Dokument-Tab).
|
||||||
|
document
|
||||||
|
.getElementById("panel-wiki-jump")!
|
||||||
|
.addEventListener("click", () => openWiki("tag:"));
|
||||||
|
|
||||||
|
view.set(draft);
|
||||||
|
await listen<string>("panel-update", (e) => {
|
||||||
|
// Erst umschalten, dann setzen: to("draft") restauriert den gemerkten
|
||||||
|
// Titel — der neue Inhalt (und damit sein Titel) muss danach kommen.
|
||||||
|
mode.to("draft");
|
||||||
|
view.set(e.payload);
|
||||||
|
onIncoming?.();
|
||||||
|
});
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
tabs.map(async (tab) => {
|
activeTabs
|
||||||
|
.filter((tab) => tab.init)
|
||||||
|
.map(async (tab) => {
|
||||||
const v = views.get(tab.mode)!;
|
const v = views.get(tab.mode)!;
|
||||||
v.set(await invoke<string>("buffer_read", { project, buffer: tab.buffer }));
|
v.set(await invoke<string>("buffer_read", { project, buffer: tab.buffer }));
|
||||||
await listen<string>(`${tab.buffer}-update`, (e) => {
|
await listen<string>(`${tab.buffer}-update`, (e) => {
|
||||||
@@ -92,5 +166,5 @@ export async function wirePanel(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { views, mode };
|
return { view, views, mode, draft };
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-17
@@ -4,13 +4,15 @@ import "@fontsource/jetbrains-mono/500.css";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
|
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
|
||||||
import { wirePanel } from "./panel-wiring";
|
import { wirePanel } from "./panel-wiring";
|
||||||
|
import { flash, panelToast } from "./tiles";
|
||||||
|
import { initArchiveForm } from "./archive-form";
|
||||||
import { applyTheme, THEMES } from "./themes";
|
import { applyTheme, THEMES } from "./themes";
|
||||||
import { applyI18n } from "./messages";
|
import { applyI18n, t } from "./messages";
|
||||||
|
|
||||||
// Abgelöstes Panel-Fenster: dieselben Modul-Tabs wie das angedockte Panel,
|
// Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt
|
||||||
// gespeist aus denselben Puffer-Update-Events.
|
// danach denselben Update-Events wie das angedockte Panel; startet in
|
||||||
|
// „Befehle", wenn es (noch) keinen Entwurf gibt.
|
||||||
applyI18n();
|
applyI18n();
|
||||||
const project = new URLSearchParams(location.search).get("project")!;
|
const project = new URLSearchParams(location.search).get("project")!;
|
||||||
const win = getCurrentWebviewWindow();
|
const win = getCurrentWebviewWindow();
|
||||||
@@ -96,25 +98,55 @@ applyTheme(picked);
|
|||||||
document.documentElement.style.background = picked.header;
|
document.documentElement.style.background = picked.header;
|
||||||
document.body.style.background = picked.header;
|
document.body.style.background = picked.header;
|
||||||
|
|
||||||
const { views, mode } = await wirePanel(project);
|
const { view, mode, draft } = await wirePanel(project, undefined, true);
|
||||||
// Beim Ablösen kommt der dort aktive Tab als URL-Parameter mit; ohne ihn
|
// Das Panel-Fenster ist die Archiv-Fläche: gewünschter Tab aus der URL,
|
||||||
// (Kaltstart): ToDo-Liste, falls gewählt und nicht leer, sonst der erste Tab.
|
// sonst das Archiv; ein hereingereichter Entwurf (Bearbeiten) gewinnt.
|
||||||
const initialMode = new URLSearchParams(location.search).get("mode");
|
const initialMode = new URLSearchParams(location.search).get("mode");
|
||||||
const todos = views.get("todo");
|
|
||||||
const first = views.keys().next().value;
|
|
||||||
if (initialMode) {
|
if (initialMode) {
|
||||||
mode.to(initialMode);
|
mode.to(initialMode);
|
||||||
} else if (todos && !todos.empty()) {
|
} else if (!draft.trim()) {
|
||||||
mode.to("todo");
|
mode.to("wiki");
|
||||||
} else if (first) {
|
|
||||||
mode.to(first);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// „Andocken": angedocktes Panel wieder einblenden, dann dieses Fenster schließen.
|
// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken
|
||||||
document.getElementById("panel-dock")!.addEventListener("click", async () => {
|
// wählt notfalls erst das Archiv-Home per Dialog.
|
||||||
await emit("panel-attached");
|
const archiveBtn = document.getElementById("panel-archive")!;
|
||||||
win.close();
|
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
||||||
|
try {
|
||||||
|
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||||
|
project,
|
||||||
});
|
});
|
||||||
|
let dir: string | undefined;
|
||||||
|
if (!configured) {
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const chosen = await open({ directory: true, title: t("panel.chooseArchiveDir") });
|
||||||
|
if (!chosen) return;
|
||||||
|
dir = chosen as string;
|
||||||
|
}
|
||||||
|
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
|
||||||
|
const path = await invoke<string>("panel_archive_cmd", {
|
||||||
|
project,
|
||||||
|
dir,
|
||||||
|
folder: meta.folder ?? null,
|
||||||
|
description: meta.description ?? null,
|
||||||
|
tags: meta.tags,
|
||||||
|
});
|
||||||
|
flash(archiveBtn, "copied", 1400);
|
||||||
|
invoke("reveal_path_cmd", { path });
|
||||||
|
} catch (e) {
|
||||||
|
flash(archiveBtn, "error", 1400);
|
||||||
|
panelToast(`Archivieren fehlgeschlagen: ${e}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
archiveBtn.addEventListener("click", () => archiveForm.toggle());
|
||||||
|
|
||||||
|
// Öffner-Klick im Terminal-Header bei stehendem Fenster: auf den
|
||||||
|
// gewünschten Tab schalten (das Fokussieren macht der Kern).
|
||||||
|
await win.listen<string>("panel-mode", (e) => mode.to(e.payload));
|
||||||
|
|
||||||
|
// Andocken gibt es nicht mehr — die Flächen sind fest verteilt (Session-Tabs
|
||||||
|
// im Dock, Archiv hier); der Knopf bleibt versteckt.
|
||||||
|
document.getElementById("panel-dock")!.hidden = true;
|
||||||
|
|
||||||
// „Schließen": Fenster zu, ohne wieder anzudocken (Panel bleibt aus, bis ein
|
// „Schließen": Fenster zu, ohne wieder anzudocken (Panel bleibt aus, bis ein
|
||||||
// neuer Entwurf kommt).
|
// neuer Entwurf kommt).
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ function setup() {
|
|||||||
const run = JSON.stringify({
|
const run = JSON.stringify({
|
||||||
query: "arch",
|
query: "arch",
|
||||||
tag: null,
|
tag: null,
|
||||||
hits: [{ note_id: "abc123def", title: "X" }],
|
home: "/tmp/archiv",
|
||||||
|
hits: [{ relpath: "a/2026-01-01_0000-x.md", title: "X", snippet: "ein **arch**iv" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("initSearchView", () => {
|
describe("initSearchView", () => {
|
||||||
@@ -73,18 +74,22 @@ describe("initSearchView", () => {
|
|||||||
expect(onSearch).toHaveBeenCalledWith("panel arch");
|
expect(onSearch).toHaveBeenCalledWith("panel arch");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rendert Treffer; Klick liefert die noteId", () => {
|
it("rendert Treffer; Klick liefert den absoluten Pfad", () => {
|
||||||
const { view, onOpen } = setup();
|
const { view, onOpen } = setup();
|
||||||
view.set(run);
|
view.set(run);
|
||||||
const tile = document.querySelector<HTMLElement>(".hit-tile")!;
|
const tile = document.querySelector<HTMLElement>(".hit-tile")!;
|
||||||
expect(tile.querySelector(".hit-title")!.textContent).toBe("X");
|
expect(tile.querySelector(".hit-title")!.textContent).toBe("X");
|
||||||
|
expect(tile.querySelectorAll("mark").length).toBe(1);
|
||||||
tile.click();
|
tile.click();
|
||||||
expect(onOpen).toHaveBeenCalledWith("abc123def");
|
expect(onOpen).toHaveBeenCalledWith(
|
||||||
|
"/tmp/archiv/a/2026-01-01_0000-x.md",
|
||||||
|
"a/2026-01-01_0000-x.md",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zeigt „Keine Treffer“ bei leerem Ergebnis", () => {
|
it("zeigt „Keine Treffer“ bei leerem Ergebnis", () => {
|
||||||
const { view } = setup();
|
const { view } = setup();
|
||||||
view.set(JSON.stringify({ query: "nix", tag: "adr", hits: [] }));
|
view.set(JSON.stringify({ query: "nix", tag: "adr", home: "/a", hits: [] }));
|
||||||
expect(document.querySelector(".hit-head")!.textContent).toBe(
|
expect(document.querySelector(".hit-head")!.textContent).toBe(
|
||||||
"Keine Treffer für „nix“ · #adr",
|
"Keine Treffer für „nix“ · #adr",
|
||||||
);
|
);
|
||||||
|
|||||||
+27
-9
@@ -1,20 +1,22 @@
|
|||||||
/// Kachel-Ansicht der Archiv-Suchtreffer: rendert die Suchtreffer-Datei
|
/// Kachel-Ansicht der Archiv-Suchtreffer: rendert die Suchtreffer-Datei
|
||||||
/// (search_archive im MCP-Server bzw. search_run aus dem Suchfeld) als
|
/// (search_archive im MCP-Server) als klickbare Kacheln — Klick reicht den
|
||||||
/// klickbare Kacheln — Klick reicht die noteId des Treffers an onOpen
|
/// absoluten Pfad und den relpath des Treffers an onOpen. Titel/Snippet/Pfad sind Fremdtext und
|
||||||
/// (öffnet die Notiz im Trilium-Fenster). Titel sind Fremdtext und gehen
|
/// gehen nie durch innerHTML; die `**…**`-Marker im Snippet werden per
|
||||||
/// nie durch innerHTML.
|
/// Split in <mark>-Elemente übersetzt.
|
||||||
|
|
||||||
import { t } from "./messages";
|
import { t } from "./messages";
|
||||||
import { renderTile } from "./tiles";
|
import { renderTile } from "./tiles";
|
||||||
|
|
||||||
interface Hit {
|
interface Hit {
|
||||||
note_id: string;
|
relpath: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
snippet: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchRun {
|
interface SearchRun {
|
||||||
query: string;
|
query: string;
|
||||||
tag?: string | null;
|
tag?: string | null;
|
||||||
|
home: string;
|
||||||
hits: Hit[];
|
hits: Hit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ export interface SearchView {
|
|||||||
|
|
||||||
export function initSearchView(
|
export function initSearchView(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
onOpen: (noteId: string) => void,
|
onOpen: (path: string, relpath: string) => void,
|
||||||
onSearch: (query: string) => void,
|
onSearch: (query: string) => void,
|
||||||
): SearchView {
|
): SearchView {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -39,7 +41,7 @@ export function initSearchView(
|
|||||||
// Live-Suche ab 3 Zeichen, entprellt (300 ms Debounce); endet die Eingabe
|
// Live-Suche ab 3 Zeichen, entprellt (300 ms Debounce); endet die Eingabe
|
||||||
// mitten im Wort, wird das letzte Wort als Präfix gesucht (arch → arch*) —
|
// mitten im Wort, wird das letzte Wort als Präfix gesucht (arch → arch*) —
|
||||||
// außer es ist ein #tag-Token. Enter sucht sofort und wörtlich — für exakte
|
// außer es ist ein #tag-Token. Enter sucht sofort und wörtlich — für exakte
|
||||||
// Trilium-Suchsyntax (Phrasen, Operatoren).
|
// FTS-Syntax (Phrasen, OR/NOT).
|
||||||
let pending: number | undefined;
|
let pending: number | undefined;
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
clearTimeout(pending);
|
clearTimeout(pending);
|
||||||
@@ -71,6 +73,21 @@ export function initSearchView(
|
|||||||
const results = document.createElement("div");
|
const results = document.createElement("div");
|
||||||
container.append(bar, results);
|
container.append(bar, results);
|
||||||
|
|
||||||
|
function snippetEl(snippet: string): HTMLElement {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "hit-snippet";
|
||||||
|
snippet.split("**").forEach((part, i) => {
|
||||||
|
if (i % 2) {
|
||||||
|
const m = document.createElement("mark");
|
||||||
|
m.textContent = part;
|
||||||
|
div.append(m);
|
||||||
|
} else if (part) {
|
||||||
|
div.append(document.createTextNode(part));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
function render(run: SearchRun) {
|
function render(run: SearchRun) {
|
||||||
results.textContent = "";
|
results.textContent = "";
|
||||||
const head = document.createElement("div");
|
const head = document.createElement("div");
|
||||||
@@ -88,9 +105,10 @@ export function initSearchView(
|
|||||||
cls: "hit-tile",
|
cls: "hit-tile",
|
||||||
parts: [
|
parts: [
|
||||||
{ cls: "hit-title", text: hit.title },
|
{ cls: "hit-title", text: hit.title },
|
||||||
{ cls: "hit-path", text: hit.note_id },
|
snippetEl(hit.snippet),
|
||||||
|
{ cls: "hit-path", text: hit.relpath },
|
||||||
],
|
],
|
||||||
onClick: () => onOpen(hit.note_id),
|
onClick: () => onOpen(`${run.home}/${hit.relpath}`, hit.relpath),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-5
@@ -76,10 +76,12 @@ header .panel-tabs[hidden] + #pool {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.tab-sep {
|
.tab-sep {
|
||||||
width: 1px;
|
width: 2px;
|
||||||
height: 16px;
|
height: 22px;
|
||||||
background: var(--line-strong);
|
background: var(--accent);
|
||||||
margin: 0 6px;
|
opacity: 0.7;
|
||||||
|
border-radius: 1px;
|
||||||
|
margin: 0 12px;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
#pool {
|
#pool {
|
||||||
@@ -265,6 +267,12 @@ header .panel-tabs[hidden] + #pool {
|
|||||||
#panel-detach {
|
#panel-detach {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
.panel-title {
|
.panel-title {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -273,6 +281,13 @@ header .panel-tabs[hidden] + #pool {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
.panel-title[contenteditable="true"] {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
.panel-btn {
|
.panel-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -316,10 +331,118 @@ header .panel-tabs[hidden] + #pool {
|
|||||||
.panel-btn.changed {
|
.panel-btn.changed {
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
|
.panel-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-family: "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.panel-editor[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.panel-tabs {
|
.panel-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.panel-btn[hidden] {
|
.panel-lang {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin-right: auto;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.panel-lang:hover {
|
||||||
|
background: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
#panel-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
#panel-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
#panel-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
#panel-content.raw {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#panel-content.md h1,
|
||||||
|
#panel-content.md h2,
|
||||||
|
#panel-content.md h3 {
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1em 0 0.4em;
|
||||||
|
}
|
||||||
|
#panel-content.md h1 {
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
#panel-content.md h2 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
#panel-content.md h3 {
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
#panel-content.md :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
#panel-content.md p,
|
||||||
|
#panel-content.md ul,
|
||||||
|
#panel-content.md ol,
|
||||||
|
#panel-content.md pre,
|
||||||
|
#panel-content.md blockquote {
|
||||||
|
margin: 0 0 0.7em;
|
||||||
|
}
|
||||||
|
#panel-content.md code {
|
||||||
|
font-family: "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
#panel-content.md pre {
|
||||||
|
background: var(--tile);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
#panel-content.md pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#panel-content.md blockquote {
|
||||||
|
border-left: 3px solid var(--line);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
#panel-content.md a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.panel-btn[hidden],
|
||||||
|
.panel-lang[hidden],
|
||||||
|
#panel-content[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-22
@@ -15,7 +15,8 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { wirePanel } from "./panel-wiring";
|
import { wirePanel } from "./panel-wiring";
|
||||||
import { applyTheme, THEMES } from "./themes";
|
import { applyTheme, THEMES } from "./themes";
|
||||||
import { panelToast } from "./tiles";
|
import { flash, panelToast } from "./tiles";
|
||||||
|
import { initArchiveForm } from "./archive-form";
|
||||||
import { applyI18n, t } from "./messages";
|
import { applyI18n, t } from "./messages";
|
||||||
|
|
||||||
// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln.
|
// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln.
|
||||||
@@ -188,10 +189,10 @@ window.addEventListener("resize", () => fit.fit());
|
|||||||
await invoke("term_start", { project, rows: term.rows, cols: term.cols });
|
await invoke("term_start", { project, rows: term.rows, cols: term.cols });
|
||||||
term.focus();
|
term.focus();
|
||||||
|
|
||||||
// --- Andockbares Panel ----------------------------------------------------
|
// --- Andockbares Entwurfs-Panel -------------------------------------------
|
||||||
// Die MCP-Tools schreiben in die Puffer-Dateien; terminal.rs meldet neuen
|
// Der Skill schreibt Entwürfe in eine Datei; terminal.rs meldet neuen Inhalt
|
||||||
// Inhalt als `<buffer>-update`. Das Panel blendet sich dann ein — außer es
|
// per `panel-update`. Das Panel blendet sich dann ein — außer es ist gerade in
|
||||||
// ist gerade in ein eigenes Fenster abgelöst (`panel-detached`).
|
// ein eigenes Fenster abgelöst (`panel-detached`).
|
||||||
const panel = document.getElementById("panel")!;
|
const panel = document.getElementById("panel")!;
|
||||||
const splitter = document.getElementById("splitter")!;
|
const splitter = document.getElementById("splitter")!;
|
||||||
|
|
||||||
@@ -211,20 +212,10 @@ function hidePanel() {
|
|||||||
|
|
||||||
// Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende
|
// Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende
|
||||||
// Update blendet das angedockte Panel ein, solange es nicht abgelöst ist.
|
// Update blendet das angedockte Panel ein, solange es nicht abgelöst ist.
|
||||||
const { views, mode } = await wirePanel(project, () => {
|
const { view, views, mode } = await wirePanel(project, () => {
|
||||||
hasContent = true;
|
hasContent = true;
|
||||||
if (!detached) showPanel();
|
if (!detached) showPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
// write_panel hat eine Notiz angelegt: das Trilium-Fenster des Projekts
|
|
||||||
// zeigt sie. Leerer Puffer = Session-Start, keine Notiz.
|
|
||||||
await listen<string>("noteopen-update", (e) => {
|
|
||||||
if (!e.payload.trim()) return;
|
|
||||||
const { noteId } = JSON.parse(e.payload);
|
|
||||||
void invoke("trilium_open", { project, noteId }).catch((err) =>
|
|
||||||
panelToast(String(err)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
// Panel startet zugeklappt — außer das ToDo-Modul ist gewählt und die Liste
|
// Panel startet zugeklappt — außer das ToDo-Modul ist gewählt und die Liste
|
||||||
// nicht leer: dann öffnet das Panel beim Start mit der ToDo-Ansicht.
|
// nicht leer: dann öffnet das Panel beim Start mit der ToDo-Ansicht.
|
||||||
const todos = views.get("todo");
|
const todos = views.get("todo");
|
||||||
@@ -238,6 +229,22 @@ if (todos && !todos.empty()) {
|
|||||||
|
|
||||||
// Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist.
|
// Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist.
|
||||||
const headerTabs = document.getElementById("panel-tabs")!;
|
const headerTabs = document.getElementById("panel-tabs")!;
|
||||||
|
// Ausnahme Archiv & Suche: öffnen standardmäßig im eigenen Panel-Fenster
|
||||||
|
// (Popup) statt in der schmalen angedockten Spalte — Capture-Phase, damit
|
||||||
|
// weder der Modus-Wechsel des Tabs noch das Einblenden des Panels feuern.
|
||||||
|
headerTabs.addEventListener(
|
||||||
|
"click",
|
||||||
|
(e) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLElement>(
|
||||||
|
'button[data-mode="wiki"], button[data-mode="search"]',
|
||||||
|
);
|
||||||
|
if (!btn) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
void invoke("open_panel_window", { project, mode: btn.dataset.mode });
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
headerTabs.addEventListener("click", () => {
|
headerTabs.addEventListener("click", () => {
|
||||||
hasContent = true;
|
hasContent = true;
|
||||||
if (!detached) showPanel();
|
if (!detached) showPanel();
|
||||||
@@ -263,17 +270,47 @@ await listen("panel-window-closed", () => {
|
|||||||
headerTabs.hidden = false;
|
headerTabs.hidden = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Der aktive Tab wandert mit ins abgelöste Fenster.
|
// Ablösen gibt es nicht mehr — die Flächen sind fest verteilt; der Knopf
|
||||||
document
|
// bleibt versteckt.
|
||||||
.getElementById("panel-detach")!
|
document.getElementById("panel-detach")!.hidden = true;
|
||||||
.addEventListener("click", () =>
|
|
||||||
invoke("open_panel_window", { project, mode: mode.current() }),
|
|
||||||
);
|
|
||||||
document.getElementById("panel-hide")!.addEventListener("click", () => {
|
document.getElementById("panel-hide")!.addEventListener("click", () => {
|
||||||
hidePanel();
|
hidePanel();
|
||||||
mode.clear();
|
mode.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Archivieren: Button klappt das Formular (Ordner/Beschreibung/Schlagwörter)
|
||||||
|
// auf; Abschicken wählt notfalls erst das Archiv-Home per Dialog.
|
||||||
|
const archiveBtn = document.getElementById("panel-archive")!;
|
||||||
|
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
||||||
|
try {
|
||||||
|
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||||
|
project,
|
||||||
|
});
|
||||||
|
let dir: string | undefined;
|
||||||
|
if (!configured) {
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const chosen = await open({ directory: true, title: t("panel.chooseArchiveDir") });
|
||||||
|
if (!chosen) return;
|
||||||
|
dir = chosen as string;
|
||||||
|
}
|
||||||
|
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
|
||||||
|
const path = await invoke<string>("panel_archive_cmd", {
|
||||||
|
project,
|
||||||
|
dir,
|
||||||
|
folder: meta.folder ?? null,
|
||||||
|
description: meta.description ?? null,
|
||||||
|
tags: meta.tags,
|
||||||
|
});
|
||||||
|
flash(archiveBtn, "copied", 1400);
|
||||||
|
invoke("reveal_path_cmd", { path });
|
||||||
|
} catch (e) {
|
||||||
|
flash(archiveBtn, "error", 1400);
|
||||||
|
panelToast(`Archivieren fehlgeschlagen: ${e}`);
|
||||||
|
invoke("term_log", { msg: `archive: ${e}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
archiveBtn.addEventListener("click", () => archiveForm.toggle());
|
||||||
|
|
||||||
// Splitter: Panelbreite ziehen.
|
// Splitter: Panelbreite ziehen.
|
||||||
splitter.addEventListener("mousedown", (e) => {
|
splitter.addEventListener("mousedown", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// Archiv-Ansicht: Baum mit Notizen (Ordner + Dokumente, Verschmelzung
|
||||||
|
// gleichnamiger Paare), Notiz-Ansicht rechts (Titel, Inhalt, Kindliste),
|
||||||
|
// Aktionen (bearbeiten, umbenennen, löschen, anlegen), Suchtreffer-Vormerkung,
|
||||||
|
// Leerzustände.
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { initWikiView } from "./wiki-view";
|
||||||
|
|
||||||
|
function setup(pending: string | null = null) {
|
||||||
|
document.body.innerHTML = `<div id="w"></div>`;
|
||||||
|
const readDoc = vi.fn(() => Promise.resolve("# Inhalt\n\nText mit [[neu]]."));
|
||||||
|
const writeDoc = vi.fn(() => Promise.resolve());
|
||||||
|
const setTitle = vi.fn();
|
||||||
|
const openWiki = vi.fn();
|
||||||
|
const actions = {
|
||||||
|
rename: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
moveFolder: vi.fn(),
|
||||||
|
createFolder: vi.fn(),
|
||||||
|
createDoc: vi.fn(),
|
||||||
|
};
|
||||||
|
let pendingSelect = pending;
|
||||||
|
const view = initWikiView(document.getElementById("w")!, {
|
||||||
|
autoStart: true,
|
||||||
|
readDoc,
|
||||||
|
writeDoc,
|
||||||
|
setTitle,
|
||||||
|
openWiki,
|
||||||
|
takePending: () => {
|
||||||
|
const p = pendingSelect;
|
||||||
|
pendingSelect = null;
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
actions,
|
||||||
|
});
|
||||||
|
return { view, readDoc, writeDoc, setTitle, openWiki, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = (relpath: string, name: string, extra: object = {}) => ({
|
||||||
|
relpath,
|
||||||
|
name,
|
||||||
|
title: name.toUpperCase(),
|
||||||
|
description: null,
|
||||||
|
tags: [],
|
||||||
|
date: "2026-07-19",
|
||||||
|
backlinks: 0,
|
||||||
|
modified: "2026-07-24",
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = JSON.stringify({
|
||||||
|
kind: "page",
|
||||||
|
home: "/tmp/archiv",
|
||||||
|
tag: null,
|
||||||
|
total: 3,
|
||||||
|
tags: [],
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
docs: [
|
||||||
|
doc("2026-07-19_1000-wurzel-doc.md", "wurzel-doc", {
|
||||||
|
backlinks: 2,
|
||||||
|
description: "Beschreibung",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "konzepte", docs: [doc("konzepte/2026-07-19_1005-neu.md", "neu")] },
|
||||||
|
{
|
||||||
|
name: "konzepte/panel",
|
||||||
|
docs: [doc("konzepte/panel/2026-07-19_1010-alt.md", "alt")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Ordner `konzepte` mit gleichnamigem Dokument daneben — eine Notiz mit
|
||||||
|
/// Inhalt und Kindern.
|
||||||
|
const mergedPage = JSON.stringify({
|
||||||
|
kind: "page",
|
||||||
|
home: "/tmp/archiv",
|
||||||
|
tag: null,
|
||||||
|
total: 2,
|
||||||
|
tags: [],
|
||||||
|
folders: [
|
||||||
|
{ name: "", docs: [doc("konzepte.md", "konzepte", { title: "Konzepte" })] },
|
||||||
|
{ name: "konzepte", docs: [doc("konzepte/2026-07-19_1005-neu.md", "neu")] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const flush = () => new Promise((r) => setTimeout(r));
|
||||||
|
|
||||||
|
describe("initWikiView — Baum", () => {
|
||||||
|
it("Baum zeigt Ordner und Dokumente, Wurzel ist gewählt", () => {
|
||||||
|
const { view } = setup();
|
||||||
|
view.set(page);
|
||||||
|
expect(document.querySelector(".wiki-tree-root")!.className).toContain("active");
|
||||||
|
const rows = [...document.querySelectorAll(".wiki-tree .wiki-tree-name")].map(
|
||||||
|
(e) => e.textContent,
|
||||||
|
);
|
||||||
|
// Unter jedem Knoten erst Dokumente, dann Ordner (mit Titel), sortiert.
|
||||||
|
expect(rows).toEqual(["WURZEL-DOC", "konzepte", "NEU", "panel", "ALT"]);
|
||||||
|
// Wurzel-Ansicht rechts: Titel + Kindzähler.
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("Archiv");
|
||||||
|
expect(document.querySelector(".wiki-children-caption")!.textContent).toBe(
|
||||||
|
"2 Dokumente",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gleichnamiges Dokument verschmilzt mit dem Ordner zur Notiz", async () => {
|
||||||
|
const { view, readDoc } = setup();
|
||||||
|
view.set(mergedPage);
|
||||||
|
const rows = [...document.querySelectorAll(".wiki-tree .wiki-tree-name")].map(
|
||||||
|
(e) => e.textContent,
|
||||||
|
);
|
||||||
|
// Kein eigenes Blatt für konzepte.md — der Ordnerknoten trägt den Titel.
|
||||||
|
expect(rows).toEqual(["Konzepte", "NEU"]);
|
||||||
|
// Auswahl des Knotens zeigt Inhalt UND Kindliste.
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click();
|
||||||
|
await flush();
|
||||||
|
expect(readDoc).toHaveBeenCalledWith("konzepte.md");
|
||||||
|
expect(document.querySelector(".wiki-note-body h1")!.textContent).toBe("Inhalt");
|
||||||
|
expect(document.querySelector(".wiki-note-children .wiki-doc-title")!.textContent).toBe(
|
||||||
|
"NEU",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initWikiView — Notiz-Ansicht", () => {
|
||||||
|
it("Dokument-Klick lädt den Body in die Notiz-Ansicht", async () => {
|
||||||
|
const { view, readDoc } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click(); // WURZEL-DOC
|
||||||
|
await flush();
|
||||||
|
expect(readDoc).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC");
|
||||||
|
// Meta als Popup am Info-Knopf.
|
||||||
|
const pop = document.querySelector<HTMLElement>(".wiki-note-info-pop")!;
|
||||||
|
expect(pop.hidden).toBe(true);
|
||||||
|
document.querySelector<HTMLElement>('.wiki-note-actions [title^="Details"]')!.click();
|
||||||
|
expect(pop.hidden).toBe(false);
|
||||||
|
expect(pop.textContent).toContain("↩ 2");
|
||||||
|
expect(document.querySelector(".wiki-note-body h1")!.textContent).toBe("Inhalt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Wikilink im Inhalt wählt die Ziel-Notiz lokal aus", async () => {
|
||||||
|
const { view, openWiki } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click(); // WURZEL-DOC
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-body a.wiki")!.click();
|
||||||
|
expect(openWiki).not.toHaveBeenCalled();
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("NEU");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Stift schaltet in den Editor, Speichern schreibt zurück", async () => {
|
||||||
|
const { view, writeDoc } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-actions .panel-btn")!.click();
|
||||||
|
await flush();
|
||||||
|
const area = document.querySelector<HTMLTextAreaElement>(".wiki-note-editor")!;
|
||||||
|
expect(area.value).toContain("# Inhalt");
|
||||||
|
// Vorschau rechts zeigt den gerenderten Rohtext.
|
||||||
|
expect(document.querySelector(".wiki-edit-preview h1")!.textContent).toBe("Inhalt");
|
||||||
|
area.value = "# Neu\n\nGeändert.";
|
||||||
|
area.dispatchEvent(new Event("input"));
|
||||||
|
expect(document.querySelector(".wiki-edit-preview h1")!.textContent).toBe("Neu");
|
||||||
|
document.querySelector<HTMLElement>(".wiki-form-submit")!.click();
|
||||||
|
await flush();
|
||||||
|
expect(writeDoc).toHaveBeenCalledWith(
|
||||||
|
"2026-07-19_1000-wurzel-doc.md",
|
||||||
|
"# Neu\n\nGeändert.",
|
||||||
|
);
|
||||||
|
// Nach dem Speichern wieder die Anzeige.
|
||||||
|
expect(document.querySelector(".wiki-note-editor")).toBeNull();
|
||||||
|
expect(document.querySelector(".wiki-note-body")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Abbrechen verwirft und zeigt wieder an", async () => {
|
||||||
|
const { view, writeDoc } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-actions .panel-btn")!.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-form-cancel")!.click();
|
||||||
|
expect(writeDoc).not.toHaveBeenCalled();
|
||||||
|
expect(document.querySelector(".wiki-note-body")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Auswahl übersteht ein Puffer-Update, Verschwundenes fällt zur Wurzel", () => {
|
||||||
|
const { view } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click();
|
||||||
|
view.set(page); // Update: Auswahl bleibt
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("konzepte");
|
||||||
|
view.set(
|
||||||
|
JSON.stringify({
|
||||||
|
kind: "page",
|
||||||
|
home: "/tmp/archiv",
|
||||||
|
tag: null,
|
||||||
|
total: 1,
|
||||||
|
tags: [],
|
||||||
|
folders: [{ name: "", docs: [doc("2026-07-19_1000-wurzel-doc.md", "wurzel-doc")] }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("Archiv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vorgemerkter Suchtreffer wählt die Notiz beim set() aus", async () => {
|
||||||
|
const { view, readDoc } = setup("konzepte/2026-07-19_1005-neu.md");
|
||||||
|
view.set(page);
|
||||||
|
await flush();
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("NEU");
|
||||||
|
expect(readDoc).toHaveBeenCalledWith("konzepte/2026-07-19_1005-neu.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initWikiView — Zurück", () => {
|
||||||
|
it("Zurück-Knopf führt zur vorherigen Auswahl", async () => {
|
||||||
|
const { view } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click(); // WURZEL-DOC
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click(); // konzepte
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("konzepte");
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-back")!.click();
|
||||||
|
await flush();
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC");
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-back")!.click();
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("Archiv");
|
||||||
|
// Verlauf leer: Knopf deaktiviert.
|
||||||
|
expect(
|
||||||
|
document.querySelector<HTMLButtonElement>(".wiki-note-back")!.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initWikiView — Aktionen", () => {
|
||||||
|
it("Klick auf den Titel bearbeitet ihn, Enter setzt den Frontmatter-Titel", async () => {
|
||||||
|
const { view, setTitle } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-title.editable")!.click();
|
||||||
|
const input = document.querySelector<HTMLInputElement>(".wiki-note-title-input")!;
|
||||||
|
expect(input.value).toBe("WURZEL-DOC");
|
||||||
|
input.value = "Neuer Titel";
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
expect(setTitle).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md", "Neuer Titel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape verwirft die Titel-Bearbeitung", async () => {
|
||||||
|
const { view, setTitle } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-title.editable")!.click();
|
||||||
|
const input = document.querySelector<HTMLInputElement>(".wiki-note-title-input")!;
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC");
|
||||||
|
expect(setTitle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Löschen meldet mit relpath", async () => {
|
||||||
|
const { view, actions } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-actions .cmd-del")!.click();
|
||||||
|
expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Plus im Notiz-Kopf: Formular legt Dokument im gewählten Ordner an", () => {
|
||||||
|
const { view, actions } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-actions .wiki-add")!.click();
|
||||||
|
const form = document.querySelector<HTMLElement>(".wiki-form")!;
|
||||||
|
expect(form.querySelector(".wiki-form-title")!.textContent).toBe("Neues Dokument");
|
||||||
|
const input = form.querySelector<HTMLInputElement>("input")!;
|
||||||
|
input.value = "Deploy Notiz";
|
||||||
|
form.querySelector<HTMLElement>(".wiki-form-submit")!.click();
|
||||||
|
expect(actions.createDoc).toHaveBeenCalledWith("konzepte", "Deploy Notiz");
|
||||||
|
expect(document.querySelector(".wiki-form")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Abbrechen und Escape schließen das Formular ohne Aktion", () => {
|
||||||
|
const { view, actions } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-actions .wiki-add")!.click();
|
||||||
|
document.querySelector<HTMLElement>(".wiki-form-cancel")!.click();
|
||||||
|
expect(document.querySelector(".wiki-form")).toBeNull();
|
||||||
|
|
||||||
|
document.querySelector<HTMLElement>(".wiki-note-actions .wiki-add")!.click();
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(".wiki-form")!
|
||||||
|
.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
expect(document.querySelector(".wiki-form")).toBeNull();
|
||||||
|
expect(actions.createDoc).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rechtsklick auf Ordner: Menü legt Ordner darunter an", () => {
|
||||||
|
const { view, actions } = setup();
|
||||||
|
view.set(page);
|
||||||
|
const summary = document.querySelector<HTMLElement>(".wiki-tree summary")!;
|
||||||
|
summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||||
|
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
||||||
|
items.find((i) => i.textContent === "Neuer Ordner")!.click();
|
||||||
|
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
|
||||||
|
expect(input.value).toBe("konzepte/");
|
||||||
|
input.value = "konzepte/neu";
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
expect(actions.createFolder).toHaveBeenCalledWith("konzepte/neu");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rechtsklick auf Ordner: Dialog ändert den Ordnerpfad", () => {
|
||||||
|
const { view, actions } = setup();
|
||||||
|
view.set(page);
|
||||||
|
const summary = document.querySelector<HTMLElement>(".wiki-tree summary")!;
|
||||||
|
summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||||
|
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
||||||
|
items.find((i) => i.textContent === "Ordnerpfad ändern")!.click();
|
||||||
|
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
|
||||||
|
expect(input.value).toBe("konzepte");
|
||||||
|
input.value = "notizen/2026";
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
expect(actions.moveFolder).toHaveBeenCalledWith("konzepte", "notizen/2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rechtsklick auf Dokument: Menü löscht mit relpath", () => {
|
||||||
|
const { view, actions } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(".wiki-tree-doc")!
|
||||||
|
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||||
|
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
||||||
|
items.find((i) => i.textContent === "Dokument löschen")!.click();
|
||||||
|
expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
|
||||||
|
expect(document.querySelector(".wiki-menu")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initWikiView — Leerzustände", () => {
|
||||||
|
it("leeres Archiv erklärt das Archivieren", () => {
|
||||||
|
const { view } = setup();
|
||||||
|
view.set(
|
||||||
|
JSON.stringify({
|
||||||
|
kind: "page",
|
||||||
|
home: "/a",
|
||||||
|
tag: null,
|
||||||
|
total: 0,
|
||||||
|
tags: [],
|
||||||
|
folders: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".wiki-empty")!.textContent).toContain(
|
||||||
|
"Das Archiv ist leer.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leerer Puffer fordert die Übersicht von selbst an", () => {
|
||||||
|
const { view, openWiki } = setup();
|
||||||
|
view.set("");
|
||||||
|
expect(view.empty()).toBe(true);
|
||||||
|
expect(openWiki).toHaveBeenCalledWith("tag:");
|
||||||
|
// Nur einmal — das Update kommt über den Puffer zurück.
|
||||||
|
view.set("");
|
||||||
|
expect(openWiki).toHaveBeenCalledTimes(1);
|
||||||
|
view.set(page);
|
||||||
|
expect(view.empty()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
/// Archiv-Ansicht des Panels, am Trilium-Layout orientiert: links ein Baum,
|
||||||
|
/// in dem die Notizen selbst hängen (Ordner = Notiz mit Kindern — ein Ordner
|
||||||
|
/// mit gleichnamigem Dokument daneben ist EINE Notiz mit Inhalt und Kindern),
|
||||||
|
/// rechts die Notiz-Ansicht: großer Titel mit Aktionen (bearbeiten,
|
||||||
|
/// umbenennen, löschen), darunter der gerenderte Inhalt bzw. für Notizen ohne
|
||||||
|
/// Inhalt die Kindliste. Titel, Beschreibungen und Pfade sind Fremdtext und
|
||||||
|
/// gehen nie durch innerHTML; der Markdown-Body läuft durch renderMarkdown.
|
||||||
|
|
||||||
|
import { renderMarkdown } from "./markdown";
|
||||||
|
import { t } from "./messages";
|
||||||
|
import { linkWikiRefs } from "./panel-view";
|
||||||
|
import { deleteAction } from "./tiles";
|
||||||
|
|
||||||
|
interface DocEntry {
|
||||||
|
relpath: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
date?: string | null;
|
||||||
|
backlinks: number;
|
||||||
|
/// Letzte Änderung (Datei-mtime, YYYY-MM-DD).
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Folder {
|
||||||
|
name: string;
|
||||||
|
docs: DocEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Page {
|
||||||
|
kind: "page";
|
||||||
|
home: string;
|
||||||
|
tag?: string | null;
|
||||||
|
total: number;
|
||||||
|
tags: { name: string; count: number }[];
|
||||||
|
folders: Folder[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dokument-/Ordner-Operationen — laufen als Tauri-Commands, der neue Stand
|
||||||
|
/// kommt über den Wiki-Puffer zurück.
|
||||||
|
export interface WikiActions {
|
||||||
|
rename(relpath: string, name: string): void;
|
||||||
|
remove(relpath: string): void;
|
||||||
|
moveFolder(folder: string, to: string): void;
|
||||||
|
createFolder(folder: string): void;
|
||||||
|
createDoc(folder: string, name: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiCallbacks {
|
||||||
|
/// Leeren Puffer sofort mit der Übersicht füllen (eigenes Fenster).
|
||||||
|
autoStart?: boolean;
|
||||||
|
/// Body eines Archiv-Dokuments (ohne Frontmatter) für die Notiz-Ansicht.
|
||||||
|
readDoc(relpath: string): Promise<string>;
|
||||||
|
/// Body einer Archiv-Notiz zurückschreiben (Bearbeiten im Archiv).
|
||||||
|
writeDoc(relpath: string, text: string): Promise<void>;
|
||||||
|
/// Anzeige-Titel einer Notiz setzen (Klick auf den Titel).
|
||||||
|
setTitle(relpath: string, title: string): void;
|
||||||
|
/// Wiki-Ziel laden (`tag:` = Übersicht in den Puffer, Einstiegs-Chip).
|
||||||
|
openWiki(name: string): void;
|
||||||
|
/// Vorgemerkte Auswahl (Suchtreffer-Sprung): einmalig abholen.
|
||||||
|
takePending?(): string | null;
|
||||||
|
actions: WikiActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiView {
|
||||||
|
set(text: string): void;
|
||||||
|
/// Noch keine Seite im Puffer (Session-Start)?
|
||||||
|
empty(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baum-Knoten: Unterordner als Kinder, Dokumente als Blätter; `content` ist
|
||||||
|
/// das mit dem Ordner verschmolzene gleichnamige Dokument (Notiz-Inhalt).
|
||||||
|
interface TreeNode {
|
||||||
|
children: Map<string, TreeNode>;
|
||||||
|
docs: DocEntry[];
|
||||||
|
content?: DocEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JS-Seite der slugify-Semantik aus archive.rs — für die lokale Auflösung
|
||||||
|
/// von Wikilinks gegen Name/Titel/Stem.
|
||||||
|
function slugify(s: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (const c of s) {
|
||||||
|
if (c === "ä" || c === "Ä") out += "ae";
|
||||||
|
else if (c === "ö" || c === "Ö") out += "oe";
|
||||||
|
else if (c === "ü" || c === "Ü") out += "ue";
|
||||||
|
else if (c === "ß") out += "ss";
|
||||||
|
else if (/[a-zA-Z0-9]/.test(c)) out += c.toLowerCase();
|
||||||
|
else if (!out.endsWith("-")) out += "-";
|
||||||
|
}
|
||||||
|
return out.replace(/^-+|-+$/g, "").slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stem(relpath: string): string {
|
||||||
|
const base = relpath.split("/").pop()!;
|
||||||
|
return base.replace(/\.md$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(p: Page): TreeNode {
|
||||||
|
const root: TreeNode = { children: new Map(), docs: [] };
|
||||||
|
const node = (path: string): TreeNode => {
|
||||||
|
let n = root;
|
||||||
|
if (!path) return n;
|
||||||
|
for (const part of path.split("/")) {
|
||||||
|
if (!n.children.has(part)) {
|
||||||
|
n.children.set(part, { children: new Map(), docs: [] });
|
||||||
|
}
|
||||||
|
n = n.children.get(part)!;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
for (const folder of p.folders) {
|
||||||
|
if (folder.name) node(folder.name);
|
||||||
|
}
|
||||||
|
// Dokumente einhängen; ein Dokument mit gleichnamigem Ordner daneben wird
|
||||||
|
// dessen Notiz-Inhalt statt eigenes Blatt.
|
||||||
|
for (const folder of p.folders) {
|
||||||
|
const parent = node(folder.name);
|
||||||
|
for (const doc of folder.docs) {
|
||||||
|
const twin = parent.children.get(doc.name);
|
||||||
|
if (twin && !twin.content) twin.content = doc;
|
||||||
|
else parent.docs.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wurzel-Konvention: `index.md` im Archiv-Root ist der Text des
|
||||||
|
// Archiv-Knotens (die Wurzel hat keinen Namen für die Zwillingsregel).
|
||||||
|
const idx = root.docs.findIndex((d) => d.name === "index");
|
||||||
|
if (idx >= 0 && !root.content) {
|
||||||
|
root.content = root.docs[idx];
|
||||||
|
root.docs.splice(idx, 1);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiView {
|
||||||
|
/// Zugeklappte Ordner und Auswahl — Auswahl ist ein Ordnerpfad ("" =
|
||||||
|
/// Wurzel) oder ein Dokument-relpath (endet auf .md); beides überlebt
|
||||||
|
/// Puffer-Updates innerhalb der Ansicht.
|
||||||
|
const closed = new Set<string>();
|
||||||
|
let selected = "";
|
||||||
|
let current: Page | null = null;
|
||||||
|
let tree: TreeNode = { children: new Map(), docs: [] };
|
||||||
|
/// Auswahl-Verlauf für den Zurück-Knopf der Notiz-Ansicht.
|
||||||
|
const history: string[] = [];
|
||||||
|
/// Bearbeitungsmodus der aktuellen Notiz (Editor statt Anzeige).
|
||||||
|
let editing = false;
|
||||||
|
|
||||||
|
const isDoc = (sel: string) => sel.endsWith(".md");
|
||||||
|
|
||||||
|
function findNode(path: string): TreeNode | null {
|
||||||
|
let n = tree;
|
||||||
|
if (!path) return n;
|
||||||
|
for (const part of path.split("/")) {
|
||||||
|
const next = n.children.get(part);
|
||||||
|
if (!next) return null;
|
||||||
|
n = next;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDoc(relpath: string): DocEntry | null {
|
||||||
|
if (!current) return null;
|
||||||
|
for (const f of current.folders) {
|
||||||
|
const hit = f.docs.find((d) => d.relpath === relpath);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordnerpfad eines Dokument-relpaths.
|
||||||
|
const docFolder = (relpath: string) =>
|
||||||
|
relpath.includes("/") ? relpath.slice(0, relpath.lastIndexOf("/")) : "";
|
||||||
|
|
||||||
|
/// Wikilink lokal auflösen (Name, Titel, Stem — Slug-Vergleich wie im
|
||||||
|
/// Backend); ohne Treffer übernimmt das Backend (Dokument-Tab).
|
||||||
|
function followWikiLink(name: string) {
|
||||||
|
const want = slugify(name);
|
||||||
|
for (const f of current?.folders ?? []) {
|
||||||
|
for (const d of f.docs) {
|
||||||
|
if ([d.name, d.title, stem(d.relpath)].some((s) => slugify(s) === want)) {
|
||||||
|
select(d.relpath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb.openWiki(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Kontextmenü (rechte Maustaste im Baum) ----------
|
||||||
|
|
||||||
|
/// Eigenes Kontextmenü an der Mausposition; Klick daneben oder Escape
|
||||||
|
/// schließt. Das Standard-Menü des Webviews ist im ganzen Archiv aus.
|
||||||
|
function openMenu(x: number, y: number, items: { label: string; run(): void }[]) {
|
||||||
|
container.querySelector(".wiki-menu")?.remove();
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.className = "wiki-menu";
|
||||||
|
menu.style.left = `${x}px`;
|
||||||
|
menu.style.top = `${y}px`;
|
||||||
|
for (const item of items) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "wiki-menu-item";
|
||||||
|
btn.textContent = item.label;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
menu.remove();
|
||||||
|
item.run();
|
||||||
|
});
|
||||||
|
menu.append(btn);
|
||||||
|
}
|
||||||
|
const close = (e: Event) => {
|
||||||
|
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
|
||||||
|
// Mousedown auf einem Menüpunkt: erst der Klick führt aus — das Menü
|
||||||
|
// räumt der Punkt selbst weg.
|
||||||
|
if (e instanceof MouseEvent && menu.contains(e.target as Node)) return;
|
||||||
|
menu.remove();
|
||||||
|
document.removeEventListener("mousedown", close);
|
||||||
|
document.removeEventListener("keydown", close);
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener("mousedown", close);
|
||||||
|
document.addEventListener("keydown", close);
|
||||||
|
});
|
||||||
|
container.append(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Baum-Spalte ----------
|
||||||
|
|
||||||
|
/// Typ-Symbol im Baum (Stroke-SVG wie die übrigen App-Icons): neutraler
|
||||||
|
/// Knoten-Kreis für Ordner-Notizen, Blatt für Dokumente.
|
||||||
|
function typeIcon(kind: "node" | "doc"): HTMLElement {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "wiki-tree-icon";
|
||||||
|
span.innerHTML =
|
||||||
|
kind === "node"
|
||||||
|
? `<svg width="17" height="17" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4.2"/></svg>`
|
||||||
|
: `<svg width="17" height="17" viewBox="0 0 16 16"><path d="M4 1.5h5.5L12.5 5v9a.9.9 0 0 1-.9.9H4a.9.9 0 0 1-.9-.9V2.4a.9.9 0 0 1 .9-.9z"/><path d="M9.5 1.5V5H13"/><path d="M5.8 8h4.4M5.8 10.5h4.4"/></svg>`;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderRow(name: string, full: string, node: TreeNode): HTMLElement {
|
||||||
|
const det = document.createElement("details");
|
||||||
|
det.className = "wiki-tree-folder";
|
||||||
|
det.open = !closed.has(full);
|
||||||
|
det.addEventListener("toggle", () => {
|
||||||
|
if (det.open) closed.delete(full);
|
||||||
|
else closed.add(full);
|
||||||
|
});
|
||||||
|
const sum = document.createElement("summary");
|
||||||
|
sum.classList.add(node.content ? "has-content" : "book");
|
||||||
|
if (full === selected || node.content?.relpath === selected) {
|
||||||
|
sum.classList.add("active");
|
||||||
|
}
|
||||||
|
// Die ganze Zeile wählt die Notiz aus (wie in Trilium); NUR der Pfeil
|
||||||
|
// klappt — deshalb das summary-Default unterbinden und selbst schalten.
|
||||||
|
sum.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
select(full);
|
||||||
|
});
|
||||||
|
const arrow = document.createElement("span");
|
||||||
|
arrow.className = "wiki-tree-arrow";
|
||||||
|
arrow.textContent = "▸";
|
||||||
|
arrow.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
det.open = !det.open;
|
||||||
|
});
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "wiki-tree-name";
|
||||||
|
label.textContent = node.content?.title ?? name;
|
||||||
|
sum.addEventListener("contextmenu", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openMenu(e.clientX, e.clientY, [
|
||||||
|
{ label: t("wiki.newDoc"), run: () => newDocForm(full) },
|
||||||
|
{ label: t("wiki.newFolder"), run: () => newFolderForm(full) },
|
||||||
|
{
|
||||||
|
label: t("wiki.folderRename"),
|
||||||
|
run: () =>
|
||||||
|
openForm(t("wiki.folderRename"), t("wiki.folderPath"), full, (v) =>
|
||||||
|
cb.actions.moveFolder(full, v),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
sum.append(arrow, typeIcon("node"), label);
|
||||||
|
det.append(sum, renderChildren(node, full));
|
||||||
|
return det;
|
||||||
|
}
|
||||||
|
|
||||||
|
function docRow(doc: DocEntry): HTMLElement {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.className = "wiki-tree-doc";
|
||||||
|
if (doc.relpath === selected) row.classList.add("active");
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "wiki-tree-name";
|
||||||
|
label.textContent = doc.title;
|
||||||
|
row.append(typeIcon("doc"), label);
|
||||||
|
row.addEventListener("click", () => select(doc.relpath));
|
||||||
|
row.addEventListener("contextmenu", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openMenu(e.clientX, e.clientY, [
|
||||||
|
{
|
||||||
|
label: t("wiki.editDoc"),
|
||||||
|
run: () => {
|
||||||
|
select(doc.relpath);
|
||||||
|
editing = true;
|
||||||
|
renderMain();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("wiki.renameDoc"),
|
||||||
|
run: () =>
|
||||||
|
openForm(t("wiki.renameDoc"), t("wiki.docName"), doc.name, (v) =>
|
||||||
|
cb.actions.rename(doc.relpath, v),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: t("wiki.deleteDoc"), run: () => cb.actions.remove(doc.relpath) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChildren(node: TreeNode, path: string): HTMLElement {
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.className = "wiki-tree-children";
|
||||||
|
// Unter einem Knoten erst seine Dokumente (weniger eingerückt), dann
|
||||||
|
// seine Ordner (stärker eingerückt) — Skizze: Knoten / Dok (4em) /
|
||||||
|
// Ordner (6em).
|
||||||
|
for (const doc of [...node.docs].sort((a, b) => a.title.localeCompare(b.title))) {
|
||||||
|
box.append(docRow(doc));
|
||||||
|
}
|
||||||
|
for (const [name, child] of [...node.children].sort((a, b) =>
|
||||||
|
a[0].localeCompare(b[0]),
|
||||||
|
)) {
|
||||||
|
box.append(folderRow(name, path ? `${path}/${name}` : name, child));
|
||||||
|
}
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree(): HTMLElement {
|
||||||
|
const aside = document.createElement("aside");
|
||||||
|
aside.className = "wiki-tree";
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "wiki-tree-head";
|
||||||
|
const root = document.createElement("button");
|
||||||
|
root.className = "wiki-tree-root" + (selected === "" ? " active" : "");
|
||||||
|
root.textContent = t("wiki.archive");
|
||||||
|
root.addEventListener("click", () => select(""));
|
||||||
|
root.addEventListener("contextmenu", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openMenu(e.clientX, e.clientY, [
|
||||||
|
{ label: t("wiki.newDoc"), run: () => newDocForm("") },
|
||||||
|
{ label: t("wiki.newFolder"), run: () => newFolderForm("") },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
head.append(root);
|
||||||
|
aside.append(head, renderChildren(tree, ""));
|
||||||
|
return aside;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Anlege-Formular (Ordner/Dokument) ----------
|
||||||
|
|
||||||
|
function newDocForm(folder: string) {
|
||||||
|
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => cb.actions.createDoc(folder, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function newFolderForm(folder: string) {
|
||||||
|
openForm(t("wiki.newFolder"), t("wiki.folderPath"), folder ? `${folder}/` : "", (v) =>
|
||||||
|
cb.actions.createFolder(v),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modaler Dialog mit einem Textfeld: Anlegen/Abbrechen; Enter legt an,
|
||||||
|
/// Escape oder Klick auf den Hintergrund schließt. Leere oder ungültige
|
||||||
|
/// Eingaben meldet das Backend als Toast.
|
||||||
|
function openForm(
|
||||||
|
title: string,
|
||||||
|
placeholder: string,
|
||||||
|
initial: string,
|
||||||
|
onSubmit: (value: string) => void,
|
||||||
|
) {
|
||||||
|
document.querySelector(".wiki-modal")?.remove();
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "wiki-modal";
|
||||||
|
const form = document.createElement("div");
|
||||||
|
form.className = "wiki-form";
|
||||||
|
const caption = document.createElement("div");
|
||||||
|
caption.className = "wiki-form-title";
|
||||||
|
caption.textContent = title;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.className = "wiki-tree-input";
|
||||||
|
input.placeholder = placeholder;
|
||||||
|
input.value = initial;
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "wiki-form-row";
|
||||||
|
const submit = document.createElement("button");
|
||||||
|
submit.className = "wiki-form-submit";
|
||||||
|
submit.textContent = t("wiki.create");
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.className = "wiki-form-cancel";
|
||||||
|
cancel.textContent = t("wiki.cancel");
|
||||||
|
const fire = () => {
|
||||||
|
const value = input.value.trim();
|
||||||
|
backdrop.remove();
|
||||||
|
onSubmit(value);
|
||||||
|
};
|
||||||
|
submit.addEventListener("click", fire);
|
||||||
|
cancel.addEventListener("click", () => backdrop.remove());
|
||||||
|
backdrop.addEventListener("mousedown", (e) => {
|
||||||
|
if (e.target === backdrop) backdrop.remove();
|
||||||
|
});
|
||||||
|
form.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") fire();
|
||||||
|
else if (e.key === "Escape") backdrop.remove();
|
||||||
|
});
|
||||||
|
row.append(submit, cancel);
|
||||||
|
form.append(caption, input, row);
|
||||||
|
backdrop.append(form);
|
||||||
|
container.append(backdrop);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Notiz-Ansicht rechts ----------
|
||||||
|
|
||||||
|
/// Ein Schritt zurück im Auswahl-Verlauf; verschwundene Ziele werden
|
||||||
|
/// übersprungen.
|
||||||
|
function goBack() {
|
||||||
|
while (history.length) {
|
||||||
|
const prev = history.pop()!;
|
||||||
|
const exists = isDoc(prev) ? !!findDoc(prev) : !!findNode(prev);
|
||||||
|
if (exists) {
|
||||||
|
select(prev, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kopf der Notiz-Ansicht: Zurück-Knopf, großer Titel, Meta-Zeile, Aktionen.
|
||||||
|
function noteHead(
|
||||||
|
title: string,
|
||||||
|
meta: (string | HTMLElement)[],
|
||||||
|
actions: HTMLElement[],
|
||||||
|
): HTMLElement {
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "wiki-note-head";
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "wiki-note-titlerow";
|
||||||
|
const back = document.createElement("button");
|
||||||
|
back.className = "wiki-note-back";
|
||||||
|
back.title = t("wiki.back");
|
||||||
|
back.textContent = "←";
|
||||||
|
back.disabled = history.length === 0;
|
||||||
|
back.addEventListener("click", goBack);
|
||||||
|
row.append(back);
|
||||||
|
const h = document.createElement("div");
|
||||||
|
h.className = "wiki-note-title";
|
||||||
|
h.textContent = title;
|
||||||
|
// Klick auf den Titel bearbeitet ihn direkt (Frontmatter-Titel der
|
||||||
|
// Notiz); Enter übernimmt, Escape verwirft. Der technische Datei-/
|
||||||
|
// Ordnername bleibt davon unberührt — der sitzt im Baum-Kontextmenü.
|
||||||
|
const titleDoc = isDoc(selected) ? findDoc(selected) : findNode(selected)?.content;
|
||||||
|
if (titleDoc && !editing) {
|
||||||
|
h.classList.add("editable");
|
||||||
|
h.title = t("wiki.titleEdit");
|
||||||
|
h.addEventListener("click", () => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.className = "wiki-note-title-input";
|
||||||
|
input.value = title;
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter") cb.setTitle(titleDoc.relpath, input.value.trim());
|
||||||
|
else if (e.key === "Escape") input.replaceWith(h);
|
||||||
|
});
|
||||||
|
input.addEventListener("blur", () => input.replaceWith(h));
|
||||||
|
h.replaceWith(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const acts = document.createElement("div");
|
||||||
|
acts.className = "wiki-note-actions";
|
||||||
|
acts.append(...actions);
|
||||||
|
row.append(h, acts);
|
||||||
|
head.append(row);
|
||||||
|
// Meta (Datum, Schlagwörter, Verweise) als kleines Popup am Info-Knopf
|
||||||
|
// rechts in den Aktionen; Klick daneben schließt es.
|
||||||
|
if (meta.length) {
|
||||||
|
const pop = document.createElement("div");
|
||||||
|
pop.className = "wiki-note-info-pop";
|
||||||
|
pop.hidden = true;
|
||||||
|
const caption = document.createElement("div");
|
||||||
|
caption.className = "wiki-info-caption";
|
||||||
|
caption.textContent = t("wiki.infoCaption");
|
||||||
|
pop.append(caption);
|
||||||
|
for (const part of meta) {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "wiki-info-line";
|
||||||
|
line.append(part);
|
||||||
|
pop.append(line);
|
||||||
|
}
|
||||||
|
const info = document.createElement("button");
|
||||||
|
info.className = "panel-btn";
|
||||||
|
info.title = t("wiki.info");
|
||||||
|
info.textContent = "ⓘ";
|
||||||
|
const close = (e: MouseEvent) => {
|
||||||
|
if (e.target === info) return;
|
||||||
|
pop.hidden = true;
|
||||||
|
document.removeEventListener("click", close);
|
||||||
|
};
|
||||||
|
info.addEventListener("click", () => {
|
||||||
|
pop.hidden = !pop.hidden;
|
||||||
|
if (!pop.hidden) {
|
||||||
|
setTimeout(() => document.addEventListener("click", close));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
acts.append(info);
|
||||||
|
row.append(pop);
|
||||||
|
}
|
||||||
|
return head;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gerenderter Markdown-Body; lädt asynchron nach — verworfen, wenn die
|
||||||
|
/// Auswahl inzwischen gewechselt hat. Ladefehler erscheinen im Body statt
|
||||||
|
/// still zu verschwinden.
|
||||||
|
function noteBody(doc: DocEntry): HTMLElement {
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "wiki-note-body";
|
||||||
|
cb.readDoc(doc.relpath).then(
|
||||||
|
(text) => {
|
||||||
|
if (selected !== doc.relpath && findNode(selected)?.content !== doc) return;
|
||||||
|
body.innerHTML = renderMarkdown(text);
|
||||||
|
linkWikiRefs(body, followWikiLink);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
body.textContent = String(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markdown-Editor der Notiz: links der Rohtext, rechts die Live-Vorschau
|
||||||
|
/// (dieselbe Darstellung wie die Anzeige, Wikilinks klickbar). Der Text
|
||||||
|
/// wandert unverändert zurück in die Datei — kein Rundlauf über ein
|
||||||
|
/// Dokumentmodell, das Zeilenumbrüche, Listenmarker oder `[[ziel]]`
|
||||||
|
/// normalisieren würde. Fehler erscheinen über dem Editor.
|
||||||
|
function noteEditor(doc: DocEntry): { el: HTMLElement; save(): void } {
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.className = "wiki-note-edit";
|
||||||
|
const err = document.createElement("div");
|
||||||
|
err.className = "wiki-note-error";
|
||||||
|
err.hidden = true;
|
||||||
|
const split = document.createElement("div");
|
||||||
|
split.className = "wiki-edit-split";
|
||||||
|
const area = document.createElement("textarea");
|
||||||
|
area.className = "wiki-note-editor";
|
||||||
|
area.spellcheck = false;
|
||||||
|
const preview = document.createElement("div");
|
||||||
|
preview.className = "wiki-note-body wiki-edit-preview";
|
||||||
|
const draw = () => {
|
||||||
|
preview.innerHTML = renderMarkdown(area.value);
|
||||||
|
linkWikiRefs(preview, followWikiLink);
|
||||||
|
};
|
||||||
|
cb.readDoc(doc.relpath).then(
|
||||||
|
(text) => {
|
||||||
|
area.value = text;
|
||||||
|
draw();
|
||||||
|
area.focus();
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
err.hidden = false;
|
||||||
|
err.textContent = String(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
area.addEventListener("input", draw);
|
||||||
|
const save = () => {
|
||||||
|
cb.writeDoc(doc.relpath, area.value).then(
|
||||||
|
() => {
|
||||||
|
editing = false;
|
||||||
|
renderMain();
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
err.hidden = false;
|
||||||
|
err.textContent = String(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
area.addEventListener("keydown", (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
split.append(area, preview);
|
||||||
|
box.append(err, split);
|
||||||
|
return { el: box, save };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kopf-Aktionen im Bearbeitungsmodus: Speichern und Abbrechen.
|
||||||
|
function editActions(save: () => void): HTMLElement[] {
|
||||||
|
const ok = document.createElement("button");
|
||||||
|
ok.className = "wiki-form-submit";
|
||||||
|
ok.textContent = t("wiki.save");
|
||||||
|
ok.addEventListener("click", save);
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.className = "wiki-form-cancel";
|
||||||
|
cancel.textContent = t("wiki.cancel");
|
||||||
|
cancel.addEventListener("click", () => {
|
||||||
|
editing = false;
|
||||||
|
renderMain();
|
||||||
|
});
|
||||||
|
return [ok, cancel];
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaParts(doc: DocEntry): string[] {
|
||||||
|
const parts = [];
|
||||||
|
if (doc.date) parts.push(doc.date);
|
||||||
|
if (doc.tags.length) parts.push(doc.tags.map((x) => `#${x}`).join(" "));
|
||||||
|
if (doc.backlinks) parts.push(`↩ ${doc.backlinks}`);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function docActions(doc: DocEntry): HTMLElement[] {
|
||||||
|
const edit = document.createElement("button");
|
||||||
|
edit.className = "panel-btn";
|
||||||
|
edit.title = t("wiki.editDoc");
|
||||||
|
edit.textContent = "✎";
|
||||||
|
edit.addEventListener("click", () => {
|
||||||
|
editing = true;
|
||||||
|
renderMain();
|
||||||
|
});
|
||||||
|
return [edit, deleteAction(t("wiki.deleteDoc"), () => cb.actions.remove(doc.relpath))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kindzeile im Dokument-Abschnitt: Titel links, Anlage-/Änderungsdatum
|
||||||
|
/// rechts; Knoten ohne Inhaltsdatei zeigen nur den Titel.
|
||||||
|
function childRow(title: string, doc: DocEntry | null, onOpen: () => void): HTMLElement {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "wiki-doc";
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "wiki-doc-line";
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "wiki-doc-title";
|
||||||
|
head.textContent = title;
|
||||||
|
line.append(head);
|
||||||
|
if (doc) {
|
||||||
|
const dates = document.createElement("div");
|
||||||
|
dates.className = "wiki-doc-date";
|
||||||
|
const parts = [];
|
||||||
|
if (doc.date) parts.push(t("wiki.createdAt", { date: doc.date }));
|
||||||
|
parts.push(t("wiki.changedAt", { date: doc.modified }));
|
||||||
|
dates.textContent = parts.join(" · ");
|
||||||
|
line.append(dates);
|
||||||
|
}
|
||||||
|
row.append(line);
|
||||||
|
row.addEventListener("click", onOpen);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMain() {
|
||||||
|
const p = current!;
|
||||||
|
const main = container.querySelector<HTMLElement>(".wiki-main")!;
|
||||||
|
main.textContent = "";
|
||||||
|
|
||||||
|
if (isDoc(selected)) {
|
||||||
|
const doc = findDoc(selected);
|
||||||
|
if (!doc) return;
|
||||||
|
if (editing) {
|
||||||
|
const ed = noteEditor(doc);
|
||||||
|
main.append(noteHead(doc.title, [], editActions(ed.save)), ed.el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const head = noteHead(doc.title, metaParts(doc), docActions(doc));
|
||||||
|
main.append(head, noteBody(doc));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = findNode(selected);
|
||||||
|
if (!node) return;
|
||||||
|
const title = node.content?.title ?? (selected ? selected.split("/").pop()! : t("wiki.archive"));
|
||||||
|
const children = [...node.children.keys()].length + node.docs.length;
|
||||||
|
const meta: string[] = node.content ? metaParts(node.content) : [];
|
||||||
|
|
||||||
|
const add = document.createElement("button");
|
||||||
|
add.className = "wiki-add";
|
||||||
|
add.title = t("wiki.newDoc");
|
||||||
|
add.textContent = "+";
|
||||||
|
add.addEventListener("click", () =>
|
||||||
|
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) =>
|
||||||
|
cb.actions.createDoc(selected, v),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const actions: HTMLElement[] = node.content
|
||||||
|
? [add, ...docActions(node.content)]
|
||||||
|
: [add];
|
||||||
|
if (editing && node.content) {
|
||||||
|
const ed = noteEditor(node.content);
|
||||||
|
main.append(noteHead(title, [], editActions(ed.save)), ed.el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const head = noteHead(title, meta, actions);
|
||||||
|
main.append(head);
|
||||||
|
if (node.content) {
|
||||||
|
main.append(noteBody(node.content));
|
||||||
|
} else {
|
||||||
|
// Jede Ordner-Notiz trägt von Haus aus Text — im Default ihren Namen.
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "wiki-note-body default";
|
||||||
|
body.textContent = title;
|
||||||
|
main.append(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children === 0 && !node.content) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "wiki-empty";
|
||||||
|
const line = document.createElement("strong");
|
||||||
|
line.textContent = p.total === 0 ? t("wiki.emptyArchive") : t("wiki.emptyFolder");
|
||||||
|
empty.append(line);
|
||||||
|
if (p.total === 0) empty.append(t("wiki.emptyHint"));
|
||||||
|
main.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "wiki-note-children";
|
||||||
|
const caption = document.createElement("div");
|
||||||
|
caption.className = "wiki-children-caption";
|
||||||
|
caption.textContent = t(children === 1 ? "wiki.docOne" : "wiki.docMany", {
|
||||||
|
count: children,
|
||||||
|
});
|
||||||
|
list.append(caption);
|
||||||
|
for (const [name, child] of [...node.children].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||||
|
const full = selected ? `${selected}/${name}` : name;
|
||||||
|
list.append(
|
||||||
|
childRow(child.content?.title ?? name, child.content ?? null, () => select(full)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const doc of [...node.docs].sort((a, b) => a.title.localeCompare(b.title))) {
|
||||||
|
list.append(childRow(doc.title, doc, () => select(doc.relpath)));
|
||||||
|
}
|
||||||
|
main.append(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(target: string, remember = true) {
|
||||||
|
if (remember && target !== selected) history.push(selected);
|
||||||
|
editing = false;
|
||||||
|
selected = target;
|
||||||
|
// Elternordner eines gewählten Dokuments aufklappen, sonst bleibt die
|
||||||
|
// Hervorhebung unsichtbar.
|
||||||
|
if (isDoc(target)) {
|
||||||
|
const parts = docFolder(target).split("/").filter(Boolean);
|
||||||
|
let path = "";
|
||||||
|
for (const part of parts) {
|
||||||
|
path = path ? `${path}/${part}` : part;
|
||||||
|
closed.delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
container.textContent = "";
|
||||||
|
const layout = document.createElement("div");
|
||||||
|
layout.className = "wiki-layout";
|
||||||
|
const main = document.createElement("div");
|
||||||
|
main.className = "wiki-main";
|
||||||
|
layout.append(renderTree(), main);
|
||||||
|
container.append(layout);
|
||||||
|
renderMain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leerer Puffer: die Übersicht direkt anfordern — das Archiv startet
|
||||||
|
/// von selbst, ohne Einstiegs-Klick. Nur einmal, das Update kommt über
|
||||||
|
/// den Wiki-Puffer zurück.
|
||||||
|
let requested = false;
|
||||||
|
|
||||||
|
// Standard-Kontextmenü des Webviews im Archiv aus — es gibt nur unser
|
||||||
|
// eigenes an Baumzeilen.
|
||||||
|
container.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
return {
|
||||||
|
set(text: string) {
|
||||||
|
container.textContent = "";
|
||||||
|
loaded = !!text.trim();
|
||||||
|
if (!loaded) {
|
||||||
|
current = null;
|
||||||
|
if (cb.autoStart && !requested) {
|
||||||
|
requested = true;
|
||||||
|
cb.openWiki("tag:");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = JSON.parse(text);
|
||||||
|
tree = buildTree(current!);
|
||||||
|
// Vorgemerkte Auswahl (Suchtreffer-Sprung) schlägt die gemerkte.
|
||||||
|
const pending = cb.takePending?.();
|
||||||
|
if (pending && findDoc(pending)) {
|
||||||
|
select(pending);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Die gewählte Notiz kann nach Umbenennen/Löschen weg sein — dann
|
||||||
|
// zurück zur Wurzel.
|
||||||
|
const exists = isDoc(selected) ? !!findDoc(selected) : !!findNode(selected);
|
||||||
|
if (selected && !exists) {
|
||||||
|
selected = "";
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
empty: () => !loaded,
|
||||||
|
};
|
||||||
|
}
|
||||||
+28
-2
@@ -27,10 +27,10 @@
|
|||||||
<div id="term"></div>
|
<div id="term"></div>
|
||||||
<div id="splitter" hidden></div>
|
<div id="splitter" hidden></div>
|
||||||
<aside id="panel" hidden>
|
<aside id="panel" hidden>
|
||||||
<!-- Modul-Container (<mode>-content) reihen sich hinter der Kopfzeile ein. -->
|
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-titlerow">
|
<div class="panel-titlerow">
|
||||||
<span class="panel-title"></span>
|
<span class="panel-title" data-i18n="panel.tabDraft">Dokument</span>
|
||||||
|
<button class="panel-btn panel-edit draft-only" id="panel-title-edit" data-i18n-title="panel.editTitle">✎</button>
|
||||||
<button class="panel-btn" id="panel-detach" data-i18n-title="panel.detach">
|
<button class="panel-btn" id="panel-detach" data-i18n-title="panel.detach">
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M12.5 8.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1h4.5" /><path d="M10 2h4v4" /><path d="M14 2 8 8" /></svg>
|
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M12.5 8.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1h4.5" /><path d="M10 2h4v4" /><path d="M14 2 8 8" /></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -38,7 +38,33 @@
|
|||||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg>
|
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<select class="panel-lang draft-only" id="panel-lang" data-i18n-title="panel.spellcheckLang">
|
||||||
|
<option value="de">DE</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="en-GB">EN-GB</option>
|
||||||
|
<option value="fr">FR</option>
|
||||||
|
<option value="es">ES</option>
|
||||||
|
<option value="it">IT</option>
|
||||||
|
<option value="nl">NL</option>
|
||||||
|
</select>
|
||||||
|
<button class="panel-btn draft-only" id="panel-wiki-jump" data-i18n-title="panel.openInWiki">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M8 3.5C6.6 2.4 4.6 2 2.5 2v11c2.1 0 4.1.4 5.5 1.5 1.4-1.1 3.4-1.5 5.5-1.5V2c-2.1 0-4.1.4-5.5 1.5z" /><path d="M8 3.5v11" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="panel-btn draft-only" id="panel-mode" data-i18n-title="panel.toggleRaw" data-i18n="panel.rendered">MD</button>
|
||||||
|
<button class="panel-btn draft-only" id="panel-content-edit" data-i18n-title="panel.editDraft">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M10.8 2.6 13.4 5.2 6 12.6l-3.1.5.5-3.1z" /><path d="M9.7 3.7 12.3 6.3" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="panel-btn draft-only" id="panel-copy" data-i18n-title="panel.copy">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="panel-btn draft-only" id="panel-archive" data-i18n-title="panel.archive">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="3.5" rx="1" /><path d="M2.8 6v6.5a1 1 0 0 0 1 1h8.4a1 1 0 0 0 1-1V6" /><path d="M6.3 9h3.4" /></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Modul-Container (<mode>-content) reihen sich hier ein. -->
|
||||||
|
<div id="panel-content" class="md"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<div id="resize-grips" aria-hidden="true">
|
<div id="resize-grips" aria-hidden="true">
|
||||||
|
|||||||
Reference in New Issue
Block a user