Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
186 changes: 186 additions & 0 deletions lua/obsidian/attachments.lua
Original file line number Diff line number Diff line change
@@ -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,
}
18 changes: 18 additions & 0 deletions lua/obsidian/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions lua/obsidian/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions test/obsidian/attachment_spec.lua
Original file line number Diff line number Diff line change
@@ -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)