diff --git a/obsidian/README.md b/obsidian/README.md
new file mode 100644
index 0000000..2756c27
--- /dev/null
+++ b/obsidian/README.md
@@ -0,0 +1,90 @@
+# Obsidian
+
+Quick daily capture and git sync for a local [Obsidian](https://obsidian.md/) vault from Noctalia.
+
+## Plugin
+
+| Field | Value |
+| --- | --- |
+| ID | `davemhammer/obsidian` |
+| Entries | Bar widget: `status`; panel: `manager`; service: `service`; launcher: `ob` |
+| Launcher Prefix | `/ob` |
+
+## Requirements
+
+Install these on `PATH` (declared in `plugin.toml` `dependencies`):
+
+- `obsidian` — desktop app / URI handler so `obsidian://` opens work (the plugin launches URIs via `xdg-open`, not the CLI)
+- `git` — status, commit, pull, push, abort
+- `xdg-open` — launches `obsidian://` URIs
+- `find`, `sort`, `head` — recent-notes scan under the vault (`find -P`)
+- `realpath` — canonicalize paths before read/write (symlink escape checks)
+
+Also configure a local vault path (must contain `.obsidian`).
+
+## Usage
+
+Set **Vault path** (and optional daily folder/format) in plugin settings. Daily notes default to the vault root with filenames `%Y-%m-%d` (override folder/format to match your Daily Notes plugin).
+
+Add the **status** bar widget (`davemhammer/obsidian:status`):
+
+- **Click** — open panel
+- **Right-click** — open today’s daily note
+
+Panel tabs:
+
+- **Daily** — type a line and **Add to daily** (appends `- HH:MM text`); open daily
+- **Recent** — last modified markdown notes; open / copy `[[link]]` / path
+- **Git** — dirty list; commit (optional message), pull, push, pull+push; **Abort** if rebase/merge stuck
+
+Launcher:
+
+- `/ob` — categories
+- `/ob capture buy milk` — append timestamped line to daily
+- `/ob daily` — open daily
+- `/ob git` — git actions
+
+```sh
+noctalia msg panel-toggle davemhammer/obsidian:manager
+```
+
+## Settings
+
+| Setting | Type | Default | Description |
+| --- | --- | --- | --- |
+| `vault_path` | `folder` | `~/Documents/Obsidian Vault` | Vault root (must contain `.obsidian`). |
+| `vault_name` | `string` | _(empty)_ | Name for `obsidian://` URIs; empty = folder name. |
+| `daily_folder` | `string` | _(empty)_ | Daily notes folder relative to vault (empty = vault root). Must stay under the vault (`..` rejected). |
+| `daily_format` | `string` | `%Y-%m-%d` | Daily filename stem without path separators (strftime); `.md` is appended. |
+| `git_commit_message` | `string` | `vault: capture from Noctalia` | Default commit message. |
+| `refresh_interval` | `int` | `20` | Rescan recent notes + git every N seconds. |
+| `notify_on_action` | `bool` | `true` | Notify after capture/git actions. |
+| `show_dirty` | `bool` (widget) | `true` | Show dirty file count on the bar. |
+
+## IPC
+
+```sh
+noctalia msg panel-toggle davemhammer/obsidian:manager
+noctalia msg plugin davemhammer/obsidian:service all refresh
+noctalia msg plugin davemhammer/obsidian:service all daily
+noctalia msg plugin davemhammer/obsidian:service all pull
+noctalia msg plugin davemhammer/obsidian:service all push
+noctalia msg plugin davemhammer/obsidian:service all sync
+```
+
+Capture with payload (text line for daily):
+
+```sh
+noctalia msg plugin davemhammer/obsidian:service all capture '{"text":"buy milk"}'
+```
+
+(Exact payload syntax depends on your Noctalia IPC version; the service expects event `capture` with a table containing `text` or `line`.)
+
+## Notes
+
+- **Filesystem:** reads/writes daily note markdown **only under** the configured vault; `daily_folder` / note paths reject `..` and absolute paths; existing path components that are symlinks are refused; `realpath` must keep the target under the vault. Recent scan uses `find -P` (never follows symlinks) for `*.md` mtimes (skips `.obsidian`, `.git`, `.claudian`); listed paths are re-normalized before display and again before open.
+- **Processes:** `find -P`, `sort`, `head` (recent notes); `realpath` + `test -L` (path confinement); `git status|add|commit|pull|push` and `git rebase|merge --abort` when requested; `xdg-open` for `obsidian://` URIs only.
+- **Vault required:** capture and open-daily refuse to create files unless `vault_path` is a real vault (contains `.obsidian`).
+- **Git pull** uses `git pull --no-rebase --autostash` so the vault is not left mid-rebase. If a rebase/merge is already in progress, use **Abort rebase/merge** on the Git tab.
+- Does not talk to Obsidian Sync cloud APIs; git is your sync layer.
+- Brand assets under `assets/` (Simple Icons–style mark).
diff --git a/obsidian/assets/obsidian-green.png b/obsidian/assets/obsidian-green.png
new file mode 100644
index 0000000..696755d
Binary files /dev/null and b/obsidian/assets/obsidian-green.png differ
diff --git a/obsidian/assets/obsidian-green.svg b/obsidian/assets/obsidian-green.svg
new file mode 100644
index 0000000..21151be
--- /dev/null
+++ b/obsidian/assets/obsidian-green.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/obsidian/assets/obsidian-grey.png b/obsidian/assets/obsidian-grey.png
new file mode 100644
index 0000000..881b3dc
Binary files /dev/null and b/obsidian/assets/obsidian-grey.png differ
diff --git a/obsidian/assets/obsidian-grey.svg b/obsidian/assets/obsidian-grey.svg
new file mode 100644
index 0000000..63c5477
--- /dev/null
+++ b/obsidian/assets/obsidian-grey.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/obsidian/assets/obsidian-purple.png b/obsidian/assets/obsidian-purple.png
new file mode 100644
index 0000000..b672072
Binary files /dev/null and b/obsidian/assets/obsidian-purple.png differ
diff --git a/obsidian/assets/obsidian-purple.svg b/obsidian/assets/obsidian-purple.svg
new file mode 100644
index 0000000..f0c270a
--- /dev/null
+++ b/obsidian/assets/obsidian-purple.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/obsidian/assets/obsidian.svg b/obsidian/assets/obsidian.svg
new file mode 100644
index 0000000..fc35287
--- /dev/null
+++ b/obsidian/assets/obsidian.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/obsidian/launcher.luau b/obsidian/launcher.luau
new file mode 100644
index 0000000..4ce7a60
--- /dev/null
+++ b/obsidian/launcher.luau
@@ -0,0 +1,191 @@
+--!nonstrict
+-- /ob launcher: daily, capture, git, panel.
+
+local STATE_KEY = "obs_snapshot"
+local COMMAND_KEY = "obs_command"
+local PANEL_ID = "davemhammer/obsidian:manager"
+
+local snapshot = noctalia.state.get(STATE_KEY) or {
+ available = false,
+ loading = true,
+ vaultName = "",
+ dailyRel = "",
+ git = { dirty = 0, isRepo = false, clean = true },
+ recent = {},
+}
+
+noctalia.state.watch(STATE_KEY, function(value)
+ if type(value) == "table" then
+ snapshot = value
+ end
+end)
+
+local function trim(s)
+ return noctalia.string.trim(tostring(s or ""))
+end
+
+local function lower(s)
+ return string.lower(tostring(s or ""))
+end
+
+local function send(action, values)
+ local command = { action = action, requestId = "launcher-" .. tostring(os.time()) }
+ if type(values) == "table" then
+ for k, v in pairs(values) do
+ command[k] = v
+ end
+ end
+ noctalia.state.set(COMMAND_KEY, command)
+end
+
+local function statusRow(title, subtitle, glyph)
+ return { id = "", title = title, subtitle = subtitle, glyph = glyph or "notebook" }
+end
+
+local function topCategories()
+ local g = snapshot.git or {}
+ local gitSub = "—"
+ if g.isRepo then
+ gitSub = g.clean and "clean" or (tostring(g.dirty or 0) .. " dirty")
+ else
+ gitSub = "no git"
+ end
+ return {
+ { id = "act:daily", title = noctalia.tr("launcher.cat.daily"), subtitle = snapshot.dailyRel or "", glyph = "calendar", score = 100 },
+ { id = "act:capture", title = noctalia.tr("launcher.cat.capture"), subtitle = noctalia.tr("launcher.cat.capture-sub"), glyph = "plus", score = 95 },
+ { id = "act:git", title = noctalia.tr("launcher.cat.git"), subtitle = gitSub, glyph = "git-commit", score = 90 },
+ { id = "act:panel", title = noctalia.tr("launcher.cat.panel"), subtitle = noctalia.tr("launcher.cat.panel-sub"), glyph = "layout-dashboard", score = 80 },
+ { id = "act:refresh", title = noctalia.tr("launcher.cat.refresh"), subtitle = noctalia.tr("launcher.cat.refresh-sub"), glyph = "refresh", score = 50 },
+ }
+end
+
+function search(query)
+ query = trim(query)
+ if not snapshot.available and not snapshot.loading then
+ launcher.setResults(query, { statusRow(noctalia.tr("launcher.missing"), "", "alert-triangle") })
+ return
+ end
+ if snapshot.loading and not snapshot.available then
+ launcher.setResults(query, { statusRow(noctalia.tr("launcher.loading"), snapshot.vaultName, "loader") })
+ send("refresh")
+ return
+ end
+
+ local head, rest = query:match("^(%S+)%s*(.-)$")
+ head = lower(head or "")
+ rest = trim(rest or "")
+
+ if head == "" then
+ launcher.setResults(query, topCategories())
+ return
+ end
+
+ if head == "capture" or head == "c" or head == "note" then
+ if rest ~= "" then
+ launcher.setResults(query, {
+ {
+ id = "do:capture:" .. rest,
+ title = "Add to daily",
+ subtitle = rest,
+ glyph = "plus",
+ score = 100,
+ },
+ })
+ return
+ end
+ launcher.setResults(query, {
+ statusRow(noctalia.tr("launcher.cat.capture"), noctalia.tr("launcher.cat.capture-sub"), "plus"),
+ })
+ return
+ end
+
+ if head == "daily" or head == "d" then
+ launcher.setResults(query, {
+ { id = "act:daily", title = noctalia.tr("launcher.cat.daily"), subtitle = snapshot.dailyRel or "", glyph = "calendar", score = 100 },
+ })
+ return
+ end
+
+ if head == "git" or head == "g" then
+ launcher.setResults(query, {
+ { id = "act:git", title = noctalia.tr("launcher.cat.git"), subtitle = "", glyph = "git-commit", score = 100 },
+ { id = "act:commit", title = "Commit", subtitle = "git add -A && commit", glyph = "git-commit", score = 90 },
+ { id = "act:pull", title = "Pull", subtitle = "git pull (merge)", glyph = "cloud-download", score = 80 },
+ { id = "act:push", title = "Push", subtitle = "git push", glyph = "cloud-upload", score = 70 },
+ { id = "act:sync", title = "Sync", subtitle = "pull + push", glyph = "refresh", score = 60 },
+ })
+ return
+ end
+
+ -- free text: match recent note titles
+ local rows = {}
+ local q = lower(query)
+ for _, n in ipairs(snapshot.recent or {}) do
+ if lower(n.name):find(q, 1, true) or lower(n.rel):find(q, 1, true) then
+ table.insert(rows, {
+ id = "note:" .. n.rel,
+ title = n.name,
+ subtitle = n.rel,
+ glyph = "file-text",
+ score = 50,
+ })
+ end
+ end
+ for _, row in ipairs(topCategories()) do
+ if lower(row.title):find(q, 1, true) then
+ table.insert(rows, row)
+ end
+ end
+ if #rows == 0 then
+ rows = topCategories()
+ end
+ launcher.setResults(query, rows)
+end
+
+function activate(id)
+ if type(id) ~= "string" or id == "" then return end
+
+ if id == "act:daily" then
+ send("open_daily")
+ return
+ end
+ if id == "act:capture" then
+ launcher.setQuery("capture ")
+ return
+ end
+ if id == "act:git" or id == "act:panel" then
+ noctalia.togglePanel(PANEL_ID)
+ return
+ end
+ if id == "act:refresh" then
+ send("refresh")
+ return
+ end
+ if id == "act:commit" then
+ send("git_commit", {})
+ return
+ end
+ if id == "act:pull" then
+ send("git_pull")
+ return
+ end
+ if id == "act:push" then
+ send("git_push")
+ return
+ end
+ if id == "act:sync" then
+ send("git_sync")
+ return
+ end
+
+ local cap = id:match("^do:capture:(.+)$")
+ if cap then
+ send("capture", { text = cap })
+ return
+ end
+
+ local note = id:match("^note:(.+)$")
+ if note then
+ send("open_note", { rel = note })
+ end
+end
diff --git a/obsidian/panel.luau b/obsidian/panel.luau
new file mode 100644
index 0000000..a281167
--- /dev/null
+++ b/obsidian/panel.luau
@@ -0,0 +1,568 @@
+--!nonstrict
+-- Obsidian panel: daily capture, recent notes, git sync.
+
+local STATE_KEY = "obs_snapshot"
+local COMMAND_KEY = "obs_command"
+local RESULT_KEY = "obs_action_result"
+
+local snapshot = noctalia.state.get(STATE_KEY) or {
+ available = false,
+ loading = true,
+ busy = false,
+ vaultName = "",
+ dailyRel = "",
+ dailyExists = false,
+ dailyPreview = "",
+ recent = {},
+ git = {
+ isRepo = false,
+ branch = "",
+ dirty = 0,
+ ahead = 0,
+ behind = 0,
+ clean = true,
+ files = {},
+ },
+ error = "",
+ updatedAt = 0,
+ revision = 0,
+}
+
+local tab = "daily" -- daily | recent | git
+local selectedId = ""
+local filterText = ""
+local filterKey = 0
+local captureText = ""
+local captureKey = 0
+local commitText = ""
+local commitKey = 0
+local requestCounter = 0
+local feedback = ""
+local feedbackError = false
+local dirty = true
+
+local render
+
+local function tr(key, subst)
+ return noctalia.tr(key, subst)
+end
+
+local function nextRequestId()
+ requestCounter += 1
+ return `panel-{requestCounter}`
+end
+
+local function send(action, values)
+ local command = { action = action, requestId = nextRequestId() }
+ if type(values) == "table" then
+ for k, v in pairs(values) do
+ command[k] = v
+ end
+ end
+ noctalia.state.set(COMMAND_KEY, command)
+ return command.requestId
+end
+
+local function lower(s)
+ return string.lower(tostring(s or ""))
+end
+
+local function matchesFilter(...)
+ local q = noctalia.string.trim(filterText)
+ if q == "" then return true end
+ for raw in q:gmatch("%S+") do
+ local term = lower(raw)
+ local hit = false
+ for i = 1, select("#", ...) do
+ local part = lower(select(i, ...))
+ if part ~= "" and part:find(term, 1, true) then
+ hit = true
+ break
+ end
+ end
+ if not hit then return false end
+ end
+ return true
+end
+
+local function listButton(props)
+ props.contentAlign = "start"
+ props.controlSize = props.controlSize or "md"
+ return ui.button(props)
+end
+
+local function selectedRecent()
+ if tab ~= "recent" then return nil end
+ for _, n in ipairs(snapshot.recent or {}) do
+ if n.id == selectedId then return n end
+ end
+ return nil
+end
+
+local function gitSummary()
+ local g = snapshot.git or {}
+ if not g.isRepo then
+ return tr("panel.git_nogit")
+ end
+ if g.clean then
+ return tr("panel.git_clean") .. (g.branch ~= "" and (` · {g.branch}`) or "")
+ end
+ return tr("panel.git_branch", {
+ branch = g.branch ~= "" and g.branch or "—",
+ ahead = g.ahead or 0,
+ behind = g.behind or 0,
+ dirty = g.dirty or 0,
+ })
+end
+
+local function emptyList(msg)
+ return ui.column({
+ key = "empty-" .. tab,
+ align = "center",
+ justify = "center",
+ padding = 24,
+ gap = 8,
+ flexGrow = 1,
+ }, {
+ ui.glyph({ name = "search", size = 36, color = "on_surface_variant" }),
+ ui.label({ text = msg, color = "on_surface_variant", textAlign = "center" }),
+ })
+end
+
+local function recentRows()
+ local rows = {}
+ for _, n in ipairs(snapshot.recent or {}) do
+ if matchesFilter(n.name, n.rel) then
+ local selected = n.id == selectedId
+ local when = ""
+ if (n.mtime or 0) > 0 then
+ when = noctalia.formatTime("%m-%d %H:%M", n.mtime)
+ end
+ table.insert(rows, listButton({
+ key = "note-" .. n.id,
+ text = (when ~= "" and (when .. " · ") or "") .. n.rel,
+ glyph = "file-text",
+ variant = selected and "primary" or "outline",
+ selected = selected,
+ onClick = function()
+ selectedId = n.id
+ feedback = ""
+ render()
+ end,
+ }))
+ end
+ end
+ return rows
+end
+
+local function gitFileRows()
+ local rows = {}
+ for _, f in ipairs((snapshot.git and snapshot.git.files) or {}) do
+ if matchesFilter(f.path, f.code) then
+ table.insert(rows, listButton({
+ key = "gf-" .. f.id,
+ text = `{f.code} {f.path}`,
+ glyph = "git-commit",
+ variant = "outline",
+ onClick = function()
+ selectedId = f.id
+ render()
+ end,
+ }))
+ end
+ end
+ return rows
+end
+
+local function itemList()
+ if tab == "daily" then
+ local preview = snapshot.dailyPreview
+ if type(preview) ~= "string" or preview == "" then
+ return emptyList(
+ snapshot.dailyExists and tr("panel.empty") or tr("panel.daily_missing")
+ )
+ end
+ return ui.column({
+ key = "daily-preview",
+ gap = 8,
+ padding = 8,
+ align = "stretch",
+ flexGrow = 1,
+ }, {
+ ui.label({
+ text = preview,
+ color = "on_surface",
+ fontSize = 12,
+ maxLines = 24,
+ }),
+ })
+ end
+
+ local rows
+ if tab == "recent" then
+ rows = recentRows()
+ else
+ rows = gitFileRows()
+ end
+ if #rows == 0 then
+ return emptyList(tr("panel.empty"))
+ end
+ return ui.column({
+ key = "items-" .. tab,
+ align = "stretch",
+ justify = "start",
+ gap = 8,
+ flexGrow = 1,
+ }, rows)
+end
+
+local function toolbar()
+ local busy = snapshot.busy == true
+
+ if tab == "daily" then
+ return ui.column({ gap = 8, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, {
+ ui.label({
+ text = tr("panel.daily_path", {
+ path = snapshot.dailyRel ~= "" and snapshot.dailyRel or "—",
+ }),
+ color = "on_surface_variant",
+ fontSize = 12,
+ maxLines = 2,
+ }),
+ ui.input({
+ key = `capture-{captureKey}`,
+ value = captureText,
+ placeholder = tr("panel.capture_placeholder"),
+ flexGrow = 1,
+ controlSize = "md",
+ onChange = "onCaptureChange",
+ onSubmit = "onCapture",
+ submitOnEnter = true,
+ }),
+ ui.row({ gap = 6 }, {
+ ui.button({
+ text = tr("actions.capture"),
+ glyph = "plus",
+ variant = "primary",
+ enabled = not busy and snapshot.available == true,
+ onClick = "onCapture",
+ }),
+ ui.button({
+ text = tr("actions.open_daily"),
+ glyph = "external-link",
+ variant = "outline",
+ enabled = snapshot.available == true,
+ onClick = "onOpenDaily",
+ }),
+ }),
+ })
+ end
+
+ if tab == "recent" then
+ local n = selectedRecent()
+ if not n then
+ return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" })
+ end
+ return ui.column({ gap = 6, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, {
+ ui.label({ text = n.rel, fontWeight = "bold", maxLines = 2 }),
+ ui.row({ gap = 6 }, {
+ ui.button({ text = tr("actions.open_note"), glyph = "external-link", variant = "primary", onClick = "onOpenNote" }),
+ ui.button({ text = tr("actions.copy_link"), glyph = "link", variant = "outline", onClick = "onCopyLink" }),
+ ui.button({ text = tr("actions.copy_path"), glyph = "copy", variant = "ghost", onClick = "onCopyPath" }),
+ }),
+ })
+ end
+
+ -- git tab
+ local g = snapshot.git or {}
+ local inProg = type(g.inProgress) == "string" and g.inProgress ~= ""
+ local gitOk = g.isRepo == true and not inProg
+ local rows = {
+ ui.label({
+ text = inProg
+ and tr("panel.git_in_progress", { kind = g.inProgress })
+ or gitSummary(),
+ color = inProg and "error"
+ or ((g.isRepo and not g.clean) and "#73c936" or "on_surface_variant"),
+ fontSize = 12,
+ maxLines = 2,
+ }),
+ }
+ if inProg then
+ table.insert(rows, ui.row({ gap = 6 }, {
+ ui.button({
+ text = tr("actions.git_abort"),
+ glyph = "x",
+ variant = "primary",
+ enabled = not busy,
+ onClick = "onGitAbort",
+ }),
+ }))
+ else
+ table.insert(rows, ui.input({
+ key = `commit-{commitKey}`,
+ value = commitText,
+ placeholder = tr("panel.commit_placeholder"),
+ flexGrow = 1,
+ controlSize = "md",
+ onChange = "onCommitChange",
+ onSubmit = "onGitCommit",
+ submitOnEnter = true,
+ }))
+ table.insert(rows, ui.row({ gap = 6 }, {
+ ui.button({
+ text = tr("actions.git_commit"),
+ glyph = "git-commit",
+ variant = "primary",
+ enabled = not busy and gitOk,
+ onClick = "onGitCommit",
+ }),
+ ui.button({
+ text = tr("actions.git_pull"),
+ glyph = "cloud-download",
+ variant = "outline",
+ enabled = not busy and gitOk,
+ onClick = "onGitPull",
+ }),
+ ui.button({
+ text = tr("actions.git_push"),
+ glyph = "cloud-upload",
+ variant = "outline",
+ enabled = not busy and gitOk,
+ onClick = "onGitPush",
+ }),
+ ui.button({
+ text = tr("actions.git_sync"),
+ glyph = "refresh",
+ variant = "outline",
+ enabled = not busy and gitOk,
+ onClick = "onGitSync",
+ }),
+ }))
+ end
+ return ui.column({
+ gap = 8,
+ padding = 10,
+ fill = "surface_variant/0.45",
+ radius = 10,
+ align = "stretch",
+ }, rows)
+end
+
+local function tabButton(label, id, cb)
+ return ui.button({
+ text = label,
+ selected = tab == id,
+ variant = tab == id and "primary" or "ghost",
+ onClick = cb,
+ })
+end
+
+render = function()
+ dirty = false
+ local notes = {}
+ if snapshot.loading then
+ table.insert(notes, ui.label({ text = tr("panel.loading"), color = "on_surface_variant" }))
+ end
+ if snapshot.busy then
+ table.insert(notes, ui.label({ text = tr("panel.busy"), color = "primary" }))
+ end
+ if type(snapshot.error) == "string" and snapshot.error ~= "" then
+ table.insert(notes, ui.label({ text = snapshot.error, color = "error", maxLines = 3 }))
+ end
+ if feedback ~= "" then
+ table.insert(notes, ui.label({
+ text = feedback,
+ color = feedbackError and "error" or "tertiary",
+ maxLines = 2,
+ }))
+ end
+
+ local g = snapshot.git or {}
+ local gitLabel = "—"
+ if g.isRepo then
+ gitLabel = g.clean and "clean" or (tostring(g.dirty or 0) .. " dirty")
+ else
+ gitLabel = "no git"
+ end
+
+ local summary = tr("panel.summary", {
+ daily = snapshot.dailyExists and "✓" or "·",
+ recent = #(snapshot.recent or {}),
+ git = gitLabel,
+ })
+
+ local titleIcon = snapshot.available and "assets/obsidian-purple.png" or "assets/obsidian-grey.png"
+ if snapshot.available and g.isRepo and not g.clean then
+ titleIcon = "assets/obsidian-green.png"
+ end
+
+ panel.render(ui.column({ flexGrow = 1, gap = 10 }, {
+ ui.row({ align = "center", gap = 10 }, {
+ ui.image({ path = titleIcon, width = 28, height = 28, fit = "contain" }),
+ ui.column({ flexGrow = 1, gap = 0 }, {
+ ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }),
+ ui.label({
+ text = tr("panel.vault", {
+ name = snapshot.vaultName ~= "" and snapshot.vaultName or "—",
+ }),
+ fontSize = 11,
+ color = "on_surface_variant",
+ }),
+ }),
+ ui.button({ text = tr("actions.open_vault"), glyph = "external-link", variant = "outline", onClick = "onOpenVault" }),
+ ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefresh" }),
+ ui.button({ glyph = "close", onClick = "onClose" }),
+ }),
+
+ ui.row({ gap = 4, align = "center" }, {
+ tabButton(tr("tabs.daily"), "daily", "onTabDaily"),
+ tabButton(tr("tabs.recent"), "recent", "onTabRecent"),
+ tabButton(tr("tabs.git"), "git", "onTabGit"),
+ }),
+
+ ui.label({
+ text = summary,
+ color = "on_surface_variant",
+ fontSize = 11,
+ maxLines = 1,
+ }),
+
+ ui.row({ gap = 8, align = "center", visible = tab == "recent" or tab == "git" }, {
+ ui.input({
+ key = `filter-{tab}-{filterKey}`,
+ value = filterText,
+ placeholder = tr("filter.placeholder"),
+ flexGrow = 1,
+ controlSize = "sm",
+ onChange = "onFilterChange",
+ }),
+ ui.button({
+ glyph = "x",
+ variant = "ghost",
+ visible = filterText ~= "",
+ onClick = "onClearFilter",
+ }),
+ }),
+
+ toolbar(),
+ ui.column({ gap = 3, align = "stretch" }, notes),
+ ui.scroll({
+ key = "scroll-" .. tab,
+ flexGrow = 1,
+ gap = 8,
+ align = "stretch",
+ }, { itemList() }),
+ ui.label({
+ text = (snapshot.updatedAt or 0) > 0
+ and tr("panel.updated", { time = noctalia.formatTime("%H:%M:%S", snapshot.updatedAt) })
+ or "",
+ color = "on_surface_variant",
+ fontSize = 11,
+ }),
+ }))
+end
+
+noctalia.state.watch(STATE_KEY, function(value)
+ if type(value) ~= "table" then return end
+ snapshot = value
+ if selectedId ~= "" and tab == "recent" and not selectedRecent() then
+ selectedId = ""
+ end
+ dirty = true
+end)
+
+noctalia.state.watch(RESULT_KEY, function(result)
+ if type(result) ~= "table" then return end
+ if type(result.requestId) ~= "string" or not result.requestId:match("^panel%-") then return end
+ feedback = tostring(result.message or "")
+ feedbackError = result.ok ~= true
+ if result.ok == true and result.action == "capture" then
+ captureText = ""
+ captureKey += 1
+ end
+ if result.ok == true and result.action == "git_commit" then
+ commitText = ""
+ commitKey += 1
+ end
+ dirty = true
+end)
+
+panel.setWantsSecondTicks(true)
+
+function onOpen(_context)
+ feedback = ""
+ send("refresh")
+ render()
+end
+
+function update()
+ if dirty then render() end
+end
+
+function onClose() panel.close() end
+function onRefresh() send("refresh") end
+function onOpenVault() send("open_vault") end
+function onOpenDaily() send("open_daily") end
+
+function onTabDaily()
+ tab = "daily"
+ selectedId = ""
+ filterKey += 1
+ render()
+end
+function onTabRecent()
+ tab = "recent"
+ selectedId = ""
+ filterKey += 1
+ render()
+end
+function onTabGit()
+ tab = "git"
+ selectedId = ""
+ filterKey += 1
+ render()
+end
+
+function onFilterChange(value)
+ filterText = if type(value) == "string" then value else ""
+ render()
+end
+function onClearFilter()
+ filterText = ""
+ filterKey += 1
+ render()
+end
+
+function onCaptureChange(value)
+ captureText = if type(value) == "string" then value else ""
+end
+function onCapture()
+ local text = noctalia.string.trim(captureText)
+ if text == "" then return end
+ send("capture", { text = text })
+end
+
+function onOpenNote()
+ local n = selectedRecent()
+ if n then send("open_note", { rel = n.rel }) end
+end
+function onCopyLink()
+ local n = selectedRecent()
+ if n then send("copy", { text = "[[" .. n.name .. "]]" }) end
+end
+function onCopyPath()
+ local n = selectedRecent()
+ if n then send("copy", { text = n.rel }) end
+end
+
+function onCommitChange(value)
+ commitText = if type(value) == "string" then value else ""
+end
+function onGitCommit()
+ send("git_commit", { message = commitText })
+end
+function onGitPull() send("git_pull") end
+function onGitPush() send("git_push") end
+function onGitSync() send("git_sync") end
+function onGitAbort() send("git_abort") end
diff --git a/obsidian/plugin.toml b/obsidian/plugin.toml
new file mode 100644
index 0000000..7eccc35
--- /dev/null
+++ b/obsidian/plugin.toml
@@ -0,0 +1,97 @@
+# Obsidian vault: daily capture + git sync.
+
+id = "davemhammer/obsidian"
+name = "Obsidian"
+version = "1.0.6"
+plugin_api = 10
+author = "davemhammer"
+license = "MIT"
+dependencies = ["obsidian", "git", "xdg-open", "find", "sort", "head", "realpath"]
+tags = ["productivity", "utility", "bar", "panel", "service", "launcher"]
+icon = "notebook"
+description = "Daily capture and git status, commit, pull, and push for an Obsidian vault."
+
+[[setting]]
+key = "vault_path"
+type = "folder"
+label_key = "settings.vault_path.label"
+description_key = "settings.vault_path.description"
+default = "~/Documents/Obsidian Vault"
+
+[[setting]]
+key = "vault_name"
+type = "string"
+label_key = "settings.vault_name.label"
+description_key = "settings.vault_name.description"
+default = ""
+
+[[setting]]
+key = "daily_folder"
+type = "string"
+label_key = "settings.daily_folder.label"
+description_key = "settings.daily_folder.description"
+default = ""
+
+[[setting]]
+key = "daily_format"
+type = "string"
+label_key = "settings.daily_format.label"
+description_key = "settings.daily_format.description"
+default = "%Y-%m-%d"
+
+[[setting]]
+key = "git_commit_message"
+type = "string"
+label_key = "settings.git_commit_message.label"
+description_key = "settings.git_commit_message.description"
+default = "vault: capture from Noctalia"
+
+[[setting]]
+key = "refresh_interval"
+type = "int"
+label_key = "settings.refresh_interval.label"
+description_key = "settings.refresh_interval.description"
+default = 20
+min = 5
+max = 300
+
+[[setting]]
+key = "notify_on_action"
+type = "bool"
+label_key = "settings.notify_on_action.label"
+description_key = "settings.notify_on_action.description"
+default = true
+
+[[widget]]
+id = "status"
+entry = "widget.luau"
+
+ [[widget.setting]]
+ key = "show_dirty"
+ type = "bool"
+ label_key = "settings.show_dirty.label"
+ description_key = "settings.show_dirty.description"
+ default = true
+
+[[panel]]
+id = "manager"
+entry = "panel.luau"
+width = 700
+height = 620
+placement = "floating"
+position = "center"
+open_near_click = true
+keyboard_focus = "exclusive"
+dismiss_on_outside_click = true
+
+[[service]]
+id = "service"
+entry = "service.luau"
+
+[[launcher_provider]]
+id = "ob"
+entry = "launcher.luau"
+prefix = "ob"
+glyph = "notebook"
+include_in_global_search = false
+debounce_ms = 80
diff --git a/obsidian/service.luau b/obsidian/service.luau
new file mode 100644
index 0000000..13a031f
--- /dev/null
+++ b/obsidian/service.luau
@@ -0,0 +1,1010 @@
+--!nonstrict
+-- Obsidian vault: daily capture + git status/commit/pull/push.
+
+local STATE_KEY = "obs_snapshot"
+local COMMAND_KEY = "obs_command"
+local RESULT_KEY = "obs_action_result"
+
+local MAX_RECENT = 40
+local MAX_GIT_FILES = 40
+
+local snapshot = {
+ available = false,
+ loading = true,
+ busy = false,
+ vaultPath = "",
+ vaultName = "",
+ dailyPath = "",
+ dailyRel = "",
+ dailyExists = false,
+ dailyPreview = "",
+ recent = {},
+ git = {
+ isRepo = false,
+ branch = "",
+ dirty = 0,
+ ahead = 0,
+ behind = 0,
+ clean = true,
+ files = {},
+ statusLine = "",
+ inProgress = "", -- "rebase" | "merge" | ""
+ },
+ error = "",
+ updatedAt = 0,
+ revision = 0,
+}
+
+local refreshGeneration = 0
+local refreshPending = false
+local refreshAgain = false
+local actionBusy = false
+local dataSignature = ""
+
+local function trim(v)
+ return noctalia.string.trim(tostring(v or ""))
+end
+
+local function shellQuote(v)
+ return "'" .. tostring(v):gsub("'", "'\\''") .. "'"
+end
+
+local function shellCommand(args)
+ local q = {}
+ for _, a in ipairs(args) do
+ table.insert(q, shellQuote(a))
+ end
+ return table.concat(q, " ")
+end
+
+local function expand(path)
+ return noctalia.expandPath(trim(path))
+end
+
+local function nowSec()
+ if type(noctalia.nowMs) == "function" then
+ local ms = noctalia.nowMs()
+ if type(ms) == "number" and ms > 0 then
+ return math.floor(ms / 1000)
+ end
+ end
+ return os.time()
+end
+
+local function refreshIntervalMs()
+ local s = tonumber(noctalia.getConfig("refresh_interval")) or 20
+ s = math.max(5, math.min(300, math.floor(s)))
+ return s * 1000
+end
+
+local function shouldNotify()
+ return noctalia.getConfig("notify_on_action") ~= false
+end
+
+local function vaultRoot()
+ return expand(noctalia.getConfig("vault_path") or "")
+end
+
+local function vaultName()
+ local n = trim(noctalia.getConfig("vault_name"))
+ if n ~= "" then
+ return n
+ end
+ local root = vaultRoot()
+ return root:match("([^/]+)$") or "vault"
+end
+
+local function dailyFolder()
+ return trim(noctalia.getConfig("daily_folder"))
+end
+
+local function dailyFormat()
+ local f = trim(noctalia.getConfig("daily_format"))
+ if f == "" then
+ return "%Y-%m-%d"
+ end
+ return f
+end
+
+local function isVault(path)
+ if path == "" or not noctalia.fileExists(path) then
+ return false
+ end
+ return noctalia.fileExists(path .. "/.obsidian")
+end
+
+local function joinPath(a, b)
+ a = tostring(a or ""):gsub("/+$", "")
+ b = tostring(b or ""):gsub("^/+", "")
+ if a == "" then return b end
+ if b == "" then return a end
+ return a .. "/" .. b
+end
+
+-- Strict relative path: no absolute paths, no ".." / ".", no backslashes/NUL.
+-- Returns cleaned relative path, or nil if rejected.
+local function normalizeRelPath(rel)
+ rel = trim(rel):gsub("\\", "/")
+ if rel == "" then
+ return ""
+ end
+ if rel:find("\0", 1, true) then
+ return nil
+ end
+ -- Absolute (Unix or Windows drive)
+ if rel:sub(1, 1) == "/" or rel:match("^%a:[/\\]") then
+ return nil
+ end
+ -- Collapse slashes and reject . / .. segments
+ local parts = {}
+ for seg in (rel .. "/"):gmatch("([^/]*)/") do
+ if seg == "" then
+ -- skip leading/duplicate slashes
+ elseif seg == "." or seg == ".." then
+ return nil
+ else
+ table.insert(parts, seg)
+ end
+ end
+ return table.concat(parts, "/")
+end
+-- Join vault root + relative path; require result stays under root (string check).
+-- Returns absPath, cleanRel or nil, errKey
+local function pathUnderVault(root, rel)
+ root = expand(root):gsub("/+$", "")
+ if root == "" then
+ return nil, nil, "empty vault"
+ end
+ local clean = normalizeRelPath(rel)
+ if clean == nil then
+ return nil, nil, "invalid path"
+ end
+ local abs = clean == "" and root or (root .. "/" .. clean)
+ if abs == root or abs:sub(1, #root + 1) == root .. "/" then
+ return abs, clean, nil
+ end
+ return nil, nil, "path outside vault"
+end
+
+-- Async: reject symlink path components and require realpath(target) under realpath(vault).
+-- onDone(ok, errMsg). Uses realpath(1) + test -L (same pattern as other community plugins).
+local function assertSafeVaultTarget(root, abs, rel, onDone)
+ root = expand(root):gsub("/+$", "")
+ abs = expand(abs)
+ rel = normalizeRelPath(rel or "")
+ if type(onDone) ~= "function" then
+ return
+ end
+ if root == "" or abs == "" or rel == nil then
+ onDone(false, "invalid path")
+ return
+ end
+ -- Shell: canonicalize vault; walk each segment of rel and refuse -L; realpath -m must stay under vault.
+ local cmd = "V=$(realpath -e "
+ .. shellQuote(root)
+ .. " 2>/dev/null) || exit 1; "
+ .. "A="
+ .. shellQuote(abs)
+ .. "; "
+ .. "R="
+ .. shellQuote(rel)
+ .. "; "
+ .. "cur="
+ .. shellQuote(root)
+ .. "; "
+ .. 'IFS=/; set -f; for s in $R; do '
+ .. '[ -n "$s" ] || continue; '
+ .. 'next="$cur/$s"; '
+ .. 'if [ -L "$next" ]; then exit 2; fi; '
+ .. 'cur="$next"; '
+ .. "done; set +f; "
+ .. 'T=$(realpath -m "$A" 2>/dev/null) || exit 1; '
+ .. 'case "$T" in "$V"|"$V"/*) exit 0 ;; *) exit 3 ;; esac'
+
+ local started = noctalia.runAsync(cmd, function(result)
+ if result and result.exitCode == 0 and not result.timedOut then
+ onDone(true, nil)
+ elseif result and result.exitCode == 2 then
+ onDone(false, "symlink in path")
+ elseif result and result.exitCode == 3 then
+ onDone(false, "path outside vault")
+ else
+ onDone(false, "path check failed")
+ end
+ end, 4000)
+ if not started then
+ onDone(false, "path check failed")
+ end
+end
+
+-- Build today's daily note relative + absolute path with strict validation.
+-- daily_format must expand to a single path segment (no / or ..).
+-- Returns rel, abs, errMessageOrNil
+local function dailyRelAndAbs()
+ local fmt = dailyFormat()
+ if fmt:find("[/\\]") or fmt:find("%.%.") then
+ return nil, nil, "invalid daily_format"
+ end
+ local stem = noctalia.formatTime(fmt, nowSec())
+ if type(stem) ~= "string" or trim(stem) == "" then
+ return nil, nil, "invalid daily name"
+ end
+ stem = trim(stem)
+ if stem:find("[/\\]") or stem:find("\0", 1, true) or stem == "." or stem == ".." then
+ return nil, nil, "invalid daily name"
+ end
+ -- Single path segment only (format must not introduce directories)
+ if stem:find("/") then
+ return nil, nil, "invalid daily name"
+ end
+ local name = stem
+ if not name:match("%.[Mm][Dd]$") then
+ name = name .. ".md"
+ end
+
+ local folder = dailyFolder()
+ local rel
+ if folder == "" then
+ rel = name
+ else
+ local folderClean = normalizeRelPath(folder)
+ if folderClean == nil then
+ return nil, nil, "invalid daily_folder"
+ end
+ if folderClean == "" then
+ rel = name
+ else
+ rel = folderClean .. "/" .. name
+ end
+ end
+
+ local abs, clean, err = pathUnderVault(vaultRoot(), rel)
+ if not abs then
+ return nil, nil, err or "invalid path"
+ end
+ return clean, abs, nil
+end
+
+-- Back-compat helpers used only when path is known-good; prefer dailyRelAndAbs.
+local function dailyRelPath()
+ local rel = dailyRelAndAbs()
+ return rel or ""
+end
+
+local function dailyAbsPath()
+ local _, abs = dailyRelAndAbs()
+ return abs or ""
+end
+
+local function ensureParentDir(path)
+ local parent = path:match("(.+)/[^/]+$")
+ if parent and parent ~= "" and not noctalia.fileExists(parent) then
+ -- Only create parents that still lie under the vault root (string + prior symlink check).
+ local root = vaultRoot():gsub("/+$", "")
+ if root ~= "" and (parent == root or parent:sub(1, #root + 1) == root .. "/") then
+ noctalia.mkdirAll(parent)
+ end
+ end
+end
+
+local function updateRevision(sig)
+ if sig ~= dataSignature then
+ dataSignature = sig
+ snapshot.revision += 1
+ end
+end
+
+local function publishSnapshot()
+ snapshot.busy = actionBusy
+ noctalia.state.set(STATE_KEY, snapshot)
+end
+
+local function actionResult(command, ok, message, extra)
+ local r = {
+ requestId = command and command.requestId or "",
+ action = command and command.action or "",
+ ok = ok,
+ message = message or "",
+ }
+ if type(extra) == "table" then
+ for k, v in pairs(extra) do
+ r[k] = v
+ end
+ end
+ noctalia.state.set(RESULT_KEY, r)
+end
+
+local function notifyOk(msg)
+ if shouldNotify() then
+ noctalia.notify(noctalia.tr("title"), msg)
+ end
+end
+
+local function notifyErr(msg)
+ noctalia.notifyError(noctalia.tr("title"), msg)
+end
+
+local function runGit(args, callback, timeoutMs)
+ local root = vaultRoot()
+ local cmd = "cd " .. shellQuote(root) .. " && " .. shellCommand(args)
+ return noctalia.runAsync(cmd, callback, timeoutMs or 60000)
+end
+
+-- Detect stuck rebase/merge so we never leave the vault detached.
+local function gitInProgressKind()
+ local root = vaultRoot()
+ if root == "" then
+ return ""
+ end
+ if noctalia.fileExists(root .. "/.git/rebase-merge")
+ or noctalia.fileExists(root .. "/.git/rebase-apply")
+ then
+ return "rebase"
+ end
+ if noctalia.fileExists(root .. "/.git/MERGE_HEAD") then
+ return "merge"
+ end
+ -- worktree / .git file case: still check common paths via git
+ return ""
+end
+
+local function refuseIfGitBusy(command)
+ local kind = gitInProgressKind()
+ if kind == "" then
+ -- also check snapshot if refresh already saw it
+ kind = (snapshot.git and snapshot.git.inProgress) or ""
+ end
+ if kind ~= "" then
+ actionResult(
+ command,
+ false,
+ noctalia.tr("result.git_in_progress", { kind = kind })
+ )
+ notifyErr(noctalia.tr("result.git_in_progress", { kind = kind }))
+ return true
+ end
+ return false
+end
+
+local function urlEncode(s)
+ return noctalia.string.urlEncode(tostring(s or ""))
+end
+
+local function openUri(uri)
+ noctalia.runAsync("xdg-open " .. shellQuote(uri))
+end
+
+local function openNoteRel(rel)
+ local vault = vaultName()
+ -- file param is vault-relative path without leading slash (already confined)
+ rel = tostring(rel or ""):gsub("^/+", "")
+ local uri = "obsidian://open?vault=" .. urlEncode(vault) .. "&file=" .. urlEncode(rel)
+ openUri(uri)
+end
+
+-- Ensure daily file exists under a validated vault path (string + realpath/symlink checks).
+-- onReady(rel, abs) when safe and ready; actionResult already set on failure.
+local function ensureDailyFile(command, onReady)
+ local root = vaultRoot()
+ if not isVault(root) then
+ actionResult(command, false, noctalia.tr("result.missing_vault"))
+ return
+ end
+ local rel, abs, err = dailyRelAndAbs()
+ if not abs then
+ actionResult(command, false, noctalia.tr("result.invalid_daily_path", { error = err or "invalid" }))
+ return
+ end
+ assertSafeVaultTarget(root, abs, rel, function(ok, safeErr)
+ if not ok then
+ actionResult(command, false, noctalia.tr("result.invalid_daily_path", { error = safeErr or "unsafe" }))
+ return
+ end
+ if not noctalia.fileExists(abs) then
+ ensureParentDir(abs)
+ local title = noctalia.formatTime(dailyFormat(), nowSec())
+ title = tostring(title or ""):gsub("[/\\]", "-")
+ local wOk, werr = noctalia.writeFile(abs, "# " .. title .. "\n")
+ if not wOk then
+ actionResult(command, false, noctalia.tr("result.failed", { error = werr or "write failed" }))
+ return
+ end
+ end
+ if type(onReady) == "function" then
+ onReady(rel, abs)
+ end
+ end)
+end
+local function openVault()
+ local vault = vaultName()
+ openUri("obsidian://open?vault=" .. urlEncode(vault))
+end
+
+local function readPreview(path, maxBytes)
+ local raw = noctalia.readFile(path)
+ if not raw then
+ return ""
+ end
+ raw = tostring(raw)
+ if #raw > (maxBytes or 400) then
+ raw = raw:sub(1, maxBytes or 400) .. "…"
+ end
+ return raw
+end
+
+local function parseGitStatus(stdout)
+ local files = {}
+ local dirty = 0
+ for line in (tostring(stdout or "") .. "\n"):gmatch("(.-)\n") do
+ if line ~= "" then
+ dirty += 1
+ if #files < MAX_GIT_FILES then
+ local xy = line:sub(1, 2)
+ local path = trim(line:sub(4))
+ -- rename: "R old -> new"
+ path = path:gsub("^.*%->%s*", "")
+ table.insert(files, {
+ id = path,
+ path = path,
+ code = xy,
+ })
+ end
+ end
+ end
+ return dirty, files
+end
+
+local function parseBranchLine(stdout)
+ -- ## main...origin/main [ahead 1, behind 2]
+ local line = tostring(stdout or ""):match("([^\n]+)") or ""
+ local branch = line:match("^##%s+([^%.%s]+)") or line:match("^##%s+(%S+)") or ""
+ branch = branch:gsub("%.%.%..*$", "")
+ local ahead = tonumber(line:match("ahead%s+(%d+)")) or 0
+ local behind = tonumber(line:match("behind%s+(%d+)")) or 0
+ return branch, ahead, behind, line
+end
+
+local function parseRecent(stdout)
+ local recent = {}
+ for line in (tostring(stdout or "") .. "\n"):gmatch("(.-)\n") do
+ local ts, rel = line:match("^([%d%.]+)\t(.+)$")
+ if rel and rel ~= "" then
+ -- Drop any path find might surface that fails strict relative rules
+ -- (absolute, .., NUL, etc.). open_note also re-checks + assertSafeVaultTarget.
+ local clean = normalizeRelPath(rel)
+ if clean ~= nil and clean ~= "" then
+ local name = clean:match("([^/]+)$") or clean
+ table.insert(recent, {
+ id = clean,
+ rel = clean,
+ name = name:gsub("%.md$", ""),
+ mtime = math.floor(tonumber(ts) or 0),
+ })
+ end
+ end
+ end
+ return recent
+end
+
+local refreshAll
+
+local function finishAction(command, ok, message)
+ actionBusy = false
+ actionResult(command, ok, message)
+ if ok then
+ notifyOk(message)
+ else
+ notifyErr(message)
+ end
+ publishSnapshot()
+ refreshAll()
+end
+
+refreshAll = function()
+ if refreshPending then
+ refreshAgain = true
+ return
+ end
+ refreshPending = true
+ refreshAgain = false
+ refreshGeneration += 1
+ local generation = refreshGeneration
+
+ local root = vaultRoot()
+ snapshot.vaultPath = root
+ snapshot.vaultName = vaultName()
+
+ local dailyRel, dailyAbs, dailyErr = dailyRelAndAbs()
+ snapshot.dailyRel = dailyRel or ""
+ snapshot.dailyPath = dailyAbs or ""
+
+ if not isVault(root) then
+ snapshot.available = false
+ snapshot.loading = false
+ snapshot.error = noctalia.tr("result.missing_vault")
+ snapshot.recent = {}
+ snapshot.dailyExists = false
+ snapshot.dailyPreview = ""
+ snapshot.git = {
+ isRepo = false,
+ branch = "",
+ dirty = 0,
+ ahead = 0,
+ behind = 0,
+ clean = true,
+ files = {},
+ statusLine = "",
+ inProgress = "",
+ }
+ refreshPending = false
+ updateRevision("no-vault")
+ publishSnapshot()
+ return
+ end
+
+ snapshot.available = true
+ if dailyErr then
+ snapshot.error = noctalia.tr("result.invalid_daily_path", { error = dailyErr })
+ else
+ snapshot.error = ""
+ end
+ if (snapshot.updatedAt or 0) == 0 then
+ snapshot.loading = true
+ publishSnapshot()
+ end
+
+ local pending = 3
+ local bag = { recent = {}, gitPorcelain = "", gitBranch = "" }
+ local finished = false
+
+ local function finish()
+ if generation ~= refreshGeneration then
+ return
+ end
+ pending -= 1
+ if pending > 0 or finished then
+ return
+ end
+ finished = true
+
+ local function applySnapshot(dailyExists, dailyPreview)
+ local okP, errP = pcall(function()
+ snapshot.dailyExists = dailyExists == true
+ snapshot.dailyPreview = dailyPreview or ""
+ snapshot.recent = parseRecent(bag.recent)
+ local dirty, files = parseGitStatus(bag.gitPorcelain)
+ local branch, ahead, behind, statusLine = parseBranchLine(bag.gitBranch)
+ local isRepo = not bag.gitFailed and (branch ~= "" or statusLine:match("^##") ~= nil)
+
+ snapshot.git = {
+ isRepo = isRepo,
+ branch = branch,
+ dirty = dirty,
+ ahead = ahead,
+ behind = behind,
+ clean = dirty == 0,
+ files = files,
+ statusLine = statusLine,
+ inProgress = gitInProgressKind(),
+ }
+ snapshot.loading = false
+ snapshot.updatedAt = nowSec()
+ refreshPending = false
+ updateRevision(table.concat({
+ snapshot.dailyRel,
+ tostring(snapshot.dailyExists),
+ tostring(#snapshot.recent),
+ branch,
+ tostring(dirty),
+ tostring(ahead),
+ tostring(behind),
+ }, "|"))
+ publishSnapshot()
+ end)
+
+ if not okP then
+ noctalia.log(`obsidian: apply failed: {tostring(errP)}`)
+ snapshot.loading = false
+ snapshot.error = tostring(errP)
+ refreshPending = false
+ publishSnapshot()
+ end
+
+ if refreshAgain then
+ refreshAgain = false
+ refreshAll()
+ end
+ end
+
+ -- Only read daily preview after symlink/realpath confinement passes
+ if snapshot.dailyPath ~= "" and snapshot.dailyRel ~= "" and noctalia.fileExists(snapshot.dailyPath) then
+ assertSafeVaultTarget(root, snapshot.dailyPath, snapshot.dailyRel, function(ok, _err)
+ if generation ~= refreshGeneration then
+ return
+ end
+ if ok then
+ applySnapshot(true, readPreview(snapshot.dailyPath, 500))
+ else
+ -- Exists but unsafe (symlink escape) — do not read through it
+ applySnapshot(false, "")
+ end
+ end)
+ else
+ applySnapshot(false, "")
+ end
+ end
+
+ -- recent files: -P never follows symlinks (GNU default, explicit for review/portability).
+ -- -type f excludes symlink notes; symlink dirs are not descended into.
+ local recentCmd = "find -P "
+ .. shellQuote(root)
+ .. " -type f -name '*.md'"
+ .. " ! -path '*/.obsidian/*' ! -path '*/.git/*' ! -path '*/.claudian/*'"
+ .. " -printf '%T@\\t%P\\n' 2>/dev/null | sort -nr | head -n "
+ .. tostring(MAX_RECENT)
+ noctalia.runAsync(recentCmd, function(result)
+ if generation ~= refreshGeneration then return end
+ bag.recent = (result and result.stdout) or ""
+ finish()
+ end, 20000)
+
+ -- git porcelain
+ runGit({ "git", "status", "--porcelain" }, function(result)
+ if generation ~= refreshGeneration then return end
+ if result and result.exitCode == 0 then
+ bag.gitPorcelain = result.stdout or ""
+ else
+ bag.gitPorcelain = ""
+ bag.gitFailed = true
+ end
+ finish()
+ end, 15000)
+
+ -- git branch / ahead behind
+ runGit({ "git", "status", "-sb" }, function(result)
+ if generation ~= refreshGeneration then return end
+ if result and result.exitCode == 0 then
+ bag.gitBranch = result.stdout or ""
+ else
+ bag.gitBranch = ""
+ bag.gitFailed = true
+ end
+ finish()
+ end, 15000)
+end
+
+local function appendDaily(command)
+ local text = trim(command.text or command.line or "")
+ if text == "" then
+ actionResult(command, false, noctalia.tr("result.failed", { error = "empty note" }))
+ return
+ end
+ local root = vaultRoot()
+ if not isVault(root) then
+ actionResult(command, false, noctalia.tr("result.missing_vault"))
+ return
+ end
+
+ local rel, path, pathErr = dailyRelAndAbs()
+ if not path then
+ actionResult(command, false, noctalia.tr("result.invalid_daily_path", { error = pathErr or "invalid" }))
+ return
+ end
+
+ assertSafeVaultTarget(root, path, rel, function(ok, safeErr)
+ if not ok then
+ actionResult(command, false, noctalia.tr("result.invalid_daily_path", { error = safeErr or "unsafe" }))
+ return
+ end
+
+ ensureParentDir(path)
+
+ local ts = noctalia.formatTime("%H:%M", nowSec())
+ local line = "- " .. ts .. " " .. text
+ line = line:gsub("[\r\n]+", " ")
+
+ local existing = ""
+ if noctalia.fileExists(path) then
+ existing = noctalia.readFile(path) or ""
+ else
+ local title = tostring(noctalia.formatTime(dailyFormat(), nowSec()) or ""):gsub("[/\\]", "-")
+ existing = "# " .. title .. "\n"
+ end
+
+ if existing ~= "" and not existing:match("\n$") then
+ existing = existing .. "\n"
+ end
+ local newBody = existing .. line .. "\n"
+ local wOk, err = noctalia.writeFile(path, newBody)
+ if not wOk then
+ finishAction(command, false, noctalia.tr("result.failed", { error = err or "write failed" }))
+ return
+ end
+
+ snapshot.dailyExists = true
+ snapshot.dailyPath = path
+ snapshot.dailyRel = rel or ""
+ finishAction(command, true, noctalia.tr("result.captured", { name = snapshot.dailyRel }))
+ end)
+end
+local function gitCommit(command)
+ if actionBusy then
+ actionResult(command, false, noctalia.tr("result.busy"))
+ return
+ end
+ if refuseIfGitBusy(command) then
+ return
+ end
+ local msg = trim(command.message or command.text)
+ if msg == "" then
+ msg = trim(noctalia.getConfig("git_commit_message"))
+ end
+ if msg == "" then
+ msg = "vault: capture from Noctalia"
+ end
+
+ actionBusy = true
+ publishSnapshot()
+
+ -- add all + commit only if there is something to commit
+ runGit({ "git", "status", "--porcelain" }, function(st)
+ if not st or st.exitCode ~= 0 then
+ finishAction(command, false, noctalia.tr("result.failed", { error = "git status failed" }))
+ return
+ end
+ if trim(st.stdout) == "" then
+ actionBusy = false
+ actionResult(command, true, noctalia.tr("result.git_nothing"))
+ notifyOk(noctalia.tr("result.git_nothing"))
+ publishSnapshot()
+ refreshAll()
+ return
+ end
+ local n = 0
+ for _ in (st.stdout .. "\n"):gmatch("(.-)\n") do
+ if _ ~= "" then n += 1 end
+ end
+
+ runGit({ "git", "add", "-A" }, function(addRes)
+ if not addRes or addRes.exitCode ~= 0 then
+ local err = trim(addRes and (addRes.stderr ~= "" and addRes.stderr or addRes.stdout) or "git add failed")
+ finishAction(command, false, noctalia.tr("result.failed", { error = err }))
+ return
+ end
+ runGit({ "git", "commit", "-m", msg }, function(cRes)
+ if cRes and cRes.exitCode == 0 then
+ finishAction(command, true, noctalia.tr("result.git_committed", { n = n }))
+ else
+ local err = trim(cRes and (cRes.stderr ~= "" and cRes.stderr or cRes.stdout) or "commit failed")
+ finishAction(command, false, noctalia.tr("result.failed", { error = err }))
+ end
+ end, 60000)
+ end, 60000)
+ end, 15000)
+end
+
+-- Merge pull (no rebase): never leaves the vault detached mid-rebase.
+-- Autostash keeps uncommitted capture edits safe across the pull.
+local PULL_ARGS = { "git", "pull", "--no-rebase", "--autostash" }
+
+local function gitPull(command)
+ if actionBusy then
+ actionResult(command, false, noctalia.tr("result.busy"))
+ return
+ end
+ if refuseIfGitBusy(command) then
+ return
+ end
+ actionBusy = true
+ publishSnapshot()
+ runGit(PULL_ARGS, function(result)
+ if result and result.exitCode == 0 then
+ finishAction(command, true, noctalia.tr("result.git_pulled"))
+ else
+ local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "pull failed")
+ finishAction(command, false, noctalia.tr("result.failed", { error = err }))
+ end
+ end, 120000)
+end
+
+local function gitPush(command)
+ if actionBusy then
+ actionResult(command, false, noctalia.tr("result.busy"))
+ return
+ end
+ if refuseIfGitBusy(command) then
+ return
+ end
+ actionBusy = true
+ publishSnapshot()
+ runGit({ "git", "push" }, function(result)
+ if result and result.exitCode == 0 then
+ finishAction(command, true, noctalia.tr("result.git_pushed"))
+ else
+ local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "push failed")
+ finishAction(command, false, noctalia.tr("result.failed", { error = err }))
+ end
+ end, 120000)
+end
+
+local function gitSync(command)
+ if actionBusy then
+ actionResult(command, false, noctalia.tr("result.busy"))
+ return
+ end
+ if refuseIfGitBusy(command) then
+ return
+ end
+ actionBusy = true
+ publishSnapshot()
+ runGit(PULL_ARGS, function(pullRes)
+ if not pullRes or pullRes.exitCode ~= 0 then
+ local err = trim(pullRes and (pullRes.stderr ~= "" and pullRes.stderr or pullRes.stdout) or "pull failed")
+ finishAction(command, false, noctalia.tr("result.failed", { error = err }))
+ return
+ end
+ runGit({ "git", "push" }, function(pushRes)
+ if pushRes and pushRes.exitCode == 0 then
+ finishAction(command, true, noctalia.tr("result.git_synced"))
+ else
+ local err = trim(pushRes and (pushRes.stderr ~= "" and pushRes.stderr or pushRes.stdout) or "push failed")
+ finishAction(command, false, noctalia.tr("result.failed", { error = err }))
+ end
+ end, 120000)
+ end, 120000)
+end
+
+local function gitAbort(command)
+ if actionBusy then
+ actionResult(command, false, noctalia.tr("result.busy"))
+ return
+ end
+ local kind = gitInProgressKind()
+ if kind == "" then
+ actionResult(command, false, noctalia.tr("result.git_not_in_progress"))
+ return
+ end
+ actionBusy = true
+ publishSnapshot()
+ local args
+ if kind == "rebase" then
+ args = { "git", "rebase", "--abort" }
+ else
+ args = { "git", "merge", "--abort" }
+ end
+ runGit(args, function(result)
+ if result and result.exitCode == 0 then
+ finishAction(command, true, noctalia.tr("result.git_aborted", { kind = kind }))
+ else
+ local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "abort failed")
+ finishAction(command, false, noctalia.tr("result.failed", { error = err }))
+ end
+ end, 30000)
+end
+
+local function executeAction(command)
+ if type(command) ~= "table" or type(command.action) ~= "string" then
+ return
+ end
+ local action = command.action
+
+ if action == "refresh" then
+ refreshAll()
+ return
+ end
+ if action == "open_vault" then
+ openVault()
+ actionResult(command, true, noctalia.tr("result.opened"))
+ return
+ end
+ if action == "open_daily" then
+ ensureDailyFile(command, function(rel, _abs)
+ openNoteRel(rel)
+ actionResult(command, true, noctalia.tr("result.opened"))
+ refreshAll()
+ end)
+ return
+ end
+ if action == "open_note" then
+ local rel = trim(command.rel or command.path or command.id)
+ if rel == "" then
+ actionResult(command, false, noctalia.tr("result.failed", { error = "missing note" }))
+ return
+ end
+ local root = vaultRoot()
+ if not isVault(root) then
+ actionResult(command, false, noctalia.tr("result.missing_vault"))
+ return
+ end
+ local abs, clean, err = pathUnderVault(root, rel)
+ if not abs then
+ actionResult(command, false, noctalia.tr("result.invalid_note_path", { error = err or "invalid" }))
+ return
+ end
+ -- URI open only (no filesystem write), still refuse symlink escape for consistency
+ assertSafeVaultTarget(root, abs, clean, function(ok, safeErr)
+ if not ok then
+ actionResult(command, false, noctalia.tr("result.invalid_note_path", { error = safeErr or "unsafe" }))
+ return
+ end
+ openNoteRel(clean)
+ actionResult(command, true, noctalia.tr("result.opened"))
+ end)
+ return
+ end
+ if action == "capture" or action == "append_daily" then
+ appendDaily(command)
+ return
+ end
+ if action == "copy" then
+ local text = trim(command.text or command.name)
+ if text ~= "" then
+ noctalia.copyToClipboard(text, "text/plain")
+ actionResult(command, true, noctalia.tr("result.copied", { name = text }))
+ notifyOk(noctalia.tr("result.copied", { name = text }))
+ end
+ return
+ end
+ if action == "git_commit" then
+ gitCommit(command)
+ return
+ end
+ if action == "git_pull" then
+ gitPull(command)
+ return
+ end
+ if action == "git_push" then
+ gitPush(command)
+ return
+ end
+ if action == "git_sync" then
+ gitSync(command)
+ return
+ end
+ if action == "git_abort" then
+ gitAbort(command)
+ return
+ end
+ if action == "git_status" then
+ refreshAll()
+ actionResult(command, true, noctalia.tr("result.success"))
+ return
+ end
+
+ actionResult(command, false, "Unknown action: " .. action)
+end
+
+noctalia.state.watch(COMMAND_KEY, executeAction)
+noctalia.setUpdateInterval(refreshIntervalMs())
+refreshAll()
+
+function update()
+ refreshAll()
+end
+
+function onConfigChanged()
+ noctalia.setUpdateInterval(refreshIntervalMs())
+ refreshPending = false
+ refreshAll()
+end
+
+function onIpc(event, payload)
+ if event == "refresh" then
+ refreshPending = false
+ refreshAll()
+ elseif event == "daily" then
+ executeAction({ action = "open_daily", requestId = "ipc-daily" })
+ elseif event == "capture" and type(payload) == "table" then
+ executeAction({
+ action = "capture",
+ text = payload.text or payload.line or "",
+ requestId = "ipc-capture",
+ })
+ elseif event == "pull" then
+ executeAction({ action = "git_pull", requestId = "ipc-pull" })
+ elseif event == "push" then
+ executeAction({ action = "git_push", requestId = "ipc-push" })
+ elseif event == "sync" then
+ executeAction({ action = "git_sync", requestId = "ipc-sync" })
+ end
+end
diff --git a/obsidian/thumbnail.webp b/obsidian/thumbnail.webp
new file mode 100644
index 0000000..c7309d2
Binary files /dev/null and b/obsidian/thumbnail.webp differ
diff --git a/obsidian/translations/en.json b/obsidian/translations/en.json
new file mode 100644
index 0000000..0f6ff4b
--- /dev/null
+++ b/obsidian/translations/en.json
@@ -0,0 +1,120 @@
+{
+ "title": "Obsidian",
+ "settings": {
+ "vault_path": {
+ "label": "Vault path",
+ "description": "Root folder of the Obsidian vault."
+ },
+ "vault_name": {
+ "label": "Vault name (URI)",
+ "description": "Name used in obsidian:// links. Empty = folder name."
+ },
+ "daily_folder": {
+ "label": "Daily notes folder",
+ "description": "Relative to vault root (empty = vault root)."
+ },
+ "daily_format": {
+ "label": "Daily filename format",
+ "description": "strftime pattern without .md (default %Y-%m-%d)."
+ },
+ "git_commit_message": {
+ "label": "Default commit message",
+ "description": "Used when Commit is clicked with an empty message field."
+ },
+ "refresh_interval": {
+ "label": "Refresh interval (seconds)",
+ "description": "How often to rescan recent notes and git status."
+ },
+ "notify_on_action": {
+ "label": "Notify on actions",
+ "description": "Desktop notification after capture / git actions."
+ },
+ "show_dirty": {
+ "label": "Show dirty count",
+ "description": "Show number of changed git files on the bar."
+ }
+ },
+ "widget": {
+ "tooltip_ok": "{vault} · daily {daily} · git {git}",
+ "tooltip_missing": "Vault path missing or not a vault",
+ "tooltip_nogit": "{vault} · no git repo",
+ "refresh_requested": "Refreshing vault…"
+ },
+ "panel": {
+ "loading": "Scanning vault…",
+ "busy": "Working…",
+ "empty": "Nothing to show.",
+ "select_hint": "Select a note for actions.",
+ "updated": "Updated {time}",
+ "vault": "{name}",
+ "summary": "Daily {daily} · {recent} recent · git {git}",
+ "capture_placeholder": "Quick note (appended to daily with timestamp)…",
+ "commit_placeholder": "Commit message (optional)…",
+ "daily_missing": "Daily note will be created on first capture.",
+ "daily_path": "Daily: {path}",
+ "git_branch": "Branch {branch} · {ahead}↑ {behind}↓ · {dirty} dirty",
+ "git_clean": "Working tree clean",
+ "git_nogit": "Not a git repository",
+ "git_files": "Changed files",
+ "git_in_progress": "Git {kind} in progress — abort before other actions"
+ },
+ "tabs": {
+ "daily": "Daily",
+ "recent": "Recent",
+ "git": "Git"
+ },
+ "filter": {
+ "placeholder": "Filter notes…"
+ },
+ "actions": {
+ "open_vault": "Open vault",
+ "open_daily": "Open daily",
+ "open_note": "Open",
+ "capture": "Add to daily",
+ "refresh": "Refresh",
+ "copy_link": "Copy [[link]]",
+ "copy_path": "Copy path",
+ "git_status": "Status",
+ "git_commit": "Commit",
+ "git_pull": "Pull",
+ "git_push": "Push",
+ "git_sync": "Pull + push",
+ "git_abort": "Abort rebase/merge"
+ },
+ "result": {
+ "success": "Done",
+ "failed": "Failed: {error}",
+ "busy": "Another operation is running.",
+ "missing_vault": "Vault not found. Set vault path in settings.",
+ "invalid_daily_path": "Daily note path is invalid or outside the vault ({error}).",
+ "invalid_note_path": "Note path is invalid or outside the vault ({error}).",
+ "captured": "Appended to {name}",
+ "opened": "Opening…",
+ "copied": "Copied {name}",
+ "git_clean": "Working tree clean",
+ "git_committed": "Committed ({n} files)",
+ "git_nothing": "Nothing to commit",
+ "git_pulled": "Pull finished",
+ "git_pushed": "Push finished",
+ "git_synced": "Pull + push finished",
+ "git_in_progress": "Git {kind} in progress — use Abort first",
+ "git_not_in_progress": "No rebase or merge in progress",
+ "git_aborted": "Aborted {kind}"
+ },
+ "launcher": {
+ "loading": "Loading vault…",
+ "missing": "Vault not configured",
+ "cat": {
+ "daily": "Open daily note",
+ "daily-sub": "Today’s note in Obsidian",
+ "capture": "Capture…",
+ "capture-sub": "Type: /ob capture your text",
+ "git": "Git status",
+ "git-sub": "Dirty / ahead / behind",
+ "panel": "Open panel",
+ "panel-sub": "Full Obsidian manager",
+ "refresh": "Refresh",
+ "refresh-sub": "Rescan vault + git"
+ }
+ }
+}
diff --git a/obsidian/widget.luau b/obsidian/widget.luau
new file mode 100644
index 0000000..9ba0170
--- /dev/null
+++ b/obsidian/widget.luau
@@ -0,0 +1,103 @@
+--!nonstrict
+
+local PANEL_ID = "davemhammer/obsidian:manager"
+local STATE_KEY = "obs_snapshot"
+local COMMAND_KEY = "obs_command"
+
+local snapshot = noctalia.state.get(STATE_KEY) or {
+ available = false,
+ git = { dirty = 0, isRepo = false },
+ vaultName = "",
+ dailyExists = false,
+}
+
+local requestId = 0
+
+local function render()
+ local available = snapshot.available == true
+ local dirty = 0
+ if type(snapshot.git) == "table" then
+ dirty = tonumber(snapshot.git.dirty) or 0
+ end
+ local showDirty = noctalia.getConfig("show_dirty") ~= false
+ local icon = available and "assets/obsidian-purple.png" or "assets/obsidian-grey.png"
+ if available and dirty > 0 then
+ icon = "assets/obsidian-green.png" -- has local changes to sync
+ end
+
+ local children = {
+ ui.image({
+ path = icon,
+ width = 16,
+ height = 16,
+ fit = "contain",
+ }),
+ }
+
+ if showDirty and available and dirty > 0 then
+ table.insert(children, ui.label({
+ text = tostring(dirty),
+ fontWeight = "bold",
+ color = "on_surface",
+ }))
+ end
+
+ if available then
+ table.insert(children, ui.box({
+ width = 7,
+ height = 7,
+ radius = 4,
+ fill = dirty > 0 and "#73c936" or "#7C3AED",
+ }))
+ end
+
+ local container = barWidget.isVertical() and ui.column or ui.row
+ barWidget.render(container({ gap = 5, align = "center" }, children))
+
+ if not available then
+ barWidget.setTooltip(noctalia.tr("widget.tooltip_missing"))
+ else
+ local git = "—"
+ if type(snapshot.git) == "table" then
+ if not snapshot.git.isRepo then
+ git = "no repo"
+ elseif snapshot.git.clean then
+ git = "clean"
+ else
+ git = tostring(dirty) .. " dirty"
+ end
+ end
+ barWidget.setTooltip(noctalia.tr("widget.tooltip_ok", {
+ vault = snapshot.vaultName ~= "" and snapshot.vaultName or "vault",
+ daily = snapshot.dailyExists and "yes" or "new",
+ git = git,
+ }))
+ end
+end
+
+noctalia.state.watch(STATE_KEY, function(value)
+ if type(value) == "table" then
+ snapshot = value
+ render()
+ end
+end)
+
+noctalia.setUpdateInterval(8000)
+render()
+
+function update()
+ render()
+end
+
+function onClick()
+ noctalia.togglePanel(PANEL_ID)
+end
+
+function onRightClick()
+ -- Quick open today's daily note (service rejects missing/invalid vault)
+ if snapshot.available ~= true then
+ return
+ end
+ requestId += 1
+ noctalia.state.set(COMMAND_KEY, { action = "open_daily", requestId = `widget-{requestId}` })
+end