Inital commit.

This commit is contained in:
Senghan Bright 2021-01-14 01:37:37 +01:00
commit 8d6b6cc48d
5 changed files with 385 additions and 0 deletions

21
LICENSE Normal file
View 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
View 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
```

View 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,
},
}

View 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,
}

View 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