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