From 5b8b86d519344bd8a34524b70260c57740ef2482 Mon Sep 17 00:00:00 2001 From: troiganto Date: Sun, 9 Feb 2025 16:09:09 +0100 Subject: [PATCH 1/7] feat(attach): add org-attach feature --- docs/configuration.org | 199 +++++++ lua/orgmode/attach/core.lua | 720 +++++++++++++++++++++++ lua/orgmode/attach/fileops.lua | 342 +++++++++++ lua/orgmode/attach/init.lua | 833 +++++++++++++++++++++++++++ lua/orgmode/attach/node.lua | 247 ++++++++ lua/orgmode/attach/translate_id.lua | 64 ++ lua/orgmode/attach/ui.lua | 235 ++++++++ lua/orgmode/config/_meta.lua | 9 + lua/orgmode/config/defaults.lua | 16 + lua/orgmode/config/init.lua | 13 + lua/orgmode/config/mappings/init.lua | 1 + lua/orgmode/init.lua | 2 + 12 files changed, 2681 insertions(+) create mode 100644 lua/orgmode/attach/core.lua create mode 100644 lua/orgmode/attach/fileops.lua create mode 100644 lua/orgmode/attach/init.lua create mode 100644 lua/orgmode/attach/node.lua create mode 100644 lua/orgmode/attach/translate_id.lua create mode 100644 lua/orgmode/attach/ui.lua diff --git a/docs/configuration.org b/docs/configuration.org index a9af671d9..f9734a175 100644 --- a/docs/configuration.org +++ b/docs/configuration.org @@ -7,6 +7,7 @@ This page contains information about all configuration that can be provided to t - [[#agenda-settings][Agenda settings]] - [[#calendar-settings][Calendar settings]] - [[#tags-settings][Tags settings]] +- [[#attachment-settings][Attachments settings]] - [[#mappings][Mappings]] - [[#features][Features]] - [[#user-interface][User interface]] @@ -1122,6 +1123,161 @@ Using the example above, setting this variable to ={'MYTAG'}=, second and third headline would have only =CHILDTAG=, where =MYTAG= would not be inherited. +** Attachments settings +:PROPERTIES: +:CUSTOM_ID: attachment-settings +:END: + +*** org_attach_id_dir +:PROPERTIES: +:CUSTOM_ID: org_attach_id_dir +:END: +- Type: =string= +- Default: ='./data/'= + +The directory where attachments are stored. If this is a relative path, it +will be interpreted relative to the directory where the Org file lives. + +*** org_attach_auto_tag +:PROPERTIES: +:CUSTOM_ID: org_attach_auto_tag +:END: +- Type: =string= +- Default: ='ATTACH'= + +Tag that is added automatically when attaching files to a headline. + +*** org_attach_preferred_new_method +:PROPERTIES: +:CUSTOM_ID: org_attach_preferred_new_method +:END: +- Type: ='id'|'dir'|'ask'|false= +- Default: ='id'= + +This setting is used when attaching files to nodes that have neither an +=ID= nor a =DIR= property. + +- =id= - create and use an =ID= property +- =dir= - create and use a =DIR= property +- =ask= - ask the user which method to use +- =false= - don't create a property; the user has to define it explicitly before attaching files + +*** org_attach_method +:PROPERTIES: +:CUSTOM_ID: org_attach_method +:END: +- Type: ='cp'|'mv'|'ln'|'lns'= +- Default: ='cp'= + +The preferred method to add files to the attachment directory. + +- =mv= - move (rename) the file +- =cp= - copy the file +- =ln= - create a hard link; not supported on all systems +- =lns= - create a symbol link; not supported on all systems; on Windows, this always creates a /junction/ + +*** org_attach_copy_directory_create_symlink +:PROPERTIES: +:CUSTOM_ID: org_attach_copy_directory_create_symlink +:END: +- Type: =boolean= +- Default: =false= + +If =true=, whenever the attachments directory itself is a symlink, and it +is copied due to the [[https://orgmode.org/manual/Attachment-defaults-and-dispatcher.html*index-C_002dc-C_002da-s][set_directory]] or [[https://orgmode.org/manual/Attachment-defaults-and-dispatcher.html*index-C_002dc-C_002da-S][unset_directory]] action, copy the +symlink itself. The default is to treat the symlink transparently as +a directory. + +*** org_attach_visit_command +:PROPERTIES: +:CUSTOM_ID: org_attach_visit_command +:END: +- Type: =string|fun(path: string)= +- Default: ='edit'= + +Command or function used to open a directory. The default opens NetRW if it +is available. + +*** org_attach_use_inheritance +:PROPERTIES: +:CUSTOM_ID: org_attach_use_inheritance +:END: +- Type: ='always'|'selective'|'never'= +- Default: ='selective'= + +Attachment inheritance for the outline. + +Enabling inheritance implies two things: +1. Attachment links will look through all parent headlines until they find + the linked attachment. +2. Running =attach= inside a node without attachments will operate on the + first parent headline that has an attachment. + +Possible values are: + +- =always= - inherit attachments +- =selective= - respect [[#org_use_property_inheritance][org_use_property_inheritance]] for the properties =DIR= and =ID= +- =never= - don't inherit attachments + +*** org_attach_store_link_p +:PROPERTIES: +:CUSTOM_ID: org_attach_store_link_p +:END: +- Type: ='attached' | 'file' | 'original' | false= +- Default: ='attached'= + +If not =false=, store a link with [[#org_store_link][org_store_link]] when attaching a file. + +- =attach= - store a =[[attachment:name]]= link +- =file= - store a =[[file:attach_dir/name]]= link +- =original= - store a =[[file:original/location]]= link + +*** org_attach_archive_delete +:PROPERTIES: +:CUSTOM_ID: org_attach_archive_delete +:END: +- Type: ='always' | 'ask' | 'never'= +- Default: ='never'= + +Determines whether attachments are deleted automatically whenever a subtree +is moved to an archive file. The value ='ask'= means to ask the user. + +*** org_attach_id_to_path_function_list +:PROPERTIES: +:CUSTOM_ID: org_attach_id_to_path_function_list +:END: +- Type: =(string | fun(id: string): (string|nil))[]= +- Default: ={ 'uuid_folder_format', 'ts_folder_format', 'fallback_folder_format' }= + +List of functions that are tried sequentially to derive an attachment path +from an =ID= property. The functions are called with a single =id= argument +until the return value is an existing folder. The ID format passed to the +functions is usually defined by [[#org_id_method][org_id_method]]. + +If no folder has been created yet for the given ID, then the first truthy +value defines the path of the folder to be created. + +The default functions avoid putting all attachment directories directly +inside [[#org_attach_id_dir][org_attach_id_dir]]. Some file systems have performance issues in +such scenarios. + +Be careful when changing this setting. If you remove a function, previously +created attachment folders may be no longer mapped correctly and Org may be +unable to detect them. + +*** org_attach_sync_delete_empty_dir +:PROPERTIES: +:CUSTOM_ID: org_attach_sync_delete_empty_dir +:END: +- Type: ='always'|'ask'|'never'= +- Default: ='ask'= + +Determines whether to delete empty directories during [[https://orgmode.org/manual/Attachment-defaults-and-dispatcher.html*index-C_002dc-C_002da-z][org_attach_sync]]. + +- =never= - never delete empty directories +- =ask= - ask the user whether to delete +- =always= - delete empty directories without asking + ** Mappings :PROPERTIES: :CUSTOM_ID: mappings @@ -2032,6 +2188,13 @@ See [[#clocking][Clocking]] for more details. - Mapped to: =obt= Tangle current file. See [[#extract-source-code-tangle][Extract source code (tangle)]] for more details. +**** org_attach +:PROPERTIES: +:CUSTOM_ID: org_attach +:END: +- Mapped to: =o= +Open the attach dispatcher. See [[#attachments][Attachments]] for more details. + **** org_show_help :PROPERTIES: :CUSTOM_ID: org_show_help @@ -2790,6 +2953,42 @@ Running [[#org_babel_tangle][org_babel_tangle]] will create file =~/org/my_tangl =print('Headline 1')= =#+end_src= +*** Attachments +:PROPERTIES: +:CUSTOM_ID: attachments +:END: + +There is almost complete support for file attachments (Orgmode link: +[[https://orgmode.org/manual/Attachments.html][Attachments]]). You can use [[#org_attach][org_attach]] to open the dispatcher and attach +files to an "attachment node" (either a headline or an entire org +file). + +Attaching a file puts it in a directory associated with the attachment node. +Based on [[#org_attach_preferred_new_method][org_attach_preferred_new_method]], this either uses the =ID= or +the =DIR= property. See also [[#org_attach_id_dir][org_attach_id_dir]], +[[#org_attach_id_to_path_function_list][org_attach_id_to_path_function_list]] and [[#org_attach_use_inheritance][org_attach_use_inheritance]] on how +to further customize the attachments directory. + +Attachment links are supported. A link like =[[attachment:file.txt]]= +looks up =file.txt= in the current node's attachments directory and opens +it. Attaching a file stores a link to the attachment. See +[[#org_attach_store_link_p][org_attach_store_link_p]] on how to configure this behavior. + +You can also attach files from a different buffer. The following +mapping attaches the path under the cursor to the current headline of the +most recently open org file: + +#+begin_src lua +vim.keymap.set('n', 'o+', function() + local file = vim.fn.expand('') + local org = require('orgmode') + org.attach:attach_to_other_buffer(file) +end) +#+end_src + +The only missing feature is expansion of attachment links before exporting +a file with [[#org_export][org_exporting]]. + ** User interface :PROPERTIES: :CUSTOM_ID: user-interface diff --git a/lua/orgmode/attach/core.lua b/lua/orgmode/attach/core.lua new file mode 100644 index 000000000..88f544cb7 --- /dev/null +++ b/lua/orgmode/attach/core.lua @@ -0,0 +1,720 @@ +local AttachNode = require('orgmode.attach.node') +local Promise = require('orgmode.utils.promise') +local config = require('orgmode.config') +local fileops = require('orgmode.attach.fileops') +local utils = require('orgmode.utils') + +---@class OrgAttachCore +---@field files OrgFiles +local AttachCore = {} +AttachCore.__index = AttachCore + +---@param opts {files:OrgFiles} +function AttachCore.new(opts) + local data = { + files = opts and opts.files, + } + return setmetatable(data, AttachCore) +end + +---Get the current attachment node. +--- +---@return OrgAttachNode +function AttachCore:get_current_node() + return AttachNode.at_cursor(self.files:get_current_file()) +end + +---Get an attachment node for an arbitrary window. +--- +---An error occurs if the given window doesn't point at a loaded org file. +--- +---@param winid integer window-ID or 0 for the current window +---@return OrgAttachNode +function AttachCore:get_node_by_winid(winid) + local bufnr = vim.api.nvim_win_get_buf(winid) + local path = vim.api.nvim_buf_get_name(bufnr) + local file = self.files:get(path) + local cursor = vim.api.nvim_win_get_cursor(winid) + return AttachNode.at_cursor(file, cursor) +end + +---@param self OrgAttachCore +---@param bufnr integer +---@return OrgFile | nil +local function get_file_by_bufnr(self, bufnr) + if not vim.api.nvim_buf_is_loaded(bufnr) then + return + end + local path = vim.api.nvim_buf_get_name(bufnr) + return self.files:load_file_sync(path) or nil +end + +---Get all attachment nodes that are pointed at in a given buffer. +--- +---If the buffer is not loaded, or if it's not an org file, this returns an +---empty list. +--- +---If the buffer is loaded but hidden, this returns a table mapping from 0 to +---the only attachment node pointed at by the mark `"` (position at last exit +---from the buffer). +--- +---If the buffer is active, this returns a table mapping from window-ID to +---attachment node containing the curser in that window. Note that two windows +---may point at the same attachment node. +--- +---See `:help windows-intro` for terminology. +--- +---@param bufnr integer +---@return OrgAttachNode[] +function AttachCore:get_nodes_by_buffer(bufnr) + local file = get_file_by_bufnr(self, bufnr) + if not file then + return {} + end + local windows = vim.fn.win_findbuf(bufnr) + if #windows == 0 then + -- Org file is loaded but hidden. + local cursor = vim.api.nvim_buf_get_mark(bufnr, '"') + return { AttachNode.at_cursor(file, cursor) } + end + -- Org file is active, collect all windows. + -- Because all nodes are in the same buffer, we use the fact that their + -- starting-line numbers are unique. This lets us deduplicate multiple + -- windows that show the same node. + local nodes = {} ---@type table + for _, winid in ipairs(windows) do + local cursor = vim.api.nvim_win_get_cursor(winid) + local node = AttachNode.at_cursor(file, cursor) + nodes[node:get_start_line()] = node + end + return vim.tbl_values(nodes) +end + +---Like `get_nodes_by_buffer()`, but only accept an unambiguous result. +--- +---If the buffer is displayed in multiple windows, *and* those windows have +---their cursors at different attachment nodes, return nil. +--- +---@param bufnr integer +---@return OrgAttachNode|nil +function AttachCore:get_single_node_by_buffer(bufnr) + local file = get_file_by_bufnr(self, bufnr) + if not file then + return {} + end + local windows = vim.fn.win_findbuf(bufnr) + if #windows == 0 then + -- Org file is loaded but hidden. + local cursor = vim.api.nvim_buf_get_mark(bufnr, '"') + return AttachNode.at_cursor(file, cursor) + end + -- Org file is active. Check that all cursors are on the same node. + -- (This is a very cold loop, so it being a bit awkward is acceptable.) + local node + for _, winid in ipairs(windows) do + local cursor = vim.api.nvim_win_get_cursor(winid) + local next_node = AttachNode.at_cursor(file, cursor) + -- Because all nodes are in the same buffer, we use the fact that their + -- starting-line numbers are unique. This lets us detect when two windows + -- point at different nodes. + if node and node:get_start_line() ~= next_node:get_start_line() then + return + end + node = AttachNode.at_cursor(file, cursor) + end + return node +end + +---List attachment nodes across buffers. +--- +---By default, the result includes all nodes pointed at by a cursor in +---a window. If `include_hidden` is true, the result also includes buffers that +---are loaded but hidden. In their case, the node that contains the `"` mark is +---used. +--- +---@param opts? { include_hidden?: boolean } +---@return OrgAttachNode[] +function AttachCore:list_current_nodes(opts) + local nodes = {} ---@type OrgAttachNode[] + local seen_bufs = {} ---@type table + for _, winid in vim.api.nvim_list_wins() do + local bufnr = vim.api.nvim_win_get_buf(winid) + local path = vim.api.nvim_buf_get_name(bufnr) + local file = self.files:load_file_sync(path) + if file then + local cursor = vim.api.nvim_win_get_cursor(winid) + nodes[#nodes + 1] = AttachNode.at_cursor(file, cursor) + end + seen_bufs[bufnr] = true + end + if opts and opts.include_hidden or false then + for _, bufnr in vim.api.nvim_list_bufs() do + if not seen_bufs[bufnr] then + local file = get_file_by_bufnr(self, bufnr) + if file then + -- Hidden buffers don't have cursors, only windows do; instead, we + -- use the mark where the buffer was last exited. + local cursor = vim.api.nvim_buf_get_mark(bufnr, '"') + nodes[#nodes + 1] = AttachNode.at_cursor(file, cursor) + end + end + end + end + return nodes +end + +---Return the directory associated with the current outline node. +--- +---First check for DIR property, then ID property. +---`org_attach_use_inheritance' determines whether inherited +---properties also will be considered. +--- +---If an ID property is found the default mechanism using that ID +---will be invoked to access the directory for the current entry. +---Note that this method returns the directory as declared by ID or +---DIR even if the directory doesn't exist in the filesystem. +--- +---@param node OrgAttachNode +---@param no_fs_check? boolean if true, return the directory even if it doesn't +--- exist +---@return string|nil attach_dir +function AttachCore:get_dir_or_nil(node, no_fs_check) + local dir = node:get_dir() + return dir and (no_fs_check or fileops.is_dir(dir)) and dir or nil +end + +---Return the directory associated with the current outline node. +--- +---First check for DIR property, then ID property. +---`org_attach_use_inheritance' determines whether inherited +---properties also will be considered. +--- +---If an ID property is found the default mechanism using that ID +---will be invoked to access the directory for the current entry. +---Note that this method returns the directory as declared by ID or +---DIR even if the directory doesn't exist in the filesystem. +--- +---@param node OrgAttachNode +---@param no_fs_check? boolean if true, return the directory even if it doesn't +--- exist +---@return string attach_dir +function AttachCore:get_dir(node, no_fs_check) + return self:get_dir_or_nil(node, no_fs_check) or error('No attachment directory for this node') +end + +---@alias orgmode.attach.core.new_method 'id' | 'dir' + +---Return existing or new directory associated with the current outline node. +--- +---`org_attach_preferred_new_method` decides how to attach new directory if +---neither ID nor DIR property exist. +--- +---If the attachment by some reason cannot be created an error will be raised. +--- +---@param node OrgAttachNode +---@param method fun(): OrgPromise +---@param new_dir fun(): OrgPromise +---@return OrgPromise +function AttachCore:get_dir_or_create(node, method, new_dir) + local dir = self:get_dir_or_nil(node) -- free `is_dir()` check + if dir then + return Promise.resolve(dir) + end + return method() + :next(function(chosen_method) + if chosen_method == 'id' then + return node:id_dir_get_or_create() + elseif chosen_method == 'dir' then + return new_dir():next(function(chosen_dir) + if not chosen_dir or chosen_dir == '' then + error('No attachment selected') + end + return node:set_dir(chosen_dir) + end) + else + error(('unknown method: %s'):format(chosen_method)) + end + end) + ---@param chosen_dir string + :next(function(chosen_dir) + local mode = 493 -- octal 0755 as decimal + return fileops.make_dir(chosen_dir, { mode = mode, parents = true, exist_ok = true }):next(function() + return chosen_dir + end) + end) +end + +---@class orgmode.attach.core.set_directory.opts +---@field do_copy fun(old: string, new: string): OrgPromise +---@field do_delete fun(old: string): OrgPromise + +---Set the DIR node property and ask to move files there. +--- +---The property defines the directory that is used for attachments +---of the entry. +--- +---@param node OrgAttachNode +---@param new_dir string +---@param opts orgmode.attach.core.set_directory.opts +---@return OrgPromise new_dir +function AttachCore:set_directory(node, new_dir, opts) + local old_dir = self:get_dir(node, true) + -- Ordering matters here: both `opts` should be evaluated before the + -- operations (copy if desired, set_dir, delete if desired) start. + ---@param do_copy? boolean + return Promise.resolve(old_dir and new_dir and opts.do_copy(old_dir, new_dir)):next(function(do_copy) + ---@param do_delete? boolean + return Promise.resolve(old_dir and opts.do_delete(old_dir)):next(function(do_delete) + if do_copy == nil or do_delete == nil then + return + end + return Promise.resolve() + :next(function() + return do_copy + and fileops.copy_directory(old_dir, new_dir, { + parents = true, + keep_times = true, + create_symlink = config.org_attach_copy_directory_create_symlink, + }) + end) + :next(function() + node:set_dir(new_dir) + end) + :next(function() + return do_delete and fileops.remove_directory(old_dir, { recursive = true }) + end) + end) + end) +end + +---Remove DIR node property. +--- +---If attachment folder is changed due to removal of DIR-property +---ask to move attachments to new location and ask to delete old +---attachment folder. +--- +---Change of attachment-folder due to unset might be if an ID +---property is set on the node, or if a separate inherited +---DIR-property exists (that is different from the unset one). +--- +---@param node OrgAttachNode +---@param opts orgmode.attach.core.set_directory.opts +---@return OrgPromise new_dir +function AttachCore:unset_directory(node, opts) + local old_dir = self:get_dir(node, true) + node:set_dir() + -- After removal, there might be a new DIR directory via inheritance. + local new_dir = self:get_dir_or_nil(node, true) + if not new_dir then + -- There is no parent node with a DIR property. Switch back to ID-based + -- directory. + new_dir = node:id_dir_get_or_create() + end + -- Ordering matters here: both `opts` should be evaluated before the + -- operations (copy if desired, delete if desired) start. + ---@param do_copy? boolean + return Promise.resolve(old_dir and new_dir and opts.do_copy(old_dir, new_dir)):next(function(do_copy) + ---@param do_delete? boolean + return Promise.resolve(old_dir and opts.do_delete(old_dir)):next(function(do_delete) + if do_copy == nil or do_delete == nil then + return + end + return Promise.resolve() + :next(function() + return do_copy + and fileops.copy_directory(old_dir, new_dir, { + parents = true, + keep_times = true, + create_symlink = config.org_attach_copy_directory_create_symlink, + }) + end) + :next(function() + return do_delete and fileops.remove_directory(old_dir, { recursive = true }) + end) + end) + end) +end + +---Turn the autotag on. +--- +---If autotagging is disabled, this does nothing. +--- +---@param node OrgAttachNode +---@return nil +function AttachCore:tag(node) + node:add_auto_tag() +end + +---Turn the autotag off. +--- +---If autotagging is disabled, this does nothing. +--- +---@param node OrgAttachNode +---@return nil +function AttachCore:untag(node) + node:remove_auto_tag() +end + +---Helper to the `attach_*()` functions. +---Like `vim.fs.basename()` but reject an empty string result. +---This also ignores trailing slashes, e.g.: +---* '/foo/bar' -> 'bar' +---* '/foo/' -> 'foo' +---* '/' -> error! +---@param path string +---@return string basename +local function basename_safe(path) + local match = path:match('^(.*[^/])/*$') + local basename = match and vim.fs.basename(match) + return basename ~= '' and basename or error('cannot determine attachment name: ' .. path) +end + +---@alias OrgAttachMethod 'cp' | 'mv' | 'ln' | 'lns' + +---@type table> +local FILE_ATTACHERS = { + mv = function(source, target) + return fileops.rename(source, target) + end, + cp = function(source, target) + if fileops.is_dir(source) then + return fileops.copy_directory(source, target, { + parents = false, + keep_times = false, + create_symlink = config.org_attach_copy_directory_create_symlink, + }) + else + return fileops.copy_file(source, target, { excl = true, ficlone = true, ficlone_force = false }) + end + end, + ln = function(source, target) + return fileops.hardlink(source, target) + end, + lns = function(source, target) + return fileops.symlink(source, target, { dir = false, junction = false, exist_ok = false }) + end, +} + +---@param method OrgAttachMethod +---@return fun(source: string, target: string): OrgPromise success +local function get_file_attacher(method) + return FILE_ATTACHERS[method] or error('unknown org_attach_method: ' .. tostring(method)) +end + +---@class orgmode.attach.core.attach.opts +---@inlinedoc +---@field attach_method OrgAttachMethod +---@field set_dir_method fun(): OrgPromise +---@field new_dir fun(): OrgPromise + +---Move/copy/link file into attachment directory of the current outline node. +--- +---@param node OrgAttachNode +---@param file string The file to attach +---@param opts orgmode.attach.core.attach.opts +---@return OrgPromise attachment_name +function AttachCore:attach(node, file, opts) + if file == '' then + utils.echo_warning('No attachment selected') + return Promise.resolve() + end + local basename = basename_safe(file) + local attach = get_file_attacher(opts.attach_method) + return self:get_dir_or_create(node, opts.set_dir_method, opts.new_dir):next(function(attach_dir) + local attach_file = vim.fs.joinpath(attach_dir, basename) + return attach(file, attach_file):next(function(success) + if not success then + return nil + end + node:add_auto_tag() + return basename + end) + end) +end + +---@class orgmode.attach.core.attach_buffer.opts +---@inlinedoc +---@field set_dir_method fun(): OrgPromise +---@field new_dir fun(): OrgPromise + +---Attach buffer's contents to current outline node. +--- +---Throws a file-exists error if it would overwrite an existing filename. +--- +---@param node OrgAttachNode +---@param bufnr integer +---@param opts orgmode.attach.core.attach_buffer.opts +---@return OrgPromise attachment_name +function AttachCore:attach_buffer(node, bufnr, opts) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local basename = basename_safe(bufname) + return self:get_dir_or_create(node, opts.set_dir_method, opts.new_dir):next(function(attach_dir) + local attach_file = vim.fs.joinpath(attach_dir, basename) + local data = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') + return utils.writefile(attach_file, data, { excl = true }):next(function() + node:add_auto_tag() + return basename + end) + end) +end + +---@class orgmode.attach.core.attach_many.result +---@field successes integer +---@field failures integer + +---Move/copy/link many files into attachment directory. +--- +---@param node OrgAttachNode +---@param files string[] +---@param opts orgmode.attach.core.attach.opts +---@return OrgPromise tally +function AttachCore:attach_many(node, files, opts) + local attach = get_file_attacher(opts.attach_method) + ---@type orgmode.attach.core.attach_many.result + local initial_tally = { successes = 0, failures = 0 } + if #files == 0 then + return Promise.resolve(initial_tally) + end + return self:get_dir_or_create(node, opts.set_dir_method, opts.new_dir):next(function(attach_dir) + return Promise + .mapSeries(function(to_be_attached) + local basename = basename_safe(to_be_attached) + local attach_file = vim.fs.joinpath(attach_dir, basename) + return attach(to_be_attached, attach_file) + end, files) + ---@param successes boolean[] + :next(function(successes) + node:add_auto_tag() + ---@param tally orgmode.attach.core.attach_many.result + ---@param success boolean + ---@return orgmode.attach.core.attach_many.result tally + return utils.reduce(successes, function(tally, success) + if success then + tally.successes = tally.successes + 1 + else + tally.failures = tally.failures + 1 + end + return tally + end, initial_tally) + end) + end) +end + +---@class orgmode.attach.core.attach_new.opts +---@inlinedoc +---@field set_dir_method fun(): OrgPromise +---@field new_dir fun(): OrgPromise +---@field edit_bang boolean +---@field edit_mods table + +---Create a new attachment FILE for the current outline node. +--- +---The attachment is opened via `:edit`. The command can be modified via +---`opts`. +--- +---@param node OrgAttachNode +---@param name string +---@param opts orgmode.attach.core.attach_new.opts +---@return OrgPromise attachment_name +function AttachCore:attach_new(node, name, opts) + if name == '' then + utils.echo_warning('No attachment selected') + return Promise.resolve() + end + return self:get_dir_or_create(node, opts.set_dir_method, opts.new_dir):next(function(attach_dir) + local path = vim.fs.joinpath(attach_dir, name) + --TODO: the emacs version doesn't run the hook here. Is this correct? + node:add_auto_tag() + ---@type vim.api.keyset.cmd + return Promise.new(function(resolve, reject) + local cmd = { cmd = 'edit', args = { path }, bang = opts.edit_bang, mods = opts.edit_mods } + vim.schedule(function() + local ok, err = pcall(vim.api.nvim_cmd, cmd, {}) + if ok then + resolve(name) + else + reject(err) + end + end) + end) + end) +end + +---Open the attachments directory via `vim.ui.open()`. +--- +---@param attach_dir string the directory to open +---@return vim.SystemObj +function AttachCore:reveal(attach_dir) + return assert(vim.ui.open(attach_dir)) +end + +---Open the attachments directory via `org_attach_visit_command`. +--- +---@param attach_dir string the directory to open +---@return nil +function AttachCore:reveal_nvim(attach_dir) + local command = config.org_attach_visit_command or 'edit' + if type(command) == 'string' then + vim.cmd[command](attach_dir) + else + command(attach_dir) + end +end + +---Open an attached file via `vim.ui.open()`. +--- +---@param node OrgAttachNode +---@param name string name of the file to open +---@return vim.SystemObj +function AttachCore:open(name, node) + local attach_dir = self:get_dir(node) + local path = vim.fs.joinpath(attach_dir, name) + return assert(vim.ui.open(path)) +end + +---Open an attached file via `:edit`. +--- +---@param node OrgAttachNode +---@param name string name of the file to open +---@return nil +function AttachCore:open_in_vim(name, node) + local attach_dir = self:get_dir(node) + local path = vim.fs.joinpath(attach_dir, name) + vim.cmd.edit(path) +end + +---Delete a single attachment. +--- +---@param node OrgAttachNode +---@param name string the name of the attachment to delete +---@return OrgPromise +function AttachCore:delete_one(node, name) + if name == '' then + utils.echo_warning('No attachment selected') + return Promise.resolve() + end + local attach_dir = self:get_dir(node) + local path = vim.fs.joinpath(attach_dir, name) + return fileops.unlink(path):next(function() + return nil + end) +end + +---Delete all attachments from the current outline node. +--- +---This actually deletes the entire attachment directory. A safer way is to +---open the directory with `reveal` and delete from there. +--- +---@param node OrgAttachNode +---@param recursive fun(): OrgPromise +---@return OrgPromise deleted_dir +function AttachCore:delete_all(node, recursive) + local attach_dir = self:get_dir(node) + -- A few synchronous FS operations here, can't really be avoided. The + -- alternative would be to evaluate `recursive` before it's necessary. + local uv = vim.uv or vim.loop + local ok, errmsg, err = uv.fs_unlink(attach_dir) + if ok then + return Promise.resolve() + elseif err ~= 'EISDIR' then + return Promise.reject(errmsg) + end + ok, errmsg, err = uv.fs_rmdir(attach_dir) + if ok then + return Promise.resolve() + elseif err ~= 'ENOTEMPTY' then + return Promise.reject(errmsg) + end + return recursive():next(function(do_recursive) + if not do_recursive then + return Promise.reject(errmsg) + end + return fileops.remove_directory(attach_dir, { recursive = true }):next(function() + node:remove_auto_tag() + return attach_dir + end) + end) +end + +---Return true if the directory contains any files without trailing `~` in +---their name. Trailing `~` is Emacs convention for swap files. +--- +---@param directory string +---@return boolean +local function has_any_non_litter_files(directory) + ---@param name string + return fileops.iterdir(directory):any(function(name) + return not vim.endswith(name, '~') + end) +end + +---Synchronize the current outline node with its attachments. +--- +---Useful after files have been added/removed externally. The Option +---`org_attach_sync_delete_empty_dir` controls the behavior for empty +---attachment directories. (This ignores files whose name ends with +---a tilde `~`.) +--- +---@param node OrgAttachNode +---@param delete_empty_dir fun(): OrgPromise +---@return OrgPromise attach_dir_if_deleted +function AttachCore:sync(node, delete_empty_dir) + local attach_dir = self:get_dir_or_nil(node) + if not attach_dir then + self:untag(node) + return Promise.resolve() + end + local non_empty = has_any_non_litter_files(attach_dir) + if non_empty then + node:add_auto_tag() + return Promise.resolve() + else + node:remove_auto_tag() + end + return delete_empty_dir():next(function(do_delete) + if not do_delete then + return Promise.resolve() + end + return fileops.remove_directory(attach_dir, { recursive = true }):next(function() + return attach_dir + end) + end) +end + +---Call `callback` with every attachment link in the file. +--- +---@param file OrgFile +---@param callback fun(attach_dir: string|false, basename: string): string|nil +---@return OrgPromise +function AttachCore:on_every_attachment_link(file, callback) + -- TODO: In a better world, this would use treesitter for parsing ... + return file:update(function() + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, true) + local prev_node = nil ---@type OrgAttachNode | nil + local attach_dir = nil ---@type string | false | nil + for i, line in ipairs(lines) do + -- Check if node has changed; if yes, invalidate cached attach_dir. + local node = AttachNode.at_cursor(file, { i + 1, 0 }) + if node ~= prev_node then + attach_dir = nil + end + ---@param basename string + ---@param bracket '[' | ']' + ---@return string + local replaced = line:gsub('%[%[attachment:([^%]]+)%]([%[%]])', function(basename, bracket) + -- Only compute attach_dir when we know that we need it! + if attach_dir == nil then + attach_dir = self:get_dir_or_nil(node, true) or false + end + local res = callback(attach_dir, basename) + return res and ('[[%s]%s'):format(res, bracket) or ('[[attachment:%s]%s'):format(basename, bracket) + end) + if replaced ~= line then + vim.api.nvim_buf_set_lines(0, i - 1, i, true, { replaced }) + end + prev_node = node + end + end) +end + +return AttachCore diff --git a/lua/orgmode/attach/fileops.lua b/lua/orgmode/attach/fileops.lua new file mode 100644 index 000000000..c3661603e --- /dev/null +++ b/lua/orgmode/attach/fileops.lua @@ -0,0 +1,342 @@ +local Promise = require('orgmode.utils.promise') +local utils = require('orgmode.utils') +local uv = vim.uv + +---@param resolve fun(...) +---@param reject fun(...) +---@return fun(err: string?, success: boolean?) +local function uv_resolver(resolve, reject) + return function(err, success) + if err then + reject(err) + else + resolve(success) + end + end +end + +---Utility functions for dealing with files. +---This module currently is only used by `OrgAttach`. However, it is general +---enough that, if it is useful for other modules, that it could be moved to +---`utils`. +--- +---IMPLEMENTATION NOTE: Every time we chain promises, we step out of fast-api +---mode and schedule another function. It is not clear what the performance +---implications are. A test run of copying a directory with 1000 files, this +---was reasonably fast and didn't block the editor. +local M = {} + +--[[ +-- libuv functions ported to use OrgPromise +--]] + +---Like `vim.uv.fs_rename`, but returns a promise. +---@param path string +---@param new_path string +---@return OrgPromise success +function M.rename(path, new_path) + return Promise.new(function(resolve, reject) + uv.fs_rename(path, new_path, uv_resolver(resolve, reject)) + end) +end + +---Like `vim.uv.fs_copyfile`, but returns a promise. +---@param path string +---@param new_path string +---@param flags? integer | uv.fs_copyfile.flags_t +---@return OrgPromise success +function M.copy_file(path, new_path, flags) + return Promise.new(function(resolve, reject) + uv.fs_copyfile(path, new_path, flags, uv_resolver(resolve, reject)) + end) +end + +---Like `vim.uv.fs_link`, but returns a promise. +---@param path string +---@param new_path string +---@return OrgPromise success +function M.hardlink(path, new_path) + return Promise.new(function(resolve, reject) + uv.fs_link(path, new_path, uv_resolver(resolve, reject)) + end) +end + +---Like `vim.uv.fs_unlink`, but returns a promise. +---@param path string +---@return OrgPromise success +function M.unlink(path) + return Promise.new(function(resolve, reject) + uv.fs_unlink(path, uv_resolver(resolve, reject)) + end) +end + +--[[ +-- Functions that have a direct libuv equivalent, but have been made more +-- convenient. +--]] + +---Like `vim.uv.fs_symlink`, but with the ability to catch `EEXIST`. +---* `exist_ok`: if `new_path` exists already, resolve to false. The default is +--- to raise the error `EEXIST`. +---* `dir`: same as for `vim.uv.fs_symlink` +---* `junction`: same as for `vim.uv.fs_symlink` +---@param path string +---@param new_path string +---@param flags? {exist_ok: boolean?, dir: boolean?, junction: boolean?} +---@return OrgPromise created true if this creates a new symlink. +function M.symlink(path, new_path, flags) + local exist_ok = flags and flags.exist_ok or false + return Promise.new(function(resolve, reject) + uv.fs_symlink(path, new_path, flags, function(err, success) + if success then + resolve(success) + elseif exist_ok and err and err:match('^EEXIST:') then + resolve(false) + else + reject(err) + end + end) + end) +end + +---Like `vim.uv.fs_mkdir`, but with more convenience options. +---* `mode`: passed directly through, the default is 0o700 (u=rwx,g=,o=). +---* `parents`: if true, missing parent directories are created recursively +---* `exist_ok`: if true and `path` points at an existing directory, resolve to +--- false. The default is to raise the error `EEXIST`. +---@param path string +---@param opts? {mode: integer?, parents: boolean?, exist_ok: boolean?} +---@return OrgPromise created true if this creates a new directory. +function M.make_dir(path, opts) + opts = opts or {} + local mode = opts.mode or 448 -- 0700 -> decimal + local parents = opts.parents or false + local exist_ok = opts.exist_ok or true + return Promise.new(function(resolve, reject) + uv.fs_mkdir(path, mode, function(err) + if not err then + return resolve(true) + elseif err:match('^EEXIST:') and exist_ok then + if M.is_dir(path) then + resolve(false) + else + error(err) + end + return + elseif err:match('^ENOENT:') and parents then + -- Remove trailing slashes. + path = path:match('^(.*[^/])') or path + local parent = vim.fs.dirname(path) + -- Avoid infinite loop if root doesn't exist: + -- https://debbugs.gnu.org/cgi/bugreport.cgi?bug=2309 + if parent == path then + return reject(err) + end + M.make_dir(parent, { mode = mode, parents = true, exist_ok = false }):next(function() + return M + .make_dir(path, { mode = mode, parents = false, exist_ok = false }) + ---@diagnostic disable-next-line: redundant-parameter + :next(resolve, reject) + end) + else + return reject(err) + end + end) + end) +end + +--[[ +-- Additional functionality that builds upon libuv. +--]] + +---Like `vim.fs.dir()`, but with a few sanity improvements: +---1. errors instead of returning nil if `fs_scandir()` fails; +---2. returns an iterator instead of an iterable +---3. works around by manually +--- calling `fs_stat()` if the filetype can't be fetched. +---@param path string +---@return Iter entries +function M.iterdir(path) + local dirs = vim.fs.dir(path) + if not dirs then + assert(vim.uv.fs_scandir(path)) + error(('could not read path: %s'):format(path)) + end + return vim + .iter(dirs) + ---@param name string + ---@param ftype? string + :map(function(name, ftype) + -- On network filesystems, ftype may be nil, see + -- + if ftype == nil then + local stat = vim.uv.fs_stat(vim.fs.joinpath(path, name)) + ftype = stat and stat.type or 'unknown' + end + ---@diagnostic disable: redundant-return-value + return name, ftype + end) +end + +---Return true if the path points at a directory. +---This simply uses `fs_stat()`, so it always resolves symlinks. +---@param path string +---@return boolean result +function M.is_dir(path) + local stat, errmsg, err = uv.fs_stat(path) + if not stat then + assert(err == 'ENOENT', errmsg) + return false + end + return stat.type == 'directory' +end + +---Return true if the path points at a symbolic link. +---@param path string +---@return boolean is_symlink +function M.is_symlink(path) + ---@diagnostic disable-next-line: param-type-mismatch + local stat, errmsg, err = uv.fs_lstat(path) + if not stat then + assert(err == 'ENOENT', errmsg) + return false + end + return stat.type == 'link' +end + +---Helper function to `copy_symlink` and `copy_stats`. +---Convert the time object returned by libuv back into seconds-since-the-epoch. +---@param time uv.fs_stat.result.time +---@return number epoch +local function to_epoch(time) + return time.sec + time.nsec / 1e9 +end + +---Copy an existing symlink as a symlink. +---* `keep_times`: if true, copy access and modification timestamps as well. +---* `exist_ok`: if true, don't raise an error if `new_path` already points at +--- an object. +---If both `keep_times` and `exist_ok`, this updates the timestamps of an +---existing symbolic link. +---@param path string +---@param new_path string +---@param flags? {keep_times: boolean?, exist_ok: boolean?} +---@return OrgPromise created true if this creates a new symlink. +function M.copy_symlink(path, new_path, flags) + local keep_times = flags and flags.keep_times or false + local exist_ok = flags and flags.exist_ok or false + local target = assert(uv.fs_readlink(path)) + return M.symlink(target, new_path, { exist_ok = exist_ok, dir = M.is_dir(target), junction = true }) + :next(function(created) + if not keep_times then + return created + end + local stat = assert(uv.fs_stat(path)) + local atime = to_epoch(stat.atime) + local mtime = to_epoch(stat.mtime) + return Promise.new(function(resolve, reject) + uv.fs_lutime(new_path, atime, mtime, function(err) + if err then + reject(err) + else + resolve(created) + end + end) + end) + end) +end + +---Copy permission bits and (potentially) timestamps from one file to another. +---@param path string +---@param new_path string +---@param keep_times boolean if true, copy access and modification timestamps +---@return nil +local function copy_stats(path, new_path, keep_times) + local stat = assert(uv.fs_stat(path)) + assert(uv.fs_chmod(new_path, stat.mode)) + if not keep_times then + return + end + local atime = to_epoch(stat.atime) + local mtime = to_epoch(stat.mtime) + assert(uv.fs_utime(new_path, atime, mtime)) + if M.is_symlink(new_path) then + assert(uv.fs_lutime(new_path, atime, mtime)) + end +end + +---Copy a directory recursively. +---* `parents`: if true, create non-existing parent directories +---* `create_symlink`: if true and `path` is a symbolic link, don't copy its +--- contents, but rather create a symbolic link to the same target +---* `keep_times`: if true, adjust file modification times of `new_path` to +--- those of `path`. +---@param path string +---@param new_path string +---@param opts? {parents: boolean?, create_symlink: boolean?, keep_times: boolean?} +---@return OrgPromise success +function M.copy_directory(path, new_path, opts) + opts = opts or {} + if opts.create_symlink then + if M.is_symlink(path) then + if M.is_dir(new_path) then + new_path = vim.fs.joinpath(new_path, vim.fs.basename(path)) + end + return M.copy_symlink(path, new_path, { exist_ok = true, keep_times = opts.keep_times }) + end + end + return M.make_dir(new_path, { parents = opts.parents, exist_ok = true }) + :next(function() + local items = M.iterdir(path):totable() ---@type [string, string][] + ---@param item [string, string] + return Promise.mapSeries(function(item) + local name, ftype = unpack(item) + local source = vim.fs.joinpath(path, name) + local target = vim.fs.joinpath(new_path, name) + if ftype == 'file' then + return M.copy_file(source, target, { excl = false, ficlone = true, ficlone_force = false }) + elseif ftype == 'link' then + return M.copy_symlink(source, target, { exist_ok = true, keep_times = opts.keep_times }) + elseif ftype == 'directory' then + return M.copy_directory(source, target, opts) + else + return vim.schedule(function() + utils.echo_error(('cannot copy special %s file %s'):format(ftype, source)) + end) + end + end, items) + end) + :next(function() + copy_stats(path, new_path, opts.keep_times) + return true + end) +end + +---Delete a directory, potentially recursively. +---* `recursive`: if true, delete all contents of `path` before deleting it. +--- The default is to only delete `path` if its an empty directory. +---@param path string +---@param opts? {recursive: boolean?} +---@return OrgPromise success +function M.remove_directory(path, opts) + if opts and opts.recursive then + local items = M.iterdir(path):totable() ---@type [string, string][] + ---@param item [string, string] + return Promise.mapSeries(function(item) + local name, ftype = unpack(item) + local subpath = vim.fs.joinpath(path, name) + if ftype == 'directory' then + return M.remove_directory(subpath, opts) + else + return M.unlink(subpath) + end + end, items):next(function() + return M.remove_directory(path) + end) + end + return Promise.new(function(resolve, reject) + uv.fs_rmdir(path, uv_resolver(resolve, reject)) + end) +end + +return M diff --git a/lua/orgmode/attach/init.lua b/lua/orgmode/attach/init.lua new file mode 100644 index 000000000..016e6711a --- /dev/null +++ b/lua/orgmode/attach/init.lua @@ -0,0 +1,833 @@ +local AttachNode = require('orgmode.attach.node') +local Core = require('orgmode.attach.core') +local Input = require('orgmode.ui.input') +local Menu = require('orgmode.ui.menu') +local Promise = require('orgmode.utils.promise') +local config = require('orgmode.config') +local ui = require('orgmode.attach.ui') +local utils = require('orgmode.utils') + +local MAX_TIMEOUT = 2 ^ 31 + +---@class OrgAttach +---@field private core OrgAttachCore +local Attach = {} +Attach.__index = Attach + +---@param opts {files:OrgFiles} +function Attach:new(opts) + local data = setmetatable({ core = Core.new(opts) }, self) + return data +end + +---The dispatcher for attachment commands. +---Shows a list of commands and prompts for another key to execute a command. +---@return nil +function Attach:prompt() + local menu = Menu:new({ + title = 'Press key for an attach command', + prompt = 'Press key for an attach command', + }) + + menu:add_option({ + label = 'Attach a file to this task.', + key = 'a', + action = function() + return self:attach() + end, + }) + menu:add_option({ + label = 'Attach a file by copying it.', + key = 'c', + action = function() + return self:attach_cp() + end, + }) + menu:add_option({ + label = 'Attach a file by moving it.', + key = 'm', + action = function() + return self:attach_mv() + end, + }) + menu:add_option({ + label = 'Attach a file by hard-linking it', + key = 'l', + action = function() + return self:attach_ln() + end, + }) + menu:add_option({ + label = 'Attach a file by symbolic-linking it.', + key = 'y', + action = function() + return self:attach_lns() + end, + }) + menu:add_option({ + label = "Attach a buffer's contents.", + key = 'b', + action = function() + return self:attach_buffer() + end, + }) + menu:add_option({ + label = 'Create a new attachment, as a vim buffer.', + key = 'n', + action = function() + return self:attach_new() + end, + }) + menu:add_separator({ length = #menu.title }) + menu:add_option({ + label = 'Open an attachment externally.', + key = 'o', + action = function() + return self:open() + end, + }) + menu:add_option({ + label = 'Open an attachment in vim.', + key = 'O', + action = function() + return self:open_in_vim() + end, + }) + menu:add_option({ + label = 'Open the attachment directory externally.', + key = 'f', + action = function() + return self:reveal() + end, + }) + menu:add_option({ + label = 'Open the attachment directory in vim.', + key = 'F', + action = function() + return self:reveal_nvim() + end, + }) + menu:add_separator({ length = #menu.title }) + menu:add_option({ + label = 'Delete an attachment', + key = 'd', + action = function() + return self:delete_one() + end, + }) + menu:add_option({ + label = 'Delete all attachments.', + key = 'D', + action = function() + return self:delete_all() + end, + }) + menu:add_option({ + label = 'Set specific attachment directory for this task.', + key = 's', + action = function() + return self:set_directory() + end, + }) + menu:add_option({ + label = 'Unset specific attachment directory for this task.', + key = 'S', + action = function() + return self:unset_directory() + end, + }) + menu:add_option({ + label = 'Synchronize this task with its attachment directory.', + key = 'z', + action = function() + return self:sync() + end, + }) + menu:add_option({ label = 'Quit', key = 'q' }) + menu:add_separator({ icon = ' ', length = 1 }) + + return menu:open() +end + +---Get the current attachment node. +--- +---@return OrgAttachNode +function Attach:get_current_node() + return self.core:get_current_node() +end + +---Get attachment node in a given file at a given position. +--- +---@param file OrgFile +---@param cursor [integer, integer] The (1,0)-indexed cursor position in the buffer +---@return OrgAttachNode +function Attach:get_node(file, cursor) + return AttachNode.at_cursor(file, cursor) +end + +---Get attachment node pointed at in a window +--- +---@param window? integer | string window-ID, window number or any argument +--- accepted by `winnr()`; if 0 or nil, use the +--- current window +---@return OrgAttachNode +function Attach:get_node_by_window(window) + local winid + if not window or window == 0 then + winid = vim.api.nvim_get_current_win() + elseif type(window) == 'string' then + winid = vim.fn.win_getid(vim.fn.winnr(window)) + elseif vim.fn.win_id2win(window) ~= 0 then + winid = window + else + winid = vim.fn.win_getid(window) + end + if winid == 0 then + error(('invalid window: %s'):format(window)) + end + return self.core:get_node_by_winid(winid) +end + +---Return the directory associated with the current outline node. +--- +---First check for DIR property, then ID property. +---`org_attach_use_inheritance' determines whether inherited +---properties also will be considered. +--- +---If an ID property is found the default mechanism using that ID +---will be invoked to access the directory for the current entry. +---Note that this method returns the directory as declared by ID or +---DIR even if the directory doesn't exist in the filesystem. +--- +---@param node? OrgAttachNode +---@param no_fs_check? boolean if true, return the directory even if it doesn't +--- exist +---@return string|nil attach_dir +function Attach:get_dir(node, no_fs_check) + node = node or self.core:get_current_node() + return self.core:get_dir_or_nil(node, no_fs_check) +end + +---Helper function to handle `org_attach_preferred_new_method()` lazily. +--- +---@return fun(): OrgPromise +local function get_set_dir_method() + local method = config.org_attach_preferred_new_method + if not method then + error('No existing directory. DIR or ID property has to be explicitly created') + end + if method == 'id' or method == 'dir' then + return function() + return Promise.resolve(method) + end + end + if method == 'ask' then + return ui.ask_new_method + end + error(('invalid value for org_attach_preferred_new_method: %s'):format(method)) +end + +---Return existing or new directory associated with the current outline node. +--- +---`org_attach_preferred_new_method` decides how to attach new directory if +---neither ID nor DIR property exist. +--- +---If the attachment by some reason cannot be created an error will be raised. +--- +---@param node? OrgAttachNode +---@return string +function Attach:get_dir_or_create(node) + node = node or self.core:get_current_node() + return self.core:get_dir_or_create(node, get_set_dir_method(), ui.ask_attach_dir_property):wait(MAX_TIMEOUT) +end + +---Set the DIR node property and ask to move files there. +--- +---The property defines the directory that is used for attachments +---of the entry. +--- +---@param node? OrgAttachNode +---@return string | nil new_dir +function Attach:set_directory(node) + node = node or self.core:get_current_node() + return ui + .ask_attach_dir_property(node:get_dir()) + ---@return string | nil + :next(function(new_dir) + if not new_dir then + return nil + end + return self.core:set_directory(node, new_dir, { + do_copy = function(old, new) + return ui.yes_or_no_or_cancel_slow(('Copy attachments from "%s" to "%s"? '):format(old, new)) + end, + do_delete = function(old) + return ui.yes_or_no_or_cancel_slow(('Delete "%s"? '):format(old)) + end, + }) + end) + :wait(MAX_TIMEOUT) +end + +---Remove DIR node property. +--- +---If attachment folder is changed due to removal of DIR-property +---ask to move attachments to new location and ask to delete old +---attachment folder. +--- +---Change of attachment-folder due to unset might be if an ID +---property is set on the node, or if a separate inherited +---DIR-property exists (that is different from the unset one). +--- +---@param node? OrgAttachNode +---@return string | nil new_dir +function Attach:unset_directory(node) + node = node or self.core:get_current_node() + return self.core + :unset_directory(node, { + do_copy = function(old, new) + return ui.yes_or_no_or_cancel_slow(('Copy attachments from "%s" to "%s"? '):format(old, new)) + end, + do_delete = function(old) + return ui.yes_or_no_or_cancel_slow(('Delete "%s"? '):format(old)) + end, + }) + :wait(MAX_TIMEOUT) +end + +---Turn the autotag on. +--- +---If autotagging is disabled, this does nothing. +--- +---@param node? OrgAttachNode +---@return nil +function Attach:tag(node) + self.core:tag(node or self.core:get_current_node()) +end + +---Turn the autotag off. +--- +---If autotagging is disabled, this does nothing. +--- +---@param node? OrgAttachNode +---@return nil +function Attach:untag(node) + self.core:untag(node or self.core:get_current_node()) +end + +---@class orgmode.attach.attach.Options +---@inlinedoc +---@field visit_dir? boolean if true, visit the directory subsequently using +--- `org_attach_visit_command` +---@field method? OrgAttachMethod The method via which to attach `file`; +--- default is taken from `org_attach_method` +---@field node? OrgAttachNode + +---Move/copy/link file into attachment directory of the current outline node. +--- +---@param file? string The file to attach. +---@param opts? orgmode.attach.attach.Options +---@return string|nil attachment_name +function Attach:attach(file, opts) + local node = opts and opts.node or self.core:get_current_node() + local visit_dir = opts and opts.visit_dir or false + local method = opts and opts.method or config.org_attach_method + return Promise + .resolve(file or Input.open('File to keep as an attachment: ', '', 'file')) + ---@param chosen_file? string + :next(function(chosen_file) + if not chosen_file then + return nil + end + return self.core:attach(node, chosen_file, { + attach_method = method, + set_dir_method = get_set_dir_method(), + new_dir = ui.ask_attach_dir_property, + }) + end) + :next(function(attachment_name) + if attachment_name then + utils.echo_info(('File %s is now an attachment'):format(attachment_name)) + if visit_dir then + local attach_dir = self.core:get_dir(node) + self.core:reveal_nvim(attach_dir) + end + end + return attachment_name + end) + :wait(MAX_TIMEOUT) +end + +---@class orgmode.attach.attach_buffer.Options +---@inlinedoc +---@field visit_dir? boolean if true, visit the directory subsequently using +--- `org_attach_visit_command` +---@field node? OrgAttachNode + +---Attach buffer's contents to current outline node. +--- +---Throws a file-exists error if it would overwrite an existing filename. +--- +---@param buffer? string | integer A buffer number or name. +---@param opts? orgmode.attach.attach_buffer.Options +---@return string|nil attachment_name +function Attach:attach_buffer(buffer, opts) + local node = opts and opts.node or self.core:get_current_node() + local visit_dir = opts and opts.visit_dir or false + return Promise + .resolve(buffer and ui.get_bufnr_verbose(buffer) or ui.select_buffer()) + ---@param bufnr? integer + :next(function(bufnr) + if not bufnr then + return nil + end + return self.core:attach_buffer(node, bufnr, { + set_dir_method = get_set_dir_method(), + new_dir = ui.ask_attach_dir_property, + }) + end) + :next(function(attachment_name) + if attachment_name then + utils.echo_info(('File %s is now an attachment'):format(attachment_name)) + if visit_dir then + local attach_dir = self.core:get_dir(node) + self.core:reveal_nvim(attach_dir) + end + end + return attachment_name + end) + :wait(MAX_TIMEOUT) +end + +---Move/copy/link many files into attachment directory. +--- +---@param files string[] +---@param opts? orgmode.attach.attach.Options +---@return string|nil attachment_name +function Attach:attach_many(files, opts) + local node = opts and opts.node or self.core:get_current_node() + local visit_dir = opts and opts.visit_dir or false + local method = opts and opts.method or config.org_attach_method + + return self.core + :attach_many(node, files, { + set_dir_method = get_set_dir_method(), + new_dir = ui.ask_attach_dir_property, + attach_method = method, + }) + :next(function(res) + if res.successes + res.failures > 0 then + local function plural(count) + return count == 1 and '' or 's' + end + local msg = ('attached %d file%s to %s'):format(res.successes, plural(res.successes), node:get_title()) + local extra = res.failures > 0 + and { { ('failed to attach %d file%s'):format(res.failures, plural(res.failures)), 'ErrorMsg' } } + or nil + utils.echo_info(msg, extra) + if res.successes > 0 and visit_dir then + local attach_dir = self.core:get_dir(node) + self.core:reveal_nvim(attach_dir) + end + end + return nil + end) + :wait(MAX_TIMEOUT) +end + +---@class orgmode.attach.attach_new.Options +---@inlinedoc +---@field bang? boolean if true, open the new file with `:edit!` +---@field mods? table command modifiers to pass to `:edit[!]`; see +--- docs for `nvim_parse_cmd()` for a list + +---Create a new attachment FILE for the current outline node. +--- +---The attachment is opened as a new buffer. +--- +---@param name? string +---@param node? OrgAttachNode +---@param edit_opts? orgmode.attach.attach_new.Options +---@return string? attachment_name +function Attach:attach_new(name, node, edit_opts) + node = node or self.core:get_current_node() + return Promise + .resolve(name or Input.open('Create attachnment named: ')) + ---@param chosen_name? string + :next(function(chosen_name) + if not chosen_name or chosen_name == '' then + return nil + end + return self.core:attach_new(node, chosen_name, { + set_dir_method = get_set_dir_method(), + new_dir = ui.ask_attach_dir_property, + edit_bang = edit_opts and edit_opts.bang or false, + edit_mods = edit_opts and edit_opts.mods or {}, + }) + end) + :next(function(attachment_name) + if attachment_name then + utils.echo_info(('new attachment %s'):format(attachment_name)) + end + return attachment_name + end) + :wait(MAX_TIMEOUT) +end + +---Attach a file by copying it. +--- +---@param node? OrgAttachNode +---@return string|nil attachment_name +function Attach:attach_cp(node) + return self:attach(nil, { method = 'cp', node = node }) +end + +---Attach a file by moving (renaming) it. +--- +---@param node? OrgAttachNode +---@return string|nil attachment_name +function Attach:attach_mv(node) + return self:attach(nil, { method = 'mv', node = node }) +end + +---Attach a file by creating a hard link to it. +--- +---Beware that this does not work on systems that do not support hard links. +---On some systems, this apparently does copy the file instead. +--- +---@param node? OrgAttachNode +---@return string|nil attachment_name +function Attach:attach_ln(node) + return self:attach(nil, { method = 'ln', node = node }) +end + +---Attach a file by creating a symbolic link to it. +--- +---Beware that this does not work on systems that do not support symbolic +---links. On some systems, this apparently does copy the file instead. +--- +---@param node? OrgAttachNode +---@return string|nil attachment_name +function Attach:attach_lns(node) + return self:attach(nil, { method = 'lns', node = node }) +end + +---@class orgmode.attach.attach_to_other_buffer.Options +---@inlinedoc +---@field window? integer | string if passed, attach to the node pointed at in +--- the given window; you can pass a window-ID, window number, or +--- `winnr()`-style strings, e.g. `#` to use the previously +--- active window. Pass 0 for the current window. It's an error +--- if the window doesn't display an org file. +---@field ask? 'always'|'multiple' determines what to do if `window` is nil; +--- if 'always', collect all nodes displayed in a window and ask the +--- user to select one. If 'multiple', only ask if more than one +--- node is displayed. If false or nil, never ask the user; accept +--- the unambiguous choice or abort. +---@field prefer_recent? 'ask'|'buffer'|'window'|boolean if not nil but +--- `window` is nil, and more than one node is displayed, +--- and one of them is more preferable than the others, +--- this one is used without asking the user. +--- Preferred nodes are those displayed in the current +--- window's current buffer and alternate buffer, as well +--- as the previous window's current buffer. Pass 'buffer' +--- to prefer the alternate buffer over the previous +--- window. Pass 'window' for the same vice versa. Pass +--- 'ask' to ask the user in case of conflict. Pass 'true' +--- to prefer only an unambiguous recent node over +--- non-recent ones. +---@field include_hidden? boolean If not nil, include not only displayed nodes, +--- but also those in hidden buffers; for those, the node +--- pointed at by the `"` mark (position when last +--- exiting the buffer) is chosen. +---@field visit_dir? boolean if not nil, open the relevant attachment directory +--- after attaching the file. +---@field method? 'cp' | 'mv' | 'ln' | 'lns' The attachment method, same values +--- as in `org_attach_method`. + +---@param file_or_files string | string[] +---@param opts? orgmode.attach.attach_to_other_buffer.Options +---@return string|nil attachment_name +function Attach:attach_to_other_buffer(file_or_files, opts) + local files = utils.ensure_array(file_or_files) ---@type string[] + return self + :find_other_node(opts) + :next(function(node) + if not node then + return nil + end + return self:attach_many(files, { + node = node, + method = opts and opts.method, + visit_dir = opts and opts.visit_dir, + }) + end) + :wait(MAX_TIMEOUT) +end + +---Helper to `Attach:attach_to_other_buffer`, unfortunately really complicated. +---@param opts? orgmode.attach.attach_to_other_buffer.Options +---@return OrgPromise +function Attach:find_other_node(opts) + local window = opts and opts.window + local ask = opts and opts.ask + local prefer_recent = opts and opts.prefer_recent + local include_hidden = opts and opts.include_hidden or false + if window then + return Promise.resolve(self:get_node_by_window(window)) + end + if prefer_recent then + local ok, node = pcall(self.core.get_current_node, self.core) + if ok then + return Promise.resolve(node) + end + local altbuf_nodes, altwin_node + if prefer_recent == 'buffer' then + altbuf_nodes = self.core:get_single_node_by_buffer(vim.fn.bufnr('#')) + if altbuf_nodes then + return Promise.resolve(altbuf_nodes) + end + ok, altwin_node = pcall(self.get_node_by_window, self, '#') + if ok then + return Promise.resolve(altwin_node) + end + elseif prefer_recent == 'window' then + ok, altwin_node = pcall(self.get_node_by_window, self, '#') + if ok then + return Promise.resolve(altwin_node) + end + altbuf_nodes = self.core:get_single_node_by_buffer(vim.fn.bufnr('#')) + if altbuf_nodes then + return Promise.resolve(altbuf_nodes) + end + else + local altbuf = vim.fn.bufnr('#') + local altwin = vim.fn.win_getid(vim.fn.winnr('#')) + -- altwin falls back to current window if previous window doesn't exist; + -- that's fine, we've handled it earlier. + ok, altwin_node = pcall(self.core.get_node_by_winid, self.core, altwin) + altwin_node = ok and altwin_node or nil + altbuf_nodes = self.core:get_nodes_by_buffer(altbuf) + if altwin_node and (#altbuf_nodes == 0 or vim.api.nvim_win_get_buf(altwin) == altbuf) then + return Promise.resolve(altwin_node) + end + if #altbuf_nodes == 1 and not altwin_node then + return Promise.resolve(altbuf_nodes[1]) + end + if prefer_recent == 'ask' then + local candidates = altbuf_nodes + if altwin_node then + table.insert(candidates, 1, altwin_node) + end + return ui.select_node(candidates) + end + -- More than one possible attachment location and not asking; fall back + -- to regular behavior. + end + end + local candidates = self.core:list_current_nodes({ include_hidden = include_hidden }) + if #candidates == 0 then + return Promise.reject('nowhere to attach to') + end + if ask == 'always' then + return ui.select_node(candidates) + end + if ask == 'multiple' then + if #candidates == 1 then + return Promise.resolve(candidates[1]) + end + return ui.select_node(candidates) + end + if ask then + return Promise.reject(('invalid value for ask: %s'):format(ask)) + end + if #candidates == 1 then + return Promise.resolve(candidates[1]) + end + return Promise.reject('more than one possible attachment location') +end + +---Open the attachments directory via `vim.ui.open()`. +--- +---@param attach_dir? string the directory to open +---@return nil +function Attach:reveal(attach_dir) + attach_dir = attach_dir or self:get_dir_or_create() + local res = self.core:reveal(attach_dir):wait() + if res.code ~= 0 then + error(('exit code %d for opening: %s'):format(res.code, attach_dir)) + end +end + +---Open the attachments directory via `org_attach_visit_command`. +--- +---@param attach_dir? string the directory to open +---@return nil +function Attach:reveal_nvim(attach_dir) + attach_dir = attach_dir or self:get_dir_or_create() + return self.core:reveal_nvim(attach_dir) +end + +---Open an attached file via `vim.ui.open()`. +--- +---@param name? string name of the file to open +---@param node? OrgAttachNode +---@return nil +function Attach:open(name, node) + node = node or self.core:get_current_node() + local attach_dir = self.core:get_dir(node) + ---@type vim.SystemObj? + local obj = Promise.resolve(name or ui.select_attachment('Open', attach_dir)) + :next(function(chosen_name) + if not chosen_name then + return + end + return self.core:open(chosen_name, node) + end) + :wait(MAX_TIMEOUT) + if obj then + local res = obj:wait() + if res.code ~= 0 then + error(('exit code %d for command: %s'):format(res.code, obj.cmd)) + end + end +end + +---Open an attached file via `:edit`. +--- +---@param name? string name of the file to open +---@param node? OrgAttachNode +---@return nil +function Attach:open_in_vim(name, node) + node = node or self.core:get_current_node() + local attach_dir = self.core:get_dir(node) + return Promise.resolve(name or ui.select_attachment('Open', attach_dir)) + :next(function(chosen_name) + if not chosen_name then + return + end + self.core:open_in_vim(chosen_name, node) + end) + :wait(MAX_TIMEOUT) +end + +---Delete a single attachment. +--- +---@param name? string the name of the attachment to delete +---@param node? OrgAttachNode +---@return nil +function Attach:delete_one(name, node) + node = node or self.core:get_current_node() + local attach_dir = self.core:get_dir(node) + return Promise.resolve(name or ui.select_attachment('Delete', attach_dir)) + :next(function(chosen_name) + if not chosen_name then + return + end + return self.core:delete_one(node, chosen_name) + end) + :wait(MAX_TIMEOUT) +end + +---Delete all attachments from the current outline node. +--- +---This actually deletes the entire attachment directory. A safer way is to +---open the directory with `reveal` and delete from there. +--- +---@param force? boolean if true, delete directory will recursively deleted with no prompts. +---@param node? OrgAttachNode +---@return nil +function Attach:delete_all(force, node) + node = node or self.core:get_current_node() + return Promise.resolve(force or ui.yes_or_no_or_cancel_slow('Remove all attachments? ')) + :next(function(do_delete) + if not do_delete then + return Promise.reject('Cancelled') + end + return self.core:delete_all(node, function() + return Promise.resolve(force or ui.yes_or_no_or_cancel_slow('Recursive? ')) + end) + end) + :next(function() + utils.echo_info('Attachment directory removed') + return nil + end) + :wait(MAX_TIMEOUT) +end + +---Maybe delete subtree attachments when archiving. +--- +---This function is called via the `OrgHeadlineArchivedEvent`. The option +---`org_attach_archive_delete' controls its behavior." +--- +---@param headline OrgHeadline +---@return nil +function Attach:maybe_delete_archived(headline) + local delete = config.org_attach_archive_delete + if delete == 'always' then + self:delete_all(true, AttachNode.from_headline(headline)) + end + if delete == 'ask' then + self:delete_all(false, AttachNode.from_headline(headline)) + end +end + +---Synchronize the current outline node with its attachments. +--- +---Useful after files have been added/removed externally. The Option +---`org_attach_sync_delete_empty_dir` controls the behavior for empty +---attachment directories. (This ignores files whose name ends with +---a tilde `~`.) +--- +---@param node? OrgAttachNode +---@return nil +function Attach:sync(node) + node = node or self.core:get_current_node() + local function delete_empty_dir() + local opt = config.org_attach_sync_delete_empty_dir + if opt == 'always' then + return Promise.resolve(true) + elseif opt == 'never' then + return Promise.resolve(false) + elseif opt == 'ask' then + return ui.yes_or_no_or_cancel_slow('Attachment directory is empty. Delete? ') + else + return Promise.reject(('invalid value for org_attach_sync_delete_empty_dir: %s'):format(opt)) + end + end + return self.core:sync(node, delete_empty_dir):wait(MAX_TIMEOUT) +end + +---Expand links in current buffer. +--- +---It is meant to be added to `org_export_before_parsing_hook`." +---TODO: Add this hook. Will require refactoring `orgmode.export`. +--- +---@param bufnr? integer +---@return nil +function Attach:expand_links(bufnr) + bufnr = bufnr or 0 + local file = self.core.files:get(vim.api.nvim_buf_get_name(bufnr)) + local total = 0 + local miss = 0 + self.core + :on_every_attachment_link(file, function(attach_dir, basename) + total = total + 1 + if not attach_dir then + miss = miss + 1 + return + end + return 'file:' .. vim.fs.joinpath(attach_dir, basename) + end) + :next(function() + if miss > 0 then + utils.echo_warning(('failed to expand %d/%d attachment links'):format(miss, total)) + else + utils.echo_info(('expanded %d attachment links'):format(total)) + end + return nil + end) + :wait(MAX_TIMEOUT) +end + +return Attach diff --git a/lua/orgmode/attach/node.lua b/lua/orgmode/attach/node.lua new file mode 100644 index 000000000..7fa74f583 --- /dev/null +++ b/lua/orgmode/attach/node.lua @@ -0,0 +1,247 @@ +local config = require('orgmode.config') +local translate_id = require('orgmode.attach.translate_id') +local fileops = require('orgmode.attach.fileops') +local fs_utils = require('orgmode.utils.fs') + +---An attachment node. This is either a headline or an org file. +--- +---We can attach files to any outline node; this may be a headline (`ID`/`DIR` +---property in headline's properties drawer) or an entire org file (`ID`/`DIR` +---property in the buffer properties drawer). This class abstracts the +---difference for us. +--- +---@class OrgAttachNode +---@field private headline? OrgHeadline +---@field private file OrgFile +local AttachNode = {} +AttachNode.__index = AttachNode + +---Constructor from headlines. +--- +---@param headline OrgHeadline +---@return OrgAttachNode +function AttachNode.from_headline(headline) + ---@type OrgAttachNode + return setmetatable({ + headline = headline, + file = headline.file, + }, AttachNode) +end + +---Constructor from files. +--- +---@param file OrgFile +---@return OrgAttachNode +function AttachNode.from_file(file) + ---@type OrgAttachNode + return setmetatable({ + file = file, + }, AttachNode) +end + +---Constructor from the current cursor position. +--- +---If the cursor is before any headline, this uses the entire file. Otherwise, +---it uses the closest headline above the cursor. +--- +---@param file OrgFile +---@param cursor? [integer, integer] (1,0)-indexed cursor position +---@return OrgAttachNode +function AttachNode.at_cursor(file, cursor) + local headline = file:get_closest_headline_or_nil(cursor) + return headline and AttachNode.from_headline(headline) or AttachNode.from_file(file) +end + +---@param path string +function AttachNode:_make_absolute(path) + if not path:match('^/') then + local base = vim.fs.dirname(self.file.filename) + path = vim.fs.joinpath(base, path) + end + return vim.fs.normalize(path, { expand_env = false }) +end + +---@return OrgFile +function AttachNode:get_file() + return self.file +end + +---@return string filename +function AttachNode:get_filename() + return self.file.filename +end + +---@return string title +function AttachNode:get_title() + return (self.headline or self.file):get_title() +end + +---Return the starting line of the attachment node. +--- +---This is zero for file nodes and the 1-based line number for headline nodes. +---This is chosen such that every attachment node in an org file has +---a different line number. +---@return integer line +function AttachNode:get_start_line() + if self.headline then + return self.headline:node():start() + 1 + end + return 0 +end + +---Look up a property, possibly recursing into parents. +--- +---If `search_parents` is nil, this uses `org_attach_use_inheritance` for the +---given property. +--- +---@param property_name string property name +---@param search_parents? boolean whether to recurse to parents +---@return string|nil property +function AttachNode:get_property(property_name, search_parents) + if search_parents == nil then + search_parents = config:use_attach_inheritance(property_name) + end + if search_parents then + return self.headline and self.headline:get_property(property_name, true) or self.file:get_property(property_name) + end + if self.headline then + return self.headline:get_property(property_name, false) + end + return (self.file:get_property(property_name)) +end + +---Set a property or, if `value` is nil, remove it. +--- +---@param name string property name +---@param value? string property value +---@return nil +function AttachNode:set_property(name, value) + (self.headline or self.file):set_property(name, value) +end + +---Get the node's ID property if it exists, or add it. +--- +---@return string id +function AttachNode:id_get_or_create() + return (self.headline or self.file):id_get_or_create() +end + +---Return an absolute folder path based on `org_attach_id_dir` and ID. +--- +---This is like `id_dir_get_or_create()`, but the ID is never added and the +---resulting path must exist in the filesystem. +--- +---@return string|nil attach_dir +function AttachNode:get_existing_id_dir() + local id = self:get_property('id') + if not id then + return nil + end + local basedir = self:_make_absolute(config.org_attach_id_dir) + if not basedir then + return nil + end + local default_basedir = self:_make_absolute('./data/') + assert(default_basedir) + for _, func in ipairs(config.org_attach_id_to_path_function_list) do + local name = translate_id(func, id) + if name then + local candidate = vim.fs.joinpath(basedir, name) + if fileops.is_dir(candidate) then + return candidate + end + local fallback = vim.fs.joinpath(default_basedir, name) + if fileops.is_dir(fallback) then + return fallback + end + end + end + return nil +end + +---Find the attachment directory associated with this node. +--- +---The result is always an absolute path. +--- +---@return string|nil attach_dir +function AttachNode:get_dir() + local dir = self:get_property('dir') + if dir then + return self:_make_absolute(dir) + end + dir = self:get_existing_id_dir() + if dir then + return dir + end + return nil +end + +---Set the attachment directory on the current node. +--- +---In addition to `set_property()`, this also ensures that the path is always +---absolute. +--- +---@param dir string +---@return string new_dir absolute attachment directory +---@overload fun(): nil +function AttachNode:set_dir(dir) + if dir then + dir = vim.fn.fnamemodify(dir, ':p') + end + self:set_property('DIR', dir) + return dir and self:_make_absolute(dir) +end + +---Return a folder path based on `org_attach_id_dir` and ID. +--- +---Try `id_to_path` functions in `org_attach_id_to_path_function_list` +---and return the first truthy result. +--- +---If the node doesn't have an ID yet, it is added. +--- +---The returned path is always absolute. +--- +---@return string attach_dir +function AttachNode:id_dir_get_or_create() + local id = self:id_get_or_create() + local basedir = self:_make_absolute(config.org_attach_id_dir) + if basedir then + for _, func in ipairs(config.org_attach_id_to_path_function_list) do + local name = translate_id(func, id) + if name then + return vim.fs.joinpath(basedir, name) + end + end + end + error(('failed to get folder for id %s, adjust org_attach_id_to_path_function_list'):format(id)) +end + +---Add the `org_attach_auto_tag`, if not yet present. +function AttachNode:add_auto_tag() + -- TODO: There is currently no way to set #+FILETAGS programmatically. Do + -- nothing when before first heading (attaching to file) to avoid blocking + -- error. This issue exists in the Emacs version of org-attach as well. + if config.org_attach_auto_tag and self.headline then + -- `add_tag()` eventually calls `vim.fn.bufnr()` inside `OrgFile`, which is + -- disallowed inside `fast-api`. Thus, we schedule this change. + vim.schedule(function() + self.headline:add_tag(config.org_attach_auto_tag) + end) + end +end + +---Remove the `org_attach_auto_tag`. +function AttachNode:remove_auto_tag() + -- TODO: There is currently no way to set #+FILETAGS programmatically. Do + -- nothing when before first heading (attaching to file) to avoid blocking + -- error. This issue exists in the Emacs version of org-attach as well. + if config.org_attach_auto_tag and self.headline then + -- `remove_tag()` eventually calls `vim.fn.bufnr()` inside `OrgFile`, which + -- is disallowed inside `fast-api`. Thus, we schedule this change. + vim.schedule(function() + self.headline:remove_tag(config.org_attach_auto_tag) + end) + end +end + +return AttachNode diff --git a/lua/orgmode/attach/translate_id.lua b/lua/orgmode/attach/translate_id.lua new file mode 100644 index 000000000..d8013b7d7 --- /dev/null +++ b/lua/orgmode/attach/translate_id.lua @@ -0,0 +1,64 @@ +local Builtins = {} + +---Translate an UUID ID into a folder-path. +--- +---Default format for how Org translates ID properties to a path for +---attachments. Useful if ID is generated with UUID. +--- +---@param id string +---@return string | nil path +function Builtins.uuid_folder_format(id) + if id:len() <= 2 then + return nil + end + return ('%s/%s'):format(id:sub(1, 2), id:sub(3)) +end + +---Translate an ID based on a timestamp to a folder-path. +--- +---Useful way of translation if ID is generated based on ISO8601 timestamp. +---Splits the attachment folder hierarchy into year+month and the rest. +--- +---@param id string +---@return string | nil path +function Builtins.ts_folder_format(id) + if id:len() <= 6 then + return nil + end + return ('%s/%s'):format(id:sub(1, 6), id:sub(7)) +end + +---Return `__/X/ID` folder path as a dumb fallback. +--- +---X is the first character in the ID string. +--- +---This function may be appended to `org_attach_id_path_function_list` to +---provide a fallback for non-standard ID values that other functions in +---`org_attach_id_path_function_list` are unable to handle. For example, +---when the ID is too short for `org_attach_id_ts_folder_format`. +--- +---However, we recommend to define a more specific function spreading entries +---over multiple folders. This function may create a large number of entries +---in a single folder, which may cause issues on some systems." +--- +---@param id string +---@return string path +function Builtins.fallback_folder_format(id) + assert(id ~= '', id) + return ('__/%s/%s'):format(id:sub(1, 1), id) +end + +---This module is a function that evaluates `func`. +--- +---If `func` is a string, look it up in this module's built-in functions and +---evaluate the result. If `func` is a function, evaluate it directly. +--- +---In either case, `func` is called with a node's ID and should either return +---a path to its attachments directory, or return nil if that's not impossible. +--- +---@param func string | fun(id: string): string? +---@param id string ID property +---@return string|nil attach_dir +return function(func, id) + return (Builtins[func] or func)(id) +end diff --git a/lua/orgmode/attach/ui.lua b/lua/orgmode/attach/ui.lua new file mode 100644 index 000000000..864f8e0cc --- /dev/null +++ b/lua/orgmode/attach/ui.lua @@ -0,0 +1,235 @@ +local AttachNode = require('orgmode.attach.node') +local Input = require('orgmode.ui.input') +local Promise = require('orgmode.utils.promise') +local fileops = require('orgmode.attach.fileops') +local utils = require('orgmode.utils') + +local M = {} + +---Yes/no dialog that forces the user to type one of the two words. +--- +---This should only be used to ask questions where one option involves +---inevitable data loss. +--- +---Uses `orgmode.ui.input` for user interaction, so it always returns +---a promise. The return value is `true` for yes, `false` for no and `nil` if +---the user cancels with ``. This should back out of the operation. Don't +---use this function if there is no way to back out. +--- +---@param msg string +---@return OrgPromise choice +function M.yes_or_no_or_cancel_slow(msg) + local function ask() + return Input.open(msg .. '(yes or no, ESC to cancel) '):next(function(answer) + answer = answer:lower() + if answer == 'yes' then + return true + elseif answer == 'no' then + return false + else + return ask() + end + end) + end + return ask() +end + +---Ask the user for a new DIR property on a node. +--- +---Errors if the user cancels. +--- +---@param prev_dir string | nil +---@return OrgPromise +function M.ask_attach_dir_property(prev_dir) + return Input.open('Attachment directory: ', prev_dir or '', 'dir') +end + +---Ask the user which way to create an attachment directory. +--- +---Used to implement `org_attach_preferred_new_method=='ask'`. +--- +---@return OrgPromise<'id'|'dir'|nil> method +function M.ask_new_method() + -- Can't use OrgMenu here because it doesn't allow us to catch ESC. + return Promise.new(function(resolve, reject) + vim.ui.select({ 'id', 'dir' }, { + prompt = 'How to create attachments directory?', + format_item = function(item) + return ('Create new %s property'):format(item:upper()) + end, + }, function(item) + if item then + resolve(item) + else + reject('Input canceled') + end + end) + end) +end + +---Dialog that has user select one among a given number of attachment nodes. +--- +---Returns nil if the user cancels with ``. +--- +---Errors if the user's selection doesn't match a single node. +--- +---@param nodes OrgAttachNode[] +---@return OrgPromise selection +function M.select_node(nodes) + ---@param arglead string + ---@return OrgAttachNode[] + local function get_matches(arglead) + return vim.fn.matchfuzzy(nodes, arglead, { matchseq = true, text_cb = AttachNode.get_title }) + end + return Input.open('Select an attachment node: ', '', get_matches):next(function(choice) + if not choice then + return nil + end + local matches = get_matches(choice) + if #matches == 1 then + return matches[1] + end + if #matches > 1 then + error('more than one match for ' .. tostring(choice)) + else + error('no matching buffer for ' .. tostring(choice)) + end + end) +end + +---Helper for `make_completion()`. +--- +---@param directory string +---@param show_hidden? boolean +---@return string[] file_names +local function list_files(directory, show_hidden) + ---@param path string + ---@return string ftype + local function resolve_links(path) + local target = vim.uv.fs_realpath(path) + local stat = target and vim.uv.fs_stat(target) + return stat and stat.type or 'file' + end + local filter = show_hidden and function() + return true + end or function(name) + return not vim.startswith(name, '.') and not vim.endswith(name, '~') + end + local dirs = {} + local files = {} + fileops + .iterdir(directory) + :filter(filter) + :map( + ---@param name string + ---@param ftype string + ---@return string name + ---@return string ftype + function(name, ftype) + if ftype == 'link' then + ftype = resolve_links(vim.fs.joinpath(directory, name)) + end + ---@diagnostic disable-next-line: redundant-return-value + return name, ftype + end + ) + ---@param name string + ---@param ftype string + :each(function(name, ftype) + if ftype == 'directory' then + dirs[#dirs + 1] = name .. '/' + else + files[#files + 1] = name + end + end) + -- Ensure that directories are sorted before files. + table.sort(dirs) + table.sort(files) + return vim.list_extend(dirs, files) +end + +---Return a completion function for attachments. +--- +---@param root string the attachment directory +---@return fun(arglead: string): string[] +local function make_completion(root) + ---@param arglead string + ---@return string[] + return function(arglead) + local dirname = vim.fs.dirname(arglead) + local searchdir = vim.fs.normalize(vim.fs.joinpath(root, dirname)) + local basename = vim.fs.basename(arglead) + local show_hidden = vim.startswith(basename, '.') + local candidates = list_files(searchdir, show_hidden) + -- Only call matchfuzzy() if it won't break. + if basename ~= '' and basename:len() <= 256 then + candidates = vim.fn.matchfuzzy(candidates, basename) + end + -- Don't prefix `./` to the paths. + if dirname ~= '.' then + candidates = vim.tbl_map(function(name) + return vim.fs.joinpath(dirname, name) + end, candidates) + end + return candidates + end +end + +---Dialog that has user select an existing attachment file. +--- +---Returns nil if the user cancels with ``. +--- +---@param action string +---@param attach_dir string +---@return OrgPromise attach_file +function M.select_attachment(action, attach_dir) + return Input.open(action .. ' attachment: ', '', make_completion(attach_dir)) +end + +---Like `vim.fn.bufnr()`, but error instead of returning `-1`. +--- +---@param buf integer | string +---@return integer bufnr +function M.get_bufnr_verbose(buf) + local bufnr = vim.fn.bufnr(buf) + if bufnr ~= -1 then + return bufnr + end + -- bufnr() failed, was there no match or more than one? + if type(buf) ~= 'string' then + error(('buffer %d does not exist'):format(buf)) + end + local matches = vim.fn.getcompletion(buf, 'buffer') + if #matches > 1 then + error('more than one match for ' .. tostring(buf)) + end + if #matches == 1 then + -- Surprise match?! + bufnr = vim.fn.bufnr(matches[1]) + if bufnr > 0 then + return bufnr + end + end + error('no matching buffer for ' .. tostring(buf)) +end + +---Simple buffer selection dialog. +--- +---Returns nil if the user backs out. +--- +---Errors if the user's choice is ambiguous. +--- +---@return OrgPromise bufnr +function M.select_buffer() + return Input.open('Select a buffer: ', '', 'buffer'):next(function(bufname) + if not bufname then + return nil + elseif bufname == '' then + utils.echo_error('Input canceled') + return nil + end + return M.get_bufnr_verbose(bufname) + end) +end + +return M diff --git a/lua/orgmode/config/_meta.lua b/lua/orgmode/config/_meta.lua index 375eca0f5..e4e89ae7d 100644 --- a/lua/orgmode/config/_meta.lua +++ b/lua/orgmode/config/_meta.lua @@ -147,6 +147,7 @@ ---@field org_set_effort? string Default: 'xe' ---@field org_show_help? string Default: 'g?' ---@field org_babel_tangle? string Default: 'bt' +---@field org_attach? string Default: '' ---@class OrgMappingsTextObjects ---@field inner_heading? string Default: 'ih' @@ -240,6 +241,14 @@ ---@field org_id_link_to_org_use_id? boolean If true, Storing a link to the headline will automatically generate ID for that headline. Default: false ---@field org_use_property_inheritance boolean | string | string[] If true, properties are inherited by sub-headlines; may also be a regex or list of property names. Default: false ---@field org_babel_default_header_args? table Default header args for org-babel blocks: Default: { [':tangle'] = 'no', [':noweb'] = 'no' } +---@field org_attach_preferred_new_method 'id' | 'dir' | 'ask' | false If true, create attachments directory when necessary according to the given method. Default: 'id' +---@field org_attach_method 'mv' | 'cp' | 'ln' | 'lns' Default method of attacahing files. Default: 'cp' +---@field org_attach_visit_command string | fun(dir: string) Command or Lua function used to open a directory. Default: 'edit' +---@field org_attach_use_inheritance 'always' | 'selective' | 'never' Determines whether headlines inherit the attachments directory of their parents. Default: 'selective' +---@field org_attach_store_link_p 'original' | 'file' | 'attached' | false If true, attaching a file stores a link to it. Default: 'attached' +---@field org_attach_archive_delete 'always' | 'ask' | 'never' Determines whether to delete a headline's attachments when it is archived. Default: 'never' +---@field org_attach_id_to_path_function_list (string | fun(id: string): (string|nil))[] List of functions used to derive the attachments directory from an ID property. +---@field org_attach_sync_delete_empty_dir 'always' | 'ask' | 'never' Determines whether to delete empty directories when using `org.attach.sync()`. Default: 'ask' ---@field win_split_mode? 'horizontal' | 'vertical' | 'auto' | 'float' | string[] How to open agenda and capture windows. Default: 'horizontal' ---@field win_border? 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | string[] Border configuration for `win_split_mode = 'float'`. Default: 'single' ---@field notifications? OrgNotificationsConfig Notification settings diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index 6053137e3..8cb2f85ae 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -71,6 +71,21 @@ local DefaultConfig = { [':tangle'] = 'no', [':noweb'] = 'no', }, + org_attach_id_dir = './data/', + org_attach_auto_tag = 'ATTACH', + org_attach_preferred_new_method = 'id', + org_attach_method = 'cp', + org_attach_copy_directory_create_symlink = false, + org_attach_visit_command = 'edit', + org_attach_use_inheritance = 'selective', + org_attach_store_link_p = 'attached', + org_attach_archive_delete = 'never', + org_attach_id_to_path_function_list = { + 'uuid_folder_format', + 'ts_folder_format', + 'fallback_folder_format', + }, + org_attach_sync_delete_empty_dir = 'ask', win_split_mode = 'horizontal', win_border = 'single', notifications = { @@ -189,6 +204,7 @@ local DefaultConfig = { org_set_effort = 'xe', org_show_help = 'g?', org_babel_tangle = 'bt', + org_attach = '', }, edit_src = { org_edit_src_abort = 'k', diff --git a/lua/orgmode/config/init.lua b/lua/orgmode/config/init.lua index 62db0296e..c22bd2b64 100644 --- a/lua/orgmode/config/init.lua +++ b/lua/orgmode/config/init.lua @@ -558,6 +558,19 @@ function Config:use_property_inheritance(property_name) end end +---@param property_name string +---@return boolean uses_inheritance +function Config:use_attach_inheritance(property_name) + local use_it = self.org_attach_use_inheritance + if use_it == 'always' then + return true + elseif use_it == 'never' then + return false + else + return self:use_property_inheritance(property_name) + end +end + ---@type OrgConfig instance = Config:new() return instance diff --git a/lua/orgmode/config/mappings/init.lua b/lua/orgmode/config/mappings/init.lua index a760b79ec..1e0ea28a9 100644 --- a/lua/orgmode/config/mappings/init.lua +++ b/lua/orgmode/config/mappings/init.lua @@ -362,6 +362,7 @@ return { 'org_mappings.org_toggle_timestamp_type', { opts = { desc = 'org toggle timestamp type', help_desc = 'Toggle timestamp active/inactive type' } } ), + org_attach = m.action('attach.prompt', { opts = { desc = 'org attach', help_desc = 'open attachment prompt' } }), }, edit_src = { org_edit_src_abort = m.custom( diff --git a/lua/orgmode/init.lua b/lua/orgmode/init.lua index e7bd120f1..47256f306 100644 --- a/lua/orgmode/init.lua +++ b/lua/orgmode/init.lua @@ -6,6 +6,7 @@ local instance = nil local auto_instance_keys = { files = true, agenda = true, + attach = true, capture = true, clock = true, org_mappings = true, @@ -56,6 +57,7 @@ function Org:init() }) :load_sync(true, 20000) self.links = require('orgmode.org.links'):new({ files = self.files }) + self.attach = require('orgmode.attach'):new({ files = self.files }) self.agenda = require('orgmode.agenda'):new({ files = self.files, highlighter = self.highlighter, From 5a7853b9c5721b603be0d1ddbb7d263224185537 Mon Sep 17 00:00:00 2001 From: troiganto Date: Sun, 9 Feb 2025 16:14:48 +0100 Subject: [PATCH 2/7] feat(attach): add attachment links --- lua/orgmode/attach/core.lua | 19 +++++++- lua/orgmode/attach/init.lua | 3 +- lua/orgmode/init.lua | 2 +- lua/orgmode/org/links/init.lua | 40 ++++++++++++++- lua/orgmode/org/links/types/attachment.lua | 57 ++++++++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 lua/orgmode/org/links/types/attachment.lua diff --git a/lua/orgmode/attach/core.lua b/lua/orgmode/attach/core.lua index 88f544cb7..27aedcbb5 100644 --- a/lua/orgmode/attach/core.lua +++ b/lua/orgmode/attach/core.lua @@ -6,13 +6,15 @@ local utils = require('orgmode.utils') ---@class OrgAttachCore ---@field files OrgFiles +---@field links OrgLinks local AttachCore = {} AttachCore.__index = AttachCore ----@param opts {files:OrgFiles} +---@param opts {files:OrgFiles, links:OrgLinks} function AttachCore.new(opts) local data = { files = opts and opts.files, + links = opts and opts.links, } return setmetatable(data, AttachCore) end @@ -427,6 +429,8 @@ function AttachCore:attach(node, file, opts) return nil end node:add_auto_tag() + local link = self.links:store_link_to_attachment({ attach_dir = attach_dir, original = file }) + vim.fn.setreg(vim.v.register, link) return basename end) end) @@ -453,6 +457,14 @@ function AttachCore:attach_buffer(node, bufnr, opts) local data = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') return utils.writefile(attach_file, data, { excl = true }):next(function() node:add_auto_tag() + -- Ignore all errors here, this is just to determine whether we can store + -- a link to `bufname`. + local bufname_exists = vim.uv.fs_stat(bufname) + local link = self.links:store_link_to_attachment({ + attach_dir = attach_dir, + original = bufname_exists and bufname or attach_file, + }) + vim.fn.setreg(vim.v.register, link) return basename end) end) @@ -480,7 +492,10 @@ function AttachCore:attach_many(node, files, opts) .mapSeries(function(to_be_attached) local basename = basename_safe(to_be_attached) local attach_file = vim.fs.joinpath(attach_dir, basename) - return attach(to_be_attached, attach_file) + return attach(to_be_attached, attach_file):next(function(success) + self.links:store_link_to_attachment({ attach_dir = attach_dir, original = to_be_attached }) + return success + end) end, files) ---@param successes boolean[] :next(function(successes) diff --git a/lua/orgmode/attach/init.lua b/lua/orgmode/attach/init.lua index 016e6711a..713125ad4 100644 --- a/lua/orgmode/attach/init.lua +++ b/lua/orgmode/attach/init.lua @@ -14,9 +14,10 @@ local MAX_TIMEOUT = 2 ^ 31 local Attach = {} Attach.__index = Attach ----@param opts {files:OrgFiles} +---@param opts {files:OrgFiles, links:OrgLinks} function Attach:new(opts) local data = setmetatable({ core = Core.new(opts) }, self) + data.core.links:add_type(require('orgmode.org.links.types.attachment'):new({ attach = data })) return data end diff --git a/lua/orgmode/init.lua b/lua/orgmode/init.lua index 47256f306..4e43febda 100644 --- a/lua/orgmode/init.lua +++ b/lua/orgmode/init.lua @@ -57,7 +57,7 @@ function Org:init() }) :load_sync(true, 20000) self.links = require('orgmode.org.links'):new({ files = self.files }) - self.attach = require('orgmode.attach'):new({ files = self.files }) + self.attach = require('orgmode.attach'):new({ files = self.files, links = self.links }) self.agenda = require('orgmode.agenda'):new({ files = self.files, highlighter = self.highlighter, diff --git a/lua/orgmode/org/links/init.lua b/lua/orgmode/org/links/init.lua index 65d6f468c..eff1d2e64 100644 --- a/lua/orgmode/org/links/init.lua +++ b/lua/orgmode/org/links/init.lua @@ -76,8 +76,11 @@ function OrgLinks:autocomplete(link) end ---@param headline OrgHeadline +---@return string url function OrgLinks:store_link_to_headline(headline) - self.stored_links[self:get_link_to_headline(headline)] = headline:get_title() + local url = self:get_link_to_headline(headline) + self.stored_links[url] = headline:get_title() + return url end ---@param headline OrgHeadline @@ -110,6 +113,41 @@ function OrgLinks:get_link_to_file(file) return ('file:%s::*%s'):format(file.filename, title) end +---@param params {attach_dir: string, original: string} +---@return string | nil url +function OrgLinks:store_link_to_attachment(params) + local url = self:get_link_to_attachment(params) + if url then + self.stored_links[url] = vim.fs.basename(params.original) + end + return url +end + +---@param params {attach_dir: string, original: string} +---@return string | nil url +function OrgLinks:get_link_to_attachment(params) + vim.validate({ + attach_dir = { params.attach_dir, 'string' }, + original = { params.original, 'string' }, + }) + local basename = vim.fs.basename(params.original) + local choice = config.org_attach_store_link_p + if choice == 'attached' then + return string.format('attachment:%s', basename) + elseif choice == 'file' then + local attach_file = vim.fs.joinpath(params.attach_dir, basename) + return string.format('file:%s', attach_file) + elseif choice == 'original' then + -- Sanity check: `original` might be a URL. Check for that and return it + -- unmodified if yes. + if params.original:match('^[A-Za-z]+://') then + return params.original + end + return string.format('file:%s', params.original) + end + return nil +end + ---@param link_location string function OrgLinks:insert_link(link_location, desc) local selected_link = OrgHyperlink:new(link_location) diff --git a/lua/orgmode/org/links/types/attachment.lua b/lua/orgmode/org/links/types/attachment.lua new file mode 100644 index 000000000..e5020d41f --- /dev/null +++ b/lua/orgmode/org/links/types/attachment.lua @@ -0,0 +1,57 @@ +---@class OrgLinkAttachment:OrgLinkType +---@field private attach OrgAttach +local OrgLinkAttachment = {} +OrgLinkAttachment.__index = OrgLinkAttachment + +---@param opts { attach: OrgAttach } +function OrgLinkAttachment:new(opts) + local this = setmetatable({ + attach = opts.attach, + }, OrgLinkAttachment) + return this +end + +---@return string +function OrgLinkAttachment:get_name() + return 'attachment' +end + +---@param link string +---@return boolean +function OrgLinkAttachment:follow(link) + local opts = self:_parse(link) + if not opts then + return false + end + self.attach:open(opts.basename, opts.node) + return true +end + +---@param link string +---@return string[] +function OrgLinkAttachment:autocomplete(link) + local opts = self:_parse(link) + if not opts then + return {} + end + local complete = self.attach:make_completion({ node = opts.node }) + return vim.tbl_map(function(name) + return 'attachment:' .. name + end, complete(opts.basename)) +end + +---@private +---@param link string +---@return { node: OrgAttachNode, basename: string } | nil +function OrgLinkAttachment:_parse(link) + local basename = link:match('^attachment:(.+)$') + if not basename then + return nil + end + return { + node = self.attach:get_current_node(), + basename = basename, + } +end + +return OrgLinkAttachment From 815327cbf56957494de354dc6d40b42e55dfd0b0 Mon Sep 17 00:00:00 2001 From: troiganto Date: Sun, 9 Feb 2025 16:16:04 +0100 Subject: [PATCH 3/7] feat(attach): add attachment events --- lua/orgmode/attach/core.lua | 10 ++++++++++ .../events/types/attach_changed_event.lua | 20 +++++++++++++++++++ .../events/types/attach_opened_event.lua | 20 +++++++++++++++++++ lua/orgmode/events/types/init.lua | 2 ++ 4 files changed, 52 insertions(+) create mode 100644 lua/orgmode/events/types/attach_changed_event.lua create mode 100644 lua/orgmode/events/types/attach_opened_event.lua diff --git a/lua/orgmode/attach/core.lua b/lua/orgmode/attach/core.lua index 27aedcbb5..6b838ce86 100644 --- a/lua/orgmode/attach/core.lua +++ b/lua/orgmode/attach/core.lua @@ -1,4 +1,5 @@ local AttachNode = require('orgmode.attach.node') +local EventManager = require('orgmode.events') local Promise = require('orgmode.utils.promise') local config = require('orgmode.config') local fileops = require('orgmode.attach.fileops') @@ -428,6 +429,7 @@ function AttachCore:attach(node, file, opts) if not success then return nil end + EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir)) node:add_auto_tag() local link = self.links:store_link_to_attachment({ attach_dir = attach_dir, original = file }) vim.fn.setreg(vim.v.register, link) @@ -456,6 +458,7 @@ function AttachCore:attach_buffer(node, bufnr, opts) local attach_file = vim.fs.joinpath(attach_dir, basename) local data = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') return utils.writefile(attach_file, data, { excl = true }):next(function() + EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir)) node:add_auto_tag() -- Ignore all errors here, this is just to determine whether we can store -- a link to `bufname`. @@ -499,6 +502,7 @@ function AttachCore:attach_many(node, files, opts) end, files) ---@param successes boolean[] :next(function(successes) + EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir)) node:add_auto_tag() ---@param tally orgmode.attach.core.attach_many.result ---@param success boolean @@ -539,6 +543,7 @@ function AttachCore:attach_new(node, name, opts) return self:get_dir_or_create(node, opts.set_dir_method, opts.new_dir):next(function(attach_dir) local path = vim.fs.joinpath(attach_dir, name) --TODO: the emacs version doesn't run the hook here. Is this correct? + EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir)) node:add_auto_tag() ---@type vim.api.keyset.cmd return Promise.new(function(resolve, reject) @@ -584,6 +589,7 @@ end function AttachCore:open(name, node) local attach_dir = self:get_dir(node) local path = vim.fs.joinpath(attach_dir, name) + EventManager.dispatch(EventManager.event.AttachOpened:new(node, path)) return assert(vim.ui.open(path)) end @@ -595,6 +601,7 @@ end function AttachCore:open_in_vim(name, node) local attach_dir = self:get_dir(node) local path = vim.fs.joinpath(attach_dir, name) + EventManager.dispatch(EventManager.event.AttachOpened:new(node, path)) vim.cmd.edit(path) end @@ -611,6 +618,7 @@ function AttachCore:delete_one(node, name) local attach_dir = self:get_dir(node) local path = vim.fs.joinpath(attach_dir, name) return fileops.unlink(path):next(function() + EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir)) return nil end) end @@ -645,6 +653,7 @@ function AttachCore:delete_all(node, recursive) return Promise.reject(errmsg) end return fileops.remove_directory(attach_dir, { recursive = true }):next(function() + EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir)) node:remove_auto_tag() return attach_dir end) @@ -679,6 +688,7 @@ function AttachCore:sync(node, delete_empty_dir) self:untag(node) return Promise.resolve() end + EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir)) local non_empty = has_any_non_litter_files(attach_dir) if non_empty then node:add_auto_tag() diff --git a/lua/orgmode/events/types/attach_changed_event.lua b/lua/orgmode/events/types/attach_changed_event.lua new file mode 100644 index 000000000..c8c9aae83 --- /dev/null +++ b/lua/orgmode/events/types/attach_changed_event.lua @@ -0,0 +1,20 @@ +---@class OrgAttachChangedEvent: OrgEvent +---@field type string +---@field node OrgAttachNode +---@field attach_dir string +local AttachChangedEvent = { + type = 'orgmode.attach_changed', +} + +---@param node OrgAttachNode +---@param attach_dir string +---@return OrgAttachChangedEvent +function AttachChangedEvent:new(node, attach_dir) + local obj = setmetatable({}, self) + self.__index = self + obj.node = node + obj.attach_dir = attach_dir + return obj +end + +return AttachChangedEvent diff --git a/lua/orgmode/events/types/attach_opened_event.lua b/lua/orgmode/events/types/attach_opened_event.lua new file mode 100644 index 000000000..feb01ea69 --- /dev/null +++ b/lua/orgmode/events/types/attach_opened_event.lua @@ -0,0 +1,20 @@ +---@class OrgAttachOpenedEvent: OrgEvent +---@field type string +---@field node OrgAttachNode +---@field path string +local AttachOpenedEvent = { + type = 'orgmode.attach_opened', +} + +---@param node OrgAttachNode +---@param path string +---@return OrgAttachOpenedEvent +function AttachOpenedEvent:new(node, path) + local obj = setmetatable({}, self) + self.__index = self + obj.node = node + obj.path = path + return obj +end + +return AttachOpenedEvent diff --git a/lua/orgmode/events/types/init.lua b/lua/orgmode/events/types/init.lua index 73afa80f8..60024f9a7 100644 --- a/lua/orgmode/events/types/init.lua +++ b/lua/orgmode/events/types/init.lua @@ -6,4 +6,6 @@ return { HeadlinePromoted = require('orgmode.events.types.headline_promoted_event'), HeadlineDemoted = require('orgmode.events.types.headline_demoted_event'), HeadingToggled = require('orgmode.events.types.heading_toggled'), + AttachChanged = require('orgmode.events.types.attach_changed_event'), + AttachOpened = require('orgmode.events.types.attach_opened_event'), } From e5416cde8d3bd69ebe14380488de1cc31ffa78e1 Mon Sep 17 00:00:00 2001 From: troiganto Date: Sat, 25 Jan 2025 23:17:04 +0100 Subject: [PATCH 4/7] feat(attach): add event to delete attachments of archived headlines --- lua/orgmode/capture/init.lua | 2 ++ .../attach_maybe_delete_archived.lua | 4 ++++ lua/orgmode/events/listeners/init.lua | 4 ++++ .../events/types/headline_archived_event.lua | 20 +++++++++++++++++++ lua/orgmode/events/types/init.lua | 1 + 5 files changed, 31 insertions(+) create mode 100644 lua/orgmode/events/listeners/attach_maybe_delete_archived.lua create mode 100644 lua/orgmode/events/types/headline_archived_event.lua diff --git a/lua/orgmode/capture/init.lua b/lua/orgmode/capture/init.lua index dc7ecc4fc..615f71eb4 100644 --- a/lua/orgmode/capture/init.lua +++ b/lua/orgmode/capture/init.lua @@ -3,6 +3,7 @@ local fs = require('orgmode.utils.fs') local config = require('orgmode.config') local Templates = require('orgmode.capture.templates') local Template = require('orgmode.capture.template') +local EventManager = require('orgmode.events') local Menu = require('orgmode.ui.menu') local Range = require('orgmode.files.elements.range') local CaptureWindow = require('orgmode.capture.window') @@ -300,6 +301,7 @@ function Capture:refile_file_headline_to_archive(headline) local headline_category = headline:get_category() local outline_path = headline:get_outline_path() + EventManager.dispatch(EventManager.event.HeadlineArchived:new(headline, destination_file)) return self :_refile_from_org_file({ source_headline = headline, diff --git a/lua/orgmode/events/listeners/attach_maybe_delete_archived.lua b/lua/orgmode/events/listeners/attach_maybe_delete_archived.lua new file mode 100644 index 000000000..543ce71b3 --- /dev/null +++ b/lua/orgmode/events/listeners/attach_maybe_delete_archived.lua @@ -0,0 +1,4 @@ +---@param event OrgHeadlineArchivedEvent +return function(event) + require('orgmode.attach'):maybe_delete_archived(event.headline) +end diff --git a/lua/orgmode/events/listeners/init.lua b/lua/orgmode/events/listeners/init.lua index 26811957d..8758aae63 100644 --- a/lua/orgmode/events/listeners/init.lua +++ b/lua/orgmode/events/listeners/init.lua @@ -1,5 +1,6 @@ local Events = require('orgmode.events.types') local AlignTags = require('orgmode.events.listeners.align_tags') +local AttachMaybeDeleteArchived = require('orgmode.events.listeners.attach_maybe_delete_archived') return { [Events.TodoChanged] = { @@ -11,4 +12,7 @@ return { [Events.HeadlinePromoted] = { AlignTags, }, + [Events.HeadlineArchived] = { + AttachMaybeDeleteArchived, + }, } diff --git a/lua/orgmode/events/types/headline_archived_event.lua b/lua/orgmode/events/types/headline_archived_event.lua new file mode 100644 index 000000000..e1a6ec3b5 --- /dev/null +++ b/lua/orgmode/events/types/headline_archived_event.lua @@ -0,0 +1,20 @@ +---@class OrgHeadlineArchivedEvent: OrgEvent +---@field type string +---@field headline OrgHeadline +---@field destination_file OrgFile +local HeadlineArchivedEvent = { + type = 'orgmode.headline_archived', +} + +---@param headline OrgHeadline +---@param destination_file OrgFile +---@return OrgHeadlineArchivedEvent +function HeadlineArchivedEvent:new(headline, destination_file) + local obj = setmetatable({}, self) + self.__index = self + obj.headline = headline + obj.destination_file = destination_file + return obj +end + +return HeadlineArchivedEvent diff --git a/lua/orgmode/events/types/init.lua b/lua/orgmode/events/types/init.lua index 60024f9a7..ead40592c 100644 --- a/lua/orgmode/events/types/init.lua +++ b/lua/orgmode/events/types/init.lua @@ -8,4 +8,5 @@ return { HeadingToggled = require('orgmode.events.types.heading_toggled'), AttachChanged = require('orgmode.events.types.attach_changed_event'), AttachOpened = require('orgmode.events.types.attach_opened_event'), + HeadlineArchived = require('orgmode.events.types.headline_archived_event'), } From 82dce2b0e2fec6eed494316954cd7c852aaecec4 Mon Sep 17 00:00:00 2001 From: troiganto Date: Wed, 29 Jan 2025 15:00:36 +0100 Subject: [PATCH 5/7] feat(global): add `:Org attach` subcommand --- docs/index.org | 3 +++ lua/orgmode/attach/init.lua | 21 +++++++++++++++++++-- lua/orgmode/org/global.lua | 20 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/index.org b/docs/index.org index 7afbefc21..98b88fa95 100644 --- a/docs/index.org +++ b/docs/index.org @@ -64,6 +64,7 @@ List of available actions: - =:Org helpgrep= - Open search agenda view that allows searching through the documentation - =:Org agenda {type?}= - Open agenda view by the shortcut, for example =:Org agenda M= will open =tags_todo= view. When =type= is omitted, it opens up Agenda view. - =:Org capture {type?}= - Open capture template by the shortcut, for example =:Org capture t=. When =type= is omitted, it opens up Capture prompt. +- =:Org attach {type?}= - Take attachment action by the shortcut, for example =:Org attach o= will prompt for an attached file to open. When =type= is omitted, it opens up Attach prompt. - =:Org install_treesitter_grammar= - Install the treesitter grammar for Orgmode. If installed, prompt to reinstall. Grammar is installed automatically on first run, but this is useful in case when there are issues with the grammar. @@ -75,3 +76,5 @@ All of the commands above can be executed through the global Lua =Org= variable. - =Org.agenda.m()= - Opens =tags= view - =Org.capture()= - Opens capture prompt - =Org.capture.t()= - Opens capture template for =t= shortcut +- =Org.attach()= - Opens attach prompt +- =Org.attach.c()= - Opens attach template for =c= shortcut diff --git a/lua/orgmode/attach/init.lua b/lua/orgmode/attach/init.lua index 713125ad4..1156001be 100644 --- a/lua/orgmode/attach/init.lua +++ b/lua/orgmode/attach/init.lua @@ -23,8 +23,8 @@ end ---The dispatcher for attachment commands. ---Shows a list of commands and prompts for another key to execute a command. ----@return nil -function Attach:prompt() +---@return OrgMenu +function Attach:_build_menu() local menu = Menu:new({ title = 'Press key for an attach command', prompt = 'Press key for an attach command', @@ -147,9 +147,26 @@ function Attach:prompt() menu:add_option({ label = 'Quit', key = 'q' }) menu:add_separator({ icon = ' ', length = 1 }) + return menu +end + +---@return nil +function Attach:prompt() + local menu = self:_build_menu() return menu:open() end +---@param key string +---@return string? +function Attach:open_by_key(key) + local menu = self:_build_menu() + local item = menu:get_entry_by_key(key) + if not item then + return utils.echo_error('No attachment action with key ' .. key) + end + return item.action() +end + ---Get the current attachment node. --- ---@return OrgAttachNode diff --git a/lua/orgmode/org/global.lua b/lua/orgmode/org/global.lua index 315236525..a1877632d 100644 --- a/lua/orgmode/org/global.lua +++ b/lua/orgmode/org/global.lua @@ -46,6 +46,25 @@ local function generate_capture_object(orgmode, config) return Capture end +---@param orgmode Org +---@param config OrgConfig +local function generate_attach_object(orgmode, config) + local Attach = setmetatable({}, { + __call = function() + return orgmode.attach:prompt() + end, + }) + + local attach_keys = { 'a', 'c', 'm', 'l', 'y', 'u', 'b', 'n', 'z', 'o', 'O', 'f', 'F', 'd', 'D', 's', 'S' } + for _, key in ipairs(attach_keys) do + Attach[key] = function() + return orgmode.agenda:open_by_key(key) + end + end + + return Attach +end + ---@param orgmode Org local build = function(orgmode) local config = require('orgmode.config') @@ -75,6 +94,7 @@ local build = function(orgmode) agenda = generate_agenda_object(orgmode, config), capture = generate_capture_object(orgmode, config), + attach = generate_attach_object(orgmode, config), } _G.Org = OrgGlobal From cfeffa09ef16af94c64280f8ad0d0fb3e51363e0 Mon Sep 17 00:00:00 2001 From: troiganto Date: Tue, 19 Nov 2024 14:36:49 +0100 Subject: [PATCH 6/7] feat(attach): add `OrgAttach:attach_url()` --- docs/configuration.org | 30 ++++ lua/orgmode/attach/core.lua | 27 ++- lua/orgmode/attach/fileops.lua | 40 +++++ lua/orgmode/attach/init.lua | 56 +++++- lua/orgmode/config/_meta.lua | 2 + lua/orgmode/config/defaults.lua | 2 + lua/orgmode/objects/remote_resource.lua | 169 ++++++++++++++++++ lua/orgmode/state/state.lua | 2 +- lua/orgmode/utils/fs.lua | 1 + tests/plenary/object/remote_resource_spec.lua | 109 +++++++++++ 10 files changed, 434 insertions(+), 4 deletions(-) create mode 100644 lua/orgmode/objects/remote_resource.lua create mode 100644 tests/plenary/object/remote_resource_spec.lua diff --git a/docs/configuration.org b/docs/configuration.org index f9734a175..f545d7d94 100644 --- a/docs/configuration.org +++ b/docs/configuration.org @@ -556,6 +556,36 @@ See [[https://orgmode.org/manual/Property-Inheritance.html][Property Inheritance - Default: ~{ [':tangle'] = 'no', [':noweb'] = no }~ Default header args for extracting source code. See [[#extract-source-code-tangle][Extract source code (tangle)]] for more details. +*** org_resource_download_policy +:PROPERTIES: +:CUSTOM_ID: org_resource_download_policy +:END: +- Type: ='always' | 'prompt' | 'safe' | 'never'= +- Default: ='prompt'= +Policy applied to requests to obtain remote resources. + +- =always= - Always download remote resources (dangerous!) +- =prompt= - Prompt before downloading an unsafe resource +- =safe= - Only download resources allowed by [[#org_safe_remote_resources][org_safe_remote_resources]] +- =never= - Never download any resources + +In Emacs Orgmode, this affects keywords like =#+setupfile= and =#+include= +on export, =org-persist-write:url=; and =org-attach-url= in non-interactive +sessions. Nvim Orgmode currently does not use this option, but defines it +for future use. + +*** org_safe_remote_resources +:PROPERTIES: +:CUSTOM_ID: org_safe_remote_resources +:END: +- Type: =string[]= +- Default: ={}= + +List of regex patterns matching safe URIs. URI regexps are applied to both +URLs and Org files requesting remote resources. The test uses +=vim.regex()=, so the regexes are always interpreted as magic and +case-sensitive. + *** calendar_week_start_day :PROPERTIES: :CUSTOM_ID: calendar_week_start_day diff --git a/lua/orgmode/attach/core.lua b/lua/orgmode/attach/core.lua index 6b838ce86..8f7519448 100644 --- a/lua/orgmode/attach/core.lua +++ b/lua/orgmode/attach/core.lua @@ -438,11 +438,36 @@ function AttachCore:attach(node, file, opts) end) end ----@class orgmode.attach.core.attach_buffer.opts +---@class orgmode.attach.core.attach_url.opts ---@inlinedoc ---@field set_dir_method fun(): OrgPromise ---@field new_dir fun(): OrgPromise +---Download a file from a URL and attach it to the current outline node. +--- +---@param node OrgAttachNode +---@param url string URL to the file to attach +---@param opts orgmode.attach.core.attach_url.opts +---@return OrgPromise attachment_name +function AttachCore:attach_url(node, url, opts) + local basename = basename_safe(url) + return self:get_dir_or_create(node, opts.set_dir_method, opts.new_dir):next(function(attach_dir) + local attach_file = vim.fs.joinpath(attach_dir, basename) + return fileops.download_file(url, attach_file, { exist_ok = false }):next(function(success) + if not success then + return nil + end + EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir)) + node:add_auto_tag() + local link = self.links:store_link_to_attachment({ attach_dir = attach_dir, original = url }) + vim.fn.setreg(vim.v.register, link) + return basename + end) + end) +end + +---@alias orgmode.attach.core.attach_buffer.opts orgmode.attach.core.attach_url.opts + ---Attach buffer's contents to current outline node. --- ---Throws a file-exists error if it would overwrite an existing filename. diff --git a/lua/orgmode/attach/fileops.lua b/lua/orgmode/attach/fileops.lua index c3661603e..c68be8f7c 100644 --- a/lua/orgmode/attach/fileops.lua +++ b/lua/orgmode/attach/fileops.lua @@ -339,4 +339,44 @@ function M.remove_directory(path, opts) end) end +--[[ +-- Scary hacks 💀 +--]] + +---Helper function to `download_file`. +---This uses NetRW to download a file and returns the download location. +---@param url string +---@return OrgPromise tmpfile +local function netrw_read(url) + return Promise.new(function(resolve, reject) + if not vim.g.loaded_netrwPlugin then + return reject('Netrw plugin must be loaded in order to download urls.') + end + vim.schedule(function() + local ok, err = pcall(vim.fn['netrw#NetRead'], 3, url) + if ok then + resolve(vim.b.netrw_tmpfile) + else + reject(err) + end + end) + end) +end + +---Download a file via NetRW. +---The file is first downloaded to a temporary location (no matter the value of +---`exist_ok`) and only then copied over to `dest`. The copy operation uses the +---`exist_ok` flag exactly like `copy_file`. +---@param url string +---@param dest string +---@param opts? {exist_ok: boolean?} +---@return OrgPromise success +function M.download_file(url, dest, opts) + opts = opts or {} + local exist_ok = opts.exist_ok or false + return netrw_read(url):next(function(source) + return M.copy_file(source, dest, { excl = not exist_ok, ficlone = true, ficlone_force = false }) + end) +end + return M diff --git a/lua/orgmode/attach/init.lua b/lua/orgmode/attach/init.lua index 1156001be..abecebeff 100644 --- a/lua/orgmode/attach/init.lua +++ b/lua/orgmode/attach/init.lua @@ -4,6 +4,7 @@ local Input = require('orgmode.ui.input') local Menu = require('orgmode.ui.menu') local Promise = require('orgmode.utils.promise') local config = require('orgmode.config') +local remote_resource = require('orgmode.objects.remote_resource') local ui = require('orgmode.attach.ui') local utils = require('orgmode.utils') @@ -65,6 +66,13 @@ function Attach:_build_menu() return self:attach_lns() end, }) + menu:add_option({ + label = 'Attach a file by download from URL.', + key = 'u', + action = function() + return self:attach_url() + end, + }) menu:add_option({ label = "Attach a buffer's contents.", key = 'b', @@ -376,18 +384,62 @@ function Attach:attach(file, opts) :wait(MAX_TIMEOUT) end ----@class orgmode.attach.attach_buffer.Options +---@class orgmode.attach.attach_url.Options ---@inlinedoc ---@field visit_dir? boolean if true, visit the directory subsequently using --- `org_attach_visit_command` ---@field node? OrgAttachNode +---Download a URL. +--- +---@param url? string +---@param opts? orgmode.attach.attach_url.Options +---@return string|nil attachment_name +function Attach:attach_url(url, opts) + local node = opts and opts.node or self.core:get_current_node() + local visit_dir = opts and opts.visit_dir or false + return Promise + .resolve() + :next(function() + if not url then + return Input.open('URL of the file to attach: ') + end + return remote_resource.should_fetch(url):next(function(ok) + if not ok then + error(("remote resource %s is unsafe, won't download"):format(url)) + end + return url + end) + end) + ---@param chosen_url? string + :next(function(chosen_url) + if not chosen_url then + return nil + end + return self.core:attach_url(node, chosen_url, { + set_dir_method = get_set_dir_method(), + new_dir = ui.ask_attach_dir_property, + }) + end) + :next(function(attachment_name) + if attachment_name then + utils.echo_info(('File %s is now an attachment'):format(attachment_name)) + if visit_dir then + local attach_dir = self.core:get_dir(node) + self.core:reveal_nvim(attach_dir) + end + end + return attachment_name + end) + :wait(MAX_TIMEOUT) +end + ---Attach buffer's contents to current outline node. --- ---Throws a file-exists error if it would overwrite an existing filename. --- ---@param buffer? string | integer A buffer number or name. ----@param opts? orgmode.attach.attach_buffer.Options +---@param opts? orgmode.attach.attach_url.Options ---@return string|nil attachment_name function Attach:attach_buffer(buffer, opts) local node = opts and opts.node or self.core:get_current_node() diff --git a/lua/orgmode/config/_meta.lua b/lua/orgmode/config/_meta.lua index e4e89ae7d..e39db7be2 100644 --- a/lua/orgmode/config/_meta.lua +++ b/lua/orgmode/config/_meta.lua @@ -249,6 +249,8 @@ ---@field org_attach_archive_delete 'always' | 'ask' | 'never' Determines whether to delete a headline's attachments when it is archived. Default: 'never' ---@field org_attach_id_to_path_function_list (string | fun(id: string): (string|nil))[] List of functions used to derive the attachments directory from an ID property. ---@field org_attach_sync_delete_empty_dir 'always' | 'ask' | 'never' Determines whether to delete empty directories when using `org.attach.sync()`. Default: 'ask' +---@field org_resource_download_policy 'always' | 'prompt' | 'safe' | 'never' Policy for downloading files from the Internet. Default: 'prompt' +---@field org_safe_remote_resources string[] List of regex patterns for URIs considered always safe to download from. Default: {} ---@field win_split_mode? 'horizontal' | 'vertical' | 'auto' | 'float' | string[] How to open agenda and capture windows. Default: 'horizontal' ---@field win_border? 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | string[] Border configuration for `win_split_mode = 'float'`. Default: 'single' ---@field notifications? OrgNotificationsConfig Notification settings diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index 8cb2f85ae..afe827f80 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -86,6 +86,8 @@ local DefaultConfig = { 'fallback_folder_format', }, org_attach_sync_delete_empty_dir = 'ask', + org_resource_download_policy = 'prompt', + org_safe_remote_resources = {}, win_split_mode = 'horizontal', win_border = 'single', notifications = { diff --git a/lua/orgmode/objects/remote_resource.lua b/lua/orgmode/objects/remote_resource.lua new file mode 100644 index 000000000..79680d0b3 --- /dev/null +++ b/lua/orgmode/objects/remote_resource.lua @@ -0,0 +1,169 @@ +local config = require('orgmode.config') +local fs = require('orgmode.utils.fs') +local utils = require('orgmode.utils') +local Menu = require('orgmode.ui.menu') +local State = require('orgmode.state.state') +local Promise = require('orgmode.utils.promise') + +local M = {} + +---Return true if the URI should be fetched. +---@param uri string +---@return OrgPromise safe +function M.should_fetch(uri) + local policy = config.org_resource_download_policy + return Promise.resolve(policy == 'always' or M.is_uri_safe(uri)):next(function(safe) + if safe then + return true + end + if policy == 'prompt' then + return M.confirm_safe(uri) + end + return false + end) +end + +---@param resource_uri string +---@param file_uri string | false +---@param patterns string[] +---@return boolean matches +local function check_patterns(resource_uri, file_uri, patterns) + for _, pattern in ipairs(patterns) do + local re = vim.regex(pattern) + if re:match_str(resource_uri) or (file_uri and re:match_str(file_uri)) then + return true + end + end + return false +end + +---Check the uri matches any of the (configured or cached) safe patterns. +---@param uri string +---@return OrgPromise safe +function M.is_uri_safe(uri) + local current_file = fs.get_real_path(utils.current_file_path()) + ---@type string | false # deduced type is `string | boolean` + local file_uri = current_file and vim.uri_from_fname(current_file) or false + local uri_patterns = {} + if config.org_safe_remote_resources then + vim.list_extend(uri_patterns, config.org_safe_remote_resources) + end + return State:load():next(function(state) + local cached = state['org_safe_remote_resources'] + if cached then + vim.list_extend(uri_patterns, cached) + end + return check_patterns(uri, file_uri, uri_patterns) + end) +end + +---@param uri string +---@return string escaped +local function uri_to_pattern(uri) + -- Escape backslashes, disable magic characters, anchor front and back of the + -- pattern. + return string.format([[\V\^%s\$]], uri:gsub([[\]], [[\\]])) +end + +---@param filename string +---@return string escaped +local function filename_to_pattern(filename) + return uri_to_pattern(vim.uri_from_fname(filename)) +end + +---@param domain string +---@return string escaped +local function domain_to_pattern(domain) + -- We construct the following regex: + -- 1. http or https protocol; + -- 2. followed by userinfo (`name:password@`), + -- 3. followed by potentially `www.` (for convenience), + -- 4. followed by the domain (in very-nomagic mode) + -- 5. followed by either a slash or nothing at all. + return string.format( + [[\v^https?://([^@/?#]*\@)?(www\.)?(\V%s\v)($|/)]], + -- `domain` here includes the host name and port. If it doesn't contain + -- characters illegal in a host or port, this encoding should do nothing. + -- If it contains illegal characters, the domain is broken in a safe way. + vim.uri_encode(domain) + ) +end + +---@param pattern string +---@return OrgPromise +local function cache_safe_pattern(pattern) + ---@param state OrgState + return State:load():next(function(state) + -- We manipulate `cached` in a strange way here to ensure that `state` gets + -- marked as dirty. + local patterns = { pattern } + local cached = state['org_safe_remote_resources'] + if cached then + vim.list_extend(patterns, cached) + end + state['org_safe_remote_resources'] = patterns + end) +end + +---Ask the user if URI should be considered safe. +---@param uri string +---@return OrgPromise safe +function M.confirm_safe(uri) + ---@type OrgMenu + return Promise.new(function(resolve) + local menu = Menu:new({ + title = string.format('An org-mode document would like to download %s, which is not considered safe.', uri), + prompt = 'Do you want to download this?', + }) + menu:add_option({ + key = '!', + label = 'Yes, and mark it as safe.', + action = function() + cache_safe_pattern(uri_to_pattern(uri)) + return true + end, + }) + local authority = uri:match('^https?://([^/?#]*)') + -- `domain` here includes the host name and port. + local domain = authority and authority:match('^[^@]*@(.*)$') or authority + if domain then + menu:add_option({ + key = 'd', + label = string.format('Yes, and mark the domain as safe. (%s)', domain), + action = function() + cache_safe_pattern(domain_to_pattern(domain)) + return true + end, + }) + end + local filename = fs.get_real_path(utils.current_file_path()) + if filename then + menu:add_option({ + key = 'f', + label = string.format('Yes, and mark the org file as safe. (%s)', filename), + action = function() + cache_safe_pattern(filename_to_pattern(filename)) + return true + end, + }) + end + menu:add_option({ + key = 'y', + label = 'Yes, just this once.', + action = function() + return true + end, + }) + menu:add_option({ + key = 'n', + label = 'No, skip this resource.', + action = function() + return false + end, + }) + menu:add_separator({ icon = ' ', length = 1 }) + resolve(menu:open()) + end) +end + +return M diff --git a/lua/orgmode/state/state.lua b/lua/orgmode/state/state.lua index 280a43555..b63d64ff4 100644 --- a/lua/orgmode/state/state.lua +++ b/lua/orgmode/state/state.lua @@ -172,7 +172,7 @@ function OrgState:wipe(overwrite) self._ctx.saved = false self._ctx.dirty = true if overwrite then - state:save_sync() + self:save_sync() end end diff --git a/lua/orgmode/utils/fs.lua b/lua/orgmode/utils/fs.lua index ed277679a..90d0fc346 100644 --- a/lua/orgmode/utils/fs.lua +++ b/lua/orgmode/utils/fs.lua @@ -21,6 +21,7 @@ function M.substitute_path(path_str) end ---@param filepath string +---@return string | false function M.get_real_path(filepath) if not filepath then return false diff --git a/tests/plenary/object/remote_resource_spec.lua b/tests/plenary/object/remote_resource_spec.lua new file mode 100644 index 000000000..bac25d9b9 --- /dev/null +++ b/tests/plenary/object/remote_resource_spec.lua @@ -0,0 +1,109 @@ +local State = require('orgmode.state.state') +local config = require('orgmode.config') +local remote = require('orgmode.objects.remote_resource') + +local config_backup = vim.deepcopy(config.opts) + +describe('Remote resource', function() + local SAFE_URL = 'https://example.com/' + local UNSAFE_URL = 'http://bad.example.org/' + + after_each(function() + config:extend(config_backup) + end) + + describe('with policy "always"', function() + before_each(function() + config:extend({ org_resource_download_policy = 'always' }) + end) + it('accepts everything', function() + assert.is.True(remote.should_fetch(UNSAFE_URL):wait()) + end) + end) + + describe('with policy "safe"', function() + before_each(function() + config:extend({ + org_resource_download_policy = 'safe', + -- This implicitly tests that we use actual regexes and not Lua + -- patterns (which would use `%` as escape character, not `\`). + org_safe_remote_resources = { '^https://.*\\.com/\\?$' }, + }) + end) + it('accepts safe URLs', function() + assert.is.True(remote.should_fetch(SAFE_URL):wait()) + end) + it('rejects unsafe URLs', function() + assert.is.False(remote.should_fetch(UNSAFE_URL):wait()) + end) + end) + + describe('with policy "prompt"', function() + before_each(function() + config:extend({ org_resource_download_policy = 'prompt' }) + end) + it('opens a prompt', function() + vim.api.nvim_input('') + assert.is.Nil(remote.should_fetch(SAFE_URL):wait()) + end) + it('accepts on "y"', function() + vim.api.nvim_input('y') + assert.is.True(remote.should_fetch(SAFE_URL):wait()) + end) + it('rejects on "n"', function() + vim.api.nvim_input('n') + assert.is.False(remote.should_fetch(SAFE_URL):wait()) + end) + describe('and saving decisions', function() + after_each(function() + State:wipe() + end) + it('accepts forever with "!"', function() + vim.api.nvim_input('!') + assert.is.True(remote.should_fetch(SAFE_URL):wait()) + config:extend({ org_resource_download_policy = 'safe' }) + assert.is.True(remote.should_fetch(SAFE_URL):wait()) + assert.is.False(remote.should_fetch(SAFE_URL .. '/more'):wait()) + assert.is.False(remote.should_fetch(UNSAFE_URL):wait()) + end) + it('accepts forever with "d"', function() + vim.api.nvim_input('d') + assert.is.True(remote.should_fetch(SAFE_URL):wait()) + config:extend({ org_resource_download_policy = 'safe' }) + assert.is.True(remote.should_fetch(SAFE_URL):wait()) + assert.is.True(remote.should_fetch(SAFE_URL .. '/more'):wait()) + assert.is.False(remote.should_fetch(UNSAFE_URL):wait()) + end) + it('accepts forever with "f"', function() + local todo_file = vim.fn.getcwd() .. '/tests/plenary/fixtures/todo.org' + vim.cmd.edit(todo_file) + vim.api.nvim_input('f') + assert.is.True(remote.should_fetch(SAFE_URL):wait()) + config:extend({ org_resource_download_policy = 'safe' }) + assert.is.True(remote.should_fetch(SAFE_URL):wait()) + assert.is.True(remote.should_fetch(UNSAFE_URL):wait()) + vim.api.nvim_buf_set_name(0, '') + assert.is.False(remote.should_fetch(SAFE_URL):wait()) + end) + end) + end) + + describe('with policy "never"', function() + before_each(function() + config:extend({ org_resource_download_policy = 'never' }) + end) + it('rejects everything', function() + assert.is.False(remote.should_fetch(SAFE_URL):wait()) + end) + end) + + describe('default config', function() + it('prompts', function() + assert.are.equal('prompt', config.org_resource_download_policy) + end) + + it('has no safe patterns', function() + assert.are.same({}, config.org_safe_remote_resources) + end) + end) +end) From bde0d30d4a3b310af8712e70c477f602dc4a8a91 Mon Sep 17 00:00:00 2001 From: troiganto Date: Sun, 9 Feb 2025 16:35:01 +0100 Subject: [PATCH 7/7] feat(attach): add config `org_attach_dir_relative` This reverts commit 272da8c217455f2fde59411f59e5e51ea44a3125. --- docs/configuration.org | 15 +++++++++-- lua/orgmode/attach/core.lua | 3 ++- lua/orgmode/attach/init.lua | 3 ++- lua/orgmode/attach/node.lua | 10 ++++--- lua/orgmode/config/defaults.lua | 1 + lua/orgmode/utils/fs.lua | 33 +++++++++++++++++++++++ tests/plenary/utils/fs_spec.lua | 48 +++++++++++++++++++++++++++++++++ 7 files changed, 106 insertions(+), 7 deletions(-) diff --git a/docs/configuration.org b/docs/configuration.org index f545d7d94..684e94e77 100644 --- a/docs/configuration.org +++ b/docs/configuration.org @@ -1168,6 +1168,16 @@ be inherited. The directory where attachments are stored. If this is a relative path, it will be interpreted relative to the directory where the Org file lives. +*** org_attach_dir_relative +:PROPERTIES: +:CUSTOM_ID: org_attach_dir_relative +:END: +- Type: =boolean= +- Default: =false= + +If =true=, whenever you add a =DIR= property to a headline, it is added as +a relative path. The default is to only add absolute paths. + *** org_attach_auto_tag :PROPERTIES: :CUSTOM_ID: org_attach_auto_tag @@ -2996,8 +3006,9 @@ file). Attaching a file puts it in a directory associated with the attachment node. Based on [[#org_attach_preferred_new_method][org_attach_preferred_new_method]], this either uses the =ID= or the =DIR= property. See also [[#org_attach_id_dir][org_attach_id_dir]], -[[#org_attach_id_to_path_function_list][org_attach_id_to_path_function_list]] and [[#org_attach_use_inheritance][org_attach_use_inheritance]] on how -to further customize the attachments directory. +[[#org_attach_dir_relative][org_attach_dir_relative]], [[#org_attach_id_to_path_function_list][org_attach_id_to_path_function_list]] and +[[#org_attach_use_inheritance][org_attach_use_inheritance]] on how to further customize the attachments +directory. Attachment links are supported. A link like =[[attachment:file.txt]]= looks up =file.txt= in the current node's attachments directory and opens diff --git a/lua/orgmode/attach/core.lua b/lua/orgmode/attach/core.lua index 8f7519448..b3afdec90 100644 --- a/lua/orgmode/attach/core.lua +++ b/lua/orgmode/attach/core.lua @@ -254,7 +254,8 @@ end ---Set the DIR node property and ask to move files there. --- ---The property defines the directory that is used for attachments ----of the entry. +---of the entry. Creates relative links if `org_attach_dir_relative' +---is true. --- ---@param node OrgAttachNode ---@param new_dir string diff --git a/lua/orgmode/attach/init.lua b/lua/orgmode/attach/init.lua index abecebeff..f8eeef378 100644 --- a/lua/orgmode/attach/init.lua +++ b/lua/orgmode/attach/init.lua @@ -270,7 +270,8 @@ end ---Set the DIR node property and ask to move files there. --- ---The property defines the directory that is used for attachments ----of the entry. +---of the entry. Creates relative links if `org_attach_dir_relative' +---is true. --- ---@param node? OrgAttachNode ---@return string | nil new_dir diff --git a/lua/orgmode/attach/node.lua b/lua/orgmode/attach/node.lua index 7fa74f583..1f767b1c9 100644 --- a/lua/orgmode/attach/node.lua +++ b/lua/orgmode/attach/node.lua @@ -178,15 +178,19 @@ end ---Set the attachment directory on the current node. --- ----In addition to `set_property()`, this also ensures that the path is always ----absolute. +---In addition to `set_property()`, this also adjusts the path to be relative, +---if required by `org_attach_dir_relative`. --- ---@param dir string ---@return string new_dir absolute attachment directory ---@overload fun(): nil function AttachNode:set_dir(dir) if dir then - dir = vim.fn.fnamemodify(dir, ':p') + if config.org_attach_dir_relative then + dir = fs_utils.make_relative(dir, vim.fs.dirname(self.file.filename)) + else + dir = vim.fn.fnamemodify(dir, ':p') + end end self:set_property('DIR', dir) return dir and self:_make_absolute(dir) diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index afe827f80..c48e07c73 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -72,6 +72,7 @@ local DefaultConfig = { [':noweb'] = 'no', }, org_attach_id_dir = './data/', + org_attach_dir_relative = false, org_attach_auto_tag = 'ATTACH', org_attach_preferred_new_method = 'id', org_attach_method = 'cp', diff --git a/lua/orgmode/utils/fs.lua b/lua/orgmode/utils/fs.lua index 90d0fc346..751049621 100644 --- a/lua/orgmode/utils/fs.lua +++ b/lua/orgmode/utils/fs.lua @@ -64,4 +64,37 @@ function M.trim_common_root(paths) return result end +---@param filepath string an absolute path +---@param base string an absolute path to an ancestor of filepath; +--- here, `'.'` represents the current working directory, and +--- *not* the current file's directory. +---@return string filepath_relative_to_base +function M.make_relative(filepath, base) + vim.validate({ + filepath = { filepath, 'string', false }, + base = { base, 'string', false }, + }) + filepath = vim.fn.fnamemodify(filepath, ':p') + base = vim.fn.fnamemodify(base, ':p') + if base:sub(-1) ~= '/' then + base = base .. '/' + end + local levels_up = 0 + for parent in vim.fs.parents(base) do + if parent:sub(-1) ~= '/' then + parent = parent .. '/' + end + if vim.startswith(filepath, parent) then + filepath = filepath:sub(parent:len() + 1) + if levels_up > 0 then + return vim.fs.joinpath(string.rep('..', levels_up, '/'), filepath) + end + return vim.fs.joinpath('.', filepath) + end + levels_up = levels_up + 1 + end + -- No common root, just return the absolute path. + return filepath +end + return M diff --git a/tests/plenary/utils/fs_spec.lua b/tests/plenary/utils/fs_spec.lua index 18afbad73..f669c46ff 100644 --- a/tests/plenary/utils/fs_spec.lua +++ b/tests/plenary/utils/fs_spec.lua @@ -86,3 +86,51 @@ describe('get_real_path', function() assert.is.False(fs_utils.get_real_path('.')) end) end) + +describe('make_relative', function() + local path = fs_utils.get_real_path(file.filename) ---@cast path string + local basename = vim.fs.basename(path) + local dirname = vim.fs.dirname(path) + local dirname_slash = vim.fs.joinpath(dirname, '') + local root = path + for parent in vim.fs.parents(path) do + root = parent + end + + it('gets the basename', function() + local expected = vim.fs.joinpath('.', basename) + local actual = fs_utils.make_relative(path, dirname) + assert.are.same(expected, actual) + end) + + it('gets the basename with trailing slash', function() + local expected = vim.fs.joinpath('.', basename) + local actual = fs_utils.make_relative(path, dirname_slash) + assert.are.same(expected, actual) + end) + + it('works one level up', function() + local parent_name = vim.fs.basename(dirname) + local expected = vim.fs.joinpath('.', parent_name, basename) + local actual = fs_utils.make_relative(path, vim.fs.dirname(dirname)) + assert.are.same(expected, actual) + end) + + it('works one level up with trailing slash', function() + local parent_name = vim.fs.basename(dirname) + local expected = vim.fs.joinpath('.', parent_name, basename) + local actual = fs_utils.make_relative(path, vim.fs.dirname(dirname) .. '/') + assert.are.same(expected, actual) + end) + + it('produces a relative path even at the root', function() + local relpath = fs_utils.make_relative(path, root) + assert(vim.endswith(path, relpath)) + end) + + it('climbs up via ..', function() + local relpath = fs_utils.make_relative(root, path) + local only_cdup = vim.regex('\\V\\(../\\)\\+') + assert(only_cdup:match_str(relpath)) + end) +end)