|
-- ============================================ |
|
-- Configuration |
|
-- ============================================ |
|
local config = { |
|
showParentHeaders = false, |
|
contextLength = 50 |
|
} |
|
|
|
-- Highlight styles |
|
local highlightStyles = { |
|
function(kw) return "==" .. kw .. "==" end, |
|
function(kw) return "==`" .. kw .. "`==" end, |
|
function(kw) return "`" .. kw .. "`" end, |
|
function(kw) return "**==" .. kw .. "==**" end, |
|
function(kw) return "**`" .. kw .. "`**" end, |
|
function(kw) return "*==`" .. kw .. "`==*" end, |
|
function(kw) return "*`" .. kw .. "`*" end, |
|
function(kw) return "**" .. kw .. "**" end, |
|
function(kw) return "*" .. kw .. "*" end, |
|
function(kw) return "*==" .. kw .. "==*" end, |
|
} |
|
|
|
-- ============================================ |
|
-- Utilities |
|
-- ============================================ |
|
local function cleanContext(s) |
|
if not s then return "" end |
|
return string.gsub(s, "[\r\n]+", " ↩ ") |
|
end |
|
|
|
local function headingPrefix(depth) |
|
if depth > 6 then depth = 6 end |
|
return string.rep("#", depth) .. " " |
|
end |
|
|
|
local function isCaseSensitive(word) |
|
return string.find(word, "%u") ~= nil |
|
end |
|
|
|
-- Normalize folder path: remove trailing slash if present |
|
local function normalizeFolder(folder) |
|
if not folder or folder == "" then return "" end |
|
if string.sub(folder, -1) == "/" then |
|
return string.sub(folder, 1, -2) |
|
end |
|
return folder |
|
end |
|
|
|
-- Check if a page is within the specified folder |
|
local function isInFolder(pageName, folder) |
|
if folder == "" then return true end |
|
-- Page must start with "folder/" to be inside the folder |
|
return string.sub(pageName, 1, #folder + 1) == folder .. "/" |
|
end |
|
|
|
-- Parse in:folder or in:"folder with spaces" from input |
|
-- Returns: folder, remainingInput |
|
local function parseInFolder(input) |
|
-- Try to match in:"quoted folder" at the START |
|
local folder, rest = string.match(input, '^in:"([^"]+)"%s*(.*)') |
|
if folder then |
|
return normalizeFolder(folder), rest |
|
end |
|
|
|
-- Try to match in:folder (no spaces) at the START |
|
folder, rest = string.match(input, '^in:(%S+)%s*(.*)') |
|
if folder then |
|
return normalizeFolder(folder), rest |
|
end |
|
|
|
-- Check if in:"quoted" is elsewhere in the input |
|
folder = string.match(input, '%s+in:"([^"]+)"') |
|
if folder then |
|
local remaining = string.gsub(input, '%s+in:"[^"]+"', '') |
|
return normalizeFolder(folder), remaining |
|
end |
|
|
|
-- Check if in:folder is elsewhere in the input |
|
folder = string.match(input, '%s+in:(%S+)') |
|
if folder then |
|
local remaining = string.gsub(input, '%s+in:%S+', '') |
|
return normalizeFolder(folder), remaining |
|
end |
|
|
|
return "", input |
|
end |
|
|
|
local function smartHighlight(text, keyword, highlightFn) |
|
if isCaseSensitive(keyword) then |
|
return string.gsub(text, keyword, highlightFn(keyword), 1) |
|
end |
|
|
|
local lowerText = string.lower(text) |
|
local lowerKw = string.lower(keyword) |
|
local pos = string.find(lowerText, lowerKw, 1, true) |
|
if not pos then return text end |
|
|
|
return |
|
string.sub(text, 1, pos - 1) .. |
|
highlightFn(string.sub(text, pos, pos + #keyword - 1)) .. |
|
string.sub(text, pos + #keyword) |
|
end |
|
|
|
local function buildHierarchicalHeaders(pageName, existingPaths) |
|
local output = {} |
|
local parts = {} |
|
|
|
for part in string.gmatch(pageName, "[^/]+") do |
|
table.insert(parts, part) |
|
end |
|
|
|
local currentPath = "" |
|
for i, part in ipairs(parts) do |
|
if i > 1 then currentPath = currentPath .. "/" end |
|
currentPath = currentPath .. part |
|
|
|
if not existingPaths[currentPath] then |
|
existingPaths[currentPath] = true |
|
if i == #parts then |
|
table.insert(output, headingPrefix(i) .. "[[" .. pageName .. "]]") |
|
elseif config.showParentHeaders then |
|
table.insert(output, headingPrefix(i) .. part) |
|
end |
|
end |
|
end |
|
|
|
return output |
|
end |
|
|
|
local function parseKeywords(input) |
|
local keywords = {} |
|
local i = 1 |
|
local len = #input |
|
|
|
while i <= len do |
|
while i <= len and string.sub(input, i, i):match("%s") do |
|
i = i + 1 |
|
end |
|
if i > len then break end |
|
|
|
local char = string.sub(input, i, i) |
|
|
|
if char == "`" then |
|
local closePos = string.find(input, "`", i + 1, true) |
|
if closePos then |
|
table.insert(keywords, string.sub(input, i + 1, closePos - 1)) |
|
i = closePos + 1 |
|
else |
|
table.insert(keywords, string.sub(input, i + 1)) |
|
break |
|
end |
|
else |
|
local wordEnd = i |
|
while wordEnd <= len do |
|
local c = string.sub(input, wordEnd, wordEnd) |
|
if c:match("%s") or c == "`" then break end |
|
wordEnd = wordEnd + 1 |
|
end |
|
table.insert(keywords, string.sub(input, i, wordEnd - 1)) |
|
i = wordEnd |
|
end |
|
end |
|
|
|
return keywords |
|
end |
|
|
|
-- ============================================ |
|
-- Search helpers |
|
-- ============================================ |
|
local function findAllPositions(content, keyword) |
|
local positions = {} |
|
local start = 1 |
|
|
|
while true do |
|
local pos = string.find(content, keyword, start, true) |
|
if not pos then break end |
|
table.insert(positions, { pos = pos, endPos = pos + #keyword - 1 }) |
|
start = pos + 1 |
|
end |
|
|
|
return positions |
|
end |
|
|
|
local function findSingleKeywordMatches(content, keyword, ctxLen) |
|
local searchContent = isCaseSensitive(keyword) |
|
and content |
|
or string.lower(content) |
|
|
|
local searchKw = isCaseSensitive(keyword) |
|
and keyword |
|
or string.lower(keyword) |
|
|
|
local matches = {} |
|
local len = #content |
|
|
|
local positions = findAllPositions(searchContent, searchKw) |
|
for _, p in ipairs(positions) do |
|
local prefixStart = math.max(1, p.pos - ctxLen) |
|
local suffixEnd = math.min(len, p.endPos + ctxLen) |
|
|
|
table.insert(matches, { |
|
prefix = cleanContext(string.sub(content, prefixStart, p.pos - 1)), |
|
suffix = cleanContext(string.sub(content, p.endPos + 1, suffixEnd)), |
|
keyword = keyword, |
|
pos = p.pos |
|
}) |
|
end |
|
|
|
return matches |
|
end |
|
|
|
local function findMultiKeywordMatches(content, keywords, ctxLen) |
|
local matches = {} |
|
local len = #content |
|
|
|
local searchContents = {} |
|
for i, kw in ipairs(keywords) do |
|
searchContents[i] = isCaseSensitive(kw) |
|
and content |
|
or string.lower(content) |
|
end |
|
|
|
local firstKw = keywords[1] |
|
local firstSearchKw = isCaseSensitive(firstKw) |
|
and firstKw |
|
or string.lower(firstKw) |
|
|
|
local anchors = findAllPositions(searchContents[1], firstSearchKw) |
|
|
|
for _, anchor in ipairs(anchors) do |
|
local windowStart = math.max(1, anchor.pos - ctxLen) |
|
local windowEnd = math.min(len, anchor.endPos + ctxLen) |
|
|
|
local ok = true |
|
for i = 2, #keywords do |
|
local window = string.sub(searchContents[i], windowStart, windowEnd) |
|
local kw = isCaseSensitive(keywords[i]) |
|
and keywords[i] |
|
or string.lower(keywords[i]) |
|
|
|
if not string.find(window, kw, 1, true) then |
|
ok = false |
|
break |
|
end |
|
end |
|
|
|
if ok then |
|
local snippet = cleanContext(string.sub(content, windowStart, windowEnd)) |
|
for i = #keywords, 1, -1 do |
|
local styleIndex = ((i - 1) % #highlightStyles) + 1 |
|
snippet = smartHighlight( |
|
snippet, |
|
keywords[i], |
|
highlightStyles[styleIndex] |
|
) |
|
end |
|
table.insert(matches, { snippet = snippet, pos = windowStart }) |
|
end |
|
end |
|
|
|
return matches |
|
end |
|
|
|
-- ============================================ |
|
-- Core search |
|
-- ============================================ |
|
local function searchGlobalOptimized(keywordInput) |
|
-- Parse in:folder syntax from input |
|
local folder, remainingInput = parseInFolder(keywordInput) |
|
|
|
local keywords = parseKeywords(remainingInput) |
|
if #keywords == 0 then return nil, 0, 0, {}, "" end |
|
|
|
local results = {} |
|
local matchCount = 0 |
|
local pageCount = 0 |
|
local existingPaths = {} |
|
folder = normalizeFolder(folder) |
|
|
|
for _, page in ipairs(space.listPages()) do |
|
if not string.find(page.name, "^search:") and isInFolder(page.name, folder) then |
|
local content = space.readPage(page.name) |
|
if content then |
|
local pageMatches = {} |
|
|
|
if #keywords == 1 then |
|
local matches = findSingleKeywordMatches( |
|
content, |
|
keywords[1], |
|
config.contextLength |
|
) |
|
|
|
for i, m in ipairs(matches) do |
|
-- Use character position (0-based) instead of line/column |
|
local link = string.format("[[%s@%d|↗]]", page.name, m.pos - 1) |
|
table.insert(pageMatches, |
|
string.format("%d. …%s%s%s… %s", i, m.prefix, highlightStyles[1](m.keyword), m.suffix, link) |
|
) |
|
end |
|
else |
|
local matches = findMultiKeywordMatches( |
|
content, |
|
keywords, |
|
config.contextLength |
|
) |
|
|
|
for i, m in ipairs(matches) do |
|
-- Use character position (0-based) instead of line/column |
|
local link = string.format("[[%s@%d|↗]]", page.name, m.pos - 1) |
|
table.insert(pageMatches, |
|
string.format("%d. …%s… %s", i, m.snippet, link) |
|
) |
|
end |
|
end |
|
|
|
if #pageMatches > 0 then |
|
pageCount = pageCount + 1 |
|
for _, h in ipairs(buildHierarchicalHeaders(page.name, existingPaths)) do |
|
table.insert(results, h) |
|
end |
|
for _, m in ipairs(pageMatches) do |
|
table.insert(results, m) |
|
matchCount = matchCount + 1 |
|
end |
|
table.insert(results, "") |
|
end |
|
end |
|
end |
|
end |
|
|
|
return results, matchCount, pageCount, keywords, folder |
|
end |
|
|
|
-- ============================================ |
|
-- UI Helpers |
|
-- ============================================ |
|
local function buildKeywordLegend(keywords) |
|
local out = {} |
|
for i, kw in ipairs(keywords) do |
|
out[i] = highlightStyles[((i - 1) % #highlightStyles) + 1](kw) |
|
end |
|
return table.concat(out, " AND ") |
|
end |
|
|
|
-- ============================================ |
|
-- Virtual Page Definition |
|
-- ============================================ |
|
virtualPage.define { |
|
pattern = "search:(.+)", |
|
run = function(keywordInput) |
|
keywordInput = keywordInput:trim() |
|
|
|
local results, matchCount, pageCount, keywords, folder = |
|
searchGlobalOptimized(keywordInput) |
|
|
|
local folderInfo = folder ~= "" and (" | Folder: `" .. folder .. "`") or "" |
|
local output = { |
|
"# 🔍 Search Results", |
|
string.format( |
|
"> Keywords: %s | Matches: %d | Pages: %d%s", |
|
buildKeywordLegend(keywords), |
|
matchCount, |
|
pageCount, |
|
folderInfo |
|
), |
|
"" |
|
} |
|
|
|
if matchCount == 0 then |
|
table.insert(output, "😔 **No results found**") |
|
else |
|
for _, line in ipairs(results) do |
|
table.insert(output, line) |
|
end |
|
end |
|
|
|
return table.concat(output, "\n") |
|
end |
|
} |
|
|
|
-- ============================================ |
|
-- Command |
|
-- ============================================ |
|
command.define { |
|
name = "Search: Find in files", |
|
run = function() |
|
local initialValue = "" |
|
|
|
local currentPage = editor.getCurrentPage() |
|
if currentPage then |
|
local existingQuery = string.match(currentPage, "^search:(.+)") |
|
if existingQuery then |
|
initialValue = existingQuery |
|
end |
|
end |
|
|
|
local keyword = editor.prompt( |
|
"Find in files", |
|
initialValue |
|
) |
|
|
|
if keyword and keyword:trim() ~= "" then |
|
editor.navigate("search:" .. keyword:trim()) |
|
end |
|
end, |
|
key = "Ctrl-Shift-f", |
|
mac = "Cmd-Shift-f", |
|
priority = 1, |
|
} |
|
|
|
-- search term: do this `that too` |
|
-- would search for: "do" and "this" and "that too" |
|
-- |
|
-- Folder filter syntax: |
|
-- in:visuals/touchdesigner myterm -> search "myterm" in folder "visuals/touchdesigner" |
|
-- in:"visuals/touch designer" myterm -> search "myterm" in folder with spaces |
|
-- myterm in:folder -> folder can also be at the end |