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

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 pdonadeo
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.

55
README.md Normal file
View File

@@ -0,0 +1,55 @@
# jasper.nvim
Neovim plugin for automatic time tracking via [Jasper](https://jasper.4sigma.it).
## Features
- Reads a per-project `.jasper_config.json` to know which Jasper activity to track.
- Automatically **starts** the timer when Neovim opens and **stops** it when Neovim closes.
- **Auto-pauses** after a configurable period of inactivity (no cursor movement, typing, etc.) and **resumes** on the next keystroke.
- Shares the auth token cache with `jasper_waybar.py` (`$XDG_CONFIG_HOME/jasper/token.json`).
- If no token is found, prompts for credentials inside Neovim.
## Requirements
- Neovim ≥ 0.10 (uses `vim.system` and `vim.uv`)
- `curl` available in `$PATH`
## Installation (lazy.nvim)
```lua
{
"yourusername/jasper.nvim",
config = function()
require("jasper").setup({
-- Global default inactivity timeout in minutes (can be overridden per project).
-- inactivity_timeout = 10,
})
end,
}
```
## Project configuration
Place a `.jasper_config.json` file at the root of the project (next to `.git`):
```json
{
"attivita_id": 12345,
"inactivity_timeout": 10
}
```
| Key | Required | Description |
|-----|----------|-------------|
| `attivita_id` | ✅ | Numeric ID of the Jasper activity (the `value` field returned by the API). |
| `inactivity_timeout` | ❌ | Minutes of inactivity before auto-pause. Defaults to `10`. |
## How it works
1. On `VimEnter`, the plugin looks for `.jasper_config.json` by walking up from the current working directory.
2. If found, it reads the cached auth token from `$XDG_CONFIG_HOME/jasper/token.json`. If the token is missing or expired, it prompts for credentials.
3. It fetches the list of timers from Jasper and finds the one associated with `attivita_id`. If none exists, it creates one.
4. The timer is **started**. A libuv countdown is armed; after `inactivity_timeout` minutes of silence the timer is **paused**.
5. Any cursor movement or keystroke resets the countdown (and resumes the timer if it was paused).
6. On `VimLeave` the timer is **stopped**.

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

8
plugin/jasper.lua Normal file
View File

@@ -0,0 +1,8 @@
-- Guard against loading twice
if vim.g.loaded_jasper then
return
end
vim.g.loaded_jasper = true
-- The plugin is activated by calling require("jasper").setup(opts) in the user config.
-- This file is intentionally minimal.