mirror of
https://github.com/kristoferssolo/telescope-frecency.nvim.git
synced 2025-10-21 20:10:38 +00:00
Inital commit.
This commit is contained in:
commit
8d6b6cc48d
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -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.
|
||||||
47
README.md
Normal file
47
README.md
Normal file
@ -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
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/sunjon/images/master/gh_readme_telescope_packer.png" height="600">
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
84
lua/telescope/_extensions/frecency.lua
Normal file
84
lua/telescope/_extensions/frecency.lua
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
91
lua/telescope/_extensions/frecency/db_client.lua
Normal file
91
lua/telescope/_extensions/frecency/db_client.lua
Normal file
@ -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('<amatch>'))")
|
||||||
|
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,
|
||||||
|
}
|
||||||
142
lua/telescope/_extensions/frecency/sql_wrapper.lua
Normal file
142
lua/telescope/_extensions/frecency/sql_wrapper.lua
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user