Skip to content

Commit f950c1b

Browse files
committed
feat(attach): add OrgAttach:attach_url()
This corresponds to the Emacs function `org-attach-url`. It also adds some functionality from the Emacs core that we don't get for free.
1 parent 273011a commit f950c1b

File tree

10 files changed

+442
-4
lines changed

10 files changed

+442
-4
lines changed

docs/configuration.org

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,36 @@ See [[https://orgmode.org/manual/Property-Inheritance.html][Property Inheritance
556556
- Default: ~{ [':tangle'] = 'no', [':noweb'] = no }~
557557
Default header args for extracting source code. See [[#extract-source-code-tangle][Extract source code (tangle)]] for more details.
558558

559+
*** org_resource_download_policy
560+
:PROPERTIES:
561+
:CUSTOM_ID: org_resource_download_policy
562+
:END:
563+
- Type: ='always' | 'prompt' | 'safe' | 'never'=
564+
- Default: ='prompt'=
565+
Policy applied to requests to obtain remote resources.
566+
567+
- =always= - Always download remote resources (dangerous!)
568+
- =prompt= - Prompt before downloading an unsafe resource
569+
- =safe= - Only download resources allowed by [[#org_safe_remote_resources][org_safe_remote_resources]]
570+
- =never= - Never download any resources
571+
572+
In Emacs Orgmode, this affects keywords like =#+setupfile= and =#+include=
573+
on export, =org-persist-write:url=; and =org-attach-url= in non-interactive
574+
sessions. Nvim Orgmode currently does not use this option, but defines it
575+
for future use.
576+
577+
*** org_safe_remote_resources
578+
:PROPERTIES:
579+
:CUSTOM_ID: org_safe_remote_resources
580+
:END:
581+
- Type: =string[]=
582+
- Default: ={}=
583+
584+
List of regex patterns matching safe URIs. URI regexps are applied to both
585+
URLs and Org files requesting remote resources. The test uses
586+
=vim.regex()=, so the regexes are always interpreted as magic and
587+
case-sensitive.
588+
559589
*** calendar_week_start_day
560590
:PROPERTIES:
561591
:CUSTOM_ID: calendar_week_start_day

lua/orgmode/attach/core.lua

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,11 +307,36 @@ function AttachCore:attach(node, file, opts)
307307
end)
308308
end
309309

310-
---@class orgmode.attach.core.attach_buffer.opts
310+
---@class orgmode.attach.core.attach_url.opts
311311
---@inlinedoc
312312
---@field set_dir_method fun(): OrgPromise<orgmode.attach.core.new_method>
313313
---@field new_dir fun(): OrgPromise<string | nil>
314314

315+
---Download a file from a URL and attach it to the current outline node.
316+
---
317+
---@param node OrgAttachNode
318+
---@param url string URL to the file to attach
319+
---@param opts orgmode.attach.core.attach_url.opts
320+
---@return OrgPromise<string|nil> attachment_name
321+
function AttachCore:attach_url(node, url, opts)
322+
local basename = basename_safe(url)
323+
return self:get_dir_or_create(node, opts.set_dir_method, opts.new_dir):next(function(attach_dir)
324+
local attach_file = vim.fs.joinpath(attach_dir, basename)
325+
return fileops.download_file(url, attach_file, { exist_ok = false }):next(function(success)
326+
if not success then
327+
return nil
328+
end
329+
EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir))
330+
node:add_auto_tag()
331+
local link = self.links:store_link_to_attachment({ attach_dir = attach_dir, original = url })
332+
vim.fn.setreg(vim.v.register, link)
333+
return basename
334+
end)
335+
end)
336+
end
337+
338+
---@alias orgmode.attach.core.attach_buffer.opts orgmode.attach.core.attach_url.opts
339+
315340
---Attach buffer's contents to current outline node.
316341
---
317342
---Throws a file-exists error if it would overwrite an existing filename.

lua/orgmode/attach/fileops.lua

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,4 +339,52 @@ function M.remove_directory(path, opts)
339339
end)
340340
end
341341

342+
--[[
343+
-- File downloads, based on <https://github.com/neovim/neovim/pull/33964>
344+
-- In a future Neovim version, this might land as `vim.net.download()`.
345+
--]]
346+
347+
---Download a file via `curl`.
348+
---Curl is invoked via `vim.system` and downloads the file at the given `url`
349+
---to the file at the path `dest.
350+
---A non-zero exit code or any output to standard error is treated as an error.
351+
---Curl follows redirects with no limit.
352+
---If `exist_ok` is true, the returned promise is immediately rejected if
353+
---`dest` already exists.
354+
---The options `retry` and `verbose` are passed through directly to curl as
355+
---the corresponding command-line arguments.
356+
---@param url string
357+
---@param dest string
358+
---@param opts? {clobber: boolean?, retry: integer?, verbose: boolean?}
359+
---@return OrgPromise<true> success
360+
function M.download_file(url, dest, opts)
361+
opts = opts or {}
362+
if not opts.exist_ok and M.exists(dest) then
363+
return Promise.reject('EEXIST: ' .. dest)
364+
end
365+
local args = { 'curl' }
366+
if opts.verbose then
367+
table.insert(args, '--verbose')
368+
else
369+
vim.list_extend(args, { '--silent', '--show-error', '--fail' })
370+
end
371+
vim.list_extend(args, {
372+
'--retry',
373+
tostring(opts.retry or 3),
374+
'--location',
375+
'--output',
376+
dest,
377+
url,
378+
})
379+
return Promise.new(function(resolve, reject)
380+
vim.system(args, { text = true }, function(res)
381+
if res.code ~= 0 or res.stderr ~= '' then
382+
reject(res.stderr ~= '' and res.stderr or 'Download failed')
383+
else
384+
resolve(true)
385+
end
386+
end)
387+
end)
388+
end
389+
342390
return M

lua/orgmode/attach/init.lua

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ local Input = require('orgmode.ui.input')
44
local Menu = require('orgmode.ui.menu')
55
local Promise = require('orgmode.utils.promise')
66
local config = require('orgmode.config')
7+
local remote_resource = require('orgmode.objects.remote_resource')
78
local ui = require('orgmode.attach.ui')
89
local utils = require('orgmode.utils')
910

@@ -65,6 +66,13 @@ function Attach:_build_menu()
6566
return self:attach_lns()
6667
end,
6768
})
69+
menu:add_option({
70+
label = 'Attach a file by download from URL.',
71+
key = 'u',
72+
action = function()
73+
return self:attach_url()
74+
end,
75+
})
6876
menu:add_option({
6977
label = "Attach a buffer's contents.",
7078
key = 'b',
@@ -356,18 +364,62 @@ function Attach:attach(file, opts)
356364
:wait(MAX_TIMEOUT)
357365
end
358366

359-
---@class orgmode.attach.attach_buffer.Options
367+
---@class orgmode.attach.attach_url.Options
360368
---@inlinedoc
361369
---@field visit_dir? boolean if true, visit the directory subsequently using
362370
--- `org_attach_visit_command`
363371
---@field node? OrgAttachNode
364372

373+
---Download a URL.
374+
---
375+
---@param url? string
376+
---@param opts? orgmode.attach.attach_url.Options
377+
---@return string|nil attachment_name
378+
function Attach:attach_url(url, opts)
379+
local node = opts and opts.node or self.core:get_current_node()
380+
local visit_dir = opts and opts.visit_dir or false
381+
return Promise
382+
.resolve()
383+
:next(function()
384+
if not url then
385+
return Input.open('URL of the file to attach: ')
386+
end
387+
return remote_resource.should_fetch(url):next(function(ok)
388+
if not ok then
389+
error(("remote resource %s is unsafe, won't download"):format(url))
390+
end
391+
return url
392+
end)
393+
end)
394+
---@param chosen_url? string
395+
:next(function(chosen_url)
396+
if not chosen_url then
397+
return nil
398+
end
399+
return self.core:attach_url(node, chosen_url, {
400+
set_dir_method = get_set_dir_method(),
401+
new_dir = ui.ask_attach_dir_property,
402+
})
403+
end)
404+
:next(function(attachment_name)
405+
if attachment_name then
406+
utils.echo_info(('File %s is now an attachment'):format(attachment_name))
407+
if visit_dir then
408+
local attach_dir = self.core:get_dir(node)
409+
self.core:reveal_nvim(attach_dir)
410+
end
411+
end
412+
return attachment_name
413+
end)
414+
:wait(MAX_TIMEOUT)
415+
end
416+
365417
---Attach buffer's contents to current outline node.
366418
---
367419
---Throws a file-exists error if it would overwrite an existing filename.
368420
---
369421
---@param buffer? string | integer A buffer number or name.
370-
---@param opts? orgmode.attach.attach_buffer.Options
422+
---@param opts? orgmode.attach.attach_url.Options
371423
---@return string|nil attachment_name
372424
function Attach:attach_buffer(buffer, opts)
373425
local node = opts and opts.node or self.core:get_current_node()

lua/orgmode/config/_meta.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@
250250
---@field org_attach_archive_delete 'always' | 'ask' | 'never' Determines whether to delete a headline's attachments when it is archived. Default: 'never'
251251
---@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.
252252
---@field org_attach_sync_delete_empty_dir 'always' | 'ask' | 'never' Determines whether to delete empty directories when using `org.attach.sync()`. Default: 'ask'
253+
---@field org_resource_download_policy 'always' | 'prompt' | 'safe' | 'never' Policy for downloading files from the Internet. Default: 'prompt'
254+
---@field org_safe_remote_resources string[] List of regex patterns for URIs considered always safe to download from. Default: {}
253255
---@field win_split_mode? 'horizontal' | 'vertical' | 'auto' | 'float' | string[] How to open agenda and capture windows. Default: 'horizontal'
254256
---@field win_border? 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | string[] Border configuration for `win_split_mode = 'float'`. Default: 'single'
255257
---@field notifications? OrgNotificationsConfig Notification settings

lua/orgmode/config/defaults.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ local DefaultConfig = {
8686
'fallback_folder_format',
8787
},
8888
org_attach_sync_delete_empty_dir = 'ask',
89+
org_resource_download_policy = 'prompt',
90+
org_safe_remote_resources = {},
8991
win_split_mode = 'horizontal',
9092
win_border = 'single',
9193
notifications = {

0 commit comments

Comments
 (0)