diff --git a/CHANGELOG.md b/CHANGELOG.md index c40d7f24..4751d0b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added file-watch: code that tracks changed notes in the vault. +- Added cache: a JSON file, which stores aliases, last update date and path to the note. Updated using file-watch. +- Added a new configuration option - `cache`, which is disabled by default. - Allow custom directory and ID logic for templates - When filling out a template with user-provided substitution functions, pass a "context" object to each invocation so that users can respond accordingly. - Added `obsidian.InsertTemplateContext` and `obsidian.CloneTemplateContext` as these new "context" objects. @@ -104,6 +107,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactored workspace module for a better api. - Fixed types in `opts.workspaces[*].overrides` to all be optional. +### Changed +- In telescope an option was added to search notes by aliases. + ## [v3.12.0](https://github.com/obsidian-nvim/obsidian.nvim/releases/tag/v3.12.0) - 2025-06-05 ### Added diff --git a/README.md b/README.md index 4a21e5c6..b63dc816 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ There's one entry point user command for this plugin: `Obsidian` - `:Obsidian yesterday` to open/create the daily note for the previous working day. +- `:Obsidian rebuild_cache` to manually update the cache of the workspace. + ### Demo [![2024-01-31 14 22 52](https://github.com/epwalsh/obsidian.nvim/assets/8812459/2986e1d2-13e8-40e2-9c9e-75691a3b662e)](https://github.com/epwalsh/obsidian.nvim/assets/8812459/2986e1d2-13e8-40e2-9c9e-75691a3b662e) @@ -566,6 +568,13 @@ require("obsidian").setup { format = "{{properties}} properties {{backlinks}} backlinks {{words}} words {{chars}} chars", }, + -- Experimental feature, disabled by default. + cache = { + enabled = false, + path = "./.cache.json", + show_tags = false -- will show tags after a note name and after aliases. + }, + ---@class obsidian.config.FooterOpts --- ---@field enabled? boolean diff --git a/doc/obsidian_api.txt b/doc/obsidian_api.txt index 83c5dde4..244218c7 100644 --- a/doc/obsidian_api.txt +++ b/doc/obsidian_api.txt @@ -331,10 +331,11 @@ Return ~ Class ~ {obsidian.note.LoadOpts} Fields ~ -{max_lines} `(integer|)`? -{load_contents} `(boolean|)`? -{collect_anchor_links} `(boolean|)`? -{collect_blocks} `(boolean|)`? +{max_lines} `(integer|? Stops)` reading file if reached max lines. +{read_only_frontmatter} `(boolean|? Stops)` reading file if reached end of frontmatter. +{load_contents} `(boolean|? Save)` contents of the file if not frontmatter or block to obsidian.Note.contents. Still reads whole file if other options are true. +{collect_anchor_links} `(boolean|? Save)` anchor_links of the file if not in frontmatter or block to obsidian.Note.anchor_links. Still reads whole file if other options are true. +{collect_blocks} `(boolean|? Save)` code blocks to obsidian.Note.blocks. Still reads whole file if other options are true. ------------------------------------------------------------------------------ *obsidian.Note.from_file()* @@ -892,4 +893,4 @@ Parameters ~ Return ~ `(string)` `(optional)` - vim:tw=78:ts=8:noet:ft=help:norl: \ No newline at end of file + vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/obsidian/api.lua b/lua/obsidian/api.lua index c8284f22..2bb3bef1 100644 --- a/lua/obsidian/api.lua +++ b/lua/obsidian/api.lua @@ -590,6 +590,152 @@ M.get_icon = function(path) return nil end +---Gets all notes from the vault. +---@param path string The path to the vault. +---@return string[]|? The path to the notes. +M.get_all_notes_from_vault = function(path) + local files = {} + + local cmd + local find_command + + -- checking for executable which support .ignore or .gitignore + if 1 == vim.fn.executable "rg" then + find_command = { "rg", "--files", "--color", "never", "--glob", "'*.md'", path } + cmd = "rg" + elseif 1 == vim.fn.executable "fd" then + find_command = { "fd", "--type", "f", "--color", "never", "--extension", "md", "--base-directory", path } + cmd = "fd" + elseif 1 == vim.fn.executable "fdfind" then + find_command = { "fdfind", "--type", "f", "--color", "never", "--extension", "md", "--base-directory", path } + cmd = "fdfind" + end + + if cmd then + local handle = io.popen(table.concat(find_command, " ")) + + if not handle then + log.error("couldn't execute cmd " .. cmd) + return {} + end + + for file in handle:lines() do + files[#files + 1] = file + end + else + -- If we couldn't find one of the executable, fallback to default implemendation + -- which doesn't respect .ignore or .gitignore. + for name, t in vim.fs.dir(path, { depth = 10 }) do + if t == "file" and vim.endswith(name, ".md") then + local full_path = vim.fs.joinpath(path, name) + files[#files + 1] = full_path + end + end + end + + return files +end + +---Use greedy search to get the folder of the file. +---@param file_path string +---@param sep string +---@return string +local get_directory_path = function(file_path, sep) + return file_path:match("(.*" .. sep .. ")") +end + +---Gets all subfolders from the vault. +---@param path string The path to the vault. +---@return string[]|? The path to the subfolders +M.get_sub_dirs_from_vault = function(path) + local subdirs = {} + + local cmd + local find_command + + -- checking for executable which support .ignore or .gitignore + if 1 == vim.fn.executable "fd" then + find_command = { "fd", "--type", "d", "--color", "never", "--base-directory", path } + cmd = "fd" + elseif 1 == vim.fn.executable "fdfind" then + find_command = { "fdfind", "--type", "f", "--color", "never", "--base-directory", path } + cmd = "fdfind" + elseif 1 == vim.fn.executable "rg" then + -- rg doesn't support searching folders, so we will filter output manually. + -- Unfortunatly, this won't return folders which are empty. + find_command = { "rg", "--files", "--color", "never", "--glob", "'*.md'", path } + cmd = "rg" + end + + if cmd then + local handle = io.popen(table.concat(find_command, " ")) + + if not handle then + log.error("couldn't execute cmd " .. cmd) + return {} + end + + if cmd == "rg" then + local path_separator = "/" + + local sysname = M.get_os() + + if sysname == M.OSType.Windows then + path_separator = "\\" + end + + for file in handle:lines() do + local subdir = get_directory_path(file, path_separator) + + if not vim.tbl_contains(subdirs, subdir) then + subdirs[#subdirs + 1] = subdir + end + end + else + for subdir in handle:lines() do + subdirs[#subdirs + 1] = subdir + end + end + else + -- the user doesn't have tools which support .gitignore or .ignore, + -- fallback to getting all folders + for name, t in vim.fs.dir(path, { depth = 10 }) do + if t == "directory" then + local full_path = vim.fs.joinpath(path, name) + subdirs[#subdirs + 1] = full_path + end + end + end + + return subdirs +end + +---Cross-platform check if the pid exists. +---@param pid string +---@return boolean +M.check_pid_exists = function(pid) + local system = M.get_os() + + if system == M.OSType.Windows then + local cmd = string.format('tasklist /FI "PID eq %d" /NH', pid) + local pipe = io.popen(cmd) + assert(pipe) + local output = pipe:read() + pipe:close() + + return output:match(pid) ~= nil + else + local cmd = "kill -0 " .. pid + local ok = os.execute(cmd) + + if ok then + return true + else + return false + end + end +end + --- Resolve a basename to full path inside the vault. --- ---@param src string diff --git a/lua/obsidian/cache.lua b/lua/obsidian/cache.lua new file mode 100644 index 00000000..a5c2ed24 --- /dev/null +++ b/lua/obsidian/cache.lua @@ -0,0 +1,347 @@ +local async = require "obsidian.async" +local Note = require "obsidian.note" +local log = require "obsidian.log" +local EventTypes = require("obsidian.filewatch").EventTypes +local uv = vim.uv +local api = require "obsidian.api" +local util = require "obsidian.util" + +---This table allows you to find the notes in your vault more quickly. +---It scans your vault and saves the founded metadata to the file specified in your CacheOpts (by default it's ".cache.json"). +---For now, this allows to search for alises of your +---notes in much shorter time. +local M = {} + +---Contains some information from the metadata of your note plus additional info. +---@class obsidian.cache.CacheNote +--- +---@field absolute_path string The full path to the note. +---@field aliases string[] The alises of the note founded in the frontmatter. +---@field last_updated number The last time the note was updated in seconds since epoch. +---@field tags string[] The tags of the note. + +---Converts the cache to JSON and saves to the file at the given path. +---@param cache_notes { [string]: obsidian.cache.CacheNote } Dictionary where key is the relative path and value is the cache of the note. +---@param cache_file_path string Location to save the cache +local save_cache_notes_to_file = function(cache_notes, cache_file_path) + local file, err = io.open(cache_file_path, "w") + + if file then + file:write(vim.json.encode(cache_notes)) + file:close() + else + error(table.concat { "Couldn't write vault index to the file: ", cache_file_path, ". Description: ", err }) + end +end + +local get_cache_path = function() + local workspace_path = Obsidian.dir.filename + local opts = Obsidian.opts + local normalized_path = vim.fs.normalize(opts.cache.path) + return vim.fs.joinpath(workspace_path, normalized_path) +end + +---@param cache_notes { [string]: obsidian.cache.CacheNote } Dictionary where key is the relative path and value is the cache of the note. +local update_cache = function(cache_notes) + Obsidian.cache = cache_notes + save_cache_notes_to_file(cache_notes, get_cache_path()) +end + +---Creates a funciton, which updates the cache file when notes in workspace are changed. +---@return fun (changed_files: obsidian.filewatch.CallbackArgs[]) +local create_on_file_change_callback = function() + return function(changed_files) + local workspace_path = Obsidian.dir.filename + + vim.schedule(function() + local cache_notes = Obsidian.cache + + if not cache_notes then + return + end + + local update_cache_file = function() + vim.schedule(function() + update_cache(cache_notes) + end) + end + + local left = #changed_files + + for _, file in ipairs(changed_files) do + local relative_path = file.absolute_path:gsub(workspace_path .. "/", "") + + ---@param note obsidian.Note|? + local update_cache_dictionary = function(err, note) + if err then + log.err("an error occured when reading from a note. " .. err) + else + if note then -- check, because a note can be deleted. If deleted, the note variable is empty. + ---@type obsidian.cache.CacheNote + local founded_cache = { + absolute_path = file.absolute_path, + aliases = note.aliases, + last_updated = file.stat.mtime.sec, + tags = note.tags, + } + + cache_notes[relative_path] = founded_cache + end + end + + left = left - 1 + + if left == 0 then + update_cache_file() + end + end + + if file.event == EventTypes.deleted and cache_notes[relative_path] then + cache_notes[relative_path] = nil + update_cache_dictionary() + else + async.run(function() + return Note.from_file_async(file.absolute_path, { read_only_frontmatter = true }) + end, update_cache_dictionary) + end + end + end) + end +end + +---Reads the cache file from client.opts.cache.path and returns the loaded cache. +---@return { [string]: obsidian.cache.CacheNote }|? Key is the relative path to the vault, value is the cache of the note. +local get_cache_notes_from_file = function() + local file, err = io.open(get_cache_path(), "r") + + if file then + local links_json = file:read() + file:close() + return vim.json.decode(links_json) + elseif err then + log.err(err) + end + + return nil +end + +---Checks for note cache that were updated outside the vault. +local check_cache_notes_are_fresh = function() + local workspace_path = Obsidian.dir.filename + + local founded_notes = api.get_all_notes_from_vault(workspace_path) + local old_cache_notes = get_cache_notes_from_file() + + if not old_cache_notes or not founded_notes then + return + end + + ---@type { [string]: obsidian.cache.CacheNote } + local updated = {} + local completed = 0 + local total = #founded_notes + local on_done = function() + completed = completed + 1 + + if completed == total then + vim.schedule(function() + update_cache(updated) + end) + end + end + + for _, founded_note in ipairs(founded_notes) do + local relative_path = founded_note:gsub(workspace_path .. "/", "") + + uv.fs_stat(founded_note, function(err, stat) + if err then + -- If the err is occured, the file is deleted, so we don't need to add it to the list. + on_done() + return + end + + local cache_note = old_cache_notes[relative_path] + + local aliases + local tags + if cache_note and cache_note.last_updated == stat.mtime.sec then + aliases = cache_note.aliases + tags = cache_note.tags + else + local note = Note.from_file(founded_note, { read_only_frontmatter = true }) + aliases = note.aliases + tags = note.tags + end + + ---@type obsidian.cache.CacheNote + local updated_cache = { + absolute_path = founded_note, + last_updated = stat.mtime.sec, + aliases = aliases, + tags = tags, + } + + updated[relative_path] = updated_cache + + on_done() + end) + end +end + +---Checks that file exits +---@param path string +---@param callback fun (result: boolean) +local check_file_exists = function(path, callback) + uv.fs_stat(path, function(err, _) + callback(err == nil) + end) +end + +---Watches the vault for changes. +local enable_filewatch = function() + local workspace_path = Obsidian.dir.filename + + -- We need a lock file to check if a neovim instance is open in the workspace. + -- This prevents creating more filewatches in the same workspace which can lead to bugs and can decrease performaance. + local lock_name = table.concat { Obsidian.dir.stem, ".lock" } + local obsidian_state_path = vim.fs.joinpath(vim.fn.stdpath "state", "obsidian/") + + local stat_of_state_folder = uv.fs_stat(obsidian_state_path) + + if not stat_of_state_folder then + ---@diagnostic disable-next-line: param-type-mismatch + local mode = assert(tonumber(755, 8)) + local ok = uv.fs_mkdir(obsidian_state_path, mode) + + if not ok then + log.err("Couldn't create folder at " .. obsidian_state_path) + return + end + end + + local lock_file_path = vim.fs.joinpath(obsidian_state_path, lock_name) + + if uv.fs_stat(lock_file_path) then + local lock_file = io.open(lock_file_path, "r") + + assert(lock_file) + + local pid = lock_file:read() + lock_file:close() + + if api.check_pid_exists(pid) then + return + end + end + + local current_nvim_pid = uv.os_getpid() + util.write_file(lock_file_path, tostring(current_nvim_pid)) + + local filewatch = require "obsidian.filewatch" + filewatch.watch(workspace_path, create_on_file_change_callback()) + + vim.api.nvim_create_autocmd({ "QuitPre", "ExitPre" }, { + callback = function() + filewatch.release_resources() + os.remove(lock_file_path) + end, + }) +end + +local check_vault_cache = function() + check_file_exists(get_cache_path(), function(exists) + if exists then + vim.schedule(function() + check_cache_notes_are_fresh() + end) + else + vim.schedule(function() + M.rebuild_cache() + end) + end + end) +end + +--- Reads all notes in the vaults and returns the founded data. +---@param callback fun (note_caches: { [string]: obsidian.cache.CacheNote }) +local get_cache_notes_from_vault = function(callback) + local workspace_path = Obsidian.dir.filename + + ---@type { [string]: obsidian.cache.CacheNote } + local created_note_caches = {} + + local founded_notes = api.get_all_notes_from_vault(workspace_path) + + assert(founded_notes) + + local notes_parsed = 0 + + local on_exit = function() + callback(created_note_caches) + end + + local on_note_parsed = function(err, note) + if err then + log.err("an error occured when reading from a note. " .. err) + return + end + + local absolute_path = note.path.filename + local relative_path = absolute_path:gsub(workspace_path .. "/", "") + + local file_stat = uv.fs_stat(absolute_path) + local last_updated + + if type(file_stat) ~= "table" then + log.err(table.concat { "couldn't get file stat from file ", absolute_path }) + last_updated = 0 + else + last_updated = file_stat.mtime.sec + end + + ---@type obsidian.cache.CacheNote + local note_cache = { + absolute_path = absolute_path, + aliases = note.aliases, + last_updated = last_updated, + tags = note.tags or {}, + } + + created_note_caches[relative_path] = note_cache + + notes_parsed = notes_parsed + 1 + + if notes_parsed == #founded_notes then + on_exit() + end + end + + for _, note_path in ipairs(founded_notes) do + async.run(function() + return Note.from_file_async(note_path, { read_only_frontmatter = true }) + end, on_note_parsed) + end +end + +--- Reads all notes in the vaults and saves them to the cache file. +M.rebuild_cache = function() + if not Obsidian.opts.cache.enabled then + log.error "The cache is disabled. Cannot rebuild cache." + return + end + + log.info "Rebuilding cache..." + + get_cache_notes_from_vault(function(cache_notes) + update_cache(cache_notes) + log.info "The cache was rebuild." + end) +end + +M.activate_cache = function() + enable_filewatch() + + check_vault_cache() +end + +return M diff --git a/lua/obsidian/commands/init.lua b/lua/obsidian/commands/init.lua index 9095e887..3fd33c5f 100644 --- a/lua/obsidian/commands/init.lua +++ b/lua/obsidian/commands/init.lua @@ -240,6 +240,8 @@ M.register("new_from_template", { nargs = "*" }) M.register("quick_switch", { nargs = "?" }) +M.register("rebuild_cache", { nargs = "?" }) + M.register("workspace", { nargs = "?" }) --------------------- diff --git a/lua/obsidian/commands/quick_switch.lua b/lua/obsidian/commands/quick_switch.lua index edf8101b..dbdcebf2 100644 --- a/lua/obsidian/commands/quick_switch.lua +++ b/lua/obsidian/commands/quick_switch.lua @@ -10,7 +10,7 @@ return function(_, data) return end - picker:find_notes() + picker:find_notes { use_cache = Obsidian.opts.cache.enabled } else search.resolve_note_async(data.args, function(note) if not note then diff --git a/lua/obsidian/commands/rebuild_cache.lua b/lua/obsidian/commands/rebuild_cache.lua new file mode 100644 index 00000000..93ac8c92 --- /dev/null +++ b/lua/obsidian/commands/rebuild_cache.lua @@ -0,0 +1,7 @@ +local cache = require "obsidian.cache" + +---@param client obsidian.Client +---@param data CommandArgs +return function(client, data) + cache:rebuild_cache() +end diff --git a/lua/obsidian/config.lua b/lua/obsidian/config.lua index 36df0f91..568c8e7f 100644 --- a/lua/obsidian/config.lua +++ b/lua/obsidian/config.lua @@ -64,6 +64,7 @@ local config = {} ---@field callbacks obsidian.config.CallbackConfig ---@field legacy_commands boolean ---@field statusline obsidian.config.StatuslineOpts +---@field cache obsidian.config.CacheOpts ---@field footer obsidian.config.FooterOpts ---@field open obsidian.config.OpenOpts ---@field checkbox obsidian.config.CheckboxOpts @@ -318,6 +319,17 @@ config.default = { ---@field post_set_workspace? fun(workspace: obsidian.Workspace) callbacks = {}, + ---@class obsidian.config.CacheOpts + --- + ---@field enabled boolean|? Use cache when searching for notes + ---@field path string The file where the cache will be saved + ---@field show_tags boolean|? Show tags in the picker + cache = { + enabled = false, + path = "./.cache.json", + show_tags = false, + }, + ---@class obsidian.config.StatuslineOpts --- ---@field format? string @@ -606,6 +618,8 @@ see https://github.com/obsidian-nvim/obsidian.nvim/wiki/Commands for details. table.insert(opts.workspaces, 1, { path = opts.dir }) end + opts.cache = tbl_override(defaults.cache, opts.cache) + return opts end diff --git a/lua/obsidian/filewatch.lua b/lua/obsidian/filewatch.lua new file mode 100644 index 00000000..0a0b021b --- /dev/null +++ b/lua/obsidian/filewatch.lua @@ -0,0 +1,218 @@ +local uv = vim.loop +local api = require "obsidian.api" + +local M = {} + +---@enum obsidian.filewatch.EventType +M.EventTypes = { + unknown = 0, + changed = 1, + renamed = 2, + deleted = 3, +} + +---@class obsidian.filewatch.CallbackArgs +---@field absolute_path string The absolute path to the changed file. +---@field event obsidian.filewatch.EventType The type of the event. +---@field stat uv.fs_stat.result|? The uv file info. + +---Creates default callback if error occured in fs_event_start. +---@param path string The filepath where error occured. +---@return fun(error: string) Default function which accepts an error. +local make_default_error_cb = function(path) + return function(error) + error(table.concat { "obsidian.watch(", path, ")", "encountered an error: ", error }) + end +end + +--- Minimal time in milliseconds to allow the event to fire for a single file. +local MIN_INTERVAL = 50 +--- The time in milleseconds when the changed files will be send to the client. +local CALLBACK_AFTER_INTERVAL = 500 + +---@type obsidian.filewatch.CallbackArgs[] +local queue_to_send = {} +---@type uv.uv_timer_t +local queue_timer +---@type uv.uv_fs_event_t[] +local watch_handlers = {} + +---Check if the event is not a duplicate or the received name is not `~` or a number. +---@param filename string +---@param last_received_files {[string]: number|?} +---@return boolean +local can_fire_callback = function(filename, last_received_files) + local now = uv.now() + + local last_callback_time = last_received_files[filename] + + if last_callback_time then + if now - last_callback_time < MIN_INTERVAL then + return false + end + end + + last_received_files[filename] = now + + if filename:sub(#filename - 2, #filename) ~= ".md" then + return false + end + + return true +end + +--- Watch path and calls on_event(filename, event_type) or on_error(error) +---@param path string +---@param on_event fun (changed_files: obsidian.filewatch.CallbackArgs[]) +---@param on_error fun (err: string) +---@param opts {recursive: boolean} +---@return uv.uv_fs_event_t +local function watch_path(path, on_event, on_error, opts) + local handle = uv.new_fs_event() + + if not handle then + error "couldn't create event handler" + end + + local flags = { + watch_entry = false, -- true = if you pass dir, watch the dir inode only, not the dir content + stat = false, -- true = don't use inotify/kqueue but periodic check, not implemented + recursive = opts.recursive, -- true = watch dirs inside dirs. For now only works on Windows and MacOS + } + + ---@type {[string]: number|?} + local last_received_files = {} + + ---Tracks the changed files and returns them to the client after some time. + ---@param send_arg obsidian.filewatch.CallbackArgs + local add_to_queue = function(send_arg) + table.insert(queue_to_send, send_arg) + + queue_timer:stop() + + queue_timer:start(CALLBACK_AFTER_INTERVAL, 0, function() + on_event(queue_to_send) + queue_to_send = {} + end) + end + + local event_cb = function(err, filename, events) + if err then + on_error(err) + return + end + + if not can_fire_callback(filename, last_received_files) then + return + end + + local folder_path = uv.fs_event_getpath(handle) + + local full_path = vim.fs.joinpath(folder_path, filename) + + uv.fs_stat(full_path, function(stat_err, stat) + if stat_err then + on_event { + absolute_path = full_path, + event = M.EventTypes.deleted, + stat = nil, + } + return + end + + local event_type + if events.change then + event_type = M.EventTypes.changed + elseif events.rename then + event_type = M.EventTypes.renamed + else + event_type = M.EventTypes.unknown + end + + add_to_queue { + absolute_path = full_path, + event = event_type, + stat = stat, + } + end) + end + + local success, err = uv.fs_event_start(handle, path, flags, event_cb) + + if not success then + error("couldn't create fs event! error - " .. err .. ". Path - " .. path) + end + + return handle +end + +---Create a watch handler (several if on Linux) which calls the callback function when a file is changed. +---Calls the callback function only after CALLBACK_AFTER_INTERVAL. +---If an error occured, called on_error function. +---TODO if a new folder will be created, it won't be tracked. +---@param folder_path string The path to the watch folder. +---@param callback fun (changed_files: obsidian.filewatch.CallbackArgs[]) +---@param on_error fun (err: string)|? +M.watch = function(folder_path, callback, on_error) + if not folder_path or folder_path == "" then + error "Path cannot be empty." + end + + assert(callback) + + for _, handler in ipairs(watch_handlers) do + local handlerPath = handler:getpath() + + if handlerPath and folder_path == handlerPath then + error("a file watch handler is already created for the given path - " .. folder_path) + return + end + end + + if on_error == nil then + on_error = make_default_error_cb(folder_path) + end + + local new_timer = uv.new_timer() + + assert(new_timer) + + queue_timer = new_timer + + local sysname = api.get_os() + + -- uv doesn't support recursive flag on Linux + if sysname == api.OSType.Linux then + table.insert(watch_handlers, watch_path(folder_path, callback, on_error, { recursive = false })) + + local subfolders = api.get_sub_dirs_from_vault(folder_path) + + assert(subfolders) + + for _, subfolder in ipairs(subfolders) do + table.insert(watch_handlers, watch_path(subfolder, callback, on_error, { recursive = false })) + end + else + watch_handlers = { watch_path(folder_path, callback, on_error, { recursive = true }) } + end +end + +M.release_resources = function() + for _, handle in ipairs(watch_handlers) do + if handle then + handle:stop() + if not handle.is_closing then + handle:close() + end + end + end + + watch_handlers = {} + + queue_timer:stop() + if not queue_timer.is_closing then + queue_timer:close() + end +end + +return M diff --git a/lua/obsidian/init.lua b/lua/obsidian/init.lua index 67c389dc..8853cfe3 100644 --- a/lua/obsidian/init.lua +++ b/lua/obsidian/init.lua @@ -50,6 +50,7 @@ obsidian.setup = function(opts) ---@field dir obsidian.Path Root of the vault for the current workspace. ---@field buf_dir obsidian.Path|? Parent directory of the current buffer. ---@field opts obsidian.config.ClientOpts Current options. + ---@field cache { [string]: obsidian.cache.CacheNote } The cached notes to use ---@field _opts obsidian.config.ClientOpts User input options. _G.Obsidian = {} @@ -59,7 +60,20 @@ obsidian.setup = function(opts) Obsidian._opts = opts - obsidian.Workspace.set(Obsidian.workspaces[1]) + local cwd_path = vim.uv.cwd() + + assert(cwd_path) + + local selected_workspace = Obsidian.workspaces[1] + + for _, value in ipairs(Obsidian.workspaces) do + if value.path.filename == cwd_path then + selected_workspace = value + break + end + end + + obsidian.Workspace.set(selected_workspace) log.set_level(Obsidian.opts.log_level) diff --git a/lua/obsidian/note.lua b/lua/obsidian/note.lua index 87a89db0..c9d76870 100644 --- a/lua/obsidian/note.lua +++ b/lua/obsidian/note.lua @@ -524,10 +524,11 @@ Note.get_field = function(self, key) end ---@class obsidian.note.LoadOpts ----@field max_lines integer|? ----@field load_contents boolean|? ----@field collect_anchor_links boolean|? ----@field collect_blocks boolean|? +---@field max_lines integer|? Stops reading file if reached max lines. +---@field read_only_frontmatter boolean|? Stops reading file if reached end of frontmatter. +---@field load_contents boolean|? Save contents of the file if not frontmatter or block to obsidian.Note.contents. Still reads whole file if other options are true. +---@field collect_anchor_links boolean|? Save anchor_links of the file if not in frontmatter or block to obsidian.Note.anchor_links. Still reads whole file if other options are true. +---@field collect_blocks boolean|? Save code blocks to obsidian.Note.blocks. Still reads whole file if other options are true. --- Initialize a note from a file. --- @@ -763,6 +764,8 @@ Note.from_lines = function(lines, path, opts) if line_idx > max_lines or (title and not opts.load_contents and not opts.collect_anchor_links and not opts.collect_blocks) + or (opts.read_only_frontmatter and not has_frontmatter) + or (opts.read_only_frontmatter and has_frontmatter and not in_frontmatter) then break end diff --git a/lua/obsidian/pickers/_telescope.lua b/lua/obsidian/pickers/_telescope.lua index 02842e75..12e8f2ed 100644 --- a/lua/obsidian/pickers/_telescope.lua +++ b/lua/obsidian/pickers/_telescope.lua @@ -2,6 +2,7 @@ local telescope = require "telescope.builtin" local telescope_actions = require "telescope.actions" local actions_state = require "telescope.actions.state" +local api = require "obsidian.api" local Path = require "obsidian.path" local abc = require "obsidian.abc" local Picker = require "obsidian.pickers.picker" @@ -15,6 +16,13 @@ local TelescopePicker = abc.new_class({ end, }, Picker) +---@class obsidian.pickers.telescope_picker.CacheSelectedEntry +--- +---@field value obsidian.cache.CacheNote[] +---@field display string +---@field ordinal string +---@field absolute_path string + ---@param prompt_bufnr integer ---@param keep_open boolean|? ---@return table|? @@ -125,6 +133,79 @@ local function attach_picker_mappings(map, opts) end end +---Creates custom picker to search the notes using obsidian.cache.NoteCache +---@param self obsidian.pickers.TelescopePicker +---@param prompt_title string +---@param opts obsidian.PickerFindOpts +---@return obsidian.Picker +local create_cache_picker = function(self, prompt_title, opts) + local pickers = require "telescope.pickers" + local finders = require "telescope.finders" + local config = require("telescope.config").values + local actions = require "telescope.actions" + + local picker_opts = { + prompt_title = prompt_title, + attach_mappings = function(prompt_bufnr, map) + actions.select_default:replace(function() + local selection = get_entry(prompt_bufnr, false) + + if not selection or not selection.absolute_path then + return + end + + vim.schedule(function() + local open_cmd = api.get_open_strategy(Obsidian.opts.open_notes_in) + api.open_buffer(selection.absolute_path, { cmd = open_cmd }) + end) + end) + + attach_picker_mappings(map, { + entry_key = "absolute_path", + callback = opts.callback, + query_mappings = opts.query_mappings, + selection_mappings = opts.selection_mappings, + }) + return true + end, + } + + local cache_without_relative_path = vim.tbl_values(Obsidian.cache) + local workspace_path = Obsidian.dir.filename + return pickers.new(picker_opts, { + cwd = opts.dir, + finder = finders.new_table { + results = cache_without_relative_path, + ---@param entry obsidian.cache.CacheNote + ---@return obsidian.pickers.telescope_picker.CacheSelectedEntry + entry_maker = function(entry) + local concated_aliases = table.concat(entry.aliases, "|") + local concated_tags = table.concat(entry.tags, " #") + local relative_path = entry.absolute_path:gsub(workspace_path .. "/", "") + local display_name + + if concated_aliases and concated_aliases ~= "" then + display_name = table.concat({ relative_path, concated_aliases }, "|") + else + display_name = relative_path + end + + if Obsidian.opts.cache.show_tags and concated_tags and concated_tags ~= "" then + display_name = table.concat({ display_name, concated_tags }, " #") + end + + return { + value = entry, + display = display_name, + ordinal = display_name, + absolute_path = entry.absolute_path, + } + end, + }, + sorter = config.generic_sorter(), + }) +end + ---@param opts obsidian.PickerFindOpts|? Options. TelescopePicker.find_files = function(self, opts) opts = opts or {} @@ -135,23 +216,28 @@ TelescopePicker.find_files = function(self, opts) selection_mappings = opts.selection_mappings, } - telescope.find_files { - prompt_title = prompt_title, - cwd = opts.dir and tostring(opts.dir) or tostring(Obsidian.dir), - find_command = self:_build_find_cmd(), - attach_mappings = function(_, map) - attach_picker_mappings(map, { - callback = function(entry) - if opts.callback then - opts.callback(entry.filename) - end - end, - query_mappings = opts.query_mappings, - selection_mappings = opts.selection_mappings, - }) - return true - end, - } + if opts.use_cache then + ---@diagnostic disable-next-line: undefined-field + create_cache_picker(self, prompt_title, opts):find() + else + telescope.find_files { + prompt_title = prompt_title, + cwd = opts.dir and tostring(opts.dir) or tostring(Obsidian.dir), + find_command = self:_build_find_cmd(), + attach_mappings = function(_, map) + attach_picker_mappings(map, { + callback = function(entry) + if opts.callback then + opts.callback(entry.filename) + end + end, + query_mappings = opts.query_mappings, + selection_mappings = opts.selection_mappings, + }) + return true + end, + } + end end ---@param opts obsidian.PickerGrepOpts|? Options. diff --git a/lua/obsidian/pickers/picker.lua b/lua/obsidian/pickers/picker.lua index 9461a017..9e01327f 100644 --- a/lua/obsidian/pickers/picker.lua +++ b/lua/obsidian/pickers/picker.lua @@ -38,6 +38,7 @@ end ---@field no_default_mappings boolean|? ---@field query_mappings obsidian.PickerMappingTable|? ---@field selection_mappings obsidian.PickerMappingTable|? +---@field use_cache boolean|? --- Find files in a directory. --- @@ -127,7 +128,7 @@ end --- Find notes by filename. --- ----@param opts { prompt_title: string|?, callback: fun(path: string)|?, no_default_mappings: boolean|? }|? Options. +---@param opts { prompt_title: string|?, callback: fun(path: string)|?, no_default_mappings: boolean|?, use_cache :boolean|? }|? Options. --- --- Options: --- `prompt_title`: Title for the prompt window. @@ -152,6 +153,7 @@ Picker.find_notes = function(self, opts) no_default_mappings = opts.no_default_mappings, query_mappings = query_mappings, selection_mappings = selection_mappings, + use_cache = opts.use_cache, } end diff --git a/lua/obsidian/search.lua b/lua/obsidian/search.lua index 55ca08f5..7efd5f05 100644 --- a/lua/obsidian/search.lua +++ b/lua/obsidian/search.lua @@ -272,9 +272,9 @@ end ---@field ignore_case boolean|? ---@field smart_case boolean|? ---@field exclude string[]|? paths to exclude ----@field max_count_per_file integer|? +---@field max_count_per_file integer|? Limit the number of matching lines per file searched to max_count_per_file. ---@field escape_path boolean|? ----@field include_non_markdown boolean|? +---@field include_non_markdown boolean|? search for .*? If false, searches for .md local SearchOpts = {} M.SearchOpts = SearchOpts diff --git a/lua/obsidian/workspace.lua b/lua/obsidian/workspace.lua index cdf15683..1f99df21 100644 --- a/lua/obsidian/workspace.lua +++ b/lua/obsidian/workspace.lua @@ -180,6 +180,10 @@ Workspace.set = function(workspace, opts) pattern = "ObsidianWorkpspaceSet", data = { workspace = workspace }, }) + + if options.cache.enabled then + require("obsidian.cache").activate_cache() + end end ---@param workspace string name of workspace