feat(noctalia): add knowledge lookup panel
Provide a portable, cache-ignored Obsidian vault lookup plugin and bind it to Super+N.
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
.ruff_cache/
|
||||
index-cache.json
|
||||
index-cache-*.json
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user