Files
community-plugins/bookmarks/bk_provider.luau
T
mguandGitHub 87624091e4 update bookmarks to 1.3.1 (#238)
* fix+refactor: translation strings refactored & fixed a ui bug affecting scrolling

- Fixed a bug where hovering over an item in populated lists would
trigger auto-scrolling.
- Fixed scrollbar rendering incorrectly.
- Refactored translation strings (converted to nested keys when
applicable)

* docs: formatting fixes + 1.3.1 changelogs

* chore: bump the version
2026-08-05 17:36:08 -04:00

293 lines
7.6 KiB
Luau

-- Launcher provider: "/bk" fuzzy-searches bookmark labels (root + one level
-- of folders, bookmarks only - same scope as the panel's own search) and
-- runs the selected one on activation. Reads the same data.json the panel
-- writes, resolved the same way (configured data_path, else the plugin's
-- data dir), so no state is shared beyond the file on disk.
local function resolveDataPath()
local configured = noctalia.getConfig("data_path")
if type(configured) == "string" and noctalia.string.trim(configured) ~= "" then
return noctalia.expandPath(noctalia.string.trim(configured)), nil
end
local dataDir, dataDirErr = noctalia.pluginDataDir()
if dataDir == nil then
return nil, dataDirErr
end
return dataDir .. "/data.json", nil
end
local function entryType(entry)
return entry.type == "folder" and "folder" or "bookmark"
end
-- Reads and decodes data.json fresh on every query. The panel is the only
-- writer and bookmark lists are small, so there's no need to cache across
-- queries here.
local function loadBookmarks()
local path, pathErr = resolveDataPath()
if path == nil then
noctalia.log("bookmarks-provider: could not resolve data path: " .. tostring(pathErr))
return {}
end
if not noctalia.fileExists(path) then
return {}
end
local raw, readErr = noctalia.readFile(path)
if raw == nil then
noctalia.log("bookmarks-provider: failed to read data file: " .. tostring(readErr))
return {}
end
local decoded, decodeErr = noctalia.json.decode(raw)
if type(decoded) ~= "table" then
noctalia.log("bookmarks-provider: data file is corrupt: " .. tostring(decodeErr))
return {}
end
return decoded
end
-- Flat list of { entry, path } across root and one level of folders,
-- bookmarks only (folders themselves aren't launchable results).
local function flatten(bookmarks)
local flat = {}
for _, entry in ipairs(bookmarks) do
if entryType(entry) == "folder" then
for _, subEntry in ipairs(entry.items or {}) do
if entryType(subEntry) ~= "folder" then
table.insert(flat, { entry = subEntry, path = entry.label or "" })
end
end
else
table.insert(flat, { entry = entry, path = nil })
end
end
return flat
end
-- Bookmarks are indexed by a stable synthetic id (path-qualified label),
-- rather than by array position, since onActivate only receives the id
-- string and the list is re-read from disk on every query.
local function resultId(item)
return (item.path or "") .. "\30" .. (item.entry.label or "")
end
local function buildResults(query)
local bookmarks = loadBookmarks()
local flat = flatten(bookmarks)
local results = {}
for _, item in ipairs(flat) do
local score
if query == "" then
score = 0
else
score = noctalia.fuzzyScore(query, item.entry.label or "")
end
if score ~= nil then
table.insert(results, {
id = resultId(item),
title = item.entry.label or "",
subtitle = item.path,
glyph = item.entry.glyph or "bookmark",
score = score,
})
end
end
table.sort(results, function(a, b)
return (a.score or 0) > (b.score or 0)
end)
return results
end
local function providerEnabled()
return noctalia.getConfig("enable_bk_provider") == true
end
-- "history" (most-recently-used first) or "usage_count" (most-frequently-
-- used first). Only affects ordering when the query is empty; a non-empty
-- query always ranks by fuzzy match score, same as before.
local function sortBy()
local value = noctalia.getConfig("bk_sort_by")
if value == "usage_count" then
return "usage_count"
end
return "history"
end
local function statsPath()
local dataDir = noctalia.pluginDataDir()
if dataDir == nil then
return nil
end
return dataDir .. (sortBy() == "history" and "/bk_history.json" or "/bk_usage.json")
end
-- Reads and decodes the current stats file (history array or usage-count
-- map, per sortBy()), falling back to an empty table when the file is
-- missing, unreadable, or corrupt. Shared by getSortScores/recordActivation
-- so the read/decode/default boilerplate only lives in one place.
local function readStatsData()
local path = statsPath()
if path == nil then
return {}, nil
end
local file = noctalia.readFile(path)
if file == nil then
return {}, path
end
local decoded = noctalia.json.decode(file)
if type(decoded) ~= "table" then
return {}, path
end
return decoded, path
end
-- Scores used to order results when the query is empty, keyed by result id.
-- History ranks by recency (higher = more recently used); usage_count ranks
-- by frequency (higher = used more often). Reads/decodes the stats file
-- once for the whole batch rather than once per result. Ids missing from
-- the stats file fall back to 0 (unranked, sorted last).
local function getSortScores(ids)
local data = readStatsData()
local scores = {}
if sortBy() == "history" then
for _, id in ipairs(ids) do
local i = table.find(data, id)
scores[id] = i ~= nil and (#data - i) or 0
end
else
for _, id in ipairs(ids) do
scores[id] = data[id] or 0
end
end
return scores
end
-- Records an activation for ranking purposes: pushes `id` to the front of
-- the history list, or increments its usage count, depending on `sort_by`.
local function recordActivation(id)
local data, path = readStatsData()
if path == nil then
return
end
if sortBy() == "history" then
local i = table.find(data, id)
if i ~= nil then
table.remove(data, i)
end
table.insert(data, 1, id)
else
data[id] = (data[id] or 0) + 1
end
local encoded = noctalia.json.encode(data)
if encoded ~= nil then
noctalia.writeFile(path, encoded)
end
end
function onQuery(query)
query = noctalia.string.trim(query)
if not providerEnabled() then
launcher.setResults(query, {})
return
end
local results = buildResults(query)
if query == "" then
local ids = {}
for _, result in ipairs(results) do
table.insert(ids, result.id)
end
local scores = getSortScores(ids)
for _, result in ipairs(results) do
result.score = scores[result.id] or 0
end
table.sort(results, function(a, b)
return (a.score or 0) > (b.score or 0)
end)
end
if #results == 0 then
launcher.setResults(query, {
{
id = "",
title = noctalia.tr("no_bookmarks"),
glyph = "bookmark-off",
},
})
return
end
launcher.setResults(query, results)
end
function onActivate(id)
if id == "" or not providerEnabled() then
return
end
local wantPath, wantLabel = id:match("^([^\30]*)\30(.*)$")
if wantLabel == nil then
return
end
if wantPath == "" then
wantPath = nil
end
local bookmarks = loadBookmarks()
for _, item in ipairs(flatten(bookmarks)) do
if item.entry.label == wantLabel and (item.path or nil) == wantPath then
local entry = item.entry
local cmd = entry.cmd
if type(cmd) ~= "string" or cmd == "" then
noctalia.notifyError(noctalia.tr("title"), noctalia.tr("err.no_command"))
return
end
local label = entry.label or cmd
recordActivation(id)
if entry.runInTerminal == true then
noctalia.runInTerminal(cmd)
return
end
local finalCmd = cmd
if entry.runInBackground == true then
finalCmd = "nohup " .. cmd .. " >/dev/null 2>&1 &"
end
local ok = noctalia.runAsync(finalCmd, function(result)
if entry.runInBackground == true then
return
end
if result.exitCode ~= 0 then
local detail = noctalia.string.trim(result.stderr or "")
local msg = noctalia.tr(
"err.exit_code",
{ label = label, code = tostring(result.exitCode) }
)
if detail ~= "" then
msg = msg .. ": " .. detail
end
noctalia.notifyError(noctalia.tr("title"), msg)
end
end)
if not ok then
noctalia.notifyError(
noctalia.tr("title"),
noctalia.tr("err.launch_failed", { label = label })
)
end
return
end
end
end