first commit

This commit is contained in:
2026-04-17 17:32:58 +02:00
commit fc8a13bd96
8 changed files with 680 additions and 0 deletions

160
lua/jasper/api.lua Normal file
View File

@@ -0,0 +1,160 @@
--- jasper/api.lua
--- Low-level HTTP calls to the Jasper API, executed via curl.
local M = {}
local JASPER_URL = "https://jasper.4sigma.it"
--- Run a curl command and return the parsed JSON body + HTTP status code.
--- @param args string[] full curl argument list (without the URL, which is last)
--- @return table|nil decoded JSON body
--- @return number HTTP status code (0 on curl error)
--- @return string|nil error message
local function curl(args)
-- Append -w to get the HTTP status on a separate last line
local full_args = vim.list_extend(vim.list_slice(args, 1), { "-s", "-w", "\n%{http_code}" })
-- args already contains the URL; rebuild properly:
-- Actually we receive the full arg list already. Add -w trick.
table.insert(args, "-w")
table.insert(args, "\n%{http_code}")
local result = vim.system(args, { text = true }):wait()
if result.code ~= 0 then
return nil, 0, "curl error (exit " .. result.code .. ")"
end
-- Split body and status code
local last_newline = result.stdout:match(".*\n()")
local status_code = tonumber(result.stdout:sub(last_newline)) or 0
local body = result.stdout:sub(1, last_newline - 2) -- strip trailing \n + status line
local ok, data = pcall(vim.fn.json_decode, body)
if not ok then
return nil, status_code, "JSON decode error"
end
return data, status_code, nil
end
--- @param path string API path, e.g. "/api/v1/timer/"
--- @param token string
--- @return table|nil, number, string|nil
local function get(path, token)
return curl({
"curl",
"-H", "Authorization: Token " .. token,
JASPER_URL .. path,
})
end
--- @param path string
--- @param token string
--- @param form table<string,any>|nil key/value pairs sent as form data
--- @return table|nil, number, string|nil
local function post(path, token, form)
local args = {
"curl", "-X", "POST",
"-H", "Authorization: Token " .. token,
}
if form then
for k, v in pairs(form) do
table.insert(args, "--data-urlencode")
table.insert(args, k .. "=" .. tostring(v))
end
end
table.insert(args, JASPER_URL .. path)
return curl(args)
end
-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
--- Fetch all timers for the current user.
--- @param token string
--- @return table[]|nil list of timer objects
--- @return string|nil error message
function M.get_timers(token)
local data, status, err = get("/api/v1/timer/", token)
if err then
return nil, err
end
if status == 401 then
return nil, "unauthorized"
end
if status ~= 200 then
return nil, "unexpected HTTP status " .. status
end
local timers_dict = data.data and data.data.timers
if type(timers_dict) ~= "table" then
return nil, "unexpected response shape"
end
local timers = {}
for _, v in pairs(timers_dict) do
table.insert(timers, v)
end
return timers, nil
end
--- Start (play) a timer.
--- @param token string
--- @param timer_id number
--- @return boolean ok
--- @return string|nil error
function M.start_timer(token, timer_id)
local data, status, err = post("/api/v1/timer/" .. timer_id .. "/play/", token)
if err then
return false, err
end
if status ~= 200 then
return false, "HTTP " .. status
end
if data.error ~= nil and data.error ~= vim.NIL then
return false, tostring(data.error)
end
return true, nil
end
--- Pause a timer.
--- @param token string
--- @param timer_id number
--- @return boolean ok
--- @return string|nil error
function M.stop_timer(token, timer_id)
local data, status, err = post("/api/v1/timer/" .. timer_id .. "/pausa/", token)
if err then
return false, err
end
if status ~= 200 then
return false, "HTTP " .. status
end
if data.error ~= nil and data.error ~= vim.NIL then
return false, tostring(data.error)
end
return true, nil
end
--- Associate an attivita with a (new) timer slot.
--- @param token string
--- @param timer_id number desired slot id (smallest free integer)
--- @param attivita_id number
--- @return boolean ok
--- @return string|nil error
function M.create_timer(token, timer_id, attivita_id)
local data, status, err = post(
"/api/v1/timer/" .. timer_id .. "/attivita/",
token,
{ attivita_id = attivita_id }
)
if err then
return false, err
end
if status ~= 200 then
return false, "HTTP " .. status
end
if data.error ~= nil and data.error ~= vim.NIL then
return false, tostring(data.error)
end
return true, nil
end
return M

