fd50b7deb9
Provide a portable, cache-ignored Obsidian vault lookup plugin and bind it to Super+N.
1081 lines
32 KiB
Lua
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
|