ed063ab1fe
Capture desktop-specific system, session, Noctalia, and Pi integration alongside shared laptop support. Retire obsolete X11 configuration and make package ownership explicit for repeatable Decman deployments.
1144 lines
34 KiB
Lua
1144 lines
34 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 pendingNavigation: { vaultPath: string, currentDir: string, selectedRelative: string }? = nil
|
|
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 navigationStatePath(): string?
|
|
local dataDir = noctalia.pluginDataDir()
|
|
if type(dataDir) ~= "string" then
|
|
return nil
|
|
end
|
|
return dataDir .. "/navigation-state.json"
|
|
end
|
|
|
|
local function saveNavigation()
|
|
if vaultPath == "" then
|
|
return
|
|
end
|
|
local path = navigationStatePath()
|
|
if path == nil then
|
|
return
|
|
end
|
|
local entry = selectedEntry()
|
|
noctalia.writeFile(path, noctalia.json.encode({
|
|
vaultPath = vaultPath,
|
|
currentDir = currentDir,
|
|
selectedRelative = entry ~= nil and tostring(entry.relative or "") or "",
|
|
}))
|
|
end
|
|
|
|
local function loadNavigation(): { vaultPath: string, currentDir: string, selectedRelative: string }?
|
|
local path = navigationStatePath()
|
|
if path == nil then
|
|
return nil
|
|
end
|
|
local raw = noctalia.readFile(path)
|
|
if type(raw) ~= "string" then
|
|
return nil
|
|
end
|
|
local saved = noctalia.json.decode(raw)
|
|
if type(saved) ~= "table"
|
|
or type(saved.vaultPath) ~= "string"
|
|
or type(saved.currentDir) ~= "string"
|
|
or type(saved.selectedRelative) ~= "string"
|
|
then
|
|
return nil
|
|
end
|
|
return {
|
|
vaultPath = saved.vaultPath,
|
|
currentDir = saved.currentDir,
|
|
selectedRelative = saved.selectedRelative,
|
|
}
|
|
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
|
|
saveNavigation()
|
|
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()
|
|
saveNavigation()
|
|
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
|
|
saveNavigation()
|
|
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
|
|
elseif pendingNavigation ~= nil then
|
|
selectRelative(pendingNavigation.selectedRelative)
|
|
end
|
|
pendingNavigation = nil
|
|
saveNavigation()
|
|
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
|
|
if actionAfterValidation == "save" then
|
|
pendingNavigation = nil
|
|
local dataDir = noctalia.pluginDataDir()
|
|
if type(dataDir) == "string" then
|
|
noctalia.writeFile(dataDir .. "/vault-path.txt", vaultPath)
|
|
end
|
|
end
|
|
if pendingNavigation ~= nil and pendingNavigation.vaultPath == vaultPath then
|
|
currentDir = pendingNavigation.currentDir
|
|
else
|
|
currentDir = ""
|
|
end
|
|
selected = 1
|
|
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()
|
|
saveNavigation()
|
|
end
|
|
|
|
function onOpen(_context)
|
|
-- A plugin runtime can outlive panel-close/open cycles; restore navigation
|
|
-- only when it belongs to the currently configured vault.
|
|
vaultEditMode = false
|
|
vaultEditPath = ""
|
|
searchMode = false
|
|
createMode = nil
|
|
createName = ""
|
|
createError = nil
|
|
cutRelative = nil
|
|
cutKind = nil
|
|
deleteRelative = nil
|
|
operationError = nil
|
|
readerMode = false
|
|
readerNote = nil
|
|
query = ""
|
|
pendingNavigation = loadNavigation()
|
|
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
|