From fd50b7deb9c97714a4a6f85d83794bdc02858782 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 13 Aug 2026 15:47:57 +0200 Subject: [PATCH] feat(noctalia): add knowledge lookup panel Provide a portable, cache-ignored Obsidian vault lookup plugin and bind it to Super+N. --- .config/hypr/hyprland.lua | 1 + .config/noctalia/10-base.toml | 2 +- .../90-machine.toml##class.arch_desktop | 2 +- .../plugins/knowledge-lookup/.gitignore | 4 + .../plugins/knowledge-lookup/create-entry.py | 75 ++ .../knowledge-lookup/focus-obsidian.py | 139 +++ .../plugins/knowledge-lookup/index.py | 250 ++++ .../plugins/knowledge-lookup/manage-entry.py | 97 ++ .../plugins/knowledge-lookup/panel.luau | 1080 +++++++++++++++++ .../plugins/knowledge-lookup/plugin.toml | 52 + .../plugins/knowledge-lookup/vault-info.py | 59 + .../plugins/knowledge-lookup/widget.luau | 17 + 12 files changed, 1776 insertions(+), 2 deletions(-) create mode 100644 .local/share/noctalia/plugins/knowledge-lookup/.gitignore create mode 100644 .local/share/noctalia/plugins/knowledge-lookup/create-entry.py create mode 100644 .local/share/noctalia/plugins/knowledge-lookup/focus-obsidian.py create mode 100644 .local/share/noctalia/plugins/knowledge-lookup/index.py create mode 100644 .local/share/noctalia/plugins/knowledge-lookup/manage-entry.py create mode 100644 .local/share/noctalia/plugins/knowledge-lookup/panel.luau create mode 100644 .local/share/noctalia/plugins/knowledge-lookup/plugin.toml create mode 100644 .local/share/noctalia/plugins/knowledge-lookup/vault-info.py create mode 100644 .local/share/noctalia/plugins/knowledge-lookup/widget.luau diff --git a/.config/hypr/hyprland.lua b/.config/hypr/hyprland.lua index f787b5b..b662ec9 100644 --- a/.config/hypr/hyprland.lua +++ b/.config/hypr/hyprland.lua @@ -69,6 +69,7 @@ hl.bind(main_mod .. " + RETURN", hl.dsp.exec_cmd(terminal)) hl.bind(main_mod .. " + CTRL + RETURN", hl.dsp.exec_cmd(terminal .. " -e zellij")) hl.bind(main_mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("rofi -show combi")) hl.bind(main_mod .. " + SPACE", hl.dsp.exec_cmd(pi_status_ui_toggle)) +hl.bind(main_mod .. " + N", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle alex/knowledge-lookup:panel")) hl.bind(main_mod .. " + S", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center")) hl.bind(main_mod .. " + SHIFT + F", hl.dsp.exec_cmd(noctalia_ipc .. "bar-toggle main")) hl.bind(main_mod .. " + SHIFT + R", hl.dsp.exec_cmd("hyprctl reload")) diff --git a/.config/noctalia/10-base.toml b/.config/noctalia/10-base.toml index a86ef1e..990b92f 100644 --- a/.config/noctalia/10-base.toml +++ b/.config/noctalia/10-base.toml @@ -37,7 +37,7 @@ pure_black_dark = true # Noctalia merges files alphabetically. Keep reusable modules here; machine-specific # layout and hardware settings live in 90-machine.toml, while 99-extend.toml is local. [plugins] -enabled = ["alex/system-status"] +enabled = ["alex/system-status", "alex/knowledge-lookup"]@@ -53,0 +54,3 @@ type = "alex/system-status:inhibitors" [widget.cpu] type = "alex/system-status:cpu" diff --git a/.config/noctalia/90-machine.toml##class.arch_desktop b/.config/noctalia/90-machine.toml##class.arch_desktop index b97a3ed..aece4ab 100644 --- a/.config/noctalia/90-machine.toml##class.arch_desktop +++ b/.config/noctalia/90-machine.toml##class.arch_desktop @@ -1,7 +1,7 @@ # Desktop-specific shell layout and hardware telemetry. # Pi Status Bridge is installed locally from ~/projects/pi-gui. [plugins] -enabled = ["alex/system-status", "alex/pi-status-bridge"] +enabled = ["alex/system-status", "alex/pi-status-bridge", "alex/knowledge-lookup"] [widget.pi-status] type = "alex/pi-status-bridge:bridge" diff --git a/.local/share/noctalia/plugins/knowledge-lookup/.gitignore b/.local/share/noctalia/plugins/knowledge-lookup/.gitignore new file mode 100644 index 0000000..07bc3ce --- /dev/null +++ b/.local/share/noctalia/plugins/knowledge-lookup/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +.ruff_cache/ +index-cache.json +index-cache-*.json diff --git a/.local/share/noctalia/plugins/knowledge-lookup/create-entry.py b/.local/share/noctalia/plugins/knowledge-lookup/create-entry.py new file mode 100644 index 0000000..9fed635 --- /dev/null +++ b/.local/share/noctalia/plugins/knowledge-lookup/create-entry.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Safely create one direct child in the configured knowledge vault.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def fail(message: str) -> int: + print(message, file=sys.stderr) + return 1 + + +def child_name(raw: str) -> str | None: + name = raw.strip() + if not name or name in {".", ".."} or "/" in name or "\\" in name or "\0" in name: + return None + if name.startswith("."): + return None + return name + + +def main() -> int: + if len(sys.argv) != 5: + return fail("usage: create-entry.py VAULT KIND CURRENT_DIRECTORY NAME") + raw_vault, kind, current_directory, raw_name = sys.argv[1:] + vault = Path(raw_vault).expanduser().resolve() + if not vault.is_dir(): + return fail(f"vault does not exist: {vault}") + if kind not in {"note", "directory"}: + return fail("kind must be note or directory") + current = (vault / current_directory).resolve() + if current != vault and vault not in current.parents: + return fail("current directory escaped the vault") + if not current.is_dir(): + return fail("current directory no longer exists") + + name = child_name(raw_name) + if name is None: + return fail("use a non-hidden direct name without slashes") + if kind == "note" and not name.lower().endswith(".md"): + name += ".md" + target = (current / name).resolve() + if target.parent != current: + return fail("target escaped the current directory") + + try: + if kind == "directory": + target.mkdir() + else: + title = target.stem + with target.open("x", encoding="utf-8") as handle: + handle.write(f"# {title}\n") + except FileExistsError: + return fail(f"already exists: {name}") + except OSError as error: + return fail(f"cannot create {name}: {error}") + + relative = target.relative_to(vault).as_posix() + json.dump( + { + "kind": kind, + "relative": relative, + "name": name, + "absolute": str(target), + }, + sys.stdout, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.local/share/noctalia/plugins/knowledge-lookup/focus-obsidian.py b/.local/share/noctalia/plugins/knowledge-lookup/focus-obsidian.py new file mode 100644 index 0000000..1bec05e --- /dev/null +++ b/.local/share/noctalia/plugins/knowledge-lookup/focus-obsidian.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Focus the most recently focused Obsidian window under Hyprland.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import time +from pathlib import Path +from urllib.parse import quote, urlencode + +OBSIDIAN_CLASS = "md.obsidian.Obsidian" +VAULT_ROOT: Path +OBSIDIAN_VAULT: str + + +def hyprctl(*arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["hyprctl", *arguments], + check=False, + capture_output=True, + text=True, + ) + + +def focus_expression(field: str, value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'hl.dispatch(hl.dsp.focus({{ {field} = "{escaped}" }}))' + + +def note_uri() -> str | None: + if len(sys.argv) != 4: + print("usage: focus-obsidian.py VAULT VAULT_ID NOTE_PATH", file=sys.stderr) + return None + note = Path(sys.argv[3]).resolve() + try: + relative = note.relative_to(VAULT_ROOT).as_posix() + except ValueError as error: + print(f"note escaped the knowledge vault: {error}", file=sys.stderr) + return None + return "obsidian://open?" + urlencode( + {"vault": OBSIDIAN_VAULT, "file": relative}, quote_via=quote + ) + + +def dispatch_uri(uri: str) -> bool: + try: + subprocess.Popen( + ["obsidian", uri], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as error: + print(f"cannot open Obsidian: {error}", file=sys.stderr) + return False + return True + + +def main() -> int: + global VAULT_ROOT, OBSIDIAN_VAULT + if len(sys.argv) != 4: + print("usage: focus-obsidian.py VAULT VAULT_ID NOTE_PATH", file=sys.stderr) + return 2 + VAULT_ROOT = Path(sys.argv[1]).expanduser().resolve() + OBSIDIAN_VAULT = sys.argv[2] + if not VAULT_ROOT.is_dir(): + print(f"vault does not exist: {VAULT_ROOT}", file=sys.stderr) + return 1 + if not OBSIDIAN_VAULT: + print("vault is not registered in Obsidian", file=sys.stderr) + return 1 + uri = note_uri() + if uri is None or not dispatch_uri(uri): + return 1 + target: dict[str, object] | None = None + for _ in range(150): + result = hyprctl("clients", "-j") + if result.returncode == 0: + try: + clients = json.loads(result.stdout) + except json.JSONDecodeError as error: + print(f"cannot parse hyprctl clients: {error}", file=sys.stderr) + clients = [] + matches = [ + client for client in clients if client.get("class") == OBSIDIAN_CLASS + ] + if matches: + target = min( + matches, key=lambda client: client.get("focusHistoryID", 1_000_000) + ) + break + time.sleep(0.1) + + if target is None: + return 1 + + workspace = target.get("workspace") + address = target.get("address") + if not isinstance(workspace, dict) or not isinstance(address, str): + return 1 + workspace_id = workspace.get("id") + if not isinstance(workspace_id, int): + return 1 + + workspace_expression = focus_expression("workspace", str(workspace_id)) + selector = f"address:{address}" + window_expression = focus_expression("window", selector) + focused_samples = 0 + for attempt in range(20): + if attempt == 0 or attempt % 5 == 0: + hyprctl("eval", workspace_expression) + hyprctl("eval", window_expression) + active = hyprctl("activewindow", "-j") + if active.returncode == 0: + try: + active_window = json.loads(active.stdout) + except json.JSONDecodeError as error: + print(f"cannot parse active Hyprland window: {error}", file=sys.stderr) + active_window = {} + focused_samples = ( + focused_samples + 1 if active_window.get("address") == address else 0 + ) + if focused_samples >= 3: + # Re-send after focus: Electron occasionally consumes the URI + # before its renderer is ready, leaving the prior note visible. + if not dispatch_uri(uri): + return 1 + time.sleep(0.35) + hyprctl("eval", window_expression) + return 0 + time.sleep(0.1) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.local/share/noctalia/plugins/knowledge-lookup/index.py b/.local/share/noctalia/plugins/knowledge-lookup/index.py new file mode 100644 index 0000000..861499e --- /dev/null +++ b/.local/share/noctalia/plugins/knowledge-lookup/index.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Build bounded JSON cache chunks for a Markdown vault.""" + +from __future__ import annotations + +import json +import os +import re +import sys +import tempfile +import unicodedata +from pathlib import Path +from typing import Any + +SKIP_DIRS = { + ".git", + ".obsidian", + ".trash", + ".stfolder", + "node_modules", + "dist", + "build", +} +HEADING = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) +WORDS = re.compile(r"[^\W_]+", re.UNICODE) +CHUNK_BYTES = 96 * 1024 +RECORD_BYTES = 48 * 1024 + + +def visible_directory(name: str) -> bool: + return name not in SKIP_DIRS and not name.startswith(".") + + +def note_title(path: Path, content: str) -> str: + match = HEADING.search(content) + return match.group(1).strip() if match else path.stem + + +def folded(value: str) -> str: + """Case-fold and strip accents so ASCII queries match Unicode notes.""" + normalized = unicodedata.normalize("NFKD", value.casefold()) + return "".join( + character for character in normalized if not unicodedata.combining(character) + ) + + +def token_buckets(content: str) -> dict[str, dict[str, list[str]]]: + buckets: dict[str, dict[str, list[str]]] = {} + for token in sorted(set(WORDS.findall(content))): + if not token: + continue + by_length = buckets.setdefault(token[0], {}) + by_length.setdefault(str(len(token)), []).append(token) + return buckets + + +def encoded(record: dict[str, Any]) -> str: + return json.dumps(record, ensure_ascii=False, separators=(",", ":")) + + +def fold_records(characters: set[str]) -> list[dict[str, Any]]: + mapping: dict[str, str] = {} + for character in characters: + variants = {character, character.lower(), character.upper(), character.title()} + for variant in variants: + for variant_character in variant: + mapping[variant_character] = folded(variant_character) + records: list[dict[str, Any]] = [] + current: dict[str, str] = {} + for source, target in sorted(mapping.items()): + candidate = {**current, source: target} + record = {"kind": "fold", "map": candidate} + if current and len(encoded(record).encode("utf-8")) > RECORD_BYTES: + records.append({"kind": "fold", "map": current}) + current = {source: target} + else: + current = candidate + if current: + records.append({"kind": "fold", "map": current}) + return records + + +def directory_record(relative: str) -> dict[str, Any]: + record = {"kind": "directory", "relative": relative} + if len(encoded(record).encode("utf-8")) > RECORD_BYTES: + raise ValueError(f"directory metadata exceeded bounded size: {relative}") + return record + + +def note_record(title: str, relative: str) -> dict[str, Any]: + display_title = title[:256] + ("…" if len(title) > 256 else "") + record = { + "kind": "note", + "id": relative, + "title": display_title, + "relative": relative, + "titleRaw": display_title.casefold(), + "pathRaw": relative.casefold(), + "titleSearch": folded(display_title), + "pathSearch": folded(relative), + } + if len(encoded(record).encode("utf-8")) > RECORD_BYTES: + raise ValueError(f"note metadata exceeded bounded size: {relative}") + return record + + +def content_record(relative: str, fragment: str) -> dict[str, Any]: + content_search = folded(fragment) + return { + "kind": "content", + "id": relative, + "fragment": fragment, + "lines": fragment.split("\n"), + "contentRaw": fragment.casefold(), + "contentSearch": content_search, + "contentTokens": token_buckets(content_search), + } + + +def content_records(relative: str, content: str) -> list[dict[str, Any]]: + """Split one note into bounded records without changing its original text.""" + pending = [content] + records: list[dict[str, Any]] = [] + while pending: + fragment = pending.pop() + record = content_record(relative, fragment) + if len(encoded(record).encode("utf-8")) <= RECORD_BYTES: + records.append(record) + continue + midpoint = max(len(fragment) // 2, 1) + if midpoint >= len(fragment): + raise ValueError(f"cannot split oversized content in {relative}") + pending.extend((fragment[midpoint:], fragment[:midpoint])) + return records + + +def index_records(root: Path) -> list[dict[str, Any]]: + notes: list[tuple[str, str, str]] = [] + indexed_directories: set[str] = set() + characters: set[str] = set() + for current, directories, filenames in os.walk(root): + directories[:] = sorted( + name + for name in directories + if visible_directory(name) and not Path(current, name).is_symlink() + ) + for directory in directories: + relative_directory = Path(current, directory).relative_to(root).as_posix() + indexed_directories.add(relative_directory) + characters.update(relative_directory) + for filename in sorted(filenames): + if not filename.lower().endswith(".md") or filename.startswith("."): + continue + path = Path(current, filename) + if path.is_symlink(): + continue + try: + content = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + relative = path.relative_to(root).as_posix() + title = note_title(path, content) + characters.update(title) + characters.update(relative) + characters.update(content) + notes.append((title, relative, content)) + + notes.sort(key=lambda note: (note[0].casefold(), note[1].casefold())) + records = fold_records(characters) + for relative in sorted(indexed_directories, key=str.casefold): + records.append(directory_record(relative)) + for title, relative, content in notes: + records.append(note_record(title, relative)) + records.extend(content_records(relative, content)) + return records + + +def write_chunks(cache: Path, records: list[dict[str, Any]]) -> list[str]: + encoded_records = [encoded(record) for record in records] + chunks: list[list[str]] = [] + current: list[str] = [] + current_bytes = 2 + for record in encoded_records: + record_bytes = len(record.encode("utf-8")) + if record_bytes > RECORD_BYTES: + raise ValueError("index record exceeded its bounded size") + separator_bytes = 1 if current else 0 + if current and current_bytes + separator_bytes + record_bytes > CHUNK_BYTES: + chunks.append(current) + current = [] + current_bytes = 2 + separator_bytes = 0 + current.append(record) + current_bytes += separator_bytes + record_bytes + if current or not chunks: + chunks.append(current) + + names: list[str] = [] + for index, chunk in enumerate(chunks): + chunk_name = f"index-cache-{index:03d}.json" + path = (cache.parent / chunk_name).resolve() + if path.parent != cache.parent: + raise ValueError("cache chunk escaped the plugin directory") + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=cache.parent, delete=False + ) as handle: + handle.write("[" + ",".join(chunk) + "]") + temporary = Path(handle.name) + os.replace(temporary, path) + names.append(chunk_name) + + active = set(names) + for stale in cache.parent.glob(f"{cache.stem}-*.json"): + if stale.name not in active: + stale.unlink(missing_ok=True) + cache.unlink(missing_ok=True) + return names + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: index.py VAULT CACHE", file=sys.stderr) + return 2 + + root = Path(sys.argv[1]).expanduser().resolve() + requested_cache = Path(sys.argv[2]).expanduser().resolve() + plugin_dir = Path(__file__).resolve().parent + if ( + requested_cache.parent != plugin_dir + or requested_cache.name != "index-cache.json" + ): + print("cache must be index-cache.json in the plugin directory", file=sys.stderr) + return 2 + if not root.is_dir(): + print(f"vault does not exist: {root}", file=sys.stderr) + return 1 + + records = index_records(root) + chunk_names = write_chunks(plugin_dir / "index-cache.json", records) + note_count = sum(record["kind"] == "note" for record in records) + json.dump( + {"chunks": chunk_names, "notes": note_count}, + sys.stdout, + separators=(",", ":"), + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.local/share/noctalia/plugins/knowledge-lookup/manage-entry.py b/.local/share/noctalia/plugins/knowledge-lookup/manage-entry.py new file mode 100644 index 0000000..cde9c76 --- /dev/null +++ b/.local/share/noctalia/plugins/knowledge-lookup/manage-entry.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Safely move or delete entries inside the knowledge vault.""" + +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path + +VAULT: Path + + +def fail(message: str) -> int: + print(message, file=sys.stderr) + return 1 + + +def vault_entry(relative: str) -> Path | None: + if not relative or relative.startswith("/") or "\0" in relative: + return None + candidate = VAULT / relative + parent = candidate.parent.resolve() + if parent != VAULT and VAULT not in parent.parents: + return None + return parent / candidate.name + + +def move_entry(source_relative: str, destination_relative: str) -> int: + source = vault_entry(source_relative) + destination_directory = (VAULT / destination_relative).resolve() + if source is None or not (source.exists() or source.is_symlink()): + return fail("selected entry no longer exists") + if destination_directory != VAULT and VAULT not in destination_directory.parents: + return fail("destination escaped the vault") + if not destination_directory.is_dir(): + return fail("destination directory no longer exists") + if source.is_dir() and ( + destination_directory == source or source in destination_directory.parents + ): + return fail("cannot move a directory into itself") + + destination = destination_directory / source.name + if destination.exists() or destination.is_symlink(): + return fail(f"already exists: {destination.name}") + try: + source.rename(destination) + except OSError as error: + return fail(f"cannot move {source.name}: {error}") + + json.dump( + { + "action": "move", + "relative": destination.relative_to(VAULT).as_posix(), + "kind": "directory" if destination.is_dir() else "note", + }, + sys.stdout, + ) + return 0 + + +def delete_entry(source_relative: str) -> int: + source = vault_entry(source_relative) + if source is None or not (source.exists() or source.is_symlink()): + return fail("selected entry no longer exists") + try: + if source.is_symlink(): + source.unlink() + elif source.is_dir(): + shutil.rmtree(source) + else: + source.unlink() + except OSError as error: + return fail(f"cannot delete {source.name}: {error}") + json.dump({"action": "delete", "relative": source_relative}, sys.stdout) + return 0 + + +def main() -> int: + global VAULT + if len(sys.argv) < 4: + return fail( + "usage: manage-entry.py VAULT ACTION SOURCE [DESTINATION_DIRECTORY]" + ) + VAULT = Path(sys.argv[1]).expanduser().resolve() + if not VAULT.is_dir(): + return fail(f"vault does not exist: {VAULT}") + action, source = sys.argv[2:4] + if action == "move" and len(sys.argv) == 5: + return move_entry(source, sys.argv[4]) + if action == "delete" and len(sys.argv) == 4: + return delete_entry(source) + return fail("invalid file operation") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.local/share/noctalia/plugins/knowledge-lookup/panel.luau b/.local/share/noctalia/plugins/knowledge-lookup/panel.luau new file mode 100644 index 0000000..be6b359 --- /dev/null +++ b/.local/share/noctalia/plugins/knowledge-lookup/panel.luau @@ -0,0 +1,1080 @@ +--!strict + +local vaultPath = "" +local vaultName = "" +local obsidianVaultId: string? = nil +local vaultError: string? = nil +local obsidianError: string? = nil +local vaultEditMode = false +local vaultEditPath = "" +local RESULT_ROWS = 10 + +local notes: { any } = {} +local indexedDirectories: { string } = {} +local noteById: { [string]: any } = {} +local queryFoldMap: { [string]: string } = {} +local visible: { any } = {} +local selected = 1 +local previewTop = 1 +local query = "" +local currentDir = "" +local readerMode = false +local readerNote: any? = nil +local createMode: string? = nil +local createName = "" +local createError: string? = nil +local cutRelative: string? = nil +local cutKind: string? = nil +local deleteRelative: string? = nil +local operationError: string? = nil +local searchMode = false +local loading = false +local loadError: string? = nil +local render: () -> () +local refreshIndex: (string?, string?) -> () +local validateVault: (string?, string?) -> () + +local function lowercase(value: any): string + return string.lower(tostring(value or "")) +end + +local function normalizeQuery(value: string): string + local normalized = "" + local index = 1 + while index <= #value do + local firstByte = string.byte(value, index) + local width = 1 + if firstByte >= 240 then + width = 4 + elseif firstByte >= 224 then + width = 3 + elseif firstByte >= 192 then + width = 2 + end + local character = value:sub(index, index + width - 1) + normalized ..= queryFoldMap[character] or lowercase(character) + index += width + end + return normalized +end + +local function fuzzyScore(text: string, needle: string): number? + if needle == "" then + return 0 + end + local score = 0 + local cursor = 1 + local previous = 0 + for index = 1, #needle do + local character = needle:sub(index, index) + local found = text:find(character, cursor, true) + if found == nil then + return nil + end + score += 8 + if found == previous + 1 then + score += 12 + end + if found == 1 or text:sub(found - 1, found - 1):match("[%s%p]") then + score += 10 + end + score -= math.min(found - cursor, 8) + previous = found + cursor = found + 1 + end + return score +end + +local function fuzzyContentScore(note: any, term: string): number? + if #term < 3 then + return nil + end + local best: number? = nil + local firstCharacter = term:sub(1, 1) + local byLength = (note.contentTokens or {})[firstCharacter] or {} + for length = #term, #term + 3 do + for _, token in ipairs(byLength[tostring(length)] or {}) do + local tokenScore = fuzzyScore(tostring(token), term) + if tokenScore ~= nil and (best == nil or tokenScore > best) then + best = tokenScore + end + end + end + return best +end + +local function firstPosition(term: string, values: { any }): number? + local best: number? = nil + for _, value in ipairs(values) do + local position = tostring(value or ""):find(term, 1, true) + if position ~= nil and (best == nil or position < best) then + best = position + end + end + return best +end + +local function scoreNote(note: any, rawQuery: string): number? + local score = 0 + for term in rawQuery:gmatch("%S+") do + local titlePos = firstPosition(term, { note.titleLower, note.titleRaw }) + local pathPos = firstPosition(term, { note.pathLower, note.pathRaw }) + local contentPos = firstPosition(term, { note.contentLower, note.contentRaw }) + if titlePos ~= nil then + score += 500 - math.min(titlePos, 100) + elseif pathPos ~= nil then + score += 320 - math.min(pathPos, 100) + elseif contentPos ~= nil then + score += 90 - math.min(math.floor(contentPos / 200), 60) + else + local fuzzyTitle = fuzzyScore(note.titleLower, term) + local fuzzyPath = fuzzyScore(note.pathLower, term) + local fuzzyContent = fuzzyContentScore(note, term) + if fuzzyTitle ~= nil then + score += 180 + fuzzyTitle + elseif fuzzyPath ~= nil then + score += 100 + fuzzyPath + elseif fuzzyContent ~= nil then + score += 30 + fuzzyContent + else + return nil + end + end + end + return score +end + +local function parentPath(path: string): string + return path:match("^(.*)/[^/]+$") or "" +end + +local function directoryEntries(): { any } + local folders: { [string]: any } = {} + local directNotes: { any } = {} + local prefix = currentDir == "" and "" or currentDir .. "/" + for _, directory in ipairs(indexedDirectories) do + if directory:sub(1, #prefix) == prefix then + local remainder = directory:sub(#prefix + 1) + local child = remainder:match("^([^/]+)") + if child ~= nil and child ~= "" then + local relative = prefix .. child + folders[relative] = folders[relative] or { + kind = "folder", + title = child, + relative = relative, + } + end + end + end + for _, note in ipairs(notes) do + if note.relative:sub(1, #prefix) == prefix then + local remainder = note.relative:sub(#prefix + 1) + local child = remainder:match("^([^/]+)/") + if child ~= nil then + local relative = prefix .. child + if folders[relative] == nil then + folders[relative] = { + kind = "folder", + title = child, + relative = relative, + } + end + elseif remainder ~= "" then + table.insert(directNotes, note) + end + end + end + local entries: { any } = {} + for _, folder in pairs(folders) do + table.insert(entries, folder) + end + table.sort(entries, function(a, b) + return lowercase(a.title) < lowercase(b.title) + end) + table.sort(directNotes, function(a, b) + return a.titleLower < b.titleLower + end) + for _, note in ipairs(directNotes) do + table.insert(entries, note) + end + return entries +end + +local function applyFilter() + local normalized = normalizeQuery(query):match("^%s*(.-)%s*$") or "" + if searchMode and normalized ~= "" then + local ranked: { any } = {} + for _, note in ipairs(notes) do + local score = scoreNote(note, normalized) + if score ~= nil then + table.insert(ranked, { note = note, score = score }) + end + end + table.sort(ranked, function(a, b) + if a.score == b.score then + return a.note.titleLower < b.note.titleLower + end + return a.score > b.score + end) + visible = {} + for _, item in ipairs(ranked) do + table.insert(visible, item.note) + end + else + visible = directoryEntries() + end + selected = math.max(1, math.min(selected, math.max(#visible, 1))) + previewTop = 1 +end + +local function selectedEntry(): any? + return visible[selected] +end + +local function fullMarkdown(note: any): string + local lines = note.lines or {} + if #lines == 0 then + return "_Empty note_" + end + return table.concat(lines, "\n") +end + +local function setSelected(index: number) + if #visible == 0 then + return + end + selected = math.max(1, math.min(index, #visible)) + previewTop = 1 + render() +end + +local function moveSelection(delta: number) + setSelected(selected + delta) +end + +local function launchObsidian(path: string) + if vaultError ~= nil then + operationError = vaultError + render() + return false + end + if obsidianVaultId == nil then + operationError = obsidianError or "No matching vault is registered in Obsidian" + render() + return false + end + local script = (noctalia.pluginDir() or "") .. "/focus-obsidian.py" + noctalia.runAsync({ + "systemd-run", + "--user", + "--collect", + "python3", + script, + vaultPath, + obsidianVaultId, + path, + }) + return true +end + +local function openInObsidian(note: any) + if launchObsidian(vaultPath .. "/" .. note.relative) then + panel.close() + end +end + +local function enterReader(note: any) + -- Inputs are uncontrolled and may retain keyboard focus after the mode that + -- created them ends. Rebuild the panel tree without an input before reader + -- mode so captured j/k chords return to the panel's onKey handler. + searchMode = false + createMode = nil + vaultEditMode = false + render() + readerMode = true + readerNote = note + previewTop = 1 + render() +end + +local function leaveReader() + readerMode = false + readerNote = nil + previewTop = 1 + render() +end + +local function selectRelative(relative: string) + for index, entry in ipairs(visible) do + if entry.relative == relative then + selected = index + return + end + end +end + +local function createEntry(kind: string, name: string) + createError = nil + local script = (noctalia.pluginDir() or "") .. "/create-entry.py" + noctalia.runAsync({ "python3", script, vaultPath, kind, currentDir, name }, function(result) + if result.exitCode ~= 0 then + createError = result.stderr ~= "" and result.stderr or "Creation failed" + render() + return + end + local created, decodeError = noctalia.json.decode(result.stdout) + if type(created) ~= "table" or type(created.relative) ~= "string" or type(created.absolute) ~= "string" then + createError = decodeError or "Creation returned invalid data" + render() + return + end + createMode = nil + createName = "" + if kind == "note" then + if launchObsidian(created.absolute) then + panel.close() + end + else + refreshIndex(created.relative, "directory") + end + end, 10000) +end + +local function submitCreate() + if createMode ~= nil then + createEntry(createMode, createName) + end +end + +local function beginCreate(kind: string) + if vaultError ~= nil or vaultPath == "" then + operationError = vaultError or "Configure a vault directory first" + render() + return + end + createMode = kind + createName = "" + createError = nil + render() +end + +local function manageEntry(arguments: { string }, callback: (any) -> ()) + if vaultError ~= nil or vaultPath == "" then + operationError = vaultError or "Configure a vault directory first" + render() + return + end + operationError = nil + local script = (noctalia.pluginDir() or "") .. "/manage-entry.py" + local command = { "python3", script, vaultPath } + for _, argument in ipairs(arguments) do + table.insert(command, argument) + end + noctalia.runAsync(command, function(result) + if result.exitCode ~= 0 then + operationError = result.stderr ~= "" and result.stderr or "File operation failed" + render() + return + end + callback(result) + end, 10000) +end + +local function cutSelected() + local entry = selectedEntry() + if entry == nil then + return + end + cutRelative = entry.relative + cutKind = entry.kind == "folder" and "directory" or "note" + deleteRelative = nil + operationError = nil + render() +end + +local function pasteCut() + if cutRelative == nil then + operationError = "Nothing is cut · press x on an entry first" + render() + return + end + manageEntry({ "move", cutRelative, currentDir }, function(result) + local moved, decodeError = noctalia.json.decode(result.stdout) + if type(moved) ~= "table" or type(moved.relative) ~= "string" then + operationError = decodeError or "Move returned invalid data" + render() + return + end + local movedRelative = moved.relative + local movedKind = cutKind + cutRelative = nil + cutKind = nil + if movedKind == "directory" then + refreshIndex(movedRelative, nil) + else + refreshIndex(movedRelative, nil) + end + end) +end + +local function requestDelete() + local entry = selectedEntry() + if entry == nil then + return + end + deleteRelative = entry.relative + operationError = nil + render() +end + +local function confirmDelete() + if deleteRelative == nil then + return + end + local deleting = deleteRelative + manageEntry({ "delete", deleting }, function(_result) + deleteRelative = nil + if cutRelative == deleting then + cutRelative = nil + cutKind = nil + end + refreshIndex() + end) +end + +local function enterSelected() + local entry = selectedEntry() + if entry == nil then + return + end + if entry.kind == "folder" then + currentDir = entry.relative + selected = 1 + previewTop = 1 + applyFilter() + render() + else + enterReader(entry) + end +end + +local function goParent() + if currentDir == "" then + return + end + local leaving = currentDir + currentDir = parentPath(currentDir) + selected = 1 + applyFilter() + for index, entry in ipairs(visible) do + if entry.kind == "folder" and entry.relative == leaving then + selected = index + break + end + end + previewTop = 1 + render() +end + +local function resultList(): any + if loading then + return ui.column({ width = 330, gap = 8, align = "stretch" }, { + ui.label({ text = "Indexing vault…", color = "on_surface_variant" }), + }) + end + if loadError ~= nil then + return ui.column({ width = 330, gap = 8, align = "stretch" }, { + ui.label({ text = "Index failed", color = "error", fontWeight = "bold" }), + ui.label({ text = loadError, color = "on_surface_variant", maxLines = 8 }), + }) + end + if #visible == 0 then + return ui.column({ width = 330, gap = 8, align = "stretch" }, { + ui.label({ text = "No matching notes", color = "on_surface_variant" }), + }) + end + + local rows: { any } = {} + local first = math.max(1, math.min(selected - math.floor(RESULT_ROWS / 2), math.max(#visible - RESULT_ROWS + 1, 1))) + local last = math.min(first + RESULT_ROWS - 1, #visible) + for index = first, last do + local entry = visible[index] + local active = index == selected + local folder = entry.kind == "folder" + local rowProps: { [string]: any } = { + key = tostring(entry.kind) .. "-" .. entry.relative, + gap = 2, + paddingH = 10, + paddingV = 7, + radius = 6, + fill = active and "primary/0.18" or "surface/0", + border = active and "primary/0.70" or "surface/0", + borderWidth = active and 1 or 0, + onClick = function() + setSelected(index) + end, + } + table.insert(rows, ui.column(rowProps, { + ui.row({ align = "center", gap = 7 }, { + ui.glyph({ name = folder and "folder" or "file-text", size = 15, color = folder and "primary" or "on_surface_variant" }), + ui.label({ text = entry.title, fontWeight = active and "bold" or "normal", color = active and "primary" or "on_surface", maxLines = 1 }), + }), + ui.label({ text = folder and "Directory" or entry.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), + })) + end + return ui.column({ width = 330, gap = 4, align = "stretch" }, rows) +end + +local function previewPane(): any + local entry = selectedEntry() + if entry == nil then + return ui.column({ flexGrow = 1, align = "stretch", gap = 8 }, { + ui.label({ text = "Empty directory", color = "on_surface_variant" }), + }) + end + if entry.kind == "folder" then + local noteCount = 0 + local childFolders: { [string]: boolean } = {} + local prefix = entry.relative .. "/" + for _, note in ipairs(notes) do + if note.relative:sub(1, #prefix) == prefix then + noteCount += 1 + local remainder = note.relative:sub(#prefix + 1) + local child = remainder:match("^([^/]+)/") + if child ~= nil then + childFolders[child] = true + end + end + end + local folderCount = 0 + for _ in pairs(childFolders) do + folderCount += 1 + end + return ui.column({ flexGrow = 1, align = "stretch", gap = 12 }, { + ui.row({ align = "center", gap = 10 }, { + ui.glyph({ name = "folder-open", size = 24, color = "primary" }), + ui.column({ flexGrow = 1, gap = 2 }, { + ui.label({ text = entry.title, fontSize = 18, fontWeight = "bold", color = "on_surface", maxLines = 1 }), + ui.label({ text = entry.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), + }), + ui.button({ text = "Enter", glyph = "arrow-right", variant = "primary", onClick = enterSelected }), + }), + ui.separator({ color = "outline" }), + ui.label({ text = `{noteCount} notes · {folderCount} subfolders`, color = "on_surface_variant" }), + ui.label({ text = "Press l or Enter to open this directory.", color = "on_surface_variant" }), + }) + end + return ui.column({ flexGrow = 1, align = "stretch", gap = 8 }, { + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.column({ flexGrow = 1, gap = 2 }, { + ui.label({ text = entry.title, fontSize = 18, fontWeight = "bold", color = "on_surface", maxLines = 1 }), + ui.label({ text = entry.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), + }), + ui.button({ text = "Open in Obsidian", glyph = "external-link", variant = "primary", onClick = function() + openInObsidian(entry) + end }), + }), + ui.separator({ color = "outline" }), + ui.scroll({ flexGrow = 1, paddingH = 8, paddingV = 4, align = "stretch" }, { + ui.markdown({ text = fullMarkdown(entry), flexGrow = 1 }), + }), + }) +end + +local function readerPane(): any + local note = readerNote + if note == nil then + return ui.column({ flexGrow = 1 }, {}) + end + return ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, { + ui.row({ align = "center", gap = 10 }, { + ui.glyph({ name = "file-text", size = 21, color = "primary" }), + ui.column({ flexGrow = 1, gap = 2 }, { + ui.label({ text = note.title, fontSize = 18, fontWeight = "bold", color = "on_surface", maxLines = 1 }), + ui.label({ text = note.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), + }), + ui.button({ text = "Open in Obsidian", glyph = "external-link", variant = "primary", onClick = function() + openInObsidian(note) + end }), + }), + ui.separator({ color = "outline" }), + ui.scroll({ + key = "reader-document", + flexGrow = 1, + paddingH = 10, + paddingV = 6, + align = "stretch", + }, { + ui.markdown({ text = fullMarkdown(note), flexGrow = 1 }), + }), + ui.separator({ color = "outline" }), + ui.label({ + text = "Mouse wheel or scrollbar to scroll · Ctrl-c finder · o open in Obsidian · Esc closes panel", + fontSize = 11, + color = "on_surface_variant", + maxLines = 1, + }), + }) +end + +render = function() + if readerMode then + -- A focused input receives printable keys before captured panel chords. + -- Replacing the entire root with a keyed reader tree drops any stale input. + panel.render(ui.column({ key = "reader-root", flexGrow = 1, align = "stretch" }, { + readerPane(), + })) + return + end + local countText = loading and "Indexing…" or (`{#visible} entries · {#notes} notes`) + local modeControl: any + if vaultEditMode then + modeControl = ui.input({ + key = "vault-path-editor", + width = 460, + value = vaultEditPath, + placeholder = "/path/to/Obsidian/vault", + focus = true, + submitOnEnter = true, + onSubmit = function() + validateVault(vaultEditPath, "save") + end, + onChange = function(value: string) + vaultEditPath = value + end, + }) + elseif createMode ~= nil then + local label = createMode == "note" and "note" or "directory" + modeControl = ui.input({ + width = 320, + value = createName, + placeholder = "New " .. label .. " name…", + focus = true, + submitOnEnter = true, + onSubmit = submitCreate, + onChange = function(value: string) + createName = value + createError = nil + end, + }) + elseif searchMode then + modeControl = ui.input({ + width = 280, + value = query, + placeholder = "Search vault…", + focus = true, + submitOnEnter = true, + onSubmit = enterSelected, + onChange = function(value: string) + query = value + selected = 1 + applyFilter() + render() + end, + }) + else + local modeText = query ~= "" and (`FILTER /{query}`) or "BROWSE" + modeControl = ui.label({ text = modeText, color = "on_surface_variant", maxLines = 1 }) + end + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, { + ui.row({ align = "center", gap = 10 }, { + ui.glyph({ name = "notes", size = 20, color = "primary" }), + ui.label({ text = "Knowledge Lookup", fontSize = 18, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = countText, color = "on_surface_variant" }), + ui.spacer({ flexGrow = 1 }), + modeControl, + ui.button({ text = vaultEditMode and "Cancel" or "Change vault", glyph = "folder", variant = "ghost", onClick = function() + vaultEditMode = not vaultEditMode + vaultEditPath = vaultPath + render() + end }), + ui.button({ glyph = "refresh", variant = "ghost", tooltip = "Re-index (r)", onClick = refreshIndex }), + ui.button({ glyph = "close", variant = "ghost", tooltip = "Close (q)", onClick = function() + panel.close() + end }), + }), + ui.row({ align = "center", gap = 8 }, { + ui.label({ text = "VAULT", fontSize = 10, fontWeight = "bold", color = "on_surface_variant" }), + ui.label({ text = vaultPath ~= "" and vaultPath or "Not configured", color = vaultError and "error" or "primary", maxLines = 1 }), + ui.label({ text = currentDir == "" and "/" or ("/" .. currentDir), color = "on_surface_variant", maxLines = 1 }), + ui.spacer({ flexGrow = 1 }), + ui.label({ text = obsidianVaultId and ("Obsidian: " .. vaultName) or "Obsidian: not registered", color = obsidianVaultId and "primary" or "on_surface_variant", maxLines = 1 }), + }), + ui.separator({ color = "outline" }), + vaultError ~= nil + and ui.column({ flexGrow = 1, gap = 14, align = "center", justify = "center" }, { + ui.glyph({ name = "folder-x", size = 42, color = "error" }), + ui.label({ text = "Vault unavailable", fontSize = 22, fontWeight = "bold", color = "error" }), + ui.label({ text = vaultError, color = "on_surface_variant", maxLines = 3 }), + ui.button({ text = "Change vault", glyph = "folder", variant = "primary", onClick = function() + vaultEditMode = true + vaultEditPath = vaultPath + render() + end }), + }) + or ui.row({ flexGrow = 1, gap = 14, align = "stretch" }, { + resultList(), + ui.separator({ orientation = "vertical", color = "outline" }), + previewPane(), + }), + ui.separator({ color = "outline" }), + ui.label({ + text = operationError + or vaultError + or createError + or obsidianError + or (deleteRelative ~= nil and ("Delete “" .. deleteRelative .. "” and its contents? y confirm · n cancel")) + or (createMode ~= nil and ("Enter creates this " .. (createMode == "note" and "note and opens it in Obsidian" or "directory"))) + or (searchMode and "Global search: type title, path, or content · Enter read · Esc close") + or (cutRelative ~= nil and ("CUT “" .. cutRelative .. "” · navigate with h/l, then p to move here")) + or "j/k select · l/Enter open · h parent · x cut · p paste/move · d delete · Ctrl-n note · Ctrl-a dir · o Obsidian · q close", + fontSize = 11, + color = (operationError or vaultError or createError) and "error" or ((obsidianError or deleteRelative ~= nil) and "primary" or "on_surface_variant"), + maxLines = 1, + }), + })) +end + +local function mergeTokenBuckets(target: any, source: any) + for firstCharacter, lengths in pairs(source or {}) do + local targetLengths = target[firstCharacter] + if targetLengths == nil then + targetLengths = {} + target[firstCharacter] = targetLengths + end + for length, tokens in pairs(lengths) do + local targetTokens = targetLengths[length] + if targetTokens == nil then + targetTokens = {} + targetLengths[length] = targetTokens + end + for _, token in ipairs(tokens) do + table.insert(targetTokens, token) + end + end + end +end + +local function mergeIndexRecord(record: any) + if type(record) ~= "table" then + return + end + if record.kind == "directory" and type(record.relative) == "string" then + table.insert(indexedDirectories, record.relative) + return + end + if record.kind == "fold" then + for source, target in pairs(record.map or {}) do + queryFoldMap[tostring(source)] = tostring(target) + end + return + end + local id = tostring(record.id or "") + if record.kind == "note" then + local note = { + kind = "note", + title = tostring(record.title or record.relative or "Untitled"), + relative = tostring(record.relative or ""), + titleLower = tostring(record.titleSearch or lowercase(record.title)), + titleRaw = tostring(record.titleRaw or lowercase(record.title)), + pathLower = tostring(record.pathSearch or lowercase(record.relative)), + pathRaw = tostring(record.pathRaw or lowercase(record.relative)), + contentLower = "", + contentRaw = "", + contentTokens = {}, + lines = {}, + } + noteById[id] = note + table.insert(notes, note) + return + end + if record.kind ~= "content" then + return + end + local note = noteById[id] + if note == nil then + return + end + note.contentLower ..= tostring(record.contentSearch or "") + note.contentRaw ..= tostring(record.contentRaw or "") + for _, line in ipairs(record.lines or {}) do + table.insert(note.lines, tostring(line)) + end + mergeTokenBuckets(note.contentTokens, record.contentTokens) +end + +local function completeIndexLoad(selectAfterRefresh: string?, actionAfterRefresh: string?) + local directoryExists = currentDir == "" + while not directoryExists and currentDir ~= "" do + for _, directory in ipairs(indexedDirectories) do + if directory == currentDir then + directoryExists = true + break + end + end + if not directoryExists then + currentDir = parentPath(currentDir) + directoryExists = currentDir == "" + end + end + loading = false + selected = 1 + applyFilter() + if selectAfterRefresh ~= nil then + selectRelative(selectAfterRefresh) + if actionAfterRefresh == "directory" then + currentDir = selectAfterRefresh + selected = 1 + applyFilter() + end + end + render() +end + +local function finishIndexLoad(selectAfterRefresh: string?, actionAfterRefresh: string?) + completeIndexLoad(selectAfterRefresh, actionAfterRefresh) +end + +local function loadIndexChunks( + chunkNames: { any }, + index: number, + selectAfterRefresh: string?, + actionAfterRefresh: string? +) + if index > #chunkNames then + finishIndexLoad(selectAfterRefresh, actionAfterRefresh) + return + end + local chunkName = chunkNames[index] + if type(chunkName) ~= "string" or chunkName:match("^index%-cache%-%d%d%d%.json$") == nil then + loading = false + loadError = "Indexer returned an invalid chunk name" + render() + return + end + local chunkPath = (noctalia.pluginDir() or "") .. "/" .. chunkName + local accepted = noctalia.readFileAsync(chunkPath, function(cacheJson, readError) + if cacheJson == nil then + loading = false + loadError = readError or "Cannot read index cache" + render() + return + end + local decoded, decodeError = noctalia.json.decode(cacheJson) + if type(decoded) ~= "table" then + loading = false + loadError = decodeError or "Index cache contains invalid JSON" + render() + return + end + for _, record in ipairs(decoded) do + mergeIndexRecord(record) + end + loadIndexChunks(chunkNames, index + 1, selectAfterRefresh, actionAfterRefresh) + end) + if not accepted then + loading = false + loadError = "Cannot queue index cache read" + render() + end +end + +refreshIndex = function(selectAfterRefresh: string?, actionAfterRefresh: string?) + if vaultError ~= nil or vaultPath == "" then + loading = false + notes = {} + indexedDirectories = {} + visible = {} + render() + return + end + loading = true + loadError = nil + render() + local pluginDir = noctalia.pluginDir() or "" + local script = pluginDir .. "/index.py" + local cache = pluginDir .. "/index-cache.json" + noctalia.runAsync({ "python3", script, vaultPath, cache }, function(result) + if result.exitCode ~= 0 then + loading = false + loadError = result.stderr ~= "" and result.stderr or "Indexer failed" + render() + return + end + local manifest, manifestError = noctalia.json.decode(result.stdout) + if type(manifest) ~= "table" or type(manifest.chunks) ~= "table" then + loading = false + loadError = manifestError or "Indexer returned an invalid cache manifest" + render() + return + end + notes = {} + indexedDirectories = {} + noteById = {} + queryFoldMap = {} + loadIndexChunks(manifest.chunks, 1, selectAfterRefresh, actionAfterRefresh) + end, 60000) +end + +validateVault = function(candidate: string?, actionAfterValidation: string?) + local configured = candidate or tostring(noctalia.getConfig("vault_path") or "") + vaultEditMode = false + loading = true + vaultError = nil + obsidianError = nil + operationError = nil + render() + local script = (noctalia.pluginDir() or "") .. "/vault-info.py" + noctalia.runAsync({ "python3", script, configured }, function(result) + local info, decodeError = noctalia.json.decode(result.stdout) + loading = false + if type(info) ~= "table" or info.ok ~= true then + vaultPath = configured + vaultName = "" + obsidianVaultId = nil + vaultError = type(info) == "table" and tostring(info.error or "Vault validation failed") + or decodeError + or "Vault validation failed" + notes = {} + indexedDirectories = {} + visible = {} + render() + return + end + vaultPath = tostring(info.path or "") + vaultName = tostring(info.name or "") + obsidianVaultId = type(info.vaultId) == "string" and info.vaultId or nil + obsidianError = type(info.obsidianError) == "string" and info.obsidianError or nil + currentDir = "" + selected = 1 + if actionAfterValidation == "save" then + local dataDir = noctalia.pluginDataDir() + if type(dataDir) == "string" then + noctalia.writeFile(dataDir .. "/vault-path.txt", vaultPath) + end + end + refreshIndex() + end, 10000) +end + +local function configuredVaultPath(): string + local dataDir = noctalia.pluginDataDir() + if type(dataDir) == "string" then + local saved = noctalia.readFile(dataDir .. "/vault-path.txt") + if type(saved) == "string" and saved ~= "" then + return saved + end + end + return tostring(noctalia.getConfig("vault_path") or "") +end + +function onClose() +end + +function onOpen(_context) + -- A plugin runtime can outlive panel-close/open cycles, but user navigation + -- must always reopen at the finder root. + vaultEditMode = false + vaultEditPath = "" + searchMode = false + createMode = nil + createName = "" + createError = nil + cutRelative = nil + cutKind = nil + deleteRelative = nil + operationError = nil + readerMode = false + readerNote = nil + query = "" + currentDir = "" + selected = 1 + previewTop = 1 + validateVault(configuredVaultPath(), nil) +end + +function onKey(chord: string, pressed: boolean) + if not pressed then + return + end + + if readerMode then + if chord == "o" and readerNote ~= nil then + openInObsidian(readerNote) + elseif chord == "ctrl+U0063" or chord == "escape" then + leaveReader() + end + return + end + + if vaultEditMode then + if chord == "enter" then + validateVault(vaultEditPath, "save") + end + return + end + + if deleteRelative ~= nil then + if chord == "y" then + confirmDelete() + elseif chord == "n" then + deleteRelative = nil + render() + end + return + end + + -- Printable keys go to the focused input first, but Enter is explicitly + -- captured by this panel and therefore must be submitted from onKey. + if createMode ~= nil then + if chord == "enter" then + submitCreate() + end + return + end + if searchMode then + if chord == "enter" then + enterSelected() + end + return + end + + if chord == "j" then + moveSelection(1) + elseif chord == "k" then + moveSelection(-1) + elseif chord == "l" or chord == "enter" then + enterSelected() + elseif chord == "h" then + goParent() + elseif chord == "g" then + setSelected(1) + elseif chord == "shift+U0047" then + setSelected(#visible) + elseif chord == "x" then + cutSelected() + elseif chord == "p" then + pasteCut() + elseif chord == "d" then + requestDelete() + elseif chord == "ctrl+U0061" then + beginCreate("directory") + elseif chord == "ctrl+U006e" then + beginCreate("note") + elseif chord == "slash" then + searchMode = true + query = "" + selected = 1 + applyFilter() + render() + elseif chord == "o" then + local entry = selectedEntry() + if entry ~= nil and entry.kind ~= "folder" then + openInObsidian(entry) + end + elseif chord == "r" then + refreshIndex() + elseif chord == "q" or chord == "escape" then + panel.close() + end +end diff --git a/.local/share/noctalia/plugins/knowledge-lookup/plugin.toml b/.local/share/noctalia/plugins/knowledge-lookup/plugin.toml new file mode 100644 index 0000000..1c88a78 --- /dev/null +++ b/.local/share/noctalia/plugins/knowledge-lookup/plugin.toml @@ -0,0 +1,52 @@ +id = "alex/knowledge-lookup" +name = "Knowledge Lookup" +version = "1.0.0" +plugin_api = 24 +author = "alex" +license = "MIT" +dependencies = [] +tags = ["productivity", "knowledge"] +icon = "notes" +description = "Search and preview a local Obsidian knowledge vault from the bar." + +[[setting]] +key = "vault_path" +type = "folder" +label_key = "Knowledge vault directory" +description_key = "Directory containing the Markdown vault to browse" +default = "" + +[[widget]] +id = "bar" +entry = "widget.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 1100 +height = 720 +placement = "floating" +position = "center" +keyboard_focus = "exclusive" +dismiss_on_outside_click = true +capture_keys = [ + "j", + "k", + "h", + "l", + "ctrl+U0063", + "ctrl+U0061", + "ctrl+U006e", + "g", + "shift+U0047", + "q", + "o", + "r", + "x", + "p", + "d", + "y", + "n", + "slash", + "enter", +] diff --git a/.local/share/noctalia/plugins/knowledge-lookup/vault-info.py b/.local/share/noctalia/plugins/knowledge-lookup/vault-info.py new file mode 100644 index 0000000..dabb57a --- /dev/null +++ b/.local/share/noctalia/plugins/knowledge-lookup/vault-info.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Validate a vault path and resolve its registered Obsidian vault ID.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +OBSIDIAN_CONFIG = Path.home() / ".config" / "obsidian" / "obsidian.json" + + +def fail(message: str) -> int: + json.dump({"ok": False, "error": message}, sys.stdout) + return 1 + + +def main() -> int: + if len(sys.argv) != 2: + return fail("No vault directory is configured") + raw_path = sys.argv[1].strip() + if not raw_path: + return fail("No vault directory is configured") + vault = Path(raw_path).expanduser().resolve() + if not vault.is_dir(): + return fail(f"Vault directory was not found: {vault}") + + vault_id: str | None = None + config_error: str | None = None + try: + config = json.loads(OBSIDIAN_CONFIG.read_text(encoding="utf-8")) + for registered_id, metadata in config.get("vaults", {}).items(): + registered_path = Path(str(metadata.get("path", ""))).expanduser().resolve() + if registered_path == vault: + vault_id = str(registered_id) + break + except FileNotFoundError: + config_error = "Obsidian has not registered any vaults yet" + except (OSError, json.JSONDecodeError, TypeError) as error: + config_error = f"Cannot read Obsidian vault registration: {error}" + + response = { + "ok": True, + "path": str(vault), + "name": vault.name, + "vaultId": vault_id, + "obsidianError": config_error + or ( + None + if vault_id + else "This directory is not registered as an Obsidian vault" + ), + } + json.dump(response, sys.stdout) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.local/share/noctalia/plugins/knowledge-lookup/widget.luau b/.local/share/noctalia/plugins/knowledge-lookup/widget.luau new file mode 100644 index 0000000..5aefa09 --- /dev/null +++ b/.local/share/noctalia/plugins/knowledge-lookup/widget.luau @@ -0,0 +1,17 @@ +--!strict + +local PANEL_ID = "alex/knowledge-lookup:panel" + +function update() + barWidget.setGlyph("book-2") + barWidget.setText("KB") + barWidget.setTooltip("Knowledge lookup · Super+N") +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + noctalia.runAsync({ "obsidian" }) +end