Major performance improvements for graph visualization: Cache System: - Add cache.lua module that stores parsed links/tags per file - Only re-parse files that have been modified (mtime check) - Cache stored in .ideadrop-graph-cache.json in idea_dir - Fast file scanning using vim.fs.find when available Layout Optimizations: - Reduce max iterations from 300 to 100 - Faster convergence with adjusted parameters - Barnes-Hut approximation for large graphs (100+ nodes) - Cache math functions locally for speed - Skip distant node pairs in repulsion calculation - Reuse visible_nodes array across iterations New Commands: - :IdeaGraph rebuild - Force full cache rebuild - :IdeaGraphClearCache - Clear cache file This makes opening the graph nearly instant for previously scanned vaults, similar to Obsidian's behavior.
274 lines
6.4 KiB
Lua
274 lines
6.4 KiB
Lua
-- ideaDrop/ui/graph/cache.lua
|
|
-- Graph cache system for fast loading - compatible with Obsidian vaults
|
|
|
|
local config = require("ideaDrop.core.config")
|
|
|
|
---@class GraphCache
|
|
---@field load fun(): table|nil
|
|
---@field save fun(data: table): nil
|
|
---@field get_file_mtime fun(path: string): number
|
|
---@field is_stale fun(cached_entry: table, file_path: string): boolean
|
|
local M = {}
|
|
|
|
-- Cache filename
|
|
local CACHE_FILE = ".ideadrop-graph-cache.json"
|
|
local OBSIDIAN_CACHE = ".obsidian/graph.json"
|
|
|
|
---Gets the cache file path
|
|
---@return string
|
|
local function get_cache_path()
|
|
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
|
|
idea_dir = vim.fn.expand(idea_dir or "")
|
|
return idea_dir .. "/" .. CACHE_FILE
|
|
end
|
|
|
|
---Gets the Obsidian cache file path
|
|
---@return string
|
|
local function get_obsidian_cache_path()
|
|
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
|
|
idea_dir = vim.fn.expand(idea_dir or "")
|
|
return idea_dir .. "/" .. OBSIDIAN_CACHE
|
|
end
|
|
|
|
---Gets file modification time
|
|
---@param path string File path
|
|
---@return number Modification time (0 if file doesn't exist)
|
|
function M.get_file_mtime(path)
|
|
local stat = vim.loop.fs_stat(path)
|
|
if stat then
|
|
return stat.mtime.sec
|
|
end
|
|
return 0
|
|
end
|
|
|
|
---Checks if a cached entry is stale (file has been modified)
|
|
---@param cached_entry table Cached file data
|
|
---@param file_path string Current file path
|
|
---@return boolean True if cache is stale
|
|
function M.is_stale(cached_entry, file_path)
|
|
if not cached_entry or not cached_entry.mtime then
|
|
return true
|
|
end
|
|
local current_mtime = M.get_file_mtime(file_path)
|
|
return current_mtime ~= cached_entry.mtime
|
|
end
|
|
|
|
---Loads the cache from disk
|
|
---@return table|nil Cache data or nil if not found/invalid
|
|
function M.load()
|
|
local cache_path = get_cache_path()
|
|
|
|
-- Check if cache file exists
|
|
if vim.fn.filereadable(cache_path) == 0 then
|
|
return nil
|
|
end
|
|
|
|
-- Read and parse cache
|
|
local ok, content = pcall(vim.fn.readfile, cache_path)
|
|
if not ok or #content == 0 then
|
|
return nil
|
|
end
|
|
|
|
local json_str = table.concat(content, "\n")
|
|
local ok2, data = pcall(vim.fn.json_decode, json_str)
|
|
if not ok2 or type(data) ~= "table" then
|
|
return nil
|
|
end
|
|
|
|
return data
|
|
end
|
|
|
|
---Saves the cache to disk
|
|
---@param data table Cache data to save
|
|
function M.save(data)
|
|
local cache_path = get_cache_path()
|
|
|
|
local ok, json_str = pcall(vim.fn.json_encode, data)
|
|
if not ok then
|
|
return
|
|
end
|
|
|
|
-- Write cache file
|
|
local file = io.open(cache_path, "w")
|
|
if file then
|
|
file:write(json_str)
|
|
file:close()
|
|
end
|
|
end
|
|
|
|
---Tries to load Obsidian's graph cache
|
|
---@return table|nil Obsidian graph data or nil
|
|
function M.load_obsidian_cache()
|
|
local obsidian_path = get_obsidian_cache_path()
|
|
|
|
if vim.fn.filereadable(obsidian_path) == 0 then
|
|
return nil
|
|
end
|
|
|
|
local ok, content = pcall(vim.fn.readfile, obsidian_path)
|
|
if not ok or #content == 0 then
|
|
return nil
|
|
end
|
|
|
|
local json_str = table.concat(content, "\n")
|
|
local ok2, data = pcall(vim.fn.json_decode, json_str)
|
|
if not ok2 or type(data) ~= "table" then
|
|
return nil
|
|
end
|
|
|
|
return data
|
|
end
|
|
|
|
---Extracts links from content (fast version)
|
|
---@param content string File content
|
|
---@return string[] Array of link targets
|
|
function M.extract_links_fast(content)
|
|
local links = {}
|
|
local seen = {}
|
|
|
|
-- Fast pattern matching for [[link]] and [[link|alias]]
|
|
for link in content:gmatch("%[%[([^%]|]+)") do
|
|
link = link:gsub("^%s+", ""):gsub("%s+$", "") -- trim
|
|
if link ~= "" and not seen[link] then
|
|
links[#links + 1] = link
|
|
seen[link] = true
|
|
end
|
|
end
|
|
|
|
return links
|
|
end
|
|
|
|
---Extracts tags from content (fast version)
|
|
---@param content string File content
|
|
---@return string[] Array of tags
|
|
function M.extract_tags_fast(content)
|
|
local tags = {}
|
|
local seen = {}
|
|
|
|
for tag in content:gmatch("#([%w%-_]+)") do
|
|
if not seen[tag] and #tag > 1 then
|
|
tags[#tags + 1] = tag
|
|
seen[tag] = true
|
|
end
|
|
end
|
|
|
|
return tags
|
|
end
|
|
|
|
---Builds cache data for a single file
|
|
---@param file_path string File path
|
|
---@return table|nil File cache entry
|
|
function M.build_file_cache(file_path)
|
|
local mtime = M.get_file_mtime(file_path)
|
|
if mtime == 0 then
|
|
return nil
|
|
end
|
|
|
|
-- Read file content
|
|
local ok, lines = pcall(vim.fn.readfile, file_path)
|
|
if not ok then
|
|
return nil
|
|
end
|
|
|
|
local content = table.concat(lines, "\n")
|
|
|
|
return {
|
|
mtime = mtime,
|
|
links = M.extract_links_fast(content),
|
|
tags = M.extract_tags_fast(content),
|
|
}
|
|
end
|
|
|
|
---Gets all markdown files using fast directory scan
|
|
---@param idea_dir string Directory to scan
|
|
---@return string[] Array of file paths
|
|
function M.scan_files_fast(idea_dir)
|
|
local files = {}
|
|
|
|
-- Use vim.fs.find for faster scanning (Neovim 0.8+)
|
|
if vim.fs and vim.fs.find then
|
|
local found = vim.fs.find(function(name)
|
|
return name:match("%.md$")
|
|
end, {
|
|
path = idea_dir,
|
|
type = "file",
|
|
limit = math.huge,
|
|
})
|
|
return found
|
|
end
|
|
|
|
-- Fallback to glob
|
|
files = vim.fn.glob(idea_dir .. "/**/*.md", false, true)
|
|
if #files == 0 then
|
|
files = vim.fn.glob(idea_dir .. "/*.md", false, true)
|
|
end
|
|
|
|
return files
|
|
end
|
|
|
|
---Builds or updates the complete cache
|
|
---@param force boolean|nil Force full rebuild
|
|
---@return table Cache data with files map
|
|
function M.build_cache(force)
|
|
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
|
|
idea_dir = vim.fn.expand(idea_dir or ""):gsub("/$", "")
|
|
|
|
-- Load existing cache
|
|
local cache = nil
|
|
if not force then
|
|
cache = M.load()
|
|
end
|
|
cache = cache or { files = {}, version = 1 }
|
|
|
|
-- Get all current files
|
|
local current_files = M.scan_files_fast(idea_dir)
|
|
local current_files_set = {}
|
|
for _, f in ipairs(current_files) do
|
|
current_files_set[f] = true
|
|
end
|
|
|
|
-- Remove deleted files from cache
|
|
local new_files_cache = {}
|
|
for path, entry in pairs(cache.files or {}) do
|
|
if current_files_set[path] then
|
|
new_files_cache[path] = entry
|
|
end
|
|
end
|
|
cache.files = new_files_cache
|
|
|
|
-- Update cache for new/modified files
|
|
local updated = 0
|
|
local skipped = 0
|
|
|
|
for _, file_path in ipairs(current_files) do
|
|
local cached = cache.files[file_path]
|
|
|
|
if M.is_stale(cached, file_path) then
|
|
local entry = M.build_file_cache(file_path)
|
|
if entry then
|
|
cache.files[file_path] = entry
|
|
updated = updated + 1
|
|
end
|
|
else
|
|
skipped = skipped + 1
|
|
end
|
|
end
|
|
|
|
-- Save updated cache
|
|
if updated > 0 then
|
|
M.save(cache)
|
|
end
|
|
|
|
return cache, updated, skipped
|
|
end
|
|
|
|
---Clears the cache
|
|
function M.clear()
|
|
local cache_path = get_cache_path()
|
|
if vim.fn.filereadable(cache_path) == 1 then
|
|
vim.fn.delete(cache_path)
|
|
end
|
|
end
|
|
|
|
return M
|