Title-based markdon picker in Neovim

Published on:2024/12/05
Banner for Title-based markdon picker in Neovim

Introduction

Switching Regular filenames for UUIDs leaves folder in a state that is hard to wrangle for everyday use. The point being to replace and learn from Emacs, not to rebuild all of its problems in Obsidian.

So let's see how can read this mess easily in my go-to editor, Neovim, using Lua.

The goal is to replace the tool I use as a file picker, Telescope to display the title values instead of the filenames. Format

Implementation

First create a new lua file that will be sourced by neovim, this depends a lot on your setup but make sure it does so only after loading the Telescope plugin.

Let's break it down in blocks and comment them.

-- Function to get a list of all markdown files
-- markdown_dir needs to be set first somewhere else
local function get_markdown_files()
    local files = {}
    -- Somewhat suprisingly there doesn't seem to be a fs module in neovim
    -- Relying on "find" here, not sure how this will fare on every platform.
    local handle = io.popen("find " .. vim.fn.expand(markdown_dir) .. " -name '*.md'")
    if handle then
        -- Iterate through each item returned by the shell sub-process
        for file in handle:lines() do
            table.insert(files, file)
        end
        handle:close()
    end
    return files
end
-- Extract the title from the frontmatter of the markdown file
local function get_title_from_frontmatter(file_path)
    local file = io.open(file_path, "r")
    if not file then return "" end

    -- Read the entire content of the file
    local content = file:read("*all")
    file:close()

    -- Use pattern matching to extract the title from the frontmatter
    -- It could be re;evan to use the yaml module but for this single
    -- it doesn't make the script any shorter or simpler
    local title = content:match("title:%s*(.-)%s*\n")
    return title or ""
end

-- Function to prepare each pair of items (title and path) for the picker
local function prepare_items_for_picker()
    local files = get_markdown_files()
    local items = {}

    for _, file in ipairs(files) do
        local title = get_title_from_frontmatter(file)
        table.insert(items, {title = title, path = file})
    end

    return items
end

-- Defines a previewer
local function define_file_previewer(self, entry, status)
    local lines = {}
    for line in io.lines(entry.path) do
        table.insert(lines, line)
    end

    vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, lines)
    -- This will allow us to keep treesitter's syntax highlighting in the previewer
    -- This even includes code blocks such as this one
    vim.api.nvim_buf_set_option(self.state.bufnr, 'filetype', 'markdown')

    vim.api.nvim_buf_call(self.state.bufnr, function()
        vim.cmd('syntax enable')
    end)
end

-- Function to create and configure the Telescope picker
local function create_telescope_picker(items)
    pickers.new({}, {
        prompt_title = "Markdown Files",
        finder = finders.new_table {
            results = items,
            entry_maker = function(entry)
                return {
                    value = entry,
                    display = entry.title ~= "" and entry.title or vim.fn.fnamemodify(entry.path, ":t"),
                    ordinal = entry.title .. " " .. entry.path,
                    path = entry.path,
                }
            end,
        },
        sorter = conf.generic_sorter({}),
        previewer = previewers.new_buffer_previewer({
            title = "File Preview",
            define_preview = define_file_previewer
        }),
        attach_mappings = function(prompt_bufnr, map)
            actions.select_default:replace(function()
                actions.close(prompt_bufnr)
                local selection = require("telescope.actions.state").get_selected_entry()
                vim.cmd("edit " .. selection.path)
            end)
            return true
        end,
    }):find()
end

-- Main function to orchestrate the markdown picker
local function markdown_picker()
    local items = prepare_items_for_picker()
    create_telescope_picker(items)
end
-- Adds a Neovim user command to launch the markdown picker
vim.api.nvim_create_user_command("MarkdownPicker", markdown_picker, {})

-- Map the Ctrl+O keybinding in normal mode to launch the markdown picker
-- This is similar to Obsidian, so the behavior is the same across editors
vim.keymap.set("n", "<C-o>", ":MarkdownPicker<CR>", { noremap = true, silent = true })

Most of the code here is meant to setup the Telescope picker instance, it takes very little effort to copy the behavior added to Obsidian in the previous article to Neovim in such a seemless manner.

Going further

Before I commit this as part of a plugin, beside gathering more requirements, we could also:

  • improve performance on larger vaults by caching the results asynchronously
    • starting with the old results and updating with the new ones as they become available
    • or filter files before listing them, like look for a certain field/value pair or a tag
  • Improve the cosmetic of it, add icons based on certain value in the frontmatter
  • or let a running instance of obsidian to the heavy lifting
    • But more on this here