From 38e3da79f35e9466b8e2c192ac2e9b3610841235 Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Wed, 23 Apr 2025 00:21:08 +0800 Subject: [PATCH 1/3] feat: drag n drop into notes --- CHANGELOG.md | 1 + lua/obsidian/attachments.lua | 172 +++++++++++++++++++++++++++++++++++ lua/obsidian/init.lua | 3 + 3 files changed, 176 insertions(+) create mode 100644 lua/obsidian/attachments.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index ae80eb98..d767c5e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added default `image_name_func` similar to Obsidian's. - Added support `text/uri-list` to `ObsidianPasteImg`. +- Added support for drag and drop into notes ### Changed diff --git a/lua/obsidian/attachments.lua b/lua/obsidian/attachments.lua new file mode 100644 index 00000000..1ca869a8 --- /dev/null +++ b/lua/obsidian/attachments.lua @@ -0,0 +1,172 @@ +-- accpeted file formats: https://help.obsidian.md/file-formats + +local filetypes = { + -- markdown + "md", + -- json canvas + "canvas", + -- images + "avif", + "bmp", + "gif", + "jpg", + "jpeg", + "png", + "svg", + "webp", + -- audio + "flac", + "m4a", + "mp3", + "ogg", + "wav", + "webm", + "3gp", + -- video + "mkv", + "mov", + "mp4", + "ogv", + "webm", + -- pdf + "pdf", +} + +local m_filetype = {} + +for _, v in ipairs(filetypes) do + m_filetype[v] = true +end + +local function supports_extension(path) + local basename = vim.fs.basename(path) + local ext = basename:match "^.+%.(.+)$" + return m_filetype[ext], basename +end + +local function insert_link(client, dst) + local new_link = "!" .. client:format_link(dst) + vim.api.nvim_put({ new_link }, "l", true, true) +end + +local function drop_local_file(line) + local from = vim.fs.abspath(line):gsub("\\", "") + local support, base = supports_extension(from) + + if support then + local Path = require "obsidian.path" + local client = require("obsidian").get_client() + local dst = (Path.new(client.dir) / client.opts.attachments.img_folder / base):resolve() + + local copy_ok, err = vim.uv.fs_copyfile(from, tostring(dst)) + if not copy_ok then + vim.notify(err or "failed to copy file", 3) + return false + end + insert_link(client, dst) + vim.notify("Copied file to " .. tostring(dst)) + return true + else + vim.notify("file extension not supported", 3) + return false + end +end + +local drop_remote_file = function(url) + local Path = require "obsidian.path" + local client = require("obsidian").get_client() + local base = client.opts.attachments.img_name_func() + + local dst = (Path.new(client.dir) / client.opts.attachments.img_folder / base):resolve() + dst = tostring(dst) + + local obj = vim.system({ "curl", url, "-o", dst }, {}):wait() + + if obj.code == 0 then + vim.notify("file " .. dst .. " saved") + insert_link(client, dst) + return true + else + vim.notify("file " .. dst .. " failed to save") + return false + end +end + +---@param str string +---@return string +local sanitize_input = function(str) + str = str:match "^%s*(.-)%s*$" -- remove leading and trailing whitespace + str = str:match '^"?(.-)"?$' -- remove double quotes + str = str:match "^'?(.-)'?$" -- remove single quotes + str = str:gsub("file://", "") -- remove "file://" + str = str:gsub("%c", "") -- remove control characters + + return str +end + +---@param str string +---@return boolean +local is_image_url = function(str) + -- return early if not a valid url to a subdomain + if not str:match "^https?://[^/]+/[^.]+" then + return false + end + + -- assume its a valid image link if it the url ends with an extension + if str:match "%.png$" or str:match "%.jpg$" or str:match "%.jpeg$" then + return true + end + + -- send a head request to the url and check content type + local cmd = { "curl", "-s", "-I", "-w", "%%{content_type}", str } + local obj = vim.system(cmd):wait() + local output, exit_code = obj.stdout, obj.code + return exit_code == 0 and output ~= nil and (output:match "image/png" ~= nil or output:match "image/jpeg" ~= nil) +end + +---@param str string +---@return boolean +local is_image_path = function(str) + str = string.lower(str) + + local has_path_sep = str:find "/" ~= nil or str:find "\\" ~= nil + local has_image_ext = str:match "^.*%.(png)$" ~= nil + or str:match "^.*%.(jpg)$" ~= nil + or str:match "^.*%.(jpeg)$" ~= nil + + return has_path_sep and has_image_ext +end + +local function handle(input) + input = sanitize_input(input) + + if is_image_url(input) then + print "here" + return drop_remote_file(input) + elseif is_image_path(input) then + return drop_local_file(input) + end + + return false +end + +-- TODO: do more checks +return { + register = function(og_paste) + return function(lines, phase) + local line = lines[1] + + -- probably not a file path or url to an image if the input is this long + if string.len(line) > 512 then + return og_paste(lines, phase) + end + + local ok = handle(line) + + if not ok then + vim.notify "Did not handle paste, calling original vim.paste" + return og_paste(lines, phase) + end + end + end, +} diff --git a/lua/obsidian/init.lua b/lua/obsidian/init.lua index 7cc8dab8..b2747af4 100644 --- a/lua/obsidian/init.lua +++ b/lua/obsidian/init.lua @@ -95,6 +95,9 @@ obsidian.setup = function(opts) local client = obsidian.new(opts) log.set_level(client.opts.log_level) + -- register drag and drop handler + vim.paste = require("obsidian.attachments").register(vim.paste) + -- Install commands. -- These will be available across all buffers, not just note buffers in the vault. obsidian.commands.install(client) From 863a77fca93adf4f2844d9cbb05ba135c130700c Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Thu, 24 Apr 2025 00:11:42 +0800 Subject: [PATCH 2/3] feat: arbitrary attachments, both remote and local --- lua/obsidian/attachments.lua | 164 +++++++++++++++++++---------------- lua/obsidian/config.lua | 14 +++ 2 files changed, 102 insertions(+), 76 deletions(-) diff --git a/lua/obsidian/attachments.lua b/lua/obsidian/attachments.lua index 1ca869a8..1d5fc71a 100644 --- a/lua/obsidian/attachments.lua +++ b/lua/obsidian/attachments.lua @@ -1,6 +1,7 @@ -- accpeted file formats: https://help.obsidian.md/file-formats -local filetypes = { +---@enum obsidian.attachment.ft +local ft = { -- markdown "md", -- json canvas @@ -32,66 +33,11 @@ local filetypes = { "pdf", } -local m_filetype = {} - -for _, v in ipairs(filetypes) do - m_filetype[v] = true -end - -local function supports_extension(path) - local basename = vim.fs.basename(path) - local ext = basename:match "^.+%.(.+)$" - return m_filetype[ext], basename -end - local function insert_link(client, dst) local new_link = "!" .. client:format_link(dst) vim.api.nvim_put({ new_link }, "l", true, true) end -local function drop_local_file(line) - local from = vim.fs.abspath(line):gsub("\\", "") - local support, base = supports_extension(from) - - if support then - local Path = require "obsidian.path" - local client = require("obsidian").get_client() - local dst = (Path.new(client.dir) / client.opts.attachments.img_folder / base):resolve() - - local copy_ok, err = vim.uv.fs_copyfile(from, tostring(dst)) - if not copy_ok then - vim.notify(err or "failed to copy file", 3) - return false - end - insert_link(client, dst) - vim.notify("Copied file to " .. tostring(dst)) - return true - else - vim.notify("file extension not supported", 3) - return false - end -end - -local drop_remote_file = function(url) - local Path = require "obsidian.path" - local client = require("obsidian").get_client() - local base = client.opts.attachments.img_name_func() - - local dst = (Path.new(client.dir) / client.opts.attachments.img_folder / base):resolve() - dst = tostring(dst) - - local obj = vim.system({ "curl", url, "-o", dst }, {}):wait() - - if obj.code == 0 then - vim.notify("file " .. dst .. " saved") - insert_link(client, dst) - return true - else - vim.notify("file " .. dst .. " failed to save") - return false - end -end - ---@param str string ---@return string local sanitize_input = function(str) @@ -106,48 +52,114 @@ end ---@param str string ---@return boolean -local is_image_url = function(str) +---@return string? +local is_remote = function(str) -- return early if not a valid url to a subdomain if not str:match "^https?://[^/]+/[^.]+" then return false end -- assume its a valid image link if it the url ends with an extension - if str:match "%.png$" or str:match "%.jpg$" or str:match "%.jpeg$" then - return true + for _, ext in ipairs(ft) do + local pattern = "%." .. ext .. "$" + + local before_pat = "%." .. ext .. "%?" + if str:match(pattern) or str:match(before_pat) then + return true, ext + end end + return false + -- send a head request to the url and check content type - local cmd = { "curl", "-s", "-I", "-w", "%%{content_type}", str } - local obj = vim.system(cmd):wait() - local output, exit_code = obj.stdout, obj.code - return exit_code == 0 and output ~= nil and (output:match "image/png" ~= nil or output:match "image/jpeg" ~= nil) + -- local cmd = { "curl", "-s", "-I", "-w", "%%{content_type}", str } + -- local obj = vim.system(cmd):wait() + -- local output, exit_code = obj.stdout, obj.code + -- return exit_code == 0 and output ~= nil and (output:match "image/png" ~= nil or output:match "image/jpeg" ~= nil) end ---@param str string ---@return boolean -local is_image_path = function(str) +---@return string? +local is_local = function(str) str = string.lower(str) + --- TODO: correct path sep local has_path_sep = str:find "/" ~= nil or str:find "\\" ~= nil - local has_image_ext = str:match "^.*%.(png)$" ~= nil - or str:match "^.*%.(jpg)$" ~= nil - or str:match "^.*%.(jpeg)$" ~= nil - return has_path_sep and has_image_ext + if not has_path_sep then + return false + end + + -- assume its a valid link if it the url ends with an extension + for _, ext in ipairs(ft) do + local end_pat = "%." .. ext .. "$" + if str:match(end_pat) then + return true, ext + end + end +end + +---@param client obsidian.Client +---@param path string +---@param ext string? +---@return boolean +---@return string +local function drop_local(client, path, ext) + local from = vim.fs.abspath(path):gsub("\\", "") + + local dst = client.opts.attachments.file_path_func(client, path, ext, false) + + -- TODO: obsidian has option to hold Ctrl to just link instead of copying + local copy_ok, err = vim.uv.fs_copyfile(from, tostring(dst)) + if not copy_ok then + vim.notify(err or "failed to copy file", 3) + return false + end + vim.notify("Copied file to " .. tostring(dst)) + return true, dst +end + +local drop_remote = function(client, url, ext) + local dst = client.opts.attachments.file_path_func(client, url, ext, true) + + dst = tostring(dst) + + local obj = vim.system({ "curl", url, "-o", dst }, {}):wait() + + if obj.code == 0 then + vim.notify("file " .. dst .. " saved") + return true, dst + else + vim.notify("file " .. dst .. " failed to save") + return false + end end -local function handle(input) +---@param client obsidian.Client +---@param input string +---@return boolean +local function try_drop(client, input) input = sanitize_input(input) - if is_image_url(input) then - print "here" - return drop_remote_file(input) - elseif is_image_path(input) then - return drop_local_file(input) + local ok, link, ext, remote, loc + remote, ext = is_remote(input) + loc, ext = is_local(input) + + if remote then + ok, link = drop_remote(client, input, ext) + elseif loc then + ok, link = drop_local(client, input, ext) + else + return false end - return false + if ok then + insert_link(client, link) + return true + else + return false + end end -- TODO: do more checks @@ -161,7 +173,7 @@ return { return og_paste(lines, phase) end - local ok = handle(line) + local ok = try_drop(require("obsidian").get_client(), line) if not ok then vim.notify "Did not handle paste, calling original vim.paste" diff --git a/lua/obsidian/config.lua b/lua/obsidian/config.lua index 3037b700..77e5e7dc 100644 --- a/lua/obsidian/config.lua +++ b/lua/obsidian/config.lua @@ -490,6 +490,20 @@ config.AttachmentsOpts.default = function() img_name_func = function() return string.format("Pasted image %s", os.date "%Y%m%d%H%M%S") end, + + file_name_func = function(uri, ft, remote) + _ = ft -- user can leverage this to put file into sub folders + if remote then + return string.format("Attachment %s", os.date "%Y%m%d%H%M%S") + else + return vim.fs.basename(uri) + end + end, + + file_path_func = function(client, path, ft, remote) + local base = client.opts.attachments.file_name_func(path, ft, remote) + return client.dir / "assets" / base + end, confirm_img_paste = true, } end From 7d56a7bb3f35651fc794dd917045a1c35b370a4c Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Sat, 26 Apr 2025 15:39:00 +0800 Subject: [PATCH 3/3] test: basic tests for link identification --- lua/obsidian/attachments.lua | 20 ++++++++-------- lua/obsidian/config.lua | 10 +++++--- test/obsidian/attachment_spec.lua | 38 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 test/obsidian/attachment_spec.lua diff --git a/lua/obsidian/attachments.lua b/lua/obsidian/attachments.lua index 1d5fc71a..3cedcadc 100644 --- a/lua/obsidian/attachments.lua +++ b/lua/obsidian/attachments.lua @@ -54,8 +54,7 @@ end ---@return boolean ---@return string? local is_remote = function(str) - -- return early if not a valid url to a subdomain - if not str:match "^https?://[^/]+/[^.]+" then + if not str:match "^https?://[^/]+/[^.]+" then -- return early if not a valid url to a subdomain return false end @@ -70,18 +69,16 @@ local is_remote = function(str) end return false - - -- send a head request to the url and check content type - -- local cmd = { "curl", "-s", "-I", "-w", "%%{content_type}", str } - -- local obj = vim.system(cmd):wait() - -- local output, exit_code = obj.stdout, obj.code - -- return exit_code == 0 and output ~= nil and (output:match "image/png" ~= nil or output:match "image/jpeg" ~= nil) end ---@param str string ---@return boolean ---@return string? local is_local = function(str) + if str:match "^https?://[^/]+/[^.]+" then -- return early if looks like url + return false + end + str = string.lower(str) --- TODO: correct path sep @@ -98,18 +95,21 @@ local is_local = function(str) return true, ext end end + return false end ---@param client obsidian.Client ---@param path string ---@param ext string? ---@return boolean ----@return string +---@return string? local function drop_local(client, path, ext) local from = vim.fs.abspath(path):gsub("\\", "") local dst = client.opts.attachments.file_path_func(client, path, ext, false) + vim.print(dst) + -- TODO: obsidian has option to hold Ctrl to just link instead of copying local copy_ok, err = vim.uv.fs_copyfile(from, tostring(dst)) if not copy_ok then @@ -164,6 +164,8 @@ end -- TODO: do more checks return { + is_remote = is_remote, + is_local = is_local, register = function(og_paste) return function(lines, phase) local line = lines[1] diff --git a/lua/obsidian/config.lua b/lua/obsidian/config.lua index 77e5e7dc..8c91ce7d 100644 --- a/lua/obsidian/config.lua +++ b/lua/obsidian/config.lua @@ -477,6 +477,8 @@ end ---@field img_name_func (fun(): string)|? ---@field img_text_func fun(client: obsidian.Client, path: obsidian.Path): string ---@field confirm_img_paste boolean Whether to confirm the paste or not. Defaults to true. +---@field file_name_func (fun(client: obsidian.Client, ft: string, remote: boolean): string) +---@field file_path_func (fun(client: obsidian.Client, path: obsidian.Path | string, ft: string, remote: boolean): string) config.AttachmentsOpts = {} ---@return obsidian.config.AttachmentsOpts @@ -491,18 +493,20 @@ config.AttachmentsOpts.default = function() return string.format("Pasted image %s", os.date "%Y%m%d%H%M%S") end, + ---@diagnostic disable-next-line: unused-local file_name_func = function(uri, ft, remote) - _ = ft -- user can leverage this to put file into sub folders if remote then return string.format("Attachment %s", os.date "%Y%m%d%H%M%S") else return vim.fs.basename(uri) end end, - file_path_func = function(client, path, ft, remote) local base = client.opts.attachments.file_name_func(path, ft, remote) - return client.dir / "assets" / base + -- HACK: format link should be more smart + local ret = client.dir / "assets" / base + -- ret.label = base + return ret end, confirm_img_paste = true, } diff --git a/test/obsidian/attachment_spec.lua b/test/obsidian/attachment_spec.lua new file mode 100644 index 00000000..280f263b --- /dev/null +++ b/test/obsidian/attachment_spec.lua @@ -0,0 +1,38 @@ +local M = require "obsidian.attachments" + +local url_with_params = [[ +https://private-user-images.githubusercontent.com/111681693/437674259-e21d6c2d-c5b5-47b1-8ee8-dcc2e03fbc3a.jpg?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDU2NTEyNDQsIm5iZiI6MTc0NTY1MDk0NCwicGF0aCI6Ii8xMTE2ODE2OTMvNDM3Njc0MjU5LWUyMWQ2YzJkLWM1YjUtNDdiMS04ZWU4LWRjYzJlMDNmYmMzYS5qcGc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwNDI2JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDQyNlQwNzAyMjRaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1kNTc5ZmY5Y2U2ZDUwOWFiYWJhYzQ1OTUyZjcyOGVlMDgxODQ1ZDQ0MDdlNDIyNjI1YmVlNjM0NTMyZTZhZTBhJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.Xkw8rMM9G0vB-uxpf9djM6Bm-x2D6mJExorLcsuWkBA +]] + +local url_simple = [[https://gpanders.com/img/nvim-virtual-lines-3.png]] + +local path_simple = "/home/runner/Notes/assets/image.png" + +describe("is_remote", function() + it("should recongize a url ending with file extension", function() + local ok, ext = M.is_remote(url_simple) + assert.equal(true, ok) + assert.equal("png", ext) + end) + it("should recongize a long url not ending with file extension", function() + local ok, ext = M.is_remote(url_with_params) + assert.equal(true, ok) + assert.equal("jpg", ext) + end) + it("should return false on file path", function() + local ok = M.is_remote(path_simple) + assert.equal(false, ok) + end) +end) + +describe("is_local", function() + it("should recongize a file path", function() + local ok, ext = M.is_local(path_simple) + assert.equal(true, ok) + assert.equal("png", ext) + end) + it("should return false on urls", function() + local ok = M.is_local(url_simple) + assert.equal(false, ok) + end) +end)