Files
community-plugins/obsidian/panel.luau
T
Dave HammerandGitHub 32b7e28c25 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.
2026-08-08 17:48:59 -04:00

569 lines
14 KiB
Luau

--!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