102
lua/jasper/auth.lua Normal file
View File

@@ -0,0 +1,102 @@
--- jasper/auth.lua
--- Token management, shared with the jasper_waybar.py Python script.
--- Token cache: $XDG_CONFIG_HOME/jasper/token.json (same path as Python)
local M = {}
local JASPER_URL = "https://jasper.4sigma.it"
--- @return string path to the token cache file
local function token_file_path()
local xdg = os.getenv("XDG_CONFIG_HOME") or (os.getenv("HOME") .. "/.config")
return xdg .. "/jasper/token.json"
end
--- Read the cached auth token from disk.
--- @return string|nil
function M.get_token()
local path = token_file_path()
if vim.fn.filereadable(path) ~= 1 then
return nil
end
local lines = vim.fn.readfile(path)
if not lines or #lines == 0 then
return nil
end
local ok, data = pcall(vim.fn.json_decode, table.concat(lines, "\n"))
if ok and type(data) == "table" and data.auth_token then
return data.auth_token
end
return nil
end
--- Delete the cached token (e.g. on expiry).
function M.delete_token()
local path = token_file_path()
if vim.fn.filereadable(path) == 1 then
vim.fn.delete(path)
end
end
--- Perform a login request and cache the resulting token.
--- @param username string
--- @param password string
--- @return string|nil token
--- @return string|nil error message
function M.login(username, password)
local result = vim.system({
"curl", "-s",
"--data-urlencode", "username=" .. username,
"--data-urlencode", "password=" .. password,
JASPER_URL .. "/api/v1/token/login/",
}, { text = true }):wait()
if result.code ~= 0 then
return nil, "curl error (exit " .. result.code .. ")"
end
local ok, data = pcall(vim.fn.json_decode, result.stdout)
if not ok or type(data) ~= "table" or not data.auth_token then
return nil, "login failed (bad credentials or unexpected response)"
end
-- Save to cache (same format as Python script)
local path = token_file_path()
local dir = vim.fn.fnamemodify(path, ":h")
vim.fn.mkdir(dir, "p")
vim.fn.writefile({ vim.fn.json_encode(data) }, path)
return data.auth_token, nil
end
--- Interactively ask the user for credentials using vim.ui.input,
--- then call login(). Calls `callback(token)` on success, `callback(nil)` on failure.
--- @param callback fun(token: string|nil)
function M.prompt_login(callback)
vim.ui.input({ prompt = "Jasper username: " }, function(username)
if not username or username == "" then
vim.notify("[Jasper] Login cancelled", vim.log.levels.WARN)
callback(nil)
return
end
vim.ui.input({ prompt = "Jasper password: ", secret = true }, function(password)
if not password or password == "" then
vim.notify("[Jasper] Login cancelled", vim.log.levels.WARN)
callback(nil)
return
end
local token, err = M.login(username, password)
if err or not token then
vim.notify("[Jasper] " .. (err or "Login failed"), vim.log.levels.ERROR)
callback(nil)
return
end
vim.notify("[Jasper] Login successful", vim.log.levels.INFO)
callback(token)
end)
end)
end
return M

53
lua/jasper/config.lua Normal file
View File

