first commit
This commit is contained in:
160
lua/jasper/api.lua
Normal file
160
lua/jasper/api.lua
Normal 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
102
lua/jasper/auth.lua
Normal 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
53
lua/jasper/config.lua
Normal 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
66
lua/jasper/init.lua
Normal 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
218
lua/jasper/timer.lua
Normal 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
|
||||
Reference in New Issue
Block a user