first commit
This commit is contained in:
18
LICENSE
Normal file
18
LICENSE
Normal 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
55
README.md
Normal 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
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
|
||||||
8
plugin/jasper.lua
Normal file
8
plugin/jasper.lua
Normal 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.
|
||||||
Reference in New Issue
Block a user