8 Commits

Author SHA1 Message Date
alex f30bf8a3bd feat(fish): preserve directory after Yazi exits
Add the standard cwd-file wrapper so Yazi navigation updates the interactive shell directory.
2026-08-13 15:50:46 +02:00
alex 11d8e9542a fix(noctalia): migrate portable widget settings
Repair the shared plugin declaration and use the current sysmon visualization schema for CPU, disk, and GPU telemetry.
2026-08-13 15:50:46 +02:00
alex 751432fbb1 feat(hypr): launch workspace applications on startup
Start Obsidian on workspace 8, Thunderbird on workspace 9, and Spotify in the scratchpad.
2026-08-13 15:50:46 +02:00
alex fd50b7deb9 feat(noctalia): add knowledge lookup panel
Provide a portable, cache-ignored Obsidian vault lookup plugin and bind it to Super+N.
2026-08-13 15:50:46 +02:00
alex ba75f3c7f7 feat(hypr): use Kitty as the default terminal 2026-08-13 15:50:46 +02:00
alex 03fbcb735a feat(ranger): use Kitty image previews 2026-08-13 15:50:46 +02:00
alex 04a89118bc fix(noctalia): suspend laptops only on battery
Require UPower to confirm battery operation before the idle timeout locks and suspends the laptop.
2026-08-13 15:45:58 +02:00
alex f9007e3225 fix(pi-status): migrate renamed bridge paths
Resolve the bridge and client from the npm installation while keeping UI launches behind the Wayland compatibility wrapper.
2026-08-13 15:45:43 +02:00
21 changed files with 1817 additions and 15 deletions
@@ -1,2 +1,3 @@
# Used by the Noctalia Pi Status Bridge adapter and the Hyprland launcher.
PI_STATUS_BRIDGE_CLIENT=/home/alex/.npm-global/bin/pi-status-bridge-client
PI_STATUS_UI_BINARY=/home/alex/.local/bin/pi-status-ui
+10
View File
@@ -32,3 +32,13 @@ set --export PATH $BUN_INSTALL/bin $PATH
# opencode
fish_add_path /home/alex/.opencode/bin
# Yazi wrapper that keeps the shell in its selected directory.
function y
set tmp (mktemp -t "yazi-cwd.XXXXXX")
command yazi $argv --cwd-file="$tmp"
if read -z cwd <"$tmp"; and [ "$cwd" != "$PWD" ]; and test -d "$cwd"
builtin cd -- "$cwd"
end
command rm -f -- "$tmp"
end
+3 -1
View File
@@ -10,7 +10,7 @@ for _, monitor in ipairs(machine.monitors or {}) do
end
local main_mod = "SUPER"
local terminal = "alacritty"
local terminal = "kitty"
local input = {
kb_layout = "us",
@@ -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"))
@@ -138,6 +139,7 @@ local function float_rule(name, match, border_size)
})
end
hl.window_rule({ name = "obsidian-workspace", match = { class = "^obsidian$" }, workspace = "8" })
hl.window_rule({ name = "thunderbird-workspace", match = { class = "^Thunderbird$" }, workspace = "9" })
float_rule("pi-status-ui", { class = "^(pi-status-ui|Pi-status-ui)$" }, 1)
float_rule("alsamixer", { title = "^alsamixer$" }, 1)
@@ -13,6 +13,7 @@ return {
"hypridle",
-- Electron's tray icon registers only when the SNI watcher already exists.
"sh -c 'sleep 2; exec mullvad-vpn'",
"obsidian",
"thunderbird",
"nextcloud",
"spotify",
@@ -22,6 +22,9 @@ return {
"noctalia",
-- Start after Noctalia has claimed the StatusNotifier watcher.
"sh -c 'sleep 2; exec mullvad-vpn'",
"obsidian",
"thunderbird",
"nextcloud",
"spotify",
},
}
+9 -4
View File
@@ -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"]
[widget.cpu]
type = "alex/system-status:cpu"
@@ -51,15 +51,20 @@ type = "alex/system-status:network-rate"
[widget.idle-inhibitors]
type = "alex/system-status:inhibitors"
[widget.knowledge-lookup]
type = "alex/knowledge-lookup:bar"
[widget.temp]
type = "sysmon"
stat = "cpu_temp"
display = "text"
visualization = "none"
show_value = true
[widget.disk]
type = "sysmon"
stat = "disk_pct"
display = "text"
stat = "disk_used_pct"
visualization = "none"
show_value = true
path = "/"
[desktop_widgets]
@@ -1,7 +1,7 @@
# Desktop-specific shell layout and hardware telemetry.
# Pi Status Bridge is installed locally from ~/projects/pi-gui.
# Pi Status Bridge is installed locally from ~/projects/pi-status-bridge.
[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"
@@ -72,12 +72,14 @@ radius = 5.0
[widget.gpu]
type = "sysmon"
stat = "gpu_usage"
display = "graph"
visualization = "graph"
show_value = true
[widget.gpu_temp]
type = "sysmon"
stat = "gpu_temp"
display = "text"
visualization = "none"
show_value = true
[control_center]
sidebar = "full"
@@ -77,6 +77,11 @@ type = "alex/system-status:battery-rate"
[widget.gpu-mode]
type = "alex/system-status:gpu-mode"
# Only suspend-then-hibernate after 30 minutes when UPower confirms battery power.
# The command fails closed on AC or when UPower is unavailable.
[idle.behavior.suspend-then-hibernate]
command = "busctl get-property org.freedesktop.UPower /org/freedesktop/UPower org.freedesktop.UPower OnBattery 2>/dev/null | grep -qx 'b true' && loginctl lock-session && systemctl suspend-then-hibernate"
[control_center]
sidebar = "full"
sidebar_section = "full"
+2 -2
View File
@@ -70,7 +70,7 @@ set vcs_backend_svn disabled
# Truncate the long commit messages to this length when shown in the statusbar.
set vcs_msg_length 50
# Use one of the supported image preview protocols
# Render image previews through Kitty's native graphics protocol.
set preview_images true
# Set the preview image method. Supported methods:
@@ -114,7 +114,7 @@ set preview_images true
# Preview images in full color with the external command "ueberzug".
# Images are shown by using a child window.
# Only for users who run X11 in GNU/Linux.
set preview_images_method ueberzug
set preview_images_method kitty
# Delay in seconds before displaying an image with the w3m method.
# Increase it in case of experiencing display corruption.
@@ -4,8 +4,8 @@ After=graphical-session.target
[Service]
Type=simple
Environment=PATH=/home/alex/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/alex/.local/bin/pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions
Environment=PATH=/home/alex/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/env pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions
Restart=on-failure
RestartSec=3
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/sh
# The opaque release window runs natively on Wayland; disable WebKit DMABUF,
# which is unreliable with this NVIDIA desktop's compositor path.
exec env WEBKIT_DISABLE_DMABUF_RENDERER=1 "$HOME/projects/pi-gui/ui/src-tauri/target/release/pi-status-ui" "$@"
exec env WEBKIT_DISABLE_DMABUF_RENDERER=1 "$HOME/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui" "$@"
@@ -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
@@ -1 +1 @@
../../../../projects/pi-gui/noctalia-v5-plugin/pi-status-bridge
../../../../projects/pi-status-bridge/noctalia-v5-plugin/pi-status-bridge