Part 1 — asset versioning: every local <script src> and <link rel=stylesheet> on the
tracked pages now carries ?v=<sha256-of-the-file> (10 hex), computed at BUILD time from
the file's bytes by work/build-asset-versions.py — never hand-typed, and idempotent
(re-running with no file change rewrites nothing). This is what makes an edited
focus-rule.js / photo-upload.js a new URL instead of a stale cache hit.
Part 2 — language facet: a language now counts for the Language filter only when its
0-5 self-assessment is >= LANG_FACET_MIN (3 = 'works in it'). The constant sits beside
the 15-point rule in the page script and its mirror in build-directory.py. Nothing
stored changes; the card's languages chart still prints every language above 0, the
search box is unchanged, and the facet stays an exact membership test against the
card's value. A '1' language no longer offers the person under that language.
The edit form carried LinkedIn/Upwork/Fiverr but not GitHub, though the
application form, the webhook and the card renderer already do. Add a
fourth optional link field mirroring the other three, under the live wire
key verbatim (lower-case github; never normalised), pre-filled from the
record and added to the save loop with the same rules: a key is written
only when the input has a value, an empty optional field never invents ""
on a record that never had one, and a link the owner cleared sends "".
No new URL check — the card's isLink() stays the only url predicate.
Both the application form (/odoo-community-developers/) and the self-service
edit form (/freelancer-edit/) now use one shared module, static/photo-upload.js
(window.O4PPhoto), so the limits live in one place:
- validate() decides on the File object BEFORE any read: type allowlist
(image/jpeg, image/png, image/webp) and an 8 MiB cap. A refused file is read
zero bytes and is never submitted.
- downscale() reads, centre-crops square, caps the longest side at 384 px,
encodes JPEG at 0.82 and steps the quality down until the result is <= 80 KiB.
Re-encoding also strips EXIF (a phone photo's GPS does not reach the page) and
refuses a spoofed type that fails to decode.
- The edit form's photo field becomes a real labelled input that keeps the
current photo when untouched; the whole-record save carries it back
byte-identical.
Existing stored photos are untouched, so the directory page is not lighter until
profiles re-upload. accept= is a hint, not validation.
Two defects, one file. (1) The edit form was missing the three profile links the
application form collects. Added as optional type=url fields pre-filled from the
record, with the application form's own labels and placeholders, under the LIVE
wire keys verbatim: "Linkedin" (capital L), "upwork", "fiverr" — casing never
normalised. (2) The save built its payload field-by-field, so Image and the three
links were absent from the body and a freelancer saving an edit wiped their own
photo and links. The payload now starts from the loaded record (read-modify-write)
and overwrites only the fields this form owns; Image rides through untouched. An
empty optional link on a record that never had one is left as the record's own
value — no invented empty strings. 'has' stays first, method stays PATCH.
No second URL check: the only url predicate is the directory card's isLink().
The save was authorised by the record id alone — ids are small
sequential integers, publicly visible on every directory card, so
a PATCH carrying only an id let one visitor overwrite another's
listing. The save now:
- uses PATCH (C.saveMethod), matching n8n
- carries the hash in the body under the same "has" key the
delete already sends, so save and delete share one credential
path, alongside the record id
- refuses to fire at all when the page holds no hash, and says why
in the status line
Everything else is unchanged: the 15-point gate, receipt only on
2xx, the honest failure line that keeps the edits, the save.disabled
handling and the DELETE shape.
The edit form's legend read "Skill areas" and carried no directive, while the
application form read "Skill profile — 15 points to distribute" and said what to
do. Same rule, worded in two places — and the edit form lost the sentence.
- New static/focus-rule.js exposes window.O4PFocus: LEGEND, DIRECTIVE, TOTAL, MAX
and message(sum). Both forms render the legend, the directive and the
wrong-total message from it, so the two cannot drift apart again.
- Edit form: legend from the constant, the application form's directive under it,
and the blocked-save message now names the direction.
- Application form: legend set from the constant, live warn and sum gate use the
same message(); the all-zero gate keeps the directive.
- Message wording: over -> "remove N", under -> "add N", zero -> "place all 15
points"; the total, aria-live, data-bad, the blocked save and the
fieldset/legend structure are unchanged.
The rule text now appears once: "You have 17 of 15 — remove 2."
Card (odoo-community-developers/):
- "Ask question anonymously" moves into its own row ABOVE the links row, in the
runtime renderer and in build-directory.py (the build-time template).
- A cog icon button sits next to it: a real <button> with an accessible name
(aria-label="Edit or delete your profile"), same quiet weight as its neighbour.
- Pressing the cog opens a modal that explains the flow and asks for the email
address, then POSTs {"email":"..."} to odoo_experts_edit (body shape in one
constant, EDIT_EMAIL_KEY). The reply is never read: one message on any 2xx, so
the box cannot tell a visitor whether an address is on file. A non-2xx fails
honestly and shows no receipt. Both controls ship dark (added only while their
route constant is set), so neither is ever a dead control.
New private page (freelancer-edit/, noindex+nofollow, unlinked, absent from
sitemap.xml and llms.txt):
- Emailed link is https://odoo4projects.com/freelancer-edit/?has=<hash> — the
same shape as /freelancer-answer/; reads `has` first, falls back to `hash`.
- GETs the edit webhook with the hash and pre-fills the form: Name, Focus (<=70),
description (<=230), the five axis numbers (must total 15), the three language
numbers (0-5), Country, OCA_link. The GET reply is read through one alias map;
a shape it does not recognise names the fields it received instead of guessing.
404 "is not registered" is a CONFIGURATION error, never "your link is invalid";
404/500 "No item to return was found" is an invalid/expired link; a network
failure keeps the hash for a retry.
- Save = POST with the record id (one constant, SAVE_METHOD). Delete is a
deliberate second action (a confirm tick gates the button) and never reports
success on a non-2xx.
- The image is read-only here, stated in the UI: replacing a photo is a different
problem from editing text.
- The hash never reaches analytics: the query is stripped before the Plausible
snippet loads. referrer policy no-referrer; JS off says so plainly.