Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f30bf8a3bd | |||
| 11d8e9542a | |||
| 751432fbb1 | |||
| fd50b7deb9 | |||
| ba75f3c7f7 | |||
| 03fbcb735a | |||
| 04a89118bc | |||
| f9007e3225 |
@@ -1,2 +1,3 @@
|
|||||||
# Used by the Noctalia Pi Status Bridge adapter and the Hyprland launcher.
|
# 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
|
PI_STATUS_UI_BINARY=/home/alex/.local/bin/pi-status-ui
|
||||||
|
|||||||
@@ -32,3 +32,13 @@ set --export PATH $BUN_INSTALL/bin $PATH
|
|||||||
|
|
||||||
# opencode
|
# opencode
|
||||||
fish_add_path /home/alex/.opencode/bin
|
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
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ for _, monitor in ipairs(machine.monitors or {}) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
local main_mod = "SUPER"
|
local main_mod = "SUPER"
|
||||||
local terminal = "alacritty"
|
local terminal = "kitty"
|
||||||
|
|
||||||
local input = {
|
local input = {
|
||||||
kb_layout = "us",
|
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 .. " + 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 .. " + 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 .. " + 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 .. " + 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 + F", hl.dsp.exec_cmd(noctalia_ipc .. "bar-toggle main"))
|
||||||
hl.bind(main_mod .. " + SHIFT + R", hl.dsp.exec_cmd("hyprctl reload"))
|
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
|
end
|
||||||
|
|
||||||
|
hl.window_rule({ name = "obsidian-workspace", match = { class = "^obsidian$" }, workspace = "8" })
|
||||||
hl.window_rule({ name = "thunderbird-workspace", match = { class = "^Thunderbird$" }, workspace = "9" })
|
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("pi-status-ui", { class = "^(pi-status-ui|Pi-status-ui)$" }, 1)
|
||||||
float_rule("alsamixer", { title = "^alsamixer$" }, 1)
|
float_rule("alsamixer", { title = "^alsamixer$" }, 1)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ return {
|
|||||||
"hypridle",
|
"hypridle",
|
||||||
-- Electron's tray icon registers only when the SNI watcher already exists.
|
-- Electron's tray icon registers only when the SNI watcher already exists.
|
||||||
"sh -c 'sleep 2; exec mullvad-vpn'",
|
"sh -c 'sleep 2; exec mullvad-vpn'",
|
||||||
|
"obsidian",
|
||||||
"thunderbird",
|
"thunderbird",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
"spotify",
|
"spotify",
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ return {
|
|||||||
"noctalia",
|
"noctalia",
|
||||||
-- Start after Noctalia has claimed the StatusNotifier watcher.
|
-- Start after Noctalia has claimed the StatusNotifier watcher.
|
||||||
"sh -c 'sleep 2; exec mullvad-vpn'",
|
"sh -c 'sleep 2; exec mullvad-vpn'",
|
||||||
|
"obsidian",
|
||||||
|
"thunderbird",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
|
"spotify",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ pure_black_dark = true
|
|||||||
# Noctalia merges files alphabetically. Keep reusable modules here; machine-specific
|
# 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.
|
# layout and hardware settings live in 90-machine.toml, while 99-extend.toml is local.
|
||||||
[plugins]
|
[plugins]
|
||||||
enabled = ["alex/system-status"]
|
enabled = ["alex/system-status", "alex/knowledge-lookup"]
|
||||||
|
|
||||||
[widget.cpu]
|
[widget.cpu]
|
||||||
type = "alex/system-status:cpu"
|
type = "alex/system-status:cpu"
|
||||||
@@ -51,15 +51,20 @@ type = "alex/system-status:network-rate"
|
|||||||
[widget.idle-inhibitors]
|
[widget.idle-inhibitors]
|
||||||
type = "alex/system-status:inhibitors"
|
type = "alex/system-status:inhibitors"
|
||||||
|
|
||||||
|
[widget.knowledge-lookup]
|
||||||
|
type = "alex/knowledge-lookup:bar"
|
||||||
|
|
||||||
[widget.temp]
|
[widget.temp]
|
||||||
type = "sysmon"
|
type = "sysmon"
|
||||||
stat = "cpu_temp"
|
stat = "cpu_temp"
|
||||||
display = "text"
|
visualization = "none"
|
||||||
|
show_value = true
|
||||||
|
|
||||||
[widget.disk]
|
[widget.disk]
|
||||||
type = "sysmon"
|
type = "sysmon"
|
||||||
stat = "disk_pct"
|
stat = "disk_used_pct"
|
||||||
display = "text"
|
visualization = "none"
|
||||||
|
show_value = true
|
||||||
path = "/"
|
path = "/"
|
||||||
|
|
||||||
[desktop_widgets]
|
[desktop_widgets]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Desktop-specific shell layout and hardware telemetry.
|
# 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]
|
[plugins]
|
||||||
enabled = ["alex/system-status", "alex/pi-status-bridge"]
|
enabled = ["alex/system-status", "alex/pi-status-bridge", "alex/knowledge-lookup"]
|
||||||
|
|
||||||
[widget.pi-status]
|
[widget.pi-status]
|
||||||
type = "alex/pi-status-bridge:bridge"
|
type = "alex/pi-status-bridge:bridge"
|
||||||
@@ -72,12 +72,14 @@ radius = 5.0
|
|||||||
[widget.gpu]
|
[widget.gpu]
|
||||||
type = "sysmon"
|
type = "sysmon"
|
||||||
stat = "gpu_usage"
|
stat = "gpu_usage"
|
||||||
display = "graph"
|
visualization = "graph"
|
||||||
|
show_value = true
|
||||||
|
|
||||||
[widget.gpu_temp]
|
[widget.gpu_temp]
|
||||||
type = "sysmon"
|
type = "sysmon"
|
||||||
stat = "gpu_temp"
|
stat = "gpu_temp"
|
||||||
display = "text"
|
visualization = "none"
|
||||||
|
show_value = true
|
||||||
|
|
||||||
[control_center]
|
[control_center]
|
||||||
sidebar = "full"
|
sidebar = "full"
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ type = "alex/system-status:battery-rate"
|
|||||||
[widget.gpu-mode]
|
[widget.gpu-mode]
|
||||||
type = "alex/system-status: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]
|
[control_center]
|
||||||
sidebar = "full"
|
sidebar = "full"
|
||||||
sidebar_section = "full"
|
sidebar_section = "full"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ set vcs_backend_svn disabled
|
|||||||
# Truncate the long commit messages to this length when shown in the statusbar.
|
# Truncate the long commit messages to this length when shown in the statusbar.
|
||||||
set vcs_msg_length 50
|
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 preview_images true
|
||||||
|
|
||||||
# Set the preview image method. Supported methods:
|
# 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".
|
# Preview images in full color with the external command "ueberzug".
|
||||||
# Images are shown by using a child window.
|
# Images are shown by using a child window.
|
||||||
# Only for users who run X11 in GNU/Linux.
|
# 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.
|
# Delay in seconds before displaying an image with the w3m method.
|
||||||
# Increase it in case of experiencing display corruption.
|
# Increase it in case of experiencing display corruption.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ After=graphical-session.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
Environment=PATH=/home/alex/.local/bin:/usr/local/bin:/usr/bin:/bin
|
Environment=PATH=/home/alex/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
ExecStart=/home/alex/.local/bin/pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions
|
ExecStart=/usr/bin/env pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# The opaque release window runs natively on Wayland; disable WebKit DMABUF,
|
# The opaque release window runs natively on Wayland; disable WebKit DMABUF,
|
||||||
# which is unreliable with this NVIDIA desktop's compositor path.
|
# 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
|
||||||
Reference in New Issue
Block a user