author: Chenzhu-Xie name: Library/xczphysics/CONFIG/Search/Text tags: meta/library
and logic Ver 3== o k ==
==o k==
o k
== sadf ==
sadf
==sadf==
o k
sadf
sadf
== sadf ==
-- ============================================
-- Configuration
-- ============================================
local config = {
showParentHeaders = false,
contextLength = 50 -- size of context window (for multi-)
}
-- Highlight styles for different keywords (recycling)
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,
}
-- Clean context string (remove newlines)
local function cleanContext(s)
if not s then return "" end
return string.gsub(s, "[\r\n]+", " ↩ ")
end
-- Generate heading prefix based on depth
local function headingPrefix(depth)
if depth > 6 then depth = 6 end
return string.rep("#", depth) .. " "
end
-- Build hierarchical headers for a page path
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 .. "](" .. pageName .. ")")
elseif config.showParentHeaders then
table.insert(output, headingPrefix(i) .. part)
end
end
end
return output
end
-- Parse keywords from input
-- Supports: `phrase with spaces` as single keyword, space separates others
local function parseKeywords(input)
local keywords = {}
local i = 1
local len = #input
while i <= len do
-- Skip whitespace
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
-- Find closing backtick
local closePos = string.find(input, "`", i + 1, true)
if closePos then
local phrase = string.sub(input, i + 1, closePos - 1)
if phrase ~= "" then
table.insert(keywords, phrase)
end
i = closePos + 1
else
-- No closing backtick, treat rest as keyword
local phrase = string.sub(input, i + 1)
if phrase ~= "" then
table.insert(keywords, phrase)
end
break
end
else
-- Regular word until whitespace or backtick
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
local word = string.sub(input, i, wordEnd - 1)
if word ~= "" then
table.insert(keywords, word)
end
i = wordEnd
end
end
return keywords
end
-- Find all positions of a keyword in content (plain text search)
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,
keyword = keyword
})
start = pos + 1
end
return positions
end
-- For single keyword: find all occurrences with context
local function findSingleKeywordMatches(content, keyword, ctxLen)
local matches = {}
local positions = findAllPositions(content, keyword)
local contentLen = #content
for _, p in ipairs(positions) do
local prefixStart = math.max(1, p.pos - ctxLen)
local suffixEnd = math.min(contentLen, p.endPos + ctxLen)
local prefix = cleanContext(string.sub(content, prefixStart, p.pos - 1))
local suffix = cleanContext(string.sub(content, p.endPos + 1, suffixEnd))
table.insert(matches, {
prefix = prefix,
suffix = suffix,
keyword = keyword
})
end
return matches
end
-- For multiple keywords: find contexts where ALL keywords appear within window
local function findMultiKeywordMatches(content, keywords, ctxLen)
local matches = {}
local contentLen = #content
-- Get all positions for first keyword
local firstKeywordPositions = findAllPositions(content, keywords[1])
for _, anchor in ipairs(firstKeywordPositions) do
-- Define search window around this anchor
local windowStart = math.max(1, anchor.pos - ctxLen)
local windowEnd = math.min(contentLen, anchor.endPos + ctxLen)
local window = string.sub(content, windowStart, windowEnd)
-- Check if all other keywords exist in this window
local allFound = true
local keywordPositionsInWindow = {}
-- Add first keyword position (relative to window)
table.insert(keywordPositionsInWindow, {
keyword = keywords[1],
keywordIndex = 1,
relPos = anchor.pos - windowStart + 1,
len = #keywords[1]
})
for i = 2, #keywords do
local kw = keywords[i]
local relPos = string.find(window, kw, 1, true)
if not relPos then
allFound = false
break
end
table.insert(keywordPositionsInWindow, {
keyword = kw,
keywordIndex = i,
relPos = relPos,
len = #kw
})
end
if allFound then
-- Build highlighted snippet
-- Sort by position descending for safe replacement
table.sort(keywordPositionsInWindow, function(a, b)
return a.relPos > b.relPos
end)
local snippet = cleanContext(window)
-- Recalculate positions after cleaning (newlines become spaces)
-- Re-find each keyword in cleaned snippet
local cleanedPositions = {}
for _, kp in ipairs(keywordPositionsInWindow) do
local pos = string.find(snippet, kp.keyword, 1, true)
if pos then
table.insert(cleanedPositions, {
keyword = kp.keyword,
keywordIndex = kp.keywordIndex,
relPos = pos,
len = kp.len
})
end
end
-- Sort descending and apply highlights
table.sort(cleanedPositions, function(a, b)
return a.relPos > b.relPos
end)
for _, p in ipairs(cleanedPositions) do
local styleIndex = ((p.keywordIndex - 1) % #highlightStyles) + 1
local highlightFn = highlightStyles[styleIndex]
local before = string.sub(snippet, 1, p.relPos - 1)
local after = string.sub(snippet, p.relPos + p.len)
snippet = before .. highlightFn(p.keyword) .. after
end
table.insert(matches, { snippet = snippet })
end
end
return matches
end
-- Core search function
local function searchGlobalOptimized(keywordInput)
if not keywordInput or keywordInput == "" then
return nil, 0, 0, {}
end
local keywords = parseKeywords(keywordInput)
if #keywords == 0 then
return nil, 0, 0, {}
end
local results = {}
local matchCount = 0
local pageCount = 0
local ctxLen = config.contextLength
local pages = space.listPages()
local existingPaths = {}
for _, page in ipairs(pages) do
if not string.find(page.name, "^search:") then
local content = space.readPage(page.name)
if content then
local pageMatches = {}
if #keywords == 1 then
-- Single keyword search
local kw = keywords[1]
if string.find(content, kw, 1, true) then
local singleMatches = findSingleKeywordMatches(content, kw, ctxLen)
for i, m in ipairs(singleMatches) do
local styleIndex = 1
local highlightFn = highlightStyles[styleIndex]
local formatted = string.format(
"%d. …%s%s%s…",
i,
m.prefix,
highlightFn(m.keyword),
m.suffix
)
table.insert(pageMatches, formatted)
end
end
else
-- Multi-keyword AND search within context window
local multiMatches = findMultiKeywordMatches(content, keywords, ctxLen)
for i, m in ipairs(multiMatches) do
local formatted = string.format("%d. …%s…", i, m.snippet)
table.insert(pageMatches, formatted)
end
end
if #pageMatches > 0 then
pageCount = pageCount + 1
local headers = buildHierarchicalHeaders(page.name, existingPaths)
for _, header in ipairs(headers) do
table.insert(results, header)
end
for _, match in ipairs(pageMatches) do
table.insert(results, match)
matchCount = matchCount + 1
end
table.insert(results, "")
end
end
end
end
return results, matchCount, pageCount, keywords
end
-- Build keyword legend for header
local function buildKeywordLegend(keywords)
local parts = {}
for i, kw in ipairs(keywords) do
local styleIndex = ((i - 1) % #highlightStyles) + 1
local highlightFn = highlightStyles[styleIndex]
table.insert(parts, highlightFn(kw))
end
return table.concat(parts, " AND ")
end
-- Virtual Page: search:keyword
virtualPage.define {
pattern = "search:(.+)",
run = function(keywordInput)
keywordInput = keywordInput:trim()
if not keywordInput or keywordInput == "" then
return [
# ⚠️ Search Error
Please provide search keywords.
**Usage:**
- `search:keyword` - single keyword
- `search:word1 word2` - AND logic (both must appear within context window)
- `search:`phrase with spaces`` - backticks for exact phrase
](
# ⚠️ Search Error
Please provide search keywords.
**Usage:**
- `search:keyword` - single keyword
- `search:word1 word2` - AND logic (both must appear within context window)
- `search:`phrase with spaces`` - backticks for exact phrase
)
end
local results, matchCount, pageCount, keywords = searchGlobalOptimized(keywordInput)
local output = {}
table.insert(output, "# 🔍 Search Results")
local legend = buildKeywordLegend(keywords)
table.insert(output, string.format(
"> Keywords: %s | Matches: %d | Pages: %d",
legend, matchCount, pageCount
))
if matchCount == 0 then
table.insert(output, "")
table.insert(output, "😔 **No results found**")
table.insert(output, "")
table.insert(output, "Suggestions:")
table.insert(output, "1. Check spelling")
table.insert(output, "2. Try fewer keywords")
table.insert(output, "3. Keywords are case-sensitive")
if #keywords > 1 then
table.insert(output, "4. All keywords must appear within " .. config.contextLength .. " characters of each other")
end
else
table.insert(output, "")
for _, line in ipairs(results) do
table.insert(output, line)
end
end
return table.concat(output, "\n")
end
}
-- Command: Global Search
command.define {
name = "Global Search",
run = function()
local keyword = editor.prompt("🔍 Search (space=AND, phrase=`a b`)", "")
if keyword and keyword:trim() ~= "" then
editor.navigate("search:" .. keyword:trim())
end
end,
key = "Ctrl-Shift-f",
mac = "Cmd-Shift-f",
priority = 1,
}
-- ============================================
-- Configuration
-- ============================================
local config = {
showParentHeaders = false,
contextLength = 15
}
-- Escape Lua pattern special characters
local function escapeLuaPattern(s)
return (s:gsub("([%%%.%[%]%*%+%-%?%^%$%(%)%{%}])", "%%%1"))
end
-- Clean context string (remove newlines, truncate)
local function cleanContext(s, maxLen)
if not s then return "" end
local cleaned = string.gsub(s, "[\r\n]+", " ")
if maxLen and #cleaned > maxLen then
cleaned = string.sub(cleaned, 1, maxLen) .. "…"
end
return cleaned
end
-- Generate heading prefix based on depth
local function headingPrefix(depth)
if depth > 6 then depth = 6 end
return string.rep("#", depth) .. " "
end
-- Build hierarchical headers for a page path
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 .. "](" .. pageName .. ")")
elseif config.showParentHeaders then
table.insert(output, headingPrefix(i) .. part)
end
end
end
return output
end
-- Extract all matches with context using plain string operations
local function findAllMatches(content, keyword)
local matches = {}
local startPos = 1
local keywordLen = #keyword
local contentLen = #content
local ctxLen = config.contextLength
while true do
local foundPos = string.find(content, keyword, startPos, true)
if not foundPos then break end
local prefixStart = math.max(1, foundPos - ctxLen)
local suffixEnd = math.min(contentLen, foundPos + keywordLen - 1 + ctxLen)
local prefix = string.sub(content, prefixStart, foundPos - 1)
local suffix = string.sub(content, foundPos + keywordLen, suffixEnd)
table.insert(matches, {
prefix = cleanContext(prefix, ctxLen),
suffix = cleanContext(suffix, ctxLen)
})
startPos = foundPos + 1
end
return matches
end
-- Core search function
local function searchGlobalOptimized(keyword)
if not keyword or keyword == "" then
return nil, 0, 0
end
local results = {}
local matchCount = 0
local pageCount = 0
local pages = space.listPages()
local existingPaths = {}
for _, page in ipairs(pages) do
if not string.find(page.name, "^search:") then
local content = space.readPage(page.name)
if content and string.find(content, keyword, 1, true) then
local allMatches = findAllMatches(content, keyword)
if #allMatches > 0 then
pageCount = pageCount + 1
local headers = buildHierarchicalHeaders(page.name, existingPaths)
for _, header in ipairs(headers) do
table.insert(results, header)
end
for i, match in ipairs(allMatches) do
local formatted = string.format(
"%d. …%s==%s==%s…",
i,
match.prefix,
keyword,
match.suffix
)
table.insert(results, formatted)
matchCount = matchCount + 1
end
table.insert(results, "")
end
end
end
end
return results, matchCount, pageCount
end
-- Virtual Page: search:keyword
virtualPage.define {
pattern = "search:(.+)",
run = function(keyword)
keyword = keyword:trim()
if not keyword or keyword == "" then
return [
# ⚠️ Search Error
Please provide a search keyword.
**Usage:** Navigate to `search:your_keyword` or use command `Global Search`
](
# ⚠️ Search Error
Please provide a search keyword.
**Usage:** Navigate to `search:your_keyword` or use command `Global Search`
)
end
local results, matchCount, pageCount = searchGlobalOptimized(keyword)
local output = {}
table.insert(output, "# 🔍 Search Results")
table.insert(output, string.format(
"> **Keyword:** ==`%s`== | **Matches:** %d | **Pages:** %d",
keyword, matchCount, pageCount
))
if matchCount == 0 then
table.insert(output, "")
table.insert(output, "😔 **No results found**")
table.insert(output, "")
table.insert(output, "Suggestions:")
table.insert(output, "1. Check spelling")
table.insert(output, "2. Try shorter or more general keywords")
table.insert(output, "3. Keywords are case-sensitive")
else
table.insert(output, "")
for _, line in ipairs(results) do
table.insert(output, line)
end
end
return table.concat(output, "\n")
end
}
-- Command: Global Search
command.define {
name = "Search: Text",
run = function()
local keyword = editor.prompt("🔍 Global Search", "")
if keyword and keyword:trim() ~= "" then
editor.navigate("search:" .. keyword:trim())
end
end,
key = "Ctrl-Shift-f",
mac = "Cmd-Shift-f",
priority = 1,
}
-- priority: 11
-- ============================================
-- 工具函数:转义 Lua 正则特殊字符
-- ============================================
-- Escape Lua pattern special characters
local function escapeLuaPattern(s)
local matches = {
["^"] = "%^", ["$"] = "%$", ["("] = "%(", [")"] = "%)",
["%"] = "%%", ["."] = "%." , ["["] = "%[", ["]"] = "%]",
["*"] = "%*", ["+"] = "%+", ["-"] = "%-", ["?"] = "%?"
}
return string.gsub(s, ".", matches)
end
-- ============================================
-- 工具函数:清理字符串(去除首尾空白、替换换行符)
-- ============================================
local function cleanContext(s, maxLen)
if not s then return "" end
-- 替换换行为空格,避免破坏列表格式
local cleaned = string.gsub(s, "[\r\n]+", " ")
-- 截断过长内容
if maxLen and #cleaned > maxLen then
cleaned = string.sub(cleaned, 1, maxLen) .. "…"
end
return cleaned
end
-- ============================================
-- 核心搜索函数(优化版)
-- ============================================
local function searchGlobalOptimized(keyword)
if not keyword or keyword == "" then
return nil, 0
end
local output = {}
local matchCount = 0
local pageCount = 0
-- 预编译 pattern(Lua 实际上每次 gmatch 都编译,但这里统一定义)
local escapedKeyword = escapeLuaPattern(keyword)
-- 捕获 keyword 前后各 15 个字符作为上下文
local pattern = "(.{0,15})" .. escapedKeyword .. "(.{0,15})"
local pages = space.listPages()
for _, page in ipairs(pages) do
-- 跳过搜索结果页本身,防止死循环
if not string.find(page.name, "^search:") then
local content = space.readPage(page.name)
if content then
-- [核心优化] 先用 plain string search 快速检查
-- string.find 的第4个参数 true 表示纯文本匹配,不解释 pattern
if string.find(content, keyword, 1, true) then
local pageMatches = {}
-- 只有确认包含关键词后,才执行昂贵的正则提取
for prefix, suffix in string.gmatch(content, pattern) do
local cleanPrefix = cleanContext(prefix, 15)
local cleanSuffix = cleanContext(suffix, 15)
-- 使用 SilverBullet 的高亮语法 ==keyword==
local formatted = string.format(
"* …%s==%s==%s…",
cleanPrefix,
keyword,
cleanSuffix
)
table.insert(pageMatches, formatted)
matchCount = matchCount + 1
end
-- 只有有匹配时才输出该页面
if #pageMatches > 0 then
pageCount = pageCount + 1
-- 页面标题(可点击的 wiki link)
table.insert(output, "### [" .. page.name .. "](" .. page.name .. ")")
-- 该页面的所有匹配项
for _, match in ipairs(pageMatches) do
table.insert(output, match)
end
-- 页面之间的空行分隔
table.insert(output, "")
end
end
end
end
end
return output, matchCount, pageCount
end
-- ============================================
-- 定义 Virtual Page:search:关键词
-- ============================================
virtualPage.define {
pattern = "search:(.+)",
run = function(keyword)
-- 清理关键词
keyword = keyword:trim()
if not keyword or keyword == "" then
return [
# ⚠️ 搜索错误
请提供搜索关键词。
**用法:** 导航到 `search:你的关键词` 或使用命令 `Global Search`
](
# ⚠️ 搜索错误
请提供搜索关键词。
**用法:** 导航到 `search:你的关键词` 或使用命令 `Global Search`
)
end
-- 执行搜索
local results, matchCount, pageCount = searchGlobalOptimized(keyword)
-- 构建输出
local output = {}
-- 头部信息
table.insert(output, "# 🔍 搜索结果")
table.insert(output, "")
table.insert(output, string.format(
"> **关键词:** `%s` | **匹配:** %d 处 | **页面:** %d 个",
keyword, matchCount, pageCount
))
table.insert(output, "")
if matchCount == 0 then
table.insert(output, "---")
table.insert(output, "")
table.insert(output, "😔 **未找到匹配结果**")
table.insert(output, "")
table.insert(output, "建议:")
table.insert(output, "* 检查拼写是否正确")
table.insert(output, "* 尝试使用更短或更通用的关键词")
table.insert(output, "* 关键词区分大小写")
else
table.insert(output, "---")
table.insert(output, "")
-- 合并搜索结果
for _, line in ipairs(results) do
table.insert(output, line)
end
end
-- 使用 table.concat 高效拼接(比 .. 连接高效得多)
return table.concat(output, "\n\n")
end
}
-- ============================================
-- 定义命令:唤起全局搜索
-- ============================================
command.define {
name = "Global Search",
run = function()
local keyword = editor.prompt("🔍 全局搜索", "输入关键词...")
if keyword and keyword:trim() ~= "" then
-- 直接导航到 virtual page,由 SilverBullet 原生渲染
editor.navigate("search:" .. keyword:trim())
end
end,
key = "Ctrl-Alt-f",
mac = "Cmd-Alt-f",
priority = 1,
}
command.define {
name = "Close Search Panel",
run = function()
editor.hidePanel('rhs')
end
}
-- Escape regular expression special characters in keywords
local function escapeKeyword(keyword)
-- List of regular expression special characters: . ^ $ * + ? ( ) [ ] { } | \
local specialChars = {
["."] = "%.",
["^"] = "%^",
["$"] = "%$",
["*"] = "%*",
["+"] = "%+",
["?"] = "%?",
["("] = "%(",
[")"] = "%)",
["["] = "%[",
["]"] = "%]",
["{"] = "%{",
["}"] = "%}",
["|"] = "%|",
["\\"] = "\\\\"
}
-- Replace special characters in the keyword with their escaped versions
return string.gsub(keyword, ".", function(char)
return specialChars[char] or char
end)
end
-- Extract 10 characters before and after the keyword (handles cases with fewer than 10 characters)
-- Parameters: content (content to search), keyword (keyword to search for)
-- Returns: Iterator (each iteration returns a match result in the format: prefix + keyword + suffix)
local function extractKeywordContext(content, keyword)
-- 1. Escape the keyword (handle special characters)
local escapedKeyword = escapeKeyword(keyword)
-- 2. Build regular expression pattern (0-10 characters before + keyword + 0-10 characters after)
local pattern = ".{0,10}" .. escapedKeyword .. ".{0,10}"
-- 3. Use string.gmatch to iterate through matches and concatenate results
return string.gmatch(content, pattern)
end
local function searchGlobal(keyword)
local result = ""
local pages = space.listPages()
for i, page in ipairs(pages) do
local matchs = ""
local content = space.readPage(page.name)
for match in extractKeywordContext(content, keyword) do
matchs = matchs .. "* " .. match .. '\n'
end
if #matchs > 0 then
result = result .. "## [" .. page.name .. "](" .. page.name .. ")\n" .. matchs
end
end
return result
end
command.define {
name = "Global Search",
run = function()
-- local keyword = editor.prompt("Keyword","")
local keyword = usrPrompt(Keyword, "")
if keyword and #keyword > 0 then
local res = searchGlobal(keyword)
if #res > 0 then
editor.showPanel('rhs', 1, markdown.markdownToHtml(res))
else
editor.flashNotification('not found', 'warn')
end
end
end,
key = "Ctrl-Alt-f",
mac = "Cmd-Alt-f",
priority = 1,
}
command.define {
name = "close Global Search",
run = function()
editor.hidePanel('rhs')
end
}