Files
dotfiles/.local/share/noctalia/plugins/knowledge-lookup/panel.luau
T
alex fd50b7deb9 feat(noctalia): add knowledge lookup panel
Provide a portable, cache-ignored Obsidian vault lookup plugin and bind it to Super+N.
2026-08-13 15:50:46 +02:00

1081 lines
32 KiB
Lua

--!strict
local vaultPath = ""
local vaultName = ""
local obsidianVaultId: string? = nil
local vaultError: string? = nil
local obsidianError: string? = nil
local vaultEditMode = false
local vaultEditPath = ""
local RESULT_ROWS = 10
local notes: { any } = {}
local indexedDirectories: { string } = {}
local noteById: { [string]: any } = {}
local queryFoldMap: { [string]: string } = {}
local visible: { any } = {}
local selected = 1
local previewTop = 1
local query = ""
local currentDir = ""
local readerMode = false
local readerNote: any? = nil
local createMode: string? = nil
local createName = ""
local createError: string? = nil
local cutRelative: string? = nil
local cutKind: string? = nil
local deleteRelative: string? = nil
local operationError: string? = nil
local searchMode = false
local loading = false
local loadError: string? = nil
local render: () -> ()
local refreshIndex: (string?, string?) -> ()
local validateVault: (string?, string?) -> ()
local function lowercase(value: any): string
return string.lower(tostring(value or ""))
end
local function normalizeQuery(value: string): string
local normalized = ""
local index = 1
while index <= #value do
local firstByte = string.byte(value, index)
local width = 1
if firstByte >= 240 then
width = 4
elseif firstByte >= 224 then
width = 3
elseif firstByte >= 192 then
width = 2
end
local character = value:sub(index, index + width - 1)
normalized ..= queryFoldMap[character] or lowercase(character)
index += width
end
return normalized
end
local function fuzzyScore(text: string, needle: string): number?
if needle == "" then
return 0
end
local score = 0
local cursor = 1
local previous = 0
for index = 1, #needle do
local character = needle:sub(index, index)
local found = text:find(character, cursor, true)
if found == nil then
return nil
end
score += 8
if found == previous + 1 then
score += 12
end
if found == 1 or text:sub(found - 1, found - 1):match("[%s%p]") then
score += 10
end
score -= math.min(found - cursor, 8)
previous = found
cursor = found + 1
end
return score
end
local function fuzzyContentScore(note: any, term: string): number?
if #term < 3 then
return nil
end
local best: number? = nil
local firstCharacter = term:sub(1, 1)
local byLength = (note.contentTokens or {})[firstCharacter] or {}
for length = #term, #term + 3 do
for _, token in ipairs(byLength[tostring(length)] or {}) do
local tokenScore = fuzzyScore(tostring(token), term)
if tokenScore ~= nil and (best == nil or tokenScore > best) then
best = tokenScore
end
end
end
return best
end
local function firstPosition(term: string, values: { any }): number?
local best: number? = nil
for _, value in ipairs(values) do
local position = tostring(value or ""):find(term, 1, true)
if position ~= nil and (best == nil or position < best) then
best = position
end
end
return best
end
local function scoreNote(note: any, rawQuery: string): number?
local score = 0
for term in rawQuery:gmatch("%S+") do
local titlePos = firstPosition(term, { note.titleLower, note.titleRaw })
local pathPos = firstPosition(term, { note.pathLower, note.pathRaw })
local contentPos = firstPosition(term, { note.contentLower, note.contentRaw })
if titlePos ~= nil then
score += 500 - math.min(titlePos, 100)
elseif pathPos ~= nil then
score += 320 - math.min(pathPos, 100)
elseif contentPos ~= nil then
score += 90 - math.min(math.floor(contentPos / 200), 60)
else
local fuzzyTitle = fuzzyScore(note.titleLower, term)
local fuzzyPath = fuzzyScore(note.pathLower, term)
local fuzzyContent = fuzzyContentScore(note, term)
if fuzzyTitle ~= nil then
score += 180 + fuzzyTitle
elseif fuzzyPath ~= nil then
score += 100 + fuzzyPath
elseif fuzzyContent ~= nil then
score += 30 + fuzzyContent
else
return nil
end
end
end
return score
end
local function parentPath(path: string): string
return path:match("^(.*)/[^/]+$") or ""
end
local function directoryEntries(): { any }
local folders: { [string]: any } = {}
local directNotes: { any } = {}
local prefix = currentDir == "" and "" or currentDir .. "/"
for _, directory in ipairs(indexedDirectories) do
if directory:sub(1, #prefix) == prefix then
local remainder = directory:sub(#prefix + 1)
local child = remainder:match("^([^/]+)")
if child ~= nil and child ~= "" then
local relative = prefix .. child
folders[relative] = folders[relative] or {
kind = "folder",
title = child,
relative = relative,
}
end
end
end
for _, note in ipairs(notes) do
if note.relative:sub(1, #prefix) == prefix then
local remainder = note.relative:sub(#prefix + 1)
local child = remainder:match("^([^/]+)/")
if child ~= nil then
local relative = prefix .. child
if folders[relative] == nil then
folders[relative] = {
kind = "folder",
title = child,
relative = relative,
}
end
elseif remainder ~= "" then
table.insert(directNotes, note)
end
end
end
local entries: { any } = {}
for _, folder in pairs(folders) do
table.insert(entries, folder)
end
table.sort(entries, function(a, b)
return lowercase(a.title) < lowercase(b.title)
end)
table.sort(directNotes, function(a, b)
return a.titleLower < b.titleLower
end)
for _, note in ipairs(directNotes) do
table.insert(entries, note)
end
return entries
end
local function applyFilter()
local normalized = normalizeQuery(query):match("^%s*(.-)%s*$") or ""
if searchMode and normalized ~= "" then
local ranked: { any } = {}
for _, note in ipairs(notes) do
local score = scoreNote(note, normalized)
if score ~= nil then
table.insert(ranked, { note = note, score = score })
end
end
table.sort(ranked, function(a, b)
if a.score == b.score then
return a.note.titleLower < b.note.titleLower
end
return a.score > b.score
end)
visible = {}
for _, item in ipairs(ranked) do
table.insert(visible, item.note)
end
else
visible = directoryEntries()
end
selected = math.max(1, math.min(selected, math.max(#visible, 1)))
previewTop = 1
end
local function selectedEntry(): any?
return visible[selected]
end
local function fullMarkdown(note: any): string
local lines = note.lines or {}
if #lines == 0 then
return "_Empty note_"
end
return table.concat(lines, "\n")
end
local function setSelected(index: number)
if #visible == 0 then
return
end
selected = math.max(1, math.min(index, #visible))
previewTop = 1
render()
end
local function moveSelection(delta: number)
setSelected(selected + delta)
end
local function launchObsidian(path: string)
if vaultError ~= nil then
operationError = vaultError
render()
return false
end
if obsidianVaultId == nil then
operationError = obsidianError or "No matching vault is registered in Obsidian"
render()
return false
end
local script = (noctalia.pluginDir() or "") .. "/focus-obsidian.py"
noctalia.runAsync({
"systemd-run",
"--user",
"--collect",
"python3",
script,
vaultPath,
obsidianVaultId,
path,
})
return true
end
local function openInObsidian(note: any)
if launchObsidian(vaultPath .. "/" .. note.relative) then
panel.close()
end
end
local function enterReader(note: any)
-- Inputs are uncontrolled and may retain keyboard focus after the mode that
-- created them ends. Rebuild the panel tree without an input before reader
-- mode so captured j/k chords return to the panel's onKey handler.
searchMode = false
createMode = nil
vaultEditMode = false
render()
readerMode = true
readerNote = note
previewTop = 1
render()
end
local function leaveReader()
readerMode = false
readerNote = nil
previewTop = 1
render()
end
local function selectRelative(relative: string)
for index, entry in ipairs(visible) do
if entry.relative == relative then
selected = index
return
end
end
end
local function createEntry(kind: string, name: string)
createError = nil
local script = (noctalia.pluginDir() or "") .. "/create-entry.py"
noctalia.runAsync({ "python3", script, vaultPath, kind, currentDir, name }, function(result)
if result.exitCode ~= 0 then
createError = result.stderr ~= "" and result.stderr or "Creation failed"
render()
return
end
local created, decodeError = noctalia.json.decode(result.stdout)
if type(created) ~= "table" or type(created.relative) ~= "string" or type(created.absolute) ~= "string" then
createError = decodeError or "Creation returned invalid data"
render()
return
end
createMode = nil
createName = ""
if kind == "note" then
if launchObsidian(created.absolute) then
panel.close()
end
else
refreshIndex(created.relative, "directory")
end
end, 10000)
end
local function submitCreate()
if createMode ~= nil then
createEntry(createMode, createName)
end
end
local function beginCreate(kind: string)
if vaultError ~= nil or vaultPath == "" then
operationError = vaultError or "Configure a vault directory first"
render()
return
end
createMode = kind
createName = ""
createError = nil
render()
end
local function manageEntry(arguments: { string }, callback: (any) -> ())
if vaultError ~= nil or vaultPath == "" then
operationError = vaultError or "Configure a vault directory first"
render()
return
end
operationError = nil
local script = (noctalia.pluginDir() or "") .. "/manage-entry.py"
local command = { "python3", script, vaultPath }
for _, argument in ipairs(arguments) do
table.insert(command, argument)
end
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
operationError = result.stderr ~= "" and result.stderr or "File operation failed"
render()
return
end
callback(result)
end, 10000)
end
local function cutSelected()
local entry = selectedEntry()
if entry == nil then
return
end
cutRelative = entry.relative
cutKind = entry.kind == "folder" and "directory" or "note"
deleteRelative = nil
operationError = nil
render()
end
local function pasteCut()
if cutRelative == nil then
operationError = "Nothing is cut · press x on an entry first"
render()
return
end
manageEntry({ "move", cutRelative, currentDir }, function(result)
local moved, decodeError = noctalia.json.decode(result.stdout)
if type(moved) ~= "table" or type(moved.relative) ~= "string" then
operationError = decodeError or "Move returned invalid data"
render()
return
end
local movedRelative = moved.relative
local movedKind = cutKind
cutRelative = nil
cutKind = nil
if movedKind == "directory" then
refreshIndex(movedRelative, nil)
else
refreshIndex(movedRelative, nil)
end
end)
end
local function requestDelete()
local entry = selectedEntry()
if entry == nil then
return
end
deleteRelative = entry.relative
operationError = nil
render()
end
local function confirmDelete()
if deleteRelative == nil then
return
end
local deleting = deleteRelative
manageEntry({ "delete", deleting }, function(_result)
deleteRelative = nil
if cutRelative == deleting then
cutRelative = nil
cutKind = nil
end
refreshIndex()
end)
end
local function enterSelected()
local entry = selectedEntry()
if entry == nil then
return
end
if entry.kind == "folder" then
currentDir = entry.relative
selected = 1
previewTop = 1
applyFilter()
render()
else
enterReader(entry)
end
end
local function goParent()
if currentDir == "" then
return
end
local leaving = currentDir
currentDir = parentPath(currentDir)
selected = 1
applyFilter()
for index, entry in ipairs(visible) do
if entry.kind == "folder" and entry.relative == leaving then
selected = index
break
end
end
previewTop = 1
render()
end
local function resultList(): any
if loading then
return ui.column({ width = 330, gap = 8, align = "stretch" }, {
ui.label({ text = "Indexing vault…", color = "on_surface_variant" }),
})
end
if loadError ~= nil then
return ui.column({ width = 330, gap = 8, align = "stretch" }, {
ui.label({ text = "Index failed", color = "error", fontWeight = "bold" }),
ui.label({ text = loadError, color = "on_surface_variant", maxLines = 8 }),
})
end
if #visible == 0 then
return ui.column({ width = 330, gap = 8, align = "stretch" }, {
ui.label({ text = "No matching notes", color = "on_surface_variant" }),
})
end
local rows: { any } = {}
local first = math.max(1, math.min(selected - math.floor(RESULT_ROWS / 2), math.max(#visible - RESULT_ROWS + 1, 1)))
local last = math.min(first + RESULT_ROWS - 1, #visible)
for index = first, last do
local entry = visible[index]
local active = index == selected
local folder = entry.kind == "folder"
local rowProps: { [string]: any } = {
key = tostring(entry.kind) .. "-" .. entry.relative,
gap = 2,
paddingH = 10,
paddingV = 7,
radius = 6,
fill = active and "primary/0.18" or "surface/0",
border = active and "primary/0.70" or "surface/0",
borderWidth = active and 1 or 0,
onClick = function()
setSelected(index)
end,
}
table.insert(rows, ui.column(rowProps, {
ui.row({ align = "center", gap = 7 }, {
ui.glyph({ name = folder and "folder" or "file-text", size = 15, color = folder and "primary" or "on_surface_variant" }),
ui.label({ text = entry.title, fontWeight = active and "bold" or "normal", color = active and "primary" or "on_surface", maxLines = 1 }),
}),
ui.label({ text = folder and "Directory" or entry.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }),
}))
end
return ui.column({ width = 330, gap = 4, align = "stretch" }, rows)
end
local function previewPane(): any
local entry = selectedEntry()
if entry == nil then
return ui.column({ flexGrow = 1, align = "stretch", gap = 8 }, {
ui.label({ text = "Empty directory", color = "on_surface_variant" }),
})
end
if entry.kind == "folder" then
local noteCount = 0
local childFolders: { [string]: boolean } = {}
local prefix = entry.relative .. "/"
for _, note in ipairs(notes) do
if note.relative:sub(1, #prefix) == prefix then
noteCount += 1
local remainder = note.relative:sub(#prefix + 1)
local child = remainder:match("^([^/]+)/")
if child ~= nil then
childFolders[child] = true
end
end
end
local folderCount = 0
for _ in pairs(childFolders) do
folderCount += 1
end
return ui.column({ flexGrow = 1, align = "stretch", gap = 12 }, {
ui.row({ align = "center", gap = 10 }, {
ui.glyph({ name = "folder-open", size = 24, color = "primary" }),
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = entry.title, fontSize = 18, fontWeight = "bold", color = "on_surface", maxLines = 1 }),
ui.label({ text = entry.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }),
}),
ui.button({ text = "Enter", glyph = "arrow-right", variant = "primary", onClick = enterSelected }),
}),
ui.separator({ color = "outline" }),
ui.label({ text = `{noteCount} notes · {folderCount} subfolders`, color = "on_surface_variant" }),
ui.label({ text = "Press l or Enter to open this directory.", color = "on_surface_variant" }),
})
end
return ui.column({ flexGrow = 1, align = "stretch", gap = 8 }, {
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = entry.title, fontSize = 18, fontWeight = "bold", color = "on_surface", maxLines = 1 }),
ui.label({ text = entry.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }),
}),
ui.button({ text = "Open in Obsidian", glyph = "external-link", variant = "primary", onClick = function()
openInObsidian(entry)
end }),
}),
ui.separator({ color = "outline" }),
ui.scroll({ flexGrow = 1, paddingH = 8, paddingV = 4, align = "stretch" }, {
ui.markdown({ text = fullMarkdown(entry), flexGrow = 1 }),
}),
})
end
local function readerPane(): any
local note = readerNote
if note == nil then
return ui.column({ flexGrow = 1 }, {})
end
return ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, {
ui.row({ align = "center", gap = 10 }, {
ui.glyph({ name = "file-text", size = 21, color = "primary" }),
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = note.title, fontSize = 18, fontWeight = "bold", color = "on_surface", maxLines = 1 }),
ui.label({ text = note.relative, fontSize = 11, color = "on_surface_variant", maxLines = 1 }),
}),
ui.button({ text = "Open in Obsidian", glyph = "external-link", variant = "primary", onClick = function()
openInObsidian(note)
end }),
}),
ui.separator({ color = "outline" }),
ui.scroll({
key = "reader-document",
flexGrow = 1,
paddingH = 10,
paddingV = 6,
align = "stretch",
}, {
ui.markdown({ text = fullMarkdown(note), flexGrow = 1 }),
}),
ui.separator({ color = "outline" }),
ui.label({
text = "Mouse wheel or scrollbar to scroll · Ctrl-c finder · o open in Obsidian · Esc closes panel",
fontSize = 11,
color = "on_surface_variant",
maxLines = 1,
}),
})
end
render = function()
if readerMode then
-- A focused input receives printable keys before captured panel chords.
-- Replacing the entire root with a keyed reader tree drops any stale input.
panel.render(ui.column({ key = "reader-root", flexGrow = 1, align = "stretch" }, {
readerPane(),
}))
return
end
local countText = loading and "Indexing…" or (`{#visible} entries · {#notes} notes`)
local modeControl: any
if vaultEditMode then
modeControl = ui.input({
key = "vault-path-editor",
width = 460,
value = vaultEditPath,
placeholder = "/path/to/Obsidian/vault",
focus = true,
submitOnEnter = true,
onSubmit = function()
validateVault(vaultEditPath, "save")
end,
onChange = function(value: string)
vaultEditPath = value
end,
})
elseif createMode ~= nil then
local label = createMode == "note" and "note" or "directory"
modeControl = ui.input({
width = 320,
value = createName,
placeholder = "New " .. label .. " name…",
focus = true,
submitOnEnter = true,
onSubmit = submitCreate,
onChange = function(value: string)
createName = value
createError = nil
end,
})
elseif searchMode then
modeControl = ui.input({
width = 280,
value = query,
placeholder = "Search vault…",
focus = true,
submitOnEnter = true,
onSubmit = enterSelected,
onChange = function(value: string)
query = value
selected = 1
applyFilter()
render()
end,
})
else
local modeText = query ~= "" and (`FILTER /{query}`) or "BROWSE"
modeControl = ui.label({ text = modeText, color = "on_surface_variant", maxLines = 1 })
end
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, {
ui.row({ align = "center", gap = 10 }, {
ui.glyph({ name = "notes", size = 20, color = "primary" }),
ui.label({ text = "Knowledge Lookup", fontSize = 18, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = countText, color = "on_surface_variant" }),
ui.spacer({ flexGrow = 1 }),
modeControl,
ui.button({ text = vaultEditMode and "Cancel" or "Change vault", glyph = "folder", variant = "ghost", onClick = function()
vaultEditMode = not vaultEditMode
vaultEditPath = vaultPath
render()
end }),
ui.button({ glyph = "refresh", variant = "ghost", tooltip = "Re-index (r)", onClick = refreshIndex }),
ui.button({ glyph = "close", variant = "ghost", tooltip = "Close (q)", onClick = function()
panel.close()
end }),
}),
ui.row({ align = "center", gap = 8 }, {
ui.label({ text = "VAULT", fontSize = 10, fontWeight = "bold", color = "on_surface_variant" }),
ui.label({ text = vaultPath ~= "" and vaultPath or "Not configured", color = vaultError and "error" or "primary", maxLines = 1 }),
ui.label({ text = currentDir == "" and "/" or ("/" .. currentDir), color = "on_surface_variant", maxLines = 1 }),
ui.spacer({ flexGrow = 1 }),
ui.label({ text = obsidianVaultId and ("Obsidian: " .. vaultName) or "Obsidian: not registered", color = obsidianVaultId and "primary" or "on_surface_variant", maxLines = 1 }),
}),
ui.separator({ color = "outline" }),
vaultError ~= nil
and ui.column({ flexGrow = 1, gap = 14, align = "center", justify = "center" }, {
ui.glyph({ name = "folder-x", size = 42, color = "error" }),
ui.label({ text = "Vault unavailable", fontSize = 22, fontWeight = "bold", color = "error" }),
ui.label({ text = vaultError, color = "on_surface_variant", maxLines = 3 }),
ui.button({ text = "Change vault", glyph = "folder", variant = "primary", onClick = function()
vaultEditMode = true
vaultEditPath = vaultPath
render()
end }),
})
or ui.row({ flexGrow = 1, gap = 14, align = "stretch" }, {
resultList(),
ui.separator({ orientation = "vertical", color = "outline" }),
previewPane(),
}),
ui.separator({ color = "outline" }),
ui.label({
text = operationError
or vaultError
or createError
or obsidianError
or (deleteRelative ~= nil and ("Delete “" .. deleteRelative .. "” and its contents? y confirm · n cancel"))
or (createMode ~= nil and ("Enter creates this " .. (createMode == "note" and "note and opens it in Obsidian" or "directory")))
or (searchMode and "Global search: type title, path, or content · Enter read · Esc close")
or (cutRelative ~= nil and ("CUT “" .. cutRelative .. "” · navigate with h/l, then p to move here"))
or "j/k select · l/Enter open · h parent · x cut · p paste/move · d delete · Ctrl-n note · Ctrl-a dir · o Obsidian · q close",
fontSize = 11,
color = (operationError or vaultError or createError) and "error" or ((obsidianError or deleteRelative ~= nil) and "primary" or "on_surface_variant"),
maxLines = 1,
}),
}))
end
local function mergeTokenBuckets(target: any, source: any)
for firstCharacter, lengths in pairs(source or {}) do
local targetLengths = target[firstCharacter]
if targetLengths == nil then
targetLengths = {}
target[firstCharacter] = targetLengths
end
for length, tokens in pairs(lengths) do
local targetTokens = targetLengths[length]
if targetTokens == nil then
targetTokens = {}
targetLengths[length] = targetTokens
end
for _, token in ipairs(tokens) do
table.insert(targetTokens, token)
end
end
end
end
local function mergeIndexRecord(record: any)
if type(record) ~= "table" then
return
end
if record.kind == "directory" and type(record.relative) == "string" then
table.insert(indexedDirectories, record.relative)
return
end
if record.kind == "fold" then
for source, target in pairs(record.map or {}) do
queryFoldMap[tostring(source)] = tostring(target)
end
return
end
local id = tostring(record.id or "")
if record.kind == "note" then
local note = {
kind = "note",
title = tostring(record.title or record.relative or "Untitled"),
relative = tostring(record.relative or ""),
titleLower = tostring(record.titleSearch or lowercase(record.title)),
titleRaw = tostring(record.titleRaw or lowercase(record.title)),
pathLower = tostring(record.pathSearch or lowercase(record.relative)),
pathRaw = tostring(record.pathRaw or lowercase(record.relative)),
contentLower = "",
contentRaw = "",
contentTokens = {},
lines = {},
}
noteById[id] = note
table.insert(notes, note)
return
end
if record.kind ~= "content" then
return
end
local note = noteById[id]
if note == nil then
return
end
note.contentLower ..= tostring(record.contentSearch or "")
note.contentRaw ..= tostring(record.contentRaw or "")
for _, line in ipairs(record.lines or {}) do
table.insert(note.lines, tostring(line))
end
mergeTokenBuckets(note.contentTokens, record.contentTokens)
end
local function completeIndexLoad(selectAfterRefresh: string?, actionAfterRefresh: string?)
local directoryExists = currentDir == ""
while not directoryExists and currentDir ~= "" do
for _, directory in ipairs(indexedDirectories) do
if directory == currentDir then
directoryExists = true
break
end
end
if not directoryExists then
currentDir = parentPath(currentDir)
directoryExists = currentDir == ""
end
end
loading = false
selected = 1
applyFilter()
if selectAfterRefresh ~= nil then
selectRelative(selectAfterRefresh)
if actionAfterRefresh == "directory" then
currentDir = selectAfterRefresh
selected = 1
applyFilter()
end
end
render()
end
local function finishIndexLoad(selectAfterRefresh: string?, actionAfterRefresh: string?)
completeIndexLoad(selectAfterRefresh, actionAfterRefresh)
end
local function loadIndexChunks(
chunkNames: { any },
index: number,
selectAfterRefresh: string?,
actionAfterRefresh: string?
)
if index > #chunkNames then
finishIndexLoad(selectAfterRefresh, actionAfterRefresh)
return
end
local chunkName = chunkNames[index]
if type(chunkName) ~= "string" or chunkName:match("^index%-cache%-%d%d%d%.json$") == nil then
loading = false
loadError = "Indexer returned an invalid chunk name"
render()
return
end
local chunkPath = (noctalia.pluginDir() or "") .. "/" .. chunkName
local accepted = noctalia.readFileAsync(chunkPath, function(cacheJson, readError)
if cacheJson == nil then
loading = false
loadError = readError or "Cannot read index cache"
render()
return
end
local decoded, decodeError = noctalia.json.decode(cacheJson)
if type(decoded) ~= "table" then
loading = false
loadError = decodeError or "Index cache contains invalid JSON"
render()
return
end
for _, record in ipairs(decoded) do
mergeIndexRecord(record)
end
loadIndexChunks(chunkNames, index + 1, selectAfterRefresh, actionAfterRefresh)
end)
if not accepted then
loading = false
loadError = "Cannot queue index cache read"
render()
end
end
refreshIndex = function(selectAfterRefresh: string?, actionAfterRefresh: string?)
if vaultError ~= nil or vaultPath == "" then
loading = false
notes = {}
indexedDirectories = {}
visible = {}
render()
return
end
loading = true
loadError = nil
render()
local pluginDir = noctalia.pluginDir() or ""
local script = pluginDir .. "/index.py"
local cache = pluginDir .. "/index-cache.json"
noctalia.runAsync({ "python3", script, vaultPath, cache }, function(result)
if result.exitCode ~= 0 then
loading = false
loadError = result.stderr ~= "" and result.stderr or "Indexer failed"
render()
return
end
local manifest, manifestError = noctalia.json.decode(result.stdout)
if type(manifest) ~= "table" or type(manifest.chunks) ~= "table" then
loading = false
loadError = manifestError or "Indexer returned an invalid cache manifest"
render()
return
end
notes = {}
indexedDirectories = {}
noteById = {}
queryFoldMap = {}
loadIndexChunks(manifest.chunks, 1, selectAfterRefresh, actionAfterRefresh)
end, 60000)
end
validateVault = function(candidate: string?, actionAfterValidation: string?)
local configured = candidate or tostring(noctalia.getConfig("vault_path") or "")
vaultEditMode = false
loading = true
vaultError = nil
obsidianError = nil
operationError = nil
render()
local script = (noctalia.pluginDir() or "") .. "/vault-info.py"
noctalia.runAsync({ "python3", script, configured }, function(result)
local info, decodeError = noctalia.json.decode(result.stdout)
loading = false
if type(info) ~= "table" or info.ok ~= true then
vaultPath = configured
vaultName = ""
obsidianVaultId = nil
vaultError = type(info) == "table" and tostring(info.error or "Vault validation failed")
or decodeError
or "Vault validation failed"
notes = {}
indexedDirectories = {}
visible = {}
render()
return
end
vaultPath = tostring(info.path or "")
vaultName = tostring(info.name or "")
obsidianVaultId = type(info.vaultId) == "string" and info.vaultId or nil
obsidianError = type(info.obsidianError) == "string" and info.obsidianError or nil
currentDir = ""
selected = 1
if actionAfterValidation == "save" then
local dataDir = noctalia.pluginDataDir()
if type(dataDir) == "string" then
noctalia.writeFile(dataDir .. "/vault-path.txt", vaultPath)
end
end
refreshIndex()
end, 10000)
end
local function configuredVaultPath(): string
local dataDir = noctalia.pluginDataDir()
if type(dataDir) == "string" then
local saved = noctalia.readFile(dataDir .. "/vault-path.txt")
if type(saved) == "string" and saved ~= "" then
return saved
end
end
return tostring(noctalia.getConfig("vault_path") or "")
end
function onClose()
end
function onOpen(_context)
-- A plugin runtime can outlive panel-close/open cycles, but user navigation
-- must always reopen at the finder root.
vaultEditMode = false
vaultEditPath = ""
searchMode = false
createMode = nil
createName = ""
createError = nil
cutRelative = nil
cutKind = nil
deleteRelative = nil
operationError = nil
readerMode = false
readerNote = nil
query = ""
currentDir = ""
selected = 1
previewTop = 1
validateVault(configuredVaultPath(), nil)
end
function onKey(chord: string, pressed: boolean)
if not pressed then
return
end
if readerMode then
if chord == "o" and readerNote ~= nil then
openInObsidian(readerNote)
elseif chord == "ctrl+U0063" or chord == "escape" then
leaveReader()
end
return
end
if vaultEditMode then
if chord == "enter" then
validateVault(vaultEditPath, "save")
end
return
end
if deleteRelative ~= nil then
if chord == "y" then
confirmDelete()
elseif chord == "n" then
deleteRelative = nil
render()
end
return
end
-- Printable keys go to the focused input first, but Enter is explicitly
-- captured by this panel and therefore must be submitted from onKey.
if createMode ~= nil then
if chord == "enter" then
submitCreate()
end
return
end
if searchMode then
if chord == "enter" then
enterSelected()
end
return
end
if chord == "j" then
moveSelection(1)
elseif chord == "k" then
moveSelection(-1)
elseif chord == "l" or chord == "enter" then
enterSelected()
elseif chord == "h" then
goParent()
elseif chord == "g" then
setSelected(1)
elseif chord == "shift+U0047" then
setSelected(#visible)
elseif chord == "x" then
cutSelected()
elseif chord == "p" then
pasteCut()
elseif chord == "d" then
requestDelete()
elseif chord == "ctrl+U0061" then
beginCreate("directory")
elseif chord == "ctrl+U006e" then
beginCreate("note")
elseif chord == "slash" then
searchMode = true
query = ""
selected = 1
applyFilter()
render()
elseif chord == "o" then
local entry = selectedEntry()
if entry ~= nil and entry.kind ~= "folder" then
openInObsidian(entry)
end
elseif chord == "r" then
refreshIndex()
elseif chord == "q" or chord == "escape" then
panel.close()
end
end