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 @@ +Obsidian \ 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 @@ +Obsidian \ 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 @@ +Obsidian \ 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 @@ +Obsidian \ 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