commit 8d6b6cc48dea70a5b9b3098294fced3b9d1844ec Author: Senghan Bright Date: Thu Jan 14 01:37:37 2021 +0100 Inital commit. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d55bfbb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 nvim-telescope + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..265012f --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# telescope-frecency.nvim + +An implementation of Mozillas [Frecency algorithm](https://developer.mozilla.org/en-US/docs/Mozilla/Tech/Places/Frecency_algorithm) for [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim). + +## Frecency: sorting by "frequency" and "recency." + +Frecency is a score given to each file loaded into a Neovim buffer. +The score is calculated by combining the timestamps recorded on each load and how recent the timestamps are: + +``` +score = frequency * recency_score / number_of_timestamps + +``` + + + + + +## Requirements + +- [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) (required) +- [sql.nvim](https://github.com/tami5/sql.nvim) (required) + +Timestamps and file records are stored in an [SQLite3](https://www.sqlite.org/index.html) database for persistence and speed. +This plugin uses `sql.nvim` to perform the database transactions. + + + +## Installation + +TODO: + +``` +abc +``` + +## Configuration + +Function for keymaps + +```lua +lua require("telescope").extensions.frecency.frecency(opts) +``` + +``` +:Telescope frecency +``` diff --git a/lua/telescope/_extensions/frecency.lua b/lua/telescope/_extensions/frecency.lua new file mode 100644 index 0000000..34e358e --- /dev/null +++ b/lua/telescope/_extensions/frecency.lua @@ -0,0 +1,84 @@ +local has_telescope, telescope = pcall(require, "telescope") + +-- TODO: make dependency errors occur in a better way +if not has_telescope then + error("This plugin requires telescope.nvim (https://github.com/nvim-telescope/telescope.nvim)") +end + +-- start the database client +print("start") +local db_client = require("telescope._extensions.frecency.db_client") +vim.defer_fn(db_client.init, 100) -- TODO: this is a crappy attempt to lessen loadtime impact, use VimEnter? + + +-- finder code + +-- local actions = require "telescope.actions" +local entry_display = require "telescope.pickers.entry_display" +local finders = require "telescope.finders" +local pickers = require "telescope.pickers" +local previewers = require "telescope.previewers" +local sorters = require "telescope.sorters" +local conf = require('telescope.config').values +local path = require('telescope.path') +local utils = require('telescope.utils') + +local frecency = function(opts) + opts = opts or {} + + local cwd = vim.fn.expand(opts.cwd or vim.fn.getcwd()) + local results = db_client.get_file_scores() + -- print(vim.inspect(results)) + + local displayer = entry_display.create { + separator = "", + hl_chars = { ["/"] = "TelescopePathSeparator"}, + items = { + { width = 8 }, + { remaining = true }, + }, + } + + -- TODO: look into why this gets called so much + local make_display = function(entry) + local filename = entry.name + + if opts.tail_path then + filename = utils.path_tail(filename) + elseif opts.shorten_path then + filename = utils.path_shorten(filename) + end + + filename = path.make_relative(filename, cwd) + + -- TODO: remove score from display; only there for debug + return displayer { + {entry.score, "Directory"}, filename + } + end + + pickers.new(opts, { + prompt_title = "Frecency files", + finder = finders.new_table { + results = results, + entry_maker = function(entry) + return { + value = entry.filename, + display = make_display, + ordinal = entry.filename, + name = entry.filename, + score = entry.score + } + end, + }, + previewer = conf.file_previewer(opts), + sorter = sorters.get_generic_fuzzy_sorter(), -- TODO: do we have to have our own sorter? we only want filtering + }):find() +end + + +return telescope.register_extension { + exports = { + frecency = frecency, + }, +} diff --git a/lua/telescope/_extensions/frecency/db_client.lua b/lua/telescope/_extensions/frecency/db_client.lua new file mode 100644 index 0000000..4a1147d --- /dev/null +++ b/lua/telescope/_extensions/frecency/db_client.lua @@ -0,0 +1,91 @@ +local sqlwrap = require("telescope._extensions.frecency.sql_wrapper") + +local MAX_TIMESTAMPS = 10 + +-- modifier used as a weight in the recency_score calculation: +local recency_modifier = { + [1] = { age = 240 , value = 100 }, -- past 4 hours + [2] = { age = 1440 , value = 80 }, -- past day + [3] = { age = 4320 , value = 60 }, -- past 3 days + [4] = { age = 10080 , value = 40 }, -- past week + [5] = { age = 43200 , value = 20 }, -- past month + [6] = { age = 129600, value = 10 } -- past 90 days +} + +local sql_wrapper = nil + +local function init() + if sql_wrapper then return end + + sql_wrapper = sqlwrap:new() + sql_wrapper:bootstrap() + print("setup") + + -- setup autocommands + vim.api.nvim_command("augroup TelescopeFrecency") + vim.api.nvim_command("autocmd!") + vim.api.nvim_command("autocmd BufEnter * lua require'telescope._extensions.frecency.db_client'.autocmd_handler(vim.fn.expand(''))") + vim.api.nvim_command("augroup END") +end + +-- TODO: move these to util.lua +local function string_isempty(s) + return s == nil or s == '' +end + +local function calculate_file_score(frequency, timestamps) + local recency_score = 0 + for _, ts in pairs(timestamps) do + for _, rank in ipairs(recency_modifier) do + if ts.age <= rank.age then + recency_score = recency_score + rank.value + goto continue + end + end + ::continue:: + end + + return frequency * recency_score / MAX_TIMESTAMPS +end + +local function filter_timestamps(timestamps, file_id) + local res = {} + for _, entry in pairs(timestamps) do + if entry.file_id == file_id then + table.insert(res, entry) + end + end + return res +end + +local function get_file_scores() + if not sql_wrapper then return end + + local files = sql_wrapper:do_transaction('get_all_filepaths') + local timestamp_ages = sql_wrapper:do_transaction('get_all_timestamp_ages') + + local scores = {} + for _, file_entry in ipairs(files) do + table.insert(scores, { + filename = file_entry.path, + score = calculate_file_score(file_entry.count, filter_timestamps(timestamp_ages, file_entry.id)) + }) + end + return scores +end + +local function autocmd_handler(filepath) + if not sql_wrapper or string_isempty(filepath) then return end + + -- check if file is registered as loaded + if not vim.b.frecency_registered then + vim.b.frecency_registered = 1 + sql_wrapper:update(filepath) + end +end + +return { + init = init, + get_file_scores = get_file_scores, + autocmd_handler = autocmd_handler, +} diff --git a/lua/telescope/_extensions/frecency/sql_wrapper.lua b/lua/telescope/_extensions/frecency/sql_wrapper.lua new file mode 100644 index 0000000..d008654 --- /dev/null +++ b/lua/telescope/_extensions/frecency/sql_wrapper.lua @@ -0,0 +1,142 @@ +local vim = vim +local uv = vim.loop + +local has_sql, sql = pcall(require, "sql") +if not has_sql then + error("This plugin requires sql.nvim (https://github.com/tami5/sql.nvim)") +end + +-- TODO: pass in max_timestamps from db.lua +local MAX_TIMESTAMPS = 10 + +-- TODO: prioritize files in project root! + +local schemas = {[[ + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + count INTEGER, + path TEXT + ); +]], +[[ + CREATE TABLE IF NOT EXISTS timestamps ( + id INTEGER PRIMARY KEY, + file_id INTEGER, + timestamp REAL, + FOREIGN KEY(file_id) REFERENCES files(id) + ); +]]} + +local queries = { + ["file_add_entry"] = "INSERT INTO files (path, count) values(:path, 1);", + ["file_update_counter"] = "UPDATE files SET count = count + 1 WHERE path = :path;", + ["timestamp_add_entry"] = "INSERT INTO timestamps (file_id, timestamp) values(:file_id, julianday('now'));", + ["timestamp_delete_before_id"] = "DELETE FROM timestamps WHERE id < :id and file_id == :file_id;", + ["get_all_filepaths"] = "SELECT * FROM files;", + ["get_all_timestamp_ages"] = "SELECT id, file_id, CAST((julianday('now') - julianday(timestamp)) * 24 * 60 AS INTEGER) AS age FROM timestamps;", + ["get_row"] = "SELECT * FROM files WHERE path == :path;", + ["get_timestamp_ids_for_file"] = "SELECT id FROM timestamps WHERE file_id == :file_id;", +} + +-- local ignore_patterns = { +-- } + +-- +local function fs_stat(path) -- TODO: move this to new file with M + local stat = uv.fs_stat(path) + local res = {} + res.exists = stat and true or false -- TODO: this is silly + res.isdirectory = (stat and stat.type == "directory") and true or false + + return res +end + +local M = {} +M.queries = queries + +function M:new() + local o = {} + setmetatable(o, self) + self.__index = self + self.db = nil + + return o +end + +function M:bootstrap(opts) + opts = opts or {} + + if self.db then + print("sql wrapper already initialised") + return + end + + self.max_entries = opts.max_entries or 2000 + + -- create the db if it doesn't exist + local db_root = opts.docs_root or "$XDG_DATA_HOME/nvim" + local db_filename = db_root .. "/file_frecency.sqlite3" + self.db = sql.open(db_filename) + if not self.db then + print("error") + return + end + + -- create tables if they don't exist + for _, s in pairs(schemas) do + self.db:eval(s) + end + self.db:close() + +end + +function M:do_transaction(query, params) + if not queries[query] then + print("invalid query_preset: " .. query ) + return + end + + local res + self.db:with_open(function(db) res = db:eval(queries[query], params) end) + return res +end + +function M:get_row_id(filepath) + local result = self:do_transaction('get_row', { path = filepath }) + + return type(result) == "table" and result[1].id or nil +end + +function M:update(filepath) + local filestat = fs_stat(filepath) + if (vim.tbl_isempty(filestat) or + filestat.exists == false or + filestat.isdirectory == true) then + return end + + -- create entry if it doesn't exist + local file_id + file_id = self:get_row_id(filepath) + if not file_id then + self:do_transaction('file_add_entry', { path = filepath }) + file_id = self:get_row_id(filepath) + else + -- ..or update existing entry + self:do_transaction('file_update_counter', { path = filepath }) + end + + -- register timestamp for this update + self:do_transaction('timestamp_add_entry', { file_id = file_id }) + + -- trim timestamps to MAX_TIMESTAMPS per file (there should be up to MAX_TS + 1 at this point) + local timestamps = self:do_transaction('get_timestamp_ids_for_file', { file_id = file_id }) + local trim_at = timestamps[(#timestamps - MAX_TIMESTAMPS) + 1] + if trim_at then + self:do_transaction('timestamp_delete_before_id', { id = trim_at.id, file_id = file_id }) + end +end + +function M:validate() +end + +return M