diff --git a/doc/notmuch.txt b/doc/notmuch.txt index b6dc4e4..fdb7b98 100644 --- a/doc/notmuch.txt +++ b/doc/notmuch.txt @@ -292,7 +292,7 @@ option will be listed with its default value. - `xdg-open` on Linux - `start` on Windows - Example customization: > + Example customization: >lua require('notmuch').setup({ open_handler = function(attachment) @@ -301,6 +301,52 @@ option will be listed with its default value. }) < +*view_handlers* + table of handlers to try for converting attachments to text for display + in a floating terminal within Neovim. Each handler contains the following keys: + - `mime` (string, mandatory): a |lua-pattern| against which the file's mime type + (obtained through `file --mime-type --brief`) is matched. + - `desc` (string, optional) an optional description, which is used as a fallback + - `try` (table): contains the actual commands that will be runned; each element + must be a table containing: + - one of `callback` or `command`. + A callback is a lua function taking a path as input + and returning: + - `success` (boolean): wether the command succeeded + - `output` (string, necessary only if success is true): + - `error` (string): an optional error message to be displayed if `success` is false + and `verbose` is true + A command is a function taking a path as its argument and returning either a + string (in which case `vim.fn.system` is used), either a table of strings (in + which case `vim.system` is used) either a table of tables of strings (the + commands will be runned one after the other through `vim.system`, the output + of the previous becoming the input of the next; if one fails, subsequent + command execution is stopped). + - `tool` (optional): used in notifications + - `verbose` (boolean): wether to notify on failure + + Example customization: >lua + + require('notmuch').setup({ + view_handlers = { + { + mime = "^application/pdf$", + desc = "View with poppler and kitty image protocol", + try = { + { + mime = "^application/x-pie-executable$" + tool = "hexyl", + verbose = true, + command = function(p) + return { "hexyl", "--color=never", p } + end, + }, + }, + }, + }, + }) +< + *view_handler* Callback function for converting attachments to text for display in a floating window within Neovim. The function receives an attachment table @@ -312,7 +358,7 @@ option will be listed with its default value. HTML, pdftotext/mutool for PDF) and falls back gracefully if tools aren't available. - Example customization: > + Example customization: >lua require('notmuch').setup({ view_handler = function(attachment) @@ -344,7 +390,7 @@ option will be listed with its default value. Default value: `false` - Example: > + Example: >lua require('notmuch').setup({ suppress_deprecation_warning = true, @@ -367,7 +413,7 @@ option will be listed with its default value. Default value: `false` - Example: > + Example: >lua require('notmuch').setup({ render_html_body = true, @@ -493,7 +539,7 @@ interact with your email directly from the editor. `{index}/{total} {sender_name} [📎{attachment_count}]` Example: "2/5 John Doe 📎1" - Example statusline integration with lualine: > + Example statusline integration with lualine: >lua require('lualine').setup({ sections = { @@ -506,7 +552,7 @@ interact with your email directly from the editor. }, }) < - Example custom keymap using buffer variables: > + Example custom keymap using buffer variables: >lua vim.keymap.set('n', 'mt', function() local thread = vim.b.notmuch_thread diff --git a/lua/notmuch/config.lua b/lua/notmuch/config.lua index 6d779bd..fea42bd 100644 --- a/lua/notmuch/config.lua +++ b/lua/notmuch/config.lua @@ -60,6 +60,7 @@ C.defaults = function() open_handler = function(attachment) require('notmuch.handlers').default_open_handler(attachment) end, + view_handlers = {}, view_handler = function(attachment) return require('notmuch.handlers').default_view_handler(attachment) end, diff --git a/lua/notmuch/handlers.lua b/lua/notmuch/handlers.lua index b960c82..313b085 100644 --- a/lua/notmuch/handlers.lua +++ b/lua/notmuch/handlers.lua @@ -30,98 +30,102 @@ H.default_view_handler = function(attachment) local path = attachment.path -- Already expanded, careful to escape -- Helper function to "try" commands in order until one works - local function try_commands(commands) - for _, cmd in ipairs(commands) do - -- Check if the tool exists - if vim.fn.executable(cmd.tool) == 1 then - local output - local success - local command = cmd.command(path) - if type(command) == 'table' then - local obj = vim.system(command):wait() - output = obj.stdout - success = (obj.code == 0) - else - output = vim.fn.system(command) - success = (vim.v.shell_error == 0) - end - - if success then - return output - end - end - end - return nil + local try_commands = function(arg) + return require("notmuch.util").try_commands(arg, path) end -- Detect file type - local filetype = vim.fn.system({ 'file', '--mime-type', '-b', path }):gsub('%s+$', '') - local ext = path:match('%.([^%.]+)$') or '' - - -- HTML files (most common) - if filetype:match('^text/html$') or ext:match('^html?$') then - return try_commands({ - { tool = 'w3m', command = function(p) return { 'w3m', '-T', 'text/html', '-dump', p } end }, - { tool = 'lynx', command = function(p) return { 'lynx', '-dump', '-nolist', p } end }, - { tool = 'elinks', command = function(p) return { 'elinks', '-dump', '-no-references', p } end }, - }) or "HTML file (install w3m, lynx, or elinks to view)" - end - - -- PDF files - if filetype:match('^application/pdf$') or ext == 'pdf' then - return try_commands({ - { tool = 'pdftotext', command = function(p) return { 'pdftotext', '-layout', p, '-' } end }, - { tool = 'mutool', command = function(p) return { 'mutool', 'draw', '-F', 'txt', p } end }, - }) or "PDF file (install pdftotext or mutool to view)" - end - - -- Images - if filetype:match('^image/') then - return try_commands({ - { tool = 'chafa', command = function(p) return { 'chafa', '--size', '80x40', p } end }, - { tool = 'catimg', command = function(p) return { 'catimg', '-w', '80', p } end }, - { tool = 'viu', command = function(p) return { 'viu', '-w', '80', p } end }, - { tool = 'exiftool', command = function(p) return { 'exiftool', p } end }, - { tool = 'identify', command = function(p) return { 'identify', '-verbose', p } end }, - }) or "Image file (install chafa, viu, or exiftool to view)" - end - - -- Office documents (docx, xlsx, pptx) - if filetype:match('officedocument') or ext:match('^(docx?|xlsx?|pptx?)$') then - return try_commands({ - { tool = 'pandoc', command = function(p) return { 'pandoc', '-t', 'plain', p } end }, - { tool = 'docx2txt', command = function(p) return { 'docx2txt', p, '-' } end }, - }) or "Office document (install pandoc or docx2txt to view)" - end - - -- Markdown - if filetype:match('^text/markdown$') or ext:match('^md$') then - return try_commands({ - { tool = 'pandoc', command = function(p) return { 'pandoc', '-t', 'plain', p } end }, - { tool = 'mdcat', command = function(p) return { 'mdcat', p } end }, - }) or vim.fn.system({ 'cat', path }) - end - - -- Archives (zip, tar, tar.gz, etc.) - if filetype:match('zip') or ext == 'zip' then - return vim.fn.system({ 'unzip', '-l', path }) - end - if filetype:match('tar') or ext:match('^tar%.?') then - return vim.fn.system({ 'tar', '-tvf', path }) - end - - -- Plain text (fallback for text/*) - if filetype:match('^text/') then - return vim.fn.system({ 'cat', path }) + local filetype = vim.fn.system({ "file", "--mime-type", "-b", path }):gsub("%s+$", "") + + local default = { + { + mime = "^text/html$", + desc = "HTML file (install w3m, lynx, or elinks to view)", + try = { + { tool = "w3m", command = function(p) return { "w3m", "-T", "text/html", "-dump", p } end }, + { tool = "lynx", command = function(p) return { "lynx", "-dump", "-nolist", p } end }, + { tool = "elinks", command = function(p) return { "elinks", "-dump", "-no-references", p } end }, + }, + }, + { + mime = "^application/pdf$", + desc = "PDF file (install pdftotext or mutool to view)", + try = { + { tool = "pdftotext", command = function(p) return { "pdftotext", "-layout", p, "-" } end }, + { tool = "mutool", command = function(p) return { "mutool", "draw", "-F", "txt", p } end }, + }, + }, + { + mime = "^image/", + desc = "Image file (install chafa, viu, or exiftool to view)", + try = { + { tool = "chafa", command = function(p) return { "chafa", "--size", "80x40", p } end }, + { tool = "catimg", command = function(p) return { "catimg", "-w", "80", p } end }, + { tool = "viu", command = function(p) return { "viu", "-w", "80", p } end }, + { tool = "exiftool", command = function(p) return { "exiftool", p } end }, + { tool = "identify", command = function(p) return { "identify", "-verbose", p } end }, + }, + }, + { + mime = "officedocument", + desc = "Office document (install pandoc or docx2txt to view)", + try = { + { tool = "pandoc", command = function(p) return { "pandoc", "-t", "plain", p } end }, + { tool = "docx2txt", command = function(p) return { "docx2txt", p, "-" } end }, + }, + }, + { + mime = "^text/markdown$", + try = { + { tool = "pandoc", command = function(p) return { "pandoc", "-t", "plain", p } end }, + { tool = "mdcat", command = function(p) return { "mdcat", p } end }, + { tool = "cat", command = function(p) return { "cat", p } end }, + }, + }, + { + mime = "zip", + try = { + { tool = "zip", command = function(p) return { "unzip", "-l", p } end }, + }, + }, + { + mime = "tar", + try = { + { tool = "tar", command = function(p) return { "tar", "-tvf", p } end }, + }, + }, + { + mime = "^text/", + try = { + { tool = "cat", command = function(p) return { "cat", p } end }, + }, + }, + { + mime = "", + desc = string.format( + "Unable to view binary file\nType: %s\nPath: %s", + filetype, + path + ), + try = { + { tool = "strings", command = function(p) return { "strings", p } end }, + }, + }, + } + + -- ensure user_config overrides default + local user_config = require("notmuch.config").options.view_handlers + local handlers = vim.iter({ user_config, default }):flatten():totable() + + for _, tbl in ipairs(handlers) do + if filetype:match(tbl.mime) then + return try_commands(tbl.try) or tbl.desc or "" + end end - return try_commands({ - { tool = 'strings', command = function(p) return { 'strings', p } end }, - }) or string.format( - "Unable to view binary file\nType: %s\nPath: %s", - filetype, - path - ) + return "No handler found for this filetype" end return H + +-- vim: tabstop=2:shiftwidth=2:expandtab diff --git a/lua/notmuch/util.lua b/lua/notmuch/util.lua index e7d9ecf..500bd9d 100644 --- a/lua/notmuch/util.lua +++ b/lua/notmuch/util.lua @@ -252,6 +252,48 @@ u.find_cursor_msg_id = function() return nil end +function u.try_commands(commands, path) + for _, cmd in ipairs(commands) do + local output + local success + local error + + if cmd.callback then + success, output, error = cmd.callback(path) + elseif cmd.command then + local command = cmd.command(path) + if type(command) == "table" then + -- use vim.system + -- { ... }: convert to { { ... } } + if #command > 0 and type(command[1]) == "string" then + command = { command } + end + -- { { ... }, { ... } }: pipe the output of a command to the next + for _, cmd in ipairs(command) do + local obj = vim.system(cmd, { stdin = output }):wait() + output = obj.stdout + success = (obj.code == 0) + error = obj.stderr + if not success then break end + end + elseif type(command) == "string" then + -- use vim.fn.system + output = vim.fn.system(command) + success = (vim.v.shell_error == 0) + end + end + + if success then + return output + else + if error and cmd.verbose then + vim.notify("failed to execute " .. (cmd.tool or "") .. ": " .. error, vim.log.levels.ERROR) + end + end + end + return nil +end + return u -- vim: tabstop=2:shiftwidth=2:expandtab:foldmethod=indent