@@ -0,0 +1,53 @@
--- jasper/config.lua
--- Reads the .jasper_config.json file from the project root.
---
--- Expected format:
--- {
--- "attivita_id": 12345,
--- "inactivity_timeout": 10 -- optional, minutes
--- }
local M = {}
--- Walk up from `cwd` looking for .jasper_config.json.
--- @return string|nil absolute path to the config file, or nil
local function find_config_file()
local path = vim.fn.getcwd()
while true do
local candidate = path .. "/.jasper_config.json"
if vim.fn.filereadable(candidate) == 1 then
return candidate
end
local parent = vim.fn.fnamemodify(path, ":h")
if parent == path then
break -- reached filesystem root
end
path = parent
end
return nil
end
--- Load and decode .jasper_config.json.
--- @return table|nil decoded config, or nil if not found / invalid
function M.load()
local config_file = find_config_file()
if not config_file then
return nil
end
local lines = vim.fn.readfile(config_file)
if not lines or #lines == 0 then
vim.notify("[Jasper] .jasper_config.json is empty", vim.log.levels.WARN)
return nil
end
local ok, data = pcall(vim.fn.json_decode, table.concat(lines, "\n"))
if not ok or type(data) ~= "table" then
vim.notify("[Jasper] .jasper_config.json is not valid JSON", vim.log.levels.WARN)
return nil
end
return data
end
return M

66
lua/jasper/init.lua Normal file
View File

@@ -0,0 +1,66 @@
--- jasper/init.lua
--- Public setup() entry point for the jasper.nvim plugin.
local M = {}
local config = require("jasper.config")
local timer = require("jasper.timer")
--- Setup the plugin.
---
--- Call this from your Neovim config, e.g.:
---
--- require("jasper").setup()
---
--- or with options:
---
--- require("jasper").setup({
--- -- Default inactivity timeout in minutes (overrides .jasper_config.json value)
--- inactivity_timeout = 10,
--- })
---
--- @param opts table|nil global plugin options
function M.setup(opts)
opts = opts or {}
local group = vim.api.nvim_create_augroup("Jasper", { clear = true })
vim.api.nvim_create_autocmd("VimEnter", {
group = group,
callback = function()
-- Read the project config
local project_cfg = config.load()
if not project_cfg then
-- No .jasper_config.json found: silently do nothing
return
end
if not project_cfg.attivita_id then
vim.notify(
"[Jasper] .jasper_config.json found but 'attivita_id' is missing",
vim.log.levels.WARN
)
return
end
-- Merge: project file values < global setup() opts (global opts take priority)
local effective_opts = {
attivita_id = project_cfg.attivita_id,
inactivity_timeout = opts.inactivity_timeout
or project_cfg.inactivity_timeout
or 10,
}
timer.setup(effective_opts)
end,
})
vim.api.nvim_create_autocmd("VimLeave", {
group = group,
callback = function()
timer.teardown()
end,
})
end
return M

218
lua/jasper/timer.lua Normal file
View File

