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:
@@ -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
|
||||
Reference in New Issue
Block a user