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..3cedcadc --- /dev/null +++ b/lua/obsidian/attachments.lua @@ -0,0 +1,186 @@ +-- accpeted file formats: https://help.obsidian.md/file-formats + +---@enum obsidian.attachment.ft +local ft = { + -- 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 function insert_link(client, dst) + local new_link = "!" .. client:format_link(dst) + vim.api.nvim_put({ new_link }, "l", true, true) +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 +---@return string? +local is_remote = function(str) + if not str:match "^https?://[^/]+/[^.]+" then -- return early if not a valid url to a subdomain + return false + end + + -- assume its a valid image link if it the url ends with an extension + 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 +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 + local has_path_sep = str:find "/" ~= nil or str:find "\\" ~= nil + + 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 + return false +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) + + 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 + 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 + +---@param client obsidian.Client +---@param input string +---@return boolean +local function try_drop(client, input) + input = sanitize_input(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 + + if ok then + insert_link(client, link) + return true + else + return false + end +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] + + -- 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 = try_drop(require("obsidian").get_client(), 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/config.lua b/lua/obsidian/config.lua index 3037b700..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 @@ -490,6 +492,22 @@ config.AttachmentsOpts.default = function() img_name_func = 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) + 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) + -- HACK: format link should be more smart + local ret = client.dir / "assets" / base + -- ret.label = base + return ret + end, confirm_img_paste = true, } 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) 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)