@@ -0,0 +1,218 @@
--- jasper/timer.lua
--- Timer state machine + inactivity auto-pause.
local M = {}
local api = require("jasper.api")
local auth = require("jasper.auth")
--- Internal state (one instance per Neovim session).
local state = {
token = nil, ---@type string|nil
timer_id = nil, ---@type number|nil
attivita_id = nil, ---@type number|nil
running = false,
-- inactivity_ms: milliseconds of silence before the timer is auto-paused
inactivity_ms = 10 * 60 * 1000,
uv_timer = nil, ---@type uv_timer_t|nil
augroup = nil, ---@type number|nil
}
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function notify(msg, level)
vim.notify("[Jasper] " .. msg, level or vim.log.levels.INFO)
end
--- Stop the uv inactivity countdown (without pausing the Jasper timer).
local function cancel_inactivity_watchdog()
if state.uv_timer then
state.uv_timer:stop()
end
end
--- (Re)start the inactivity countdown.
--- When it fires the Jasper timer is paused.
local function arm_inactivity_watchdog()
if not state.uv_timer or not state.running then
return
end
state.uv_timer:stop()
state.uv_timer:start(
state.inactivity_ms,
0,
vim.schedule_wrap(function()
if not state.running then
return
end
local ok, err = api.stop_timer(state.token, state.timer_id)
if ok then
state.running = false
notify("Timer paused (inactivity)")
else
notify("Could not pause timer: " .. (err or ""), vim.log.levels.WARN)
end
end)
)
end
--- Find the smallest non-negative integer not already used as a timer_id.
--- @param timers table[]
--- @return number
local function next_free_timer_id(timers)
local used = {}
for _, t in ipairs(timers) do
used[t.timer_id] = true
end
local id = 0
while used[id] do
id = id + 1
end
return id
end
--- Find an existing timer for `attivita_id` or create a new one.
--- @param token string
--- @param attivita_id number
--- @param timers table[]
--- @return number|nil timer_id
--- @return string|nil error
local function find_or_create_timer(token, attivita_id, timers)
for _, t in ipairs(timers) do
if t["attivita__id"] == attivita_id then
return t.timer_id, nil
end
end
-- No existing timer: create one in the smallest free slot
local new_id = next_free_timer_id(timers)
local ok, err = api.create_timer(token, new_id, attivita_id)
if not ok then
return nil, "Cannot create timer: " .. (err or "unknown error")
end
notify("New timer created (ID " .. new_id .. ")")
return new_id, nil
end
-- ---------------------------------------------------------------------------
-- Activity callback (called from autocmds)
-- ---------------------------------------------------------------------------
function M.on_activity()
if not state.timer_id then
return
end
if not state.running then
-- Resume after an inactivity pause
local ok, err = api.start_timer(state.token, state.timer_id)
if ok then
state.running = true
notify("Timer resumed")
else
notify("Could not resume timer: " .. (err or ""), vim.log.levels.WARN)
return
end
end
arm_inactivity_watchdog()
end
-- ---------------------------------------------------------------------------
-- Lifecycle
-- ---------------------------------------------------------------------------
--- Start tracking: called after token + timer_id are known.
--- @param token string
--- @param timer_id number
local function begin_tracking(token, timer_id)
state.token = token
state.timer_id = timer_id
-- Create the libuv timer used for inactivity detection
state.uv_timer = vim.uv.new_timer()
-- Register activity autocmds
state.augroup = vim.api.nvim_create_augroup("JasperActivity", { clear = true })
vim.api.nvim_create_autocmd(
{ "CursorMoved", "CursorMovedI", "InsertCharPre", "BufEnter", "FocusGained" },
{ group = state.augroup, callback = M.on_activity }
)
-- Start the timer immediately
local ok, err = api.start_timer(token, timer_id)
if not ok then
notify("Could not start timer: " .. (err or ""), vim.log.levels.ERROR)
return
end
state.running = true
notify("Timer started (ID " .. timer_id .. ")")
arm_inactivity_watchdog()
end
--- Called after a valid token is obtained (either from cache or fresh login).
--- Fetches timers, creates one if needed, then begins tracking.
--- @param token string
local function on_token_ready(token)
local timers, err = api.get_timers(token)
if err == "unauthorized" then
-- Token is expired: delete it and ask the user to login again
auth.delete_token()
notify("Session expired. Please re-open Neovim to login again.", vim.log.levels.WARN)
return
end
if err then
notify("Cannot fetch timers: " .. err, vim.log.levels.ERROR)
return
end
local timer_id, terr = find_or_create_timer(token, state.attivita_id, timers)
if not timer_id then
notify(terr or "Cannot get timer", vim.log.levels.ERROR)
return
end
begin_tracking(token, timer_id)
end
--- Entry point called on VimEnter.
--- @param opts table { attivita_id, inactivity_timeout }
function M.setup(opts)
state.attivita_id = opts.attivita_id
state.inactivity_ms = (opts.inactivity_timeout or 10) * 60 * 1000
local token = auth.get_token()
if token then
on_token_ready(token)
else
auth.prompt_login(function(new_token)
if new_token then
on_token_ready(new_token)
end
end)
end
end
--- Stop tracking: called on VimLeave.
function M.teardown()
cancel_inactivity_watchdog()
if state.uv_timer then
state.uv_timer:close()
state.uv_timer = nil
end
if state.running and state.timer_id then
local ok, err = api.stop_timer(state.token, state.timer_id)
if ok then
state.running = false
else
-- Best-effort; we're exiting anyway
vim.notify("[Jasper] Could not stop timer on exit: " .. (err or ""), vim.log.levels.WARN)
end
end
end
return M