author: Chenzhu-Xie name: Library/xczphysics/CONFIG/Search/Text tags: meta/library

pageDecoration.prefix: "🌐 "

Global Search

add 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,
}

Virtual Page Ver 2

-- ============================================
-- 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,
}

Virtual Page Ver 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
}

Old Ver

  1. https://community.silverbullet.md/t/fixing-silver-bullets-chinese-search-gap-a-space-lua-global-search-implementation/3157
-- 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
}