Add davemhammer/obsidian (#285)

* Add davemhammer/obsidian

Address review: vault path confinement, open_daily vault check,
declare runtime deps (xdg-open, find, sort, head), version 1.0.3.

* Address review: realpath + symlink confinement for vault paths

Reject symlink path components and require realpath -m under vault
before daily read/mkdir/write. Add realpath dependency; v1.0.4.

* Harden recent-notes scan: find -P + path normalize (v1.0.5)

Explicitly never follow symlinks when listing recent markdown; drop
invalid relative paths before display. Align README Notes and bump
version so PR trust attestation matches code.

* Docs: clarify obsidian URI handler and IPC (v1.0.6)

Align README with code for dependency role, capture IPC, and git abort.
This commit is contained in:
Dave Hammer
2026-08-08 17:48:59 -04:00
committed by GitHub
parent 95595088d9
commit 32b7e28c25
15 changed files with 2183 additions and 0 deletions
+90
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Obsidian</title><path fill="#73c936" d="M19.355 18.538a68.967 68.959 0 0 0 1.858-2.954.81.81 0 0 0-.062-.9c-.516-.685-1.504-2.075-2.042-3.362-.553-1.321-.636-3.375-.64-4.377a1.707 1.707 0 0 0-.358-1.05l-3.198-4.064a3.744 3.744 0 0 1-.076.543c-.106.503-.307 1.004-.536 1.5-.134.29-.29.6-.446.914l-.31.626c-.516 1.068-.997 2.227-1.132 3.59-.124 1.26.046 2.73.815 4.481.128.011.257.025.386.044a6.363 6.363 0 0 1 3.326 1.505c.916.79 1.744 1.922 2.415 3.5zM8.199 22.569c.073.012.146.02.22.02.78.024 2.095.092 3.16.29.87.16 2.593.64 4.01 1.055 1.083.316 2.198-.548 2.355-1.664.114-.814.33-1.735.725-2.58l-.01.005c-.67-1.87-1.522-3.078-2.416-3.849a5.295 5.295 0 0 0-2.778-1.257c-1.54-.216-2.952.19-3.84.45.532 2.218.368 4.829-1.425 7.531zM5.533 9.938c-.023.1-.056.197-.098.29L2.82 16.059a1.602 1.602 0 0 0 .313 1.772l4.116 4.24c2.103-3.101 1.796-6.02.836-8.3-.728-1.73-1.832-3.081-2.55-3.831zM9.32 14.01c.615-.183 1.606-.465 2.745-.534-.683-1.725-.848-3.233-.716-4.577.154-1.552.7-2.847 1.235-3.95.113-.235.223-.454.328-.664.149-.297.288-.577.419-.86.217-.47.379-.885.46-1.27.08-.38.08-.72-.014-1.043-.095-.325-.297-.675-.68-1.06a1.6 1.6 0 0 0-1.475.36l-4.95 4.452a1.602 1.602 0 0 0-.513.952l-.427 2.83c.672.59 2.328 2.316 3.335 4.711.09.21.175.43.253.653z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Obsidian</title><path fill="#8a8a8a" d="M19.355 18.538a68.967 68.959 0 0 0 1.858-2.954.81.81 0 0 0-.062-.9c-.516-.685-1.504-2.075-2.042-3.362-.553-1.321-.636-3.375-.64-4.377a1.707 1.707 0 0 0-.358-1.05l-3.198-4.064a3.744 3.744 0 0 1-.076.543c-.106.503-.307 1.004-.536 1.5-.134.29-.29.6-.446.914l-.31.626c-.516 1.068-.997 2.227-1.132 3.59-.124 1.26.046 2.73.815 4.481.128.011.257.025.386.044a6.363 6.363 0 0 1 3.326 1.505c.916.79 1.744 1.922 2.415 3.5zM8.199 22.569c.073.012.146.02.22.02.78.024 2.095.092 3.16.29.87.16 2.593.64 4.01 1.055 1.083.316 2.198-.548 2.355-1.664.114-.814.33-1.735.725-2.58l-.01.005c-.67-1.87-1.522-3.078-2.416-3.849a5.295 5.295 0 0 0-2.778-1.257c-1.54-.216-2.952.19-3.84.45.532 2.218.368 4.829-1.425 7.531zM5.533 9.938c-.023.1-.056.197-.098.29L2.82 16.059a1.602 1.602 0 0 0 .313 1.772l4.116 4.24c2.103-3.101 1.796-6.02.836-8.3-.728-1.73-1.832-3.081-2.55-3.831zM9.32 14.01c.615-.183 1.606-.465 2.745-.534-.683-1.725-.848-3.233-.716-4.577.154-1.552.7-2.847 1.235-3.95.113-.235.223-.454.328-.664.149-.297.288-.577.419-.86.217-.47.379-.885.46-1.27.08-.38.08-.72-.014-1.043-.095-.325-.297-.675-.68-1.06a1.6 1.6 0 0 0-1.475.36l-4.95 4.452a1.602 1.602 0 0 0-.513.952l-.427 2.83c.672.59 2.328 2.316 3.335 4.711.09.21.175.43.253.653z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Obsidian</title><path fill="#7C3AED" d="M19.355 18.538a68.967 68.959 0 0 0 1.858-2.954.81.81 0 0 0-.062-.9c-.516-.685-1.504-2.075-2.042-3.362-.553-1.321-.636-3.375-.64-4.377a1.707 1.707 0 0 0-.358-1.05l-3.198-4.064a3.744 3.744 0 0 1-.076.543c-.106.503-.307 1.004-.536 1.5-.134.29-.29.6-.446.914l-.31.626c-.516 1.068-.997 2.227-1.132 3.59-.124 1.26.046 2.73.815 4.481.128.011.257.025.386.044a6.363 6.363 0 0 1 3.326 1.505c.916.79 1.744 1.922 2.415 3.5zM8.199 22.569c.073.012.146.02.22.02.78.024 2.095.092 3.16.29.87.16 2.593.64 4.01 1.055 1.083.316 2.198-.548 2.355-1.664.114-.814.33-1.735.725-2.58l-.01.005c-.67-1.87-1.522-3.078-2.416-3.849a5.295 5.295 0 0 0-2.778-1.257c-1.54-.216-2.952.19-3.84.45.532 2.218.368 4.829-1.425 7.531zM5.533 9.938c-.023.1-.056.197-.098.29L2.82 16.059a1.602 1.602 0 0 0 .313 1.772l4.116 4.24c2.103-3.101 1.796-6.02.836-8.3-.728-1.73-1.832-3.081-2.55-3.831zM9.32 14.01c.615-.183 1.606-.465 2.745-.534-.683-1.725-.848-3.233-.716-4.577.154-1.552.7-2.847 1.235-3.95.113-.235.223-.454.328-.664.149-.297.288-.577.419-.86.217-.47.379-.885.46-1.27.08-.38.08-.72-.014-1.043-.095-.325-.297-.675-.68-1.06a1.6 1.6 0 0 0-1.475.36l-4.95 4.452a1.602 1.602 0 0 0-.513.952l-.427 2.83c.672.59 2.328 2.316 3.335 4.711.09.21.175.43.253.653z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Obsidian</title><path d="M19.355 18.538a68.967 68.959 0 0 0 1.858-2.954.81.81 0 0 0-.062-.9c-.516-.685-1.504-2.075-2.042-3.362-.553-1.321-.636-3.375-.64-4.377a1.707 1.707 0 0 0-.358-1.05l-3.198-4.064a3.744 3.744 0 0 1-.076.543c-.106.503-.307 1.004-.536 1.5-.134.29-.29.6-.446.914l-.31.626c-.516 1.068-.997 2.227-1.132 3.59-.124 1.26.046 2.73.815 4.481.128.011.257.025.386.044a6.363 6.363 0 0 1 3.326 1.505c.916.79 1.744 1.922 2.415 3.5zM8.199 22.569c.073.012.146.02.22.02.78.024 2.095.092 3.16.29.87.16 2.593.64 4.01 1.055 1.083.316 2.198-.548 2.355-1.664.114-.814.33-1.735.725-2.58l-.01.005c-.67-1.87-1.522-3.078-2.416-3.849a5.295 5.295 0 0 0-2.778-1.257c-1.54-.216-2.952.19-3.84.45.532 2.218.368 4.829-1.425 7.531zM5.533 9.938c-.023.1-.056.197-.098.29L2.82 16.059a1.602 1.602 0 0 0 .313 1.772l4.116 4.24c2.103-3.101 1.796-6.02.836-8.3-.728-1.73-1.832-3.081-2.55-3.831zM9.32 14.01c.615-.183 1.606-.465 2.745-.534-.683-1.725-.848-3.233-.716-4.577.154-1.552.7-2.847 1.235-3.95.113-.235.223-.454.328-.664.149-.297.288-.577.419-.86.217-.47.379-.885.46-1.27.08-.38.08-.72-.014-1.043-.095-.325-.297-.675-.68-1.06a1.6 1.6 0 0 0-1.475.36l-4.95 4.452a1.602 1.602 0 0 0-.513.952l-.427 2.83c.672.59 2.328 2.316 3.335 4.711.09.21.175.43.253.653z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+191
View File
@@ -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
+568
View File
@@ -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
+97
View File
@@ -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
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+120
View File
@@ -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"
}
}
}
+103
View File
@@ -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