- JavaScript 54%
- Lua 29%
- Python 10.8%
- Vim Script 3.3%
- Nix 1.5%
- Other 1.4%
|
|
||
|---|---|---|
| ci | ||
| nvim | ||
| .ci-tron.json | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| .nvim.lua | ||
| build.js | ||
| compile.py | ||
| eurecia_extract_options_bookmarklet.src.js | ||
| eurecia_extract_week_bookmarklet.src.js | ||
| eurecia_fill_bookmarklet.src.js | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| timesheet.template.json | ||
| week.example.tsheet | ||
Eurécia timesheet bookmarklets
Three bookmarklets for the Eurécia "Suivi Activités" weekly timesheet without touching DevTools each week:
eurecia_extract_options_bookmarklet— crawls the cascading Domain → Project → Customer → CIR dropdowns on the currently open week and dumps every valid{value, text}combination as JSON. Feedscompile.py(see Writing timesheets as plain text below), which turns a short hand-written.tsheetfile into precise timesheet data — hence exact{value, text}pairs, not just display text.eurecia_fill_bookmarklet— takes timesheet JSON (from the clipboard) and fills the currently open week's form. Strictly validates the JSON first and selects Domain/Project/Customer/CIR by exact{value, text}pair (as produced by the extractor above), verifying the resulting value matches before reporting success. It never clicks "Enregistrer" / "Soumettre à validation" — you review the on-page result panel and save yourself.eurecia_extract_week_bookmarklet— the reverse direction: reads the entries already logged on the currently open week and renders them as a.tsheetweek block, ready to paste onto the end of a.tsheetfile (e.g.real.tsheet). Works on both submitted ("Validée") and unsubmitted ("Nouvelle") weeks.
All three scripts only touch the DOM of the Eurécia timesheet page; nothing is sent anywhere over the network by these scripts themselves.
Note: this project was made with heavy LLM assistance (most of the code and docs were written with an AI coding assistant). Review the scripts before trusting them with your timesheet, and always check the filled form yourself before saving.
Setup
The *.url.txt bookmarklet files are generated (and gitignored): run
node build.js (or nix run .#build) first.
Chrome strips the javascript: scheme when you paste a full bookmarklet URL
into the address bar, so bookmarks must be created via the bookmark editor,
not the omnibox:
- Bookmarks bar → right-click → "Add page" (or Bookmark Manager → "Add new bookmark").
- Name it (e.g. "Eurécia: extract options" / "Eurécia: fill timesheet" / "Eurécia: extract week").
- Paste the entire contents of the matching
*.url.txtfile into the URL field, save.
Workflow
open the Eurécia week
│
▼
click "extract options" bookmarklet ──► valid-value tree JSON (clipboard)
│
▼
(your app, TBD) turns the tree + your hours into timesheet.json
│
▼
copy timesheet.json to the clipboard (e.g. `xclip -selection clipboard < timesheet.json`)
│
▼
click "fill timesheet" bookmarklet ──► form filled, on-page panel shows per-field results
│
▼
review the form yourself, click Enregistrer
If clipboard read/write is denied by the browser, all three bookmarklets
fall back gracefully (fill prompts you to paste; the two extractors still
show their output in an on-page textarea you can copy manually).
To pull an already-logged week the other direction — page to .tsheet
text — open that week and click "extract week" instead:
open an already-logged Eurécia week
│
▼
click "extract week" bookmarklet ──► .tsheet week block (clipboard)
│
▼
paste onto the end of real.tsheet (or wherever)
How Domain/Project/Customer/CIR selection actually works
These four fields are select2 widgets, and getting them right took several rounds of live debugging against the real page. Three things are true of them that aren't obvious from the DOM:
-
No level's full choice list is reliably written into the native
<select>. Only whichever value is currently selected (plus maybe one stray entry) ever ends up there. First confirmed for Domain (select.optionsshowed 1, the rendered dropdown showed 4) — initially assumed not to apply to Project/Customer/CIR, because every domain tested at the time happened to have exactly one project, which trivially looks "fully populated" either way. It doesn't generalize: a domain with real data had 40 real projects, and the native<select>only ever showed 1 of them. So every level is read by opening its select2 popup and reading the rendered results — never the native<select>'s options. -
Setting a
<select>'s value programmatically doesn't reliably fire the next cascade level. NeitherjQuery(el).val(x).trigger('change')nor injecting a fresh<option>node and triggeringchangereliably kicks off the AJAX call that loads the next level's options — select2 tracks its own "did this really change" state separately from the raw DOM value, and a programmatic set can silently fail to register as a real selection. The only mechanism confirmed to work at every level is a genuine click: open the dropdown, find the rendered result whose text matches, and dispatch amouseupon it (select2 listens for mouseup on its results). Both bookmarklets do this everywhere — there's no.val()-based path left. -
A value read before selecting isn't always the value you get after selecting. Confirmed live: a Project entry's pre-selection listing had its own display text as the
valueattribute, but actually clicking it resolved to a completely different opaque id — consistently, on repeat selections. So the extractor never trusts a listing'svalue; it always captures the value that comes back from actually clicking each item (including CIR, a leaf with nothing to cascade into, which still gets clicked once purely to capture its true value). -
A level can render as a tree, not a flat list. Confirmed live on Project, on an account with a real hierarchical catalog: a category header like
"E4XX"has an expand-icon child (i.icon-section-closed/.icon-section-open) and isn't itself selectable — clicking its row pops up a message instead of selecting (this is the exact symptom that first surfaced the bug: "only the first entry gets extracted" under a domain whose project list was actually a tree). Its children (e.g."E4XX/Base") are present in the flat DOM list from the start but carryaria-disabled="true"until the category's icon is clicked to expand it, which clears that attribute and makes them genuinely selectable. Both scripts handle this automatically: the extractor skips category headers entirely (they aren't real values, just group labels) and auto-expands a child's category before clicking it; the filler does the same when the targettextturns out to be a disabled child. Only one level of nesting has been observed — no child entry itself has an expand icon — so deeper trees aren't handled.
Net effect: value is often an opaque internal id (confirmed for Domain,
Project, Customer, CIR at various points), not the display text — so text
has to travel alongside value everywhere, both in the extractor's output
and the filler's input, because text is what's used to find the right
dropdown entry to click, and value is what's verified afterward.
eurecia_extract_options_bookmarklet
window.__eureciaExtractBookmarkletMain(opts?)
Runs on row 0 of the open week as scratch space — saves its current values, crawls the cascade, then restores them, so it doesn't permanently change your timesheet (avoid running it mid-edit on an unsaved row, though). Restoring a row that started genuinely blank clears fields directly rather than clicking the dropdown's blank entry — that entry resolves to an internal sentinel value distinct from true "nothing selected", confirmed live.
Output shape:
[
{
"domain": { "value": "TENANT---...-cb53", "text": "PreSales" },
"projects": [
{
"project": { "value": "TENANT---...-cb57", "text": "Presales Support" },
"customers": [
{
"customer": { "value": "TENANT---...-bb0", "text": "All" },
"cirs": [{ "value": "TENANT---...-c31", "text": "AUTRE" }]
}
]
}
]
}
]
Cost warning: roughly one AJAX round trip per cascade step, plus one per
item just to open its dropdown and read the full rendered list (point 1
above), plus one more per leaf item to capture its true value (point 3). A
single domain can have dozens of projects (confirmed: 40 under one domain) —
with customers and CIRs nested under each, a full maxDepth: 4 crawl of an
account with real data can mean many hundreds or thousands of round trips and
take a long time. maxDepth defaults to 4 (full depth) anyway, since
Customer/CIR are needed for real .tsheet entries and a shallow crawl just
defers that cost to a second run — pass a shallower maxDepth explicitly if
you only need part of the tree:
window.__eureciaExtractBookmarkletMain({ maxDepth: 1 }) // domains only
window.__eureciaExtractBookmarkletMain({ maxDepth: 2 }) // + projects
window.__eureciaExtractBookmarkletMain({ maxDepth: 3 }) // + customers
window.__eureciaExtractBookmarkletMain() // + CIR (default, full depth)
eurecia_fill_bookmarklet
Input JSON: an array of day entries. The array may span several weeks (e.g.
a multi-week .tsheet compiled in one shot — see
Writing timesheets as plain text
below); the bookmarklet auto-detects which week is currently open by reading
the dates actually rendered on the page and only fills entries whose date
matches one of them. Entries for other weeks are reported as "skipped" in the
result panel, not as failures — so the same clipboard JSON can be reused
unchanged every week without trimming it down first. If none of the entries
match the open week at all, the panel says so instead of filling anything.
[
{
"date": "31/08/2026",
"days": 1,
"domain": { "value": "TENANT---...-cb53", "text": "PreSales" },
"project": { "value": "TENANT---...-cb57", "text": "Presales Support" },
"customer": { "value": "TENANT---...-bb0", "text": "All" },
"cir": { "value": "TENANT---...-c31", "text": "AUTRE" },
"comment": "Client call"
},
{ "date": "01/09/2026", "days": 0.5 }
]
date:dd/mm/yyyy, must match the page's "Date" column exactly.days: number in[0, 7], split into integer + nearest eighth-of-a-day fraction the page supports.domain/project/customer/cir: optional{value, text}objects (both required together), taken verbatim from the extractor's output — don't hand-editvalue(see point 3 above for why). The field is selected by clicking the dropdown result matchingtext, then the resulting value is checked againstvalue; a mismatch (stale/wrong pair, or the option no longer exists) is reported as a per-field error, nothing is guessed at.comment: optional free text.
Validation runs before any DOM change and reports every schema-level problem
at once (bad/duplicate dates, out-of-range days, wrong field shapes). If
validation fails, nothing is filled. Per-field selection failures (wrong
value, option not found) are only knowable at fill time and are reported per
field in the result panel instead.
Also exposed for manual use in DevTools: window.fillTimesheet(entries, opts?).
Template
timesheet.template.json is a ready-to-edit starting point — a Mon–Fri week
using a real {value, text} pair from this account (PostSales /
Support-IP, pulled from options.json, a saved extractor run). To use it:
- Replace each
<DATE_LUNDI>…<DATE_VENDREDI>placeholder with the realdd/mm/yyyydate shown in that row's "Date" column for the week you have open (JSON has no comments, so these are deliberately invalid dates — left as-is, validation will point at exactly which one you forgot). - Swap
domain/project(and addcustomer/cirif needed) for whichever entries apply to each day — copy the matching{value, text}pairs from a fresh extractor run (oroptions.jsonif it's still current) rather than typing them by hand. - Copy the file's contents to the clipboard and run the fill bookmarklet.
eurecia_extract_week_bookmarklet
window.__eureciaExtractWeekBookmarkletMain()
Reads the entries already logged on the currently open week and renders
them as a .tsheet week block (see
Writing timesheets as plain text
below) — copied to the clipboard and shown in an on-page textarea, ready to
paste onto the end of a .tsheet file. This is the reverse direction of
eurecia_fill_bookmarklet: page → text, not text → page.
Works on both week statuses, which render very differently:
- "Nouvelle" (unsubmitted) weeks use the same live edit-form ids
eurecia_fill_bookmarkletfills (daysWorked_int_<i>/daysWorked_fraction_<i>,imputationStructureId{1,2,3,4}_<i>,comment_<i>,dateActivite_<i>). - "Validée" (submitted) weeks render Domain/Project/Customer/CIR as
plain read-only text with no stable id to key off — read positionally
from each row's table cells instead (confirmed stable against known data:
columns 4/5/6/7 of the row containing that row's
daysWorked_<i>input). Days and Comment remain live-editable inputs even on a submitted week and do have ids (daysWorked_<i>,comment_<i>).
Rows are never emitted as .tsheet path entries unless they have days >
0 and a real top-level domain (PreSales, R&D, PostSales, Other).
Everything else falls into one of two buckets:
- Silently dropped — a row with
daysof exactly 0 and no comment, confirmed live to show up as leftover mid-edit/scratch state (a fully populated domain/project/customer/cir but no real time logged), not something you'd want appearing as a phantom entry; same for a genuinely unfilled future day. - Noted, not dropped — a row outside the real-domain whitelist that does
have a comment. Eurécia lists placeholder rows like "Jour Férié" /
"Jours Non Travaillés" / "Congés payés" alongside real entries; these
aren't project time, but losing them entirely would also lose the
reminder that part of that day is already accounted for elsewhere. These
come through as a
# congé: <date> — <comment>line in the output (a#-prefixed line is already a comment as far ascompile.pyis concerned, so it never resolves as a path entry) and as a separate count in the result panel.
Customer/CIR are folded into the comment as [Customer/Cir] rather than
emitted as a full 4-level path — this bookmarklet reads Customer/CIR
straight off the live page, not from options.json, so it has no way to
know whether your saved options.json was crawled deep enough (maxDepth: 3 or 4) for those names to actually resolve as path segments when the
.tsheet file is compiled; folding them into the comment instead never
risks a compile error.
Also exposed for manual use in DevTools:
window.extractTimesheetWeek().
Writing timesheets as plain text (.tsheet)
Hand-editing timesheet.template.json works but is tedious (verbose JSON,
opaque {value, text} pairs, manual date arithmetic). compile.py is a
small, dependency-free Python script that compiles a short plain-text format
into that same JSON, resolving human-readable names against a saved
options.json (an extractor run) instead of you having to copy {value, text} pairs by hand.
Format
A week YYYY-MM-DD line — the Monday of that week — starts a new week
block; every entry's date is computed from the block's Monday plus a day
keyword, so you never type dd/mm/yyyy by hand. A file can contain a single
week line (one week), or several, one after another — each entry belongs
to whichever week line most recently preceded it, so a running multi-week
.tsheet compiles down to one JSON array spanning all of them (see
eurecia_fill_bookmarklet above for how filling picks out just the current
week from that array). Duplicating the same Monday twice in one file, or
putting an entry before any week line, is a compile error. Each entry line
is:
<day> [<days>] [<domain>[/<project>[/<customer>[/<cir>]]]] [<comment>]
<day>:mon/tue/wed/thu/fri/sat/sun, case-insensitive. French is also accepted, either abbreviated (lun/mar/mer/jeu/ven/sam/dim) or spelled out (lundi/mardi/mercredi/jeudi/vendredi/samedi/dimanche) — useful since that's what the Eurécia page itself displays.<days>: a whole number of eighths of a day,0–56(8= 1 full day,4= half a day,56= 7 days) — matching the granularity the page itself supports (the fill bookmarklet splitsdaysinto integer + nearest eighth-of-a-day fraction). Optional — a day can bemonalone, ormon 8with no path, matching the fill bookmarklet's own schema wheredaysand every cascade field are independently optional.- The path segments are matched case-insensitively and exactly against
options.json's tree (no fuzzy matching — a typo is a compile error, not a silent wrong-project selection). Segments may contain spaces (e.g.Presales Support). <comment>is free text, no marker needed — it runs from the first run of 2 or more consecutive spaces after the path to end of line. A single space stays part of the path (segment names routinely have one, e.g.Team Meetings), so only a wider gap starts the comment — the same gap:TimesheetFmtcolumn-aligns entries with, so a formatted file always parses back the same way. A whole line starting with#(optionally indented) is still a full-line comment, ignored anywhere — that's unrelated to the per-entry<comment>field and unaffected by this.- A day can have more than one entry (splitting it across projects) —
see
eurecia_fill_bookmarklet's "Nouveau" row auto-add above. If a day's entries don't sum to exactly 1,compile.pyprints a warning (not an error — it doesn't blockbuild); with multipleweekblocks in one file, this check is done per week, so e.g.monin one week andmonin another are never summed together.
Example
week 2026-08-31
mon 8 PreSales/Presales Support kickoff call
tue 4 PostSales/Support-IP
tue 4 PreSales/Presales Support half day split across two projects
wed 8 PostSales/Support-IP
thu 8 PostSales/Support-IP
fri 4 PostSales/Support-IP half day, deliberately under a full day -> warns
week 2026-09-07
mon 8 PostSales/Support-IP
tue 8 PostSales/Support-IP
A file doesn't need a second week block — a single-week file (just the
first block above) works exactly as before.
Checking this (python3 compile.py check week.tsheet) reports exactly one
warning — "fri" (week 2026-08-31) entries sum to 0.5 days, expected 1 — and
nothing else; every other day in both weeks resolves cleanly. A four-level
domain/project/customer/cir
path is also valid (see eurecia_extract_options_bookmarklet's output shape
above) but only resolves if your saved options.json was crawled deep enough
(maxDepth: 3 or 4) to include that project's customers/CIRs — the
bookmarklet now defaults to maxDepth: 4, but an older, shallower
options.json on disk won't have them until it's re-crawled.
PreSales/Presales Support and PostSales/Support-IP are the same
illustrative names used earlier in this README (from
eurecia_extract_options_bookmarklet's output shape and
timesheet.template.json) — swap them for whatever your own options.json
actually contains.
compile.py
python3 compile.py check week.tsheet [--options path/to/options.json]
python3 compile.py build week.tsheet [--options path/to/options.json] [--no-clipboard] [--out file.json]
--optionsdefaults tooptions.jsonnext to the script.checkprints onefile:line:col: messageline per problem (gcc-style, so editors can turn it into a jumpable list) and exits non-zero on errors; warnings print the same way but don't affect the exit code.builddoes the same validation, then on success copies the JSON to the clipboard (triesxclip,wl-copy,pbcopyin turn; falls back to printing it to stdout if none are found).--out file.jsonwrites to a file instead. On error, nothing is emitted — same all-or-nothing validation the fill bookmarklet itself does.
Neovim integration
nvim/ in this repo adds filetype detection, syntax highlighting, and two
commands for .tsheet files.
Automatic, via this repo's .nvim.lua: add vim.o.exrc = true to your
own config once, then trust this file once (:trust while it's the current
buffer, or vim.secure.trust({action='allow', path='.../ts/.nvim.lua'})) —
after that, opening nvim with ~/work/ts as your working directory
auto-loads everything below, no per-project edits to your own config needed.
See :h 'exrc' / :h :trust. (week.example.tsheet in this repo carries a
header comment with this same reminder.)
Manually, add the nvim/ dir to your runtimepath instead:
vim.opt.rtp:append('/path/to/ts/nvim')
or as a local plugin spec (lazy.nvim):
{ dir = '/path/to/ts/nvim', ft = 'tsheet' }
Then, in a .tsheet buffer:
:TimesheetCheck— runscompile.py checkin the background and loads any problems into the quickfix list (:copen,:cnext); warnings show up the same way but the buffer still reports "Timesheet OK (with warnings)".:TimesheetBuild— runscompile.py build; on success, shows a confirmation that the JSON was copied to the clipboard (ready to paste into the fill bookmarklet).:TimesheetFmt— aligns the day/days/path/comment columns of each contiguous block of entry lines (blank lines, comment lines, and theweekline each start a new block).- Completion of day keywords and domain/project/customer/cir names while
typing (against
options.json), viaomnifunc(<C-x><C-o>) and, if nvim-cmp is installed, atsheetcmp source registered automatically for this filetype.
Run nvim/lua/tsheet/test/run.sh to run the (dependency-free, headless)
test suite for this Neovim integration.
Development
Both bookmarklets are plain IIFEs; edit the *.src.js files directly, then
generate the *.url.txt bookmarklet URLs:
node build.js
No minification — the bookmarklet body is the readable source verbatim
(wrapped in javascript: and URI-encoded), well under any browser bookmark
length limit.
Nix
A flake.nix provides the tooling (node, python3, neovim, clipboard tools):
nix develop # dev shell
nix build # result/*.url.txt, built from *.src.js
nix run .#build # regenerate *.url.txt in the working tree
nix run . -- check week.example.tsheet # compile.py (check / build)
nix run .#test # headless neovim test suite
nix flake check # runs the neovim tests in the sandbox
CI
.gitlab-ci.yml and .ci-tron.json both run nix build (through
ci/build.sh) and nix flake check. The GitLab runner is a shell executor,
so the jobs call docker run nixos/nix themselves (and chown/remove
everything afterwards). The built *.url.txt bookmarklets are kept as
artifacts: dist/ on GitLab, $CI_TRON_ARTIFACTS_DIR on ci-tron.
On GitLab, pushing a tag vX.Y.Z also publishes a release with the bookmarklets
attached (ci/release.sh, via the GitLab API).
Known gaps
- Real
navigator.clipboardpermission prompts and an actual bookmark click haven't been exercised end-to-end (only simulated via injected JSON in place of clipboard content) — do one manual run of each bookmarklet before relying on them weekly. - If a level's list is large enough that select2 paginates it (loads more results only once you scroll the dropdown), only what's rendered on first open would be read. Not observed in testing — a 40-item Project list rendered in full immediately with no scrolling — but also not something this script actively handles, so an even larger list on a different account could still be truncated silently.
- Selection matches by exact
text. If a dropdown has two entries with identical display text but different underlying values, the first DOM match wins — not something observed, but not ruled out either. - Tree handling (point 4 above) only covers one level of nesting. If a real account has a category whose children are themselves categories (a deeper tree), those grandchildren wouldn't be reached — not observed, but the 40-item Project list that surfaced the one-level case makes it plausible a different account could have deeper structures.
- The full 4-level cascade (down to a real CIR) was only verified for one
specific chain (
PreSales → Presales Support → All → AUTRE); the multi-hundred-item scale confirmed at the Project level hasn't been separately confirmed at the Customer or CIR level. eurecia_extract_week_bookmarklet's "Validée" (submitted-week) path reads Domain/Project/Customer/CIR positionally by table-cell index, since those cells carry no id in that mode — verified against one real week's known data, but a different account/locale whose read-only table has a different column layout wouldn't be caught until the output looked wrong.