commit fc8a13bd96a66bc15e9b079f219e32faab7078b2 Author: Paolo Donadeo Date: Fri Apr 17 17:32:58 2026 +0200 first commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bddfd06 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9b0aa1 --- /dev/null +++ b/README.md @@ -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**. diff --git a/lua/jasper/api.lua b/lua/jasper/api.lua new file mode 100644 index 0000000..e2872da --- /dev/null +++ b/lua/jasper/api.lua @@ -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|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 diff --git a/lua/jasper/auth.lua b/lua/jasper/auth.lua new file mode 100644 index 0000000..1250edc --- /dev/null +++ b/lua/jasper/auth.lua @@ -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 diff --git a/lua/jasper/config.lua b/lua/jasper/config.lua new file mode 100644 index 0000000..f4ccd08 --- /dev/null +++ b/lua/jasper/config.lua @@ -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 diff --git a/lua/jasper/init.lua b/lua/jasper/init.lua new file mode 100644 index 0000000..a63d6b1 --- /dev/null +++ b/lua/jasper/init.lua @@ -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 diff --git a/lua/jasper/timer.lua b/lua/jasper/timer.lua new file mode 100644 index 0000000..0ed94f1 --- /dev/null +++ b/lua/jasper/timer.lua @@ -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 diff --git a/plugin/jasper.lua b/plugin/jasper.lua new file mode 100644 index 0000000..ea53f2c --- /dev/null +++ b/plugin/jasper.lua @@ -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.