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