From 87720793f18275a2baf101ff7266adbb0a4b6e68 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sat, 13 Apr 2024 21:06:48 -0500 Subject: [PATCH] Implement roam origin (#19) * Implement roam origin functionality * Add origin as option to node api * Update expr_under_cursor, link_under_cursor, and node_under_cursor to support explicit window * Add open config option for node view * Update select-node to take exclusion list * Update add_origin to exclude self * Update node view to support navigating to origin * Add goto_next_node and goto_prev_node api methods --- DOCS.org | 326 +++++++++++++++++++++------ README.org | 28 ++- lua/org-roam/api.lua | 5 + lua/org-roam/api/node.lua | 36 +-- lua/org-roam/api/origin.lua | 127 +++++++++++ lua/org-roam/config.lua | 20 ++ lua/org-roam/core/database.lua | 23 +- lua/org-roam/core/file.lua | 25 +- lua/org-roam/core/file/node.lua | 4 + lua/org-roam/database.lua | 20 ++ lua/org-roam/database/loader.lua | 4 +- lua/org-roam/database/schema.lua | 18 +- lua/org-roam/setup.lua | 44 ++++ lua/org-roam/ui/node-view.lua | 10 +- lua/org-roam/ui/node-view/window.lua | 34 +++ lua/org-roam/ui/select-node.lua | 26 ++- lua/org-roam/utils.lua | 46 +++- spec/database_spec.lua | 16 ++ spec/files/three.org | 1 + spec/files/two.org | 1 + 20 files changed, 671 insertions(+), 143 deletions(-) create mode 100644 lua/org-roam/api/origin.lua diff --git a/DOCS.org b/DOCS.org index bc1b3d2..19e7a77 100644 --- a/DOCS.org +++ b/DOCS.org @@ -66,25 +66,39 @@ Configuration settings used to specify keybindings. *** add alias - + Adds an alias to the node under cursor. - + Takes a string representing the keybinding. Defaults to =naa=. - + + #+begin_src lua + require("org-roam"):setup({ + bindings = { + add_alias = "naa", + }, + }) + #+end_src + +*** add origin + + Adds an origin to the node under cursor. + + Takes a string representing the keybinding. Defaults to =noa=. + #+begin_src lua require("org-roam"):setup({ - add_alias = { - capture = "naa", + bindings = { + add_origin = "noa", }, }) #+end_src *** capture - + Opens a roam capture window. - + Takes a string representing the keybinding. Defaults to =nc=. - + #+begin_src lua require("org-roam"):setup({ bindings = { @@ -92,15 +106,15 @@ }, }) #+end_src - + *** complete at point - + Completes the node under cursor by searching for a node with matching title or alias. If exactly one match is found, the text under cursor is replaced with the link; otherwise, a selection dialog pops up to pick the node. - + Takes a string representing the keybinding. Defaults to =n.=. - + #+begin_src lua require("org-roam"):setup({ bindings = { @@ -108,16 +122,16 @@ }, }) #+end_src - + *** find node - + Finds a node by title or alias and opens it in the current window. - + If the node does not exist, opens a capture buffer for the new node using the title. - + Takes a string representing the keybinding. Defaults to =nf=. - + #+begin_src lua require("org-roam"):setup({ bindings = { @@ -125,16 +139,47 @@ }, }) #+end_src - + +*** goto next node + + Goes to the next node sequentially based on origin of the node under cursor. + + If more than one node has the node under cursor as its origin, a selection + dialog is displayed to choose the node. + + Takes a string representing the keybinding. Defaults to =nn=. + + #+begin_src lua + require("org-roam"):setup({ + bindings = { + goto_next_node = "nn", + }, + }) + #+end_src + +*** goto prev node + + Goes to the previous node sequentially based on origin of the node under cursor. + + Takes a string representing the keybinding. Defaults to =np=. + + #+begin_src lua + require("org-roam"):setup({ + bindings = { + goto_prev_node = "np", + }, + }) + #+end_src + *** insert node - + Inserts a link at cursor position to a node by title or alias. - + If the node does not exist, opens a capture buffer for the new node using the title. - + Takes a string representing the keybinding. Defaults to =ni=. - + #+begin_src lua require("org-roam"):setup({ bindings = { @@ -142,14 +187,14 @@ }, }) #+end_src - + *** insert node immediate - + Inserts a link at cursor position to a node by title or alias. Unlike =insert_node=, this does not open a capture buffer if a new node is created. - + Takes a string representing the keybinding. Defaults to =nm=. - + #+begin_src lua require("org-roam"):setup({ bindings = { @@ -157,14 +202,14 @@ }, }) #+end_src - + *** quickfix backlinks - + Opens the quickfix list, populating it with backlinks for the node under cursor. - + Takes a string representing the keybinding. Defaults to =nq=. - + #+begin_src lua require("org-roam"):setup({ bindings = { @@ -174,27 +219,41 @@ #+end_src *** remove alias - + Removes an alias from the node under cursor. - + Takes a string representing the keybinding. Defaults to =nar=. - + #+begin_src lua require("org-roam"):setup({ - remove_alias = { - capture = "nar", + bindings = { + remove_alias = "nar", }, }) #+end_src - + +*** remove origin + + Removes the origin from the node under cursor. + + Takes a string representing the keybinding. Defaults to =nor=. + + #+begin_src lua + require("org-roam"):setup({ + bindings = { + remove_origin = "nor", + }, + }) + #+end_src + *** toggle roam buffer - + Opens the roam buffer for the node under cursor, updating the buffer when the cursor moves to a different node. See the user interface [[#org-roam-buffer][Org Roam Buffer]] section for details. - + Takes a string representing the keybinding. Defaults to =nl=. - + #+begin_src lua require("org-roam"):setup({ bindings = { @@ -202,15 +261,15 @@ }, }) #+end_src - + *** toggle roam buffer fixed - + Opens the roam buffer for a specific node, and will not change as the cursor moves across nodes. See the user interface [[#org-roam-buffer][Org Roam Buffer]] section for details. - + Takes a string representing the keybinding. Defaults to =nb=. - + #+begin_src lua require("org-roam"):setup({ bindings = { @@ -224,13 +283,13 @@ Configuration settings tied to the roam database. *** path - + Sets the path where the roam database will be stored & loaded when persisting to disk. - + Takes a string representing the path. Defaults to For example, =~/.local/share/nvim/org-roam.nvim/db=. - + #+begin_src lua require("org-roam"):setup({ database = { @@ -238,15 +297,15 @@ }, }) #+end_src - + *** persist - + If true, the database will be written to disk to save on future loading times; otherwise, whenever neovim boots the entire database will need to be rebuilt. - + Takes a boolean. Defaults to =true=. - + #+begin_src lua require("org-roam"):setup({ database = { @@ -254,15 +313,15 @@ }, }) #+end_src - + *** update on save - + If true, updates database whenever a write occurs. If you have large files, it is recommended to disable this option and manually update using the vim command =RoamUpdate=. - + Takes a boolean. Defaults to =true=. - + #+begin_src lua require("org-roam"):setup({ database = { @@ -278,9 +337,9 @@ *** target Target where the immediate-mode node should be written. - + Takes a string. Defaults to =%r%[sep]%<%Y%m%d%H%M%S>-%[slug].org=. - + #+begin_src lua require("org-roam"):setup({ immediate = { @@ -288,13 +347,13 @@ }, }) #+end_src - + *** template Template to use for the immediate-mode node's content. - + Takes a string. Defaults to ==. - + #+begin_src lua require("org-roam"):setup({ immediate = { @@ -302,7 +361,7 @@ }, }) #+end_src - + ** templates A map of templates associated with roam. These have the exact same format @@ -362,7 +421,32 @@ }, }) #+end_src - + +**** open + + Configuration to open the node view window. + + Takes a string or a function that returns a window handle. + Defaults to =botright vsplit | vertical resize 50=. + + #+begin_src lua + require("org-roam"):setup({ + ui = { + node_view = { + open = function() + return vim.api.nvim_open_win(0, false, { + relative = "editor", + row = 0, + col = 0, + width = 50, + height = 20, + }) + end, + }, + }, + }) + #+end_src + **** show keybindings If true, will include a section covering available keybindings. @@ -398,18 +482,22 @@ * Bindings - | Name | Keybinding | Description | - |--------------------------+---------------+-------------------------------------------------------------------------| - | add_alias | =naa= | Adds an alias to the node under cursor. | - | capture | =nc= | Opens org-roam capture window. | - | complete_at_point | =n.= | Completes the node under cursor. | - | find_node | =nf= | Finds node and moves to it, creating it if it does not exist. | - | insert_node | =ni= | Inserts node at cursor position, creating it if it does not exist. | - | insert_node_immediate | =nm= | Same as =insert_node=, but skips opening capture buffer. | - | quickfix_backlinks | =nq= | Opens the quickfix menu for backlinks to the current node under cursor. | - | remove_alias | =nar= | Removes an alias from the node under cursor. | - | toggle_roam_buffer | =nl= | Toggles the org-roam node-view buffer for the node under cursor. | - | toggle_roam_buffer_fixed | =nb= | Toggles a fixed org-roam node-view buffer for a selected node. | + | Name | Keybinding | Description | + |--------------------------+---------------+---------------------------------------------------------------------------| + | add_alias | =naa= | Adds an alias to the node under cursor. | + | add_origin | =noa= | Adds an origin to the node under cursor. | + | capture | =nc= | Opens org-roam capture window. | + | complete_at_point | =n.= | Completes the node under cursor. | + | find_node | =nf= | Finds node and moves to it, creating it if it does not exist. | + | goto_next_node | =nn= | Goes to the next node in sequence (via origin) for the node under cursor. | + | goto_prev_node | =np= | Goes to the prev node in sequence (via origin) for the node under cursor. | + | insert_node | =ni= | Inserts node at cursor position, creating it if it does not exist. | + | insert_node_immediate | =nm= | Same as =insert_node=, but skips opening capture buffer. | + | quickfix_backlinks | =nq= | Opens the quickfix menu for backlinks to the current node under cursor. | + | remove_alias | =nar= | Removes an alias from the node under cursor. | + | remove_origin | =nor= | Removes the origin from the node under cursor. | + | toggle_roam_buffer | =nl= | Toggles the org-roam node-view buffer for the node under cursor. | + | toggle_roam_buffer_fixed | =nb= | Toggles a fixed org-roam node-view buffer for a selected node. | ** Modifying bindings @@ -442,13 +530,17 @@ require("org-roam"):setup({ bindings = { add_alias = "naa", + add_origin = "noa", capture = "nc", complete_at_point = "", find_node = "nf", + goto_next_node = "nn", + goto_prev_node = "np", insert_node = "ni", insert_node_immediate = "nm", quickfix_backlinks = "nq", remove_alias = "nar", + remove_origin = "nor", toggle_roam_buffer = "nl", toggle_roam_buffer_fixed = "nb", }, @@ -504,6 +596,30 @@ roam.api.add_alias({ alias = "My Alias" }) #+end_src +** Add Origin + + roam.api.add_origin({opts}) + + Description: + + Adds an origin to the node under cursor. + Will replace the existing origin. + + If no `origin` is specified, a prompt is provided. + + Parameters: + + - {opts} optional table. + - origin: optional, if provided, added to the node under cursor, otherwise + prompts for an origin to add to the node under cursor. + + Example: + + #+begin_src lua + local roam = require("org-roam") + roam.api.add_origin({ origin = "1234" }) + #+end_src + ** Capture Node roam.api.capture_node({opts}, {cb}) @@ -516,11 +632,12 @@ Parameters: - {opts} optional table. - - title: optional, seeds the capture dialog with the title string. - immediate: optional, if true, skips displaying the capture buffer and instead populates a file using the immediate configuration. If title is also provided, it is used as the title of the created node. + - origin: optional, id of node acting as origin of this node. + - title: optional, seeds the capture dialog with the title string. - {cb} optional callback when finished. Is passed the id of the created node, or nil if capture was canceled. @@ -570,6 +687,7 @@ Parameters: - {opts} optional table. + - origin: optional, id of node acting as origin of this node (creation-only). - title: optional, seeds the select dialog with the title string. Example: @@ -579,6 +697,50 @@ roam.api.find_node({ title = "Some Node" }) #+end_src +** Goto Next Node + + roam.api.goto_next_node({opts}) + + Description: + + Goes to the next node in sequence for the node under cursor. + + Leverages a lookup of nodes whose origin match the node under cursor. + + Parameters: + + - {opts} optional table. + - in: optional, id of window where buffer will be loaded. + + Example: + + #+begin_src lua + local roam = require("org-roam") + roam.api.goto_next_node({ win = 123 }) + #+end_src + +** Goto Prev Node + + roam.api.goto_prev_node({opts}) + + Description: + + Goes to the previous node in sequence for the node under cursor. + + Leverages a lookup of the node using the origin of the node under cursor. + + Parameters: + + - {opts} optional table. + - in: optional, id of window where buffer will be loaded. + + Example: + + #+begin_src lua + local roam = require("org-roam") + roam.api.goto_prev_node({ win = 123 }) + #+end_src + ** Insert Node roam.api.insert_node({opts}) @@ -603,6 +765,7 @@ instead populates a file using the immediate configuration. If title is also provided, it is used as the title of the created node. + - origin: optional, id of node acting as origin of this node (creation-only). - title: optional, seeds the select dialog with the title string. - ranges: optional, list of ranges to replace. Each range is comprised of the following fields: @@ -697,6 +860,21 @@ roam.api.remove_alias({ all = true }) #+end_src +** Remove Origin + + roam.api.remove_origin() + + Description: + + Removes the origin from the node under cursor. + + Example: + + #+begin_src lua + local roam = require("org-roam") + roam.api.remove_origin() + #+end_src + * Database ** Files diff --git a/README.org b/README.org index 934eef2..491a994 100644 --- a/README.org +++ b/README.org @@ -61,18 +61,22 @@ ** Bindings - | Name | Keybinding | Description | - |--------------------------+---------------+-------------------------------------------------------------------------| - | add_alias | =naa= | Adds an alias to the node under cursor. | - | capture | =nc= | Opens org-roam capture window. | - | complete_at_point | =n.= | Completes the node under cursor. | - | find_node | =nf= | Finds node and moves to it, creating it if it does not exist. | - | insert_node | =ni= | Inserts node at cursor position, creating it if it does not exist. | - | insert_node_immediate | =nm= | Same as =insert_node=, but skips opening capture buffer. | - | quickfix_backlinks | =nq= | Opens the quickfix menu for backlinks to the current node under cursor. | - | remove_alias | =nar= | Removes an alias from the node under cursor. | - | toggle_roam_buffer | =nl= | Toggles the org-roam node-view buffer for the node under cursor. | - | toggle_roam_buffer_fixed | =nb= | Toggles a fixed org-roam node-view buffer for a selected node. | + | Name | Keybinding | Description | + |--------------------------+---------------+---------------------------------------------------------------------------| + | add_alias | =naa= | Adds an alias to the node under cursor. | + | add_origin | =noa= | Adds an origin to the node under cursor. | + | capture | =nc= | Opens org-roam capture window. | + | complete_at_point | =n.= | Completes the node under cursor. | + | find_node | =nf= | Finds node and moves to it, creating it if it does not exist. | + | goto_next_node | =nn= | Goes to the next node in sequence (via origin) for the node under cursor. | + | goto_prev_node | =np= | Goes to the prev node in sequence (via origin) for the node under cursor. | + | insert_node | =ni= | Inserts node at cursor position, creating it if it does not exist. | + | insert_node_immediate | =nm= | Same as =insert_node=, but skips opening capture buffer. | + | quickfix_backlinks | =nq= | Opens the quickfix menu for backlinks to the current node under cursor. | + | remove_alias | =nar= | Removes an alias from the node under cursor. | + | remove_origin | =nor= | Removes the origin from the node under cursor. | + | toggle_roam_buffer | =nl= | Toggles the org-roam node-view buffer for the node under cursor. | + | toggle_roam_buffer_fixed | =nb= | Toggles a fixed org-roam node-view buffer for a selected node. | ** Documentation diff --git a/lua/org-roam/api.lua b/lua/org-roam/api.lua index a7ca6c8..6aad30a 100644 --- a/lua/org-roam/api.lua +++ b/lua/org-roam/api.lua @@ -7,6 +7,7 @@ local AliasApi = require("org-roam.api.alias") local CompletionApi = require("org-roam.api.completion") local NodeApi = require("org-roam.api.node") +local OriginApi = require("org-roam.api.origin") local open_quickfix = require("org-roam.ui.quickfix") local open_node_view = require("org-roam.ui.node-view") @@ -14,12 +15,16 @@ local open_node_view = require("org-roam.ui.node-view") local M = {} M.add_alias = AliasApi.add_alias +M.add_origin = OriginApi.add_origin M.capture_node = NodeApi.capture M.complete_node = CompletionApi.complete_node_under_cursor M.find_node = NodeApi.find +M.goto_next_node = OriginApi.goto_next_node +M.goto_prev_node = OriginApi.goto_prev_node M.insert_node = NodeApi.insert M.open_node_buffer = open_node_view M.open_quickfix_list = open_quickfix M.remove_alias = AliasApi.remove_alias +M.remove_origin = OriginApi.remove_origin return M diff --git a/lua/org-roam/api/node.lua b/lua/org-roam/api/node.lua index c1a1305..b3e4e05 100644 --- a/lua/org-roam/api/node.lua +++ b/lua/org-roam/api/node.lua @@ -93,7 +93,7 @@ end ---Construct org-roam template with custom expansions applied. ---@param template_opts OrgCaptureTemplateOpts ----@param opts? {title?:string} +---@param opts? {origin?:string, title?:string} ---@return OrgCaptureTemplate local function build_template(template_opts, opts) opts = opts or {} @@ -134,9 +134,14 @@ local function build_template(template_opts, opts) local prefix = { ":PROPERTIES:", ":ID: " .. require('orgmode.org.id').new(), - ":END:", } + if opts.origin then + table.insert(prefix, ":ROAM_ORIGIN: " .. opts.origin) + end + + table.insert(prefix, ":END:") + -- Grab the title, which if it does not exist and we detect -- that we need it, we will prompt for it local title = opts.title @@ -166,7 +171,7 @@ local function build_template(template_opts, opts) end ---Construct org-roam templates with custom expansions applied. ----@param opts? {title?:string} +---@param opts? {origin?:string, title?:string} ---@return OrgCaptureTemplates local function build_templates(opts) opts = opts or {} @@ -229,7 +234,7 @@ end ---Creates a node if it does not exist, and restores the current window ---configuration upon completion. ----@param opts? {immediate?:boolean, title?:string} +---@param opts? {immediate?:boolean, origin?:string, title?:string} ---@param cb? fun(id:org-roam.core.database.Id|nil) function M.capture(opts, cb) opts = opts or {} @@ -238,7 +243,10 @@ function M.capture(opts, cb) if opts.immediate then M.__capture_immediate(opts, cb) else - local templates = build_templates({ title = opts.title }) + local templates = build_templates({ + origin = opts.origin, + title = opts.title, + }) local on_pre_refile = make_on_pre_refile(opts) local on_post_refile = make_on_post_refile(cb) db:files():next(function(files) @@ -256,15 +264,13 @@ function M.capture(opts, cb) end ---@private ----@param opts {title?:string} +---@param opts {origin:string|nil, title:string|nil} ---@param cb fun(id:org-roam.core.database.Id|nil) function M.__capture_immediate(opts, cb) local template = build_template({ target = CONFIG.immediate.target, template = CONFIG.immediate.template, - }, { - title = opts.title, - }) + }, opts) ---@param content string[]|nil template:compile():next(function(content) @@ -320,7 +326,7 @@ end ---If `ranges` is provided, will replace the given ranges within the buffer ---versus inserting at point. ---where everything uses 1-based indexing and inclusive. ----@param opts? {immediate?:boolean, title?:string, ranges?:org-roam.utils.Range[]} +---@param opts? {immediate?:boolean, origin?:string, title?:string, ranges?:org-roam.utils.Range[]} function M.insert(opts) opts = opts or {} local winnr = vim.api.nvim_get_current_win() @@ -383,7 +389,11 @@ function M.insert(opts) return end - M.capture({ title = node.label, immediate = opts.immediate }, function(id) + M.capture({ + immediate = opts.immediate, + origin = opts.origin, + title = node.label, + }, function(id) if id then insert_link(id) return @@ -393,7 +403,7 @@ function M.insert(opts) end ---Creates a node if it does not exist, and visits the node. ----@param opts? {title?:string} +---@param opts? {origin?:string, title?:string} function M.find(opts) opts = opts or {} local winnr = vim.api.nvim_get_current_win() @@ -423,7 +433,7 @@ function M.find(opts) return end - M.capture({ title = node.label }, function(id) + M.capture({ origin = opts.origin, title = node.label }, function(id) if id then visit_node(id) return diff --git a/lua/org-roam/api/origin.lua b/lua/org-roam/api/origin.lua new file mode 100644 index 0000000..91b0b6f --- /dev/null +++ b/lua/org-roam/api/origin.lua @@ -0,0 +1,127 @@ +------------------------------------------------------------------------------- +-- ORIGIN.LUA +-- +-- Contains functionality tied to the roam origin api. +------------------------------------------------------------------------------- + +local db = require("org-roam.database") +local select_node = require("org-roam.ui.select-node") +local utils = require("org-roam.utils") + +local ORIGIN_PROP_NAME = "ROAM_ORIGIN" + +local M = {} + +---Adds an origin to the node under cursor. +---Will replace the existing origin. +--- +---If no `origin` is specified, a prompt is provided. +--- +---@param opts? {origin?:string} +function M.add_origin(opts) + opts = opts or {} + utils.node_under_cursor(function(node) + if not node then return end + + db:load_file({ path = node.file }):next(function(results) + -- Get the OrgFile instance + local file = results.file + + -- Look for a file or headline that matches our node + local entry = utils.find_id_match(file, node.id) + + if entry and opts.origin then + entry:set_property(ORIGIN_PROP_NAME, opts.origin) + elseif entry then + -- If no origin specified, we load up a selection dialog + -- to pick a node other than the current one + select_node({ exclude = { node.id } }, function(selection) + if selection.id then + entry:set_property(ORIGIN_PROP_NAME, selection.id) + end + end) + end + + return file + end) + end) +end + +---Goes to the previous node in sequence for the node under cursor. +---Leverages a lookup of the node using the origin of the node under cursor. +---@param opts? {win?:integer} +function M.goto_prev_node(opts) + opts = opts or {} + local winnr = opts.win or vim.api.nvim_get_current_win() + + ---@param node org-roam.core.file.Node|nil + local function goto_node(node) + if not node then return end + utils.goto_node({ node = node, win = winnr }) + end + + utils.node_under_cursor(function(node) + if not node or not node.origin then return end + db:get(node.origin):next(goto_node) + end, { win = winnr }) +end + +---Goes to the next node in sequence for the node under cursor. +---Leverages a lookup of nodes whose origin match the node under cursor. +---@param opts? {win?:integer} +function M.goto_next_node(opts) + opts = opts or {} + local winnr = opts.win or vim.api.nvim_get_current_win() + + ---@param node org-roam.core.file.Node|nil + local function goto_node(node) + if not node then return end + utils.goto_node({ node = node, win = winnr }) + end + + utils.node_under_cursor(function(node) + if not node then return end + db:find_nodes_by_origin(node.id):next(function(nodes) + if #nodes == 0 then return nodes end + if #nodes == 1 then + goto_node(nodes[1]) + return nodes + end + + local ids = vim.tbl_map(function(n) + return n.id + end, nodes) + + select_node({ include = ids }, function(selection) + if selection.id then + db:get(selection.id):next(goto_node) + end + end) + + return nodes + end) + end, { win = winnr }) +end + +---Removes the origin from the node under cursor. +function M.remove_origin() + utils.node_under_cursor(function(node) + if not node then return end + + db:load_file({ path = node.file }):next(function(results) + -- Get the OrgFile instance + local file = results.file + + -- Look for a file or headline that matches our node + local entry = utils.find_id_match(file, node.id) + + if entry then + entry:set_property(ORIGIN_PROP_NAME, nil) + end + + return file + end) + end) +end + +return M diff --git a/lua/org-roam/config.lua b/lua/org-roam/config.lua index 3c1c2f3..e2d9c19 100644 --- a/lua/org-roam/config.lua +++ b/lua/org-roam/config.lua @@ -51,6 +51,9 @@ local config = setmetatable({ ---Adds an alias to the node under cursor. add_alias = "naa", + ---Adds an origin to the node under cursor. + add_origin = "noa", + ---Opens org-roam capture window. capture = "nc", @@ -60,6 +63,15 @@ local config = setmetatable({ ---Finds node and moves to it. find_node = "nf", + ---Goes to the next node sequentially based on origin of the node under cursor. + --- + ---If more than one node has the node under cursor as its origin, a selection + ---dialog is displayed to choose the node. + goto_next_node = "nn", + + ---Goes to the previous node sequentially based on origin of the node under cursor. + goto_prev_node = "np", + ---Inserts node at cursor position. insert_node = "ni", @@ -72,6 +84,9 @@ local config = setmetatable({ ---Removes an alias from the node under cursor. remove_alias = "nar", + ---Removes the origin from the node under cursor. + remove_origin = "nor", + ---Toggles the org-roam node-view buffer for the node under cursor. toggle_roam_buffer = "nl", @@ -135,6 +150,11 @@ local config = setmetatable({ ---@type boolean highlight_previews = true, + ---Configuration to open the node view window. + ---Can be a string, or a function that returns the window handle. + ---@type string|fun():integer + open = "botright vsplit | vertical resize 50", + ---If true, will include a section covering available keybindings. ---@type boolean show_keybindings = true, diff --git a/lua/org-roam/core/database.lua b/lua/org-roam/core/database.lua index d4aefeb..d416895 100644 --- a/lua/org-roam/core/database.lua +++ b/lua/org-roam/core/database.lua @@ -442,16 +442,19 @@ function M:reindex(opts) -- For each value, we cache a pointer to the node's id for _, value in ipairs(result) do - if type(self.__indexes[name][value]) == "nil" then - -- Create empty cache for value if doesn't exist yet - self.__indexes[name][value] = {} - end - - -- Store the node's id in the lookup cache - if not should_remove then - self.__indexes[name][value][id] = true - else - self.__indexes[name][value][id] = nil + -- Protect against list with nil values + if type(value) ~= "nil" then + if type(self.__indexes[name][value]) == "nil" then + -- Create empty cache for value if doesn't exist yet + self.__indexes[name][value] = {} + end + + -- Store the node's id in the lookup cache + if not should_remove then + self.__indexes[name][value][id] = true + else + self.__indexes[name][value][id] = nil + end end end end diff --git a/lua/org-roam/core/file.lua b/lua/org-roam/core/file.lua index 7da124b..9c3f67a 100644 --- a/lua/org-roam/core/file.lua +++ b/lua/org-roam/core/file.lua @@ -10,6 +10,13 @@ local Node = require("org-roam.core.file.node") local Range = require("org-roam.core.file.range") local utils = require("org-roam.core.file.utils") +local KEYS = { + DIR_TITLE = "TITLE", + PROP_ALIASES = "ROAM_ALIASES", + PROP_ID = "ID", + PROP_ORIGIN = "ROAM_ORIGIN", +} + ---@class org-roam.core.File ---@field filename string ---@field links org-roam.core.file.Link[] @@ -96,21 +103,25 @@ function M:from_org_file(file) end -- Build up our file-level node - local id = file:get_property("id") + local id = file:get_property(KEYS.PROP_ID) if id then local tags = file:get_filetags() table.sort(tags) + local origin = file:get_property(KEYS.PROP_ORIGIN) + origin = origin and vim.trim(origin) + table.insert(nodes, Node:new({ id = id, + origin = origin, range = Range:new( { row = 0, column = 0, offset = 0 }, { row = math.huge, column = math.huge, offset = math.huge } ), file = file.filename, mtime = file.metadata.mtime, - title = file:get_directive("title"), - aliases = utils.parse_property_value(file:get_property("roam_aliases") or ""), + title = file:get_directive(KEYS.DIR_TITLE), + aliases = utils.parse_property_value(file:get_property(KEYS.PROP_ALIASES) or ""), tags = tags, level = 0, linked = {}, @@ -119,7 +130,7 @@ function M:from_org_file(file) -- Build up our section-level nodes for _, headline in ipairs(file:get_headlines()) do - local id = headline:get_property("id") + local id = headline:get_property(KEYS.PROP_ID) if id then -- NOTE: By default, this will get filetags and respect tag inheritance -- for nested headlines. If this is turned off in orgmode, then @@ -128,13 +139,17 @@ function M:from_org_file(file) local tags = headline:get_tags() table.sort(tags) + local origin = headline:get_property(KEYS.PROP_ORIGIN) + origin = origin and vim.trim(origin) + table.insert(nodes, Node:new({ id = id, + origin = origin, range = Range:from_node(headline.headline:parent()), file = file.filename, mtime = file.metadata.mtime, title = headline:get_title(), - aliases = utils.parse_property_value(headline:get_property("roam_aliases") or ""), + aliases = utils.parse_property_value(headline:get_property(KEYS.PROP_ALIASES) or ""), tags = tags, level = headline:get_level(), linked = {}, diff --git a/lua/org-roam/core/file/node.lua b/lua/org-roam/core/file/node.lua index 0f77d9e..3f6d544 100644 --- a/lua/org-roam/core/file/node.lua +++ b/lua/org-roam/core/file/node.lua @@ -69,6 +69,7 @@ ---@class org-roam.core.file.Node ---@field id string #unique id associated with the node +---@field origin string|nil #unique id associated with node where this node originated ---@field range org-roam.core.file.Range #range representing full node ---@field file string #path to file where node is located ---@field mtime integer #last time the node's file was modified (nanoseconds) @@ -82,6 +83,7 @@ M.__index = M ---@class org-roam.core.file.Node.NewOpts ---@field id string +---@field origin? string ---@field range org-roam.core.file.Range ---@field file string ---@field mtime integer @@ -98,6 +100,7 @@ function M:new(opts) local instance = {} setmetatable(instance, M) instance.id = opts.id + instance.origin = opts.origin instance.range = opts.range instance.file = opts.file instance.mtime = opts.mtime @@ -130,6 +133,7 @@ function M:hash() return vim.fn.sha256(table.concat({ self.id, + self.origin or "", string.format("%s%s", self.range.start.offset, self.range.end_.offset), self.file, self.title, diff --git a/lua/org-roam/database.lua b/lua/org-roam/database.lua index fa2e63a..f70fe18 100644 --- a/lua/org-roam/database.lua +++ b/lua/org-roam/database.lua @@ -243,6 +243,26 @@ function M:find_nodes_by_file_sync(file, opts) return self:find_nodes_by_file(file):wait(opts.timeout) end +---Retrieves nodes with the specified origin. +---@param origin string +---@return OrgPromise +function M:find_nodes_by_origin(origin) + ---@diagnostic disable-next-line:missing-return-value + return self:__get_loader():database():next(function(db) + local ids = db:find_by_index(schema.ORIGIN, origin) + return vim.tbl_values(db:get_many(ids)) + end) +end + +---Retrieves nodes with the specified origin. +---@param origin string +---@param opts? {timeout?:integer} +---@return org-roam.core.file.Node[] +function M:find_nodes_by_origin_sync(origin, opts) + opts = opts or {} + return self:find_nodes_by_origin(origin):wait(opts.timeout) +end + ---Retrieves nodes with the specified tag. ---@param tag string ---@return OrgPromise diff --git a/lua/org-roam/database/loader.lua b/lua/org-roam/database/loader.lua index 0bf8bcb..547b309 100644 --- a/lua/org-roam/database/loader.lua +++ b/lua/org-roam/database/loader.lua @@ -318,7 +318,9 @@ function M:load_file(opts) return { file = file, - nodes = db:find_by_index(schema.FILE, file.filename), + nodes = vim.tbl_values( + db:get_many(db:find_by_index(schema.FILE, file.filename)) + ), } end) end) diff --git a/lua/org-roam/database/schema.lua b/lua/org-roam/database/schema.lua index abad0e8..6b6b722 100644 --- a/lua/org-roam/database/schema.lua +++ b/lua/org-roam/database/schema.lua @@ -6,10 +6,11 @@ ---@enum org-roam.database.Schema local M = { - ALIAS = "alias", - FILE = "file", - TAG = "tag", - TITLE = "title", + ALIAS = "alias", + FILE = "file", + ORIGIN = "origin", + TAG = "tag", + TITLE = "title", } ---Updates a schema (series of indexes) for the specified database. @@ -27,10 +28,11 @@ function M:update(db) local new_indexes = {} for name, indexer in pairs({ - [self.ALIAS] = field("aliases"), - [self.FILE] = field("file"), - [self.TAG] = field("tags"), - [self.TITLE] = field("title"), + [self.ALIAS] = field("aliases"), + [self.FILE] = field("file"), + [self.ORIGIN] = field("origin"), + [self.TAG] = field("tags"), + [self.TITLE] = field("title"), }) do if not db:has_index(name) then db:new_index(name, indexer) diff --git a/lua/org-roam/setup.lua b/lua/org-roam/setup.lua index 4f80a10..c87f5f3 100644 --- a/lua/org-roam/setup.lua +++ b/lua/org-roam/setup.lua @@ -168,6 +168,26 @@ local function define_commands(config) desc = "Removes an alias from the current node under cursor", nargs = "*", }) + + vim.api.nvim_create_user_command("RoamAddOrigin", function(opts) + ---@type string|nil + local origin = vim.trim(opts.args) + if origin and origin == "" then + origin = nil + end + + require("org-roam.api").add_origin({ origin = origin }) + end, { + desc = "Adds an origin to the current node under cursor", + nargs = "*", + }) + + vim.api.nvim_create_user_command("RoamRemoveOrigin", function() + require("org-roam.api").remove_origin() + end, { + bang = true, + desc = "Removes an origin from the current node under cursor", + }) end ---@param config org-roam.Config @@ -244,6 +264,30 @@ local function define_keybindings(config) require("org-roam.api").remove_alias ) + assign( + bindings.add_origin, + "Adds an origin to the roam node under cursor", + require("org-roam.api").add_origin + ) + + assign( + bindings.remove_origin, + "Removes the origin from the roam node under cursor", + require("org-roam.api").remove_origin + ) + + assign( + bindings.goto_prev_node, + "Goes to the previous node sequentially based on origin of the node under cursor", + require("org-roam.api").goto_prev_node + ) + + assign( + bindings.goto_next_node, + "Goes to the next node sequentially based on origin of the node under cursor", + require("org-roam.api").goto_next_node + ) + assign( bindings.quickfix_backlinks, "Open quickfix of backlinks for org-roam node under cursor", diff --git a/lua/org-roam/ui/node-view.lua b/lua/org-roam/ui/node-view.lua index 17602bc..1cc1046 100644 --- a/lua/org-roam/ui/node-view.lua +++ b/lua/org-roam/ui/node-view.lua @@ -4,6 +4,7 @@ -- Toggles a view of a node's backlinks, citations, and unlinked references. ------------------------------------------------------------------------------- +local CONFIG = require("org-roam.config") local EVENTS = require("org-roam.events") local select_node = require("org-roam.ui.select-node") local utils = require("org-roam.utils") @@ -29,7 +30,9 @@ local function toggle_node_view() or false if not CURSOR_NODE_VIEW or invalid_window then - CURSOR_NODE_VIEW = Window:new() + CURSOR_NODE_VIEW = Window:new({ + open = CONFIG.ui.node_view.open, + }) -- Whenever the node changes, rerender the window ---@param node org-roam.core.file.Node|nil @@ -67,7 +70,10 @@ local function toggle_fixed_node_view(id) local function toggle_view(id) if id then if not NODE_VIEW[id] then - NODE_VIEW[id] = Window:new({ id = id }) + NODE_VIEW[id] = Window:new({ + id = id, + open = CONFIG.ui.node_view.open, + }) end NODE_VIEW[id]:toggle() diff --git a/lua/org-roam/ui/node-view/window.lua b/lua/org-roam/ui/node-view/window.lua index a3df1a9..afbcc31 100644 --- a/lua/org-roam/ui/node-view/window.lua +++ b/lua/org-roam/ui/node-view/window.lua @@ -44,6 +44,7 @@ local KEYBINDINGS = { ---Mapping of kind -> highlight group. local HL = { NODE_TITLE = "Title", + NODE_ORIGIN = "Title", COMMENT = "Comment", KEYBINDING = "WarningMsg", SECTION_LABEL = "Title", @@ -237,6 +238,39 @@ local function render(this, node, details) C.hl(node.title, HL.NODE_TITLE), }) + -- If we have an origin for the node, display it next + if node.origin then + local origin_node = db:get_sync(node.origin) + if origin_node then + local function do_open() + local win = vim.api.nvim_get_current_win() + local filter = function(winnr) return winnr ~= win end + + WindowPicker + :new({ + autoselect = true, + filter = filter, + }) + :on_choice(function(winnr) + require("org-roam.utils").goto_node({ + node = origin_node, + win = winnr, + }) + end) + :open() + end + + table.insert(lines, { + C.hl( + "Origin: ", + HL.NORMAL + ), + C.hl(origin_node.title, HL.NODE_ORIGIN), + C.action(KEYBINDINGS.OPEN_LINK.key, do_open), + }) + end + end + -- Insert a blank line as a divider table.insert(lines, "") diff --git a/lua/org-roam/ui/select-node.lua b/lua/org-roam/ui/select-node.lua index b6ea386..ae1f85b 100644 --- a/lua/org-roam/ui/select-node.lua +++ b/lua/org-roam/ui/select-node.lua @@ -8,8 +8,8 @@ local db = require("org-roam.database") local Select = require("org-roam.core.ui.select") ---Opens up a selection dialog populated with nodes (titles and aliases). ----@overload fun(cb:fun(selection:{id:org-roam.core.database.Id, label:string})) ----@param opts {allow_select_missing?:boolean, auto_select?:boolean, init_input?:string} +---@overload fun(cb:fun(selection:{id:org-roam.core.database.Id|nil, label:string})) +---@param opts {allow_select_missing?:boolean, auto_select?:boolean, exclude?:string[], include?:string[], init_input?:string} ---@param cb fun(selection:{id:org-roam.core.database.Id|nil, label:string}) return function(opts, cb) if type(opts) == "function" then @@ -24,12 +24,22 @@ return function(opts, cb) -- and by aliases to get candidate ids. ---@type {id:org-roam.core.database.Id, label:string} local items = {} - for _, id in ipairs(db:ids()) do - local node = db:get_sync(id) - if node then - table.insert(items, { id = id, label = node.title }) - for _, alias in ipairs(node.aliases) do - table.insert(items, { id = id, label = alias }) + for _, id in ipairs(opts.include or db:ids()) do + local skip = false + + -- If we were given an exclusion list, check if the id is in that list + -- and if so we will skip including this node in our dialog + if opts.exclude and vim.tbl_contains(opts.exclude, id) then + skip = true + end + + if not skip then + local node = db:get_sync(id) + if node then + table.insert(items, { id = id, label = node.title }) + for _, alias in ipairs(node.aliases) do + table.insert(items, { id = id, label = alias }) + end end end end diff --git a/lua/org-roam/utils.lua b/lua/org-roam/utils.lua index 4d300b0..ca09376 100644 --- a/lua/org-roam/utils.lua +++ b/lua/org-roam/utils.lua @@ -98,28 +98,34 @@ end ---Retrieves the expression under cursor. In the case that ---an expression is not found or not within an orgmode buffer, ---the current word under cursor via `` is returned. +---@param opts? {win?:integer} ---@return string -function M.expr_under_cursor() +function M.expr_under_cursor(opts) + opts = opts or {} + local bufnr = vim.api.nvim_win_get_buf(opts.win or 0) + -- Figure out our word, trying out treesitter -- if we are in an orgmode buffer, otherwise -- defaulting back to the vim word under cursor local word = vim.fn.expand("") - if vim.api.nvim_buf_get_option(0, "filetype") == "org" then + if vim.api.nvim_buf_get_option(bufnr, "filetype") == "org" then ---@type TSNode|nil local ts_node = vim.treesitter.get_node() if ts_node and ts_node:type() == "expr" then ---@type string - word = vim.treesitter.get_node_text(ts_node, 0) + word = vim.treesitter.get_node_text(ts_node, bufnr) end end return word end ---Looks for a link under cursor. If it exists, the raw parsed link is returned. +---@param opts? {win?:integer} ---@return org-roam.core.file.Link|nil -function M.link_under_cursor() - local bufnr = vim.api.nvim_win_get_buf(0) - local cursor = vim.api.nvim_win_get_cursor(0) +function M.link_under_cursor(opts) + opts = opts or {} + local bufnr = vim.api.nvim_win_get_buf(opts.win or 0) + local cursor = vim.api.nvim_win_get_cursor(opts.win or 0) local offset = vim.api.nvim_buf_get_offset(bufnr, cursor[1] - 1) + cursor[2] local cache = get_buffer_cache(bufnr) @@ -135,10 +141,12 @@ end --- result when possible. The cache is discarded whenever the current --- buffer is detected as changed as seen via `b:changedtick`. --- ----@param cb fun(id:org-roam.core.file.Node|nil) -function M.node_under_cursor(cb) - local bufnr = vim.api.nvim_win_get_buf(0) - local cursor = vim.api.nvim_win_get_cursor(0) +---@param cb fun(node:org-roam.core.file.Node|nil) +---@param opts? {win?:integer} +function M.node_under_cursor(cb, opts) + opts = opts or {} + local bufnr = vim.api.nvim_win_get_buf(opts.win or 0) + local cursor = vim.api.nvim_win_get_cursor(opts.win or 0) local offset = vim.api.nvim_buf_get_offset(bufnr, cursor[1] - 1) + cursor[2] ---@return org-roam.core.file.Node|nil @@ -209,6 +217,24 @@ function M.find_id_match(file, id) end end +---Opens the node in the specified window, defaulting to the current window. +---@param opts {node:org-roam.core.file.Node, win?:integer} +function M.goto_node(opts) + local node = opts.node + local win = opts.win or vim.api.nvim_get_current_win() + vim.api.nvim_set_current_win(win) + vim.cmd.edit(node.file) + + local row = node.range.start.row + 1 + local col = node.range.start.column + + -- NOTE: We need to schedule to ensure the file has loaded + -- into the buffer before we try to move the cursor! + vim.schedule(function() + vim.api.nvim_win_set_cursor(win, { row, col }) + end) +end + ---@class org-roam.utils.Range ---@field start_row integer #starting row (one-indexed, inclusive) ---@field start_col integer #starting column (one-indexed, inclusive) diff --git a/spec/database_spec.lua b/spec/database_spec.lua index 41a3e8a..207b1b8 100644 --- a/spec/database_spec.lua +++ b/spec/database_spec.lua @@ -177,6 +177,22 @@ describe("org-roam.database", function() assert.are.same({ "3" }, retrieve_ids(three_path)) end) + it("should support retrieving nodes by origin", function() + -- Trigger initial loading of all files + db:load():wait() + + ---@param origin string + ---@return org-roam.core.database.Id[] + local function retrieve_ids(origin) + local nodes = db:find_nodes_by_origin_sync(origin) + return vim.tbl_map(function(node) return node.id end, nodes) + end + + assert.are.same({ "2" }, retrieve_ids("1")) + assert.are.same({ "3" }, retrieve_ids("2")) + assert.are.same({}, retrieve_ids("3")) + end) + it("should support retrieving nodes by tag", function() -- Trigger initial loading of all files db:load():wait() diff --git a/spec/files/three.org b/spec/files/three.org index 90e621e..05a51dc 100644 --- a/spec/files/three.org +++ b/spec/files/three.org @@ -1,6 +1,7 @@ :PROPERTIES: :ID: 3 :ROAM_ALIASES: three +:ROAM_ORIGIN: 2 :END: #+FILETAGS: :three: diff --git a/spec/files/two.org b/spec/files/two.org index ab68c79..43841b3 100644 --- a/spec/files/two.org +++ b/spec/files/two.org @@ -1,6 +1,7 @@ :PROPERTIES: :ID: 2 :ROAM_ALIASES: two +:ROAM_ORIGIN: 1 :END: #+FILETAGS: :two: