directory card photos: load from the image endpoint, not inline base64

The webhook no longer sends `Image`, so the card photo comes from its own
endpoint, addressed by the record id — one <img> per card, one request.

- static/expert-image.js: the route AND its query-parameter name in ONE
  constant (id, confirmed by measurement; unverified in the brief), plus an
  optional version token used only when the payload carries one.
- Directory card: <img src="…odoo_experts_image?id=<id>">, width/height
  reserve the 72x72 box, decoding=async, eager for the first row and lazy
  below it; onerror swaps in the SAME monogram a card with no photo shows,
  so a failing image looks exactly like today.
- The static build-time card loses its 185 KB inline base64 photo and shows
  a monogram: it is the no-JavaScript fallback, where there is no onerror to
  fall back with. Page 341,345 -> 158,428 bytes.
- /freelancer-edit/: the photo block is a look, not a field (no file input),
  and the whole-record save sends NO `Image` key at all.
This commit is contained in:
webmaster committed 2026-11-03 22:59:53 +00:00
1 parent 9d14652a57
commit 2b0e89191a
3 files changed
+134 -71

No files matched your search

+30 -46
View File
@@ -149,6 +149,7 @@
<!-- The 15-point rule, in ONE copy for both forms (this one and the application form). -->
<script src="/static/focus-rule.js?v=22ac72a8f0"></script>
<script src="/static/photo-upload.js?v=c17638457b"></script>
<script src="/static/expert-image.js?v=e496d4a4e3"></script>
<!-- 1) Read the credential, then take it OUT of the address bar.
The hash arrives as ?has=... or ?hash=... (both spellings accepted, 'has' first — the
@@ -574,53 +575,36 @@
form.appendChild(linkField("edFiverr", "fiverr", "Fiverr URL",
readField(record, "fiverr"), "https://www.fiverr.com/…"));
// The photo is editable here with the SAME rules as the application form
// (static/photo-upload.js). A picked file is validated and downscaled on the spot;
// leaving it untouched keeps the record's own Image, which rides back byte-identical
// because the save sends the whole record (see the submit handler below).
var newImage = null;
// The photo block is a LOOK, not a field. The photo does not live in this record
// any more — the payload carries no `Image` — it comes from its own endpoint,
// addressed by the record id (window.O4PExpertImage). So nothing here edits it,
// and the save below sends no `Image` key at all: not the value, not null, not "".
var photoWrap = el("div", "field");
var photoLabel = el("label", "", "Photo");
photoLabel.setAttribute("for", "edImage");
photoWrap.appendChild(photoLabel);
photoWrap.appendChild(el("label", "", "Photo"));
var photoRow = el("div", "photo-readonly");
var img = readField(record, "Image");
var currentImage = (typeof img === "string" && (img.indexOf("https://") === 0 || img.indexOf("data:image/") === 0)) ? img : null;
var previewImg = el("img");
previewImg.id = "edImagePreview";
previewImg.alt = "";
if (currentImage) {
previewImg.src = currentImage;
photoRow.appendChild(previewImg);
} else {
previewImg.hidden = true;
photoRow.appendChild(previewImg);
photoRow.appendChild(el("span", "mono", initials(who)));
var photoMono = el("span", "mono", initials(who));
photoRow.appendChild(photoMono);
if (rec && rec.id !== undefined && rec.id !== null && rec.id !== "") {
var photoImg = el("img");
photoImg.id = "edImagePreview";
photoImg.alt = "";
photoImg.decoding = "async";
photoImg.hidden = true; // the monogram stands in until the photo is there
photoImg.addEventListener("load", function () {
if (photoMono.parentNode) photoMono.parentNode.removeChild(photoMono);
photoImg.hidden = false;
}, { once: true });
photoImg.addEventListener("error", function () {
// A failing photo is the monogram: no broken-image icon, no empty box.
if (photoImg.parentNode) photoImg.parentNode.removeChild(photoImg);
}, { once: true });
photoImg.src = O4PExpertImage.url(String(rec.id), rec);
photoRow.appendChild(photoImg);
}
photoWrap.appendChild(photoRow);
var photoInput = el("input");
photoInput.type = "file";
photoInput.id = "edImage";
photoInput.name = "Image";
photoInput.accept = "image/jpeg,image/png,image/webp";
photoWrap.appendChild(photoInput);
var photoResult = el("p", "hint", "");
photoResult.id = "edImageResult";
photoResult.setAttribute("role", "status");
photoResult.setAttribute("aria-live", "polite");
photoWrap.appendChild(photoResult);
photoWrap.appendChild(el("p", "hint",
"JPEG, PNG or WebP, up to 8 MB — we resize it to a square of at most 384 px first. " +
"Leave it to keep your current photo."));
O4PPhoto.wire(photoInput, previewImg, photoResult, function (dataUrl) {
newImage = dataUrl;
var fb = photoRow.querySelector(".mono");
if (fb) fb.hidden = !!dataUrl;
if (dataUrl) return; // wire already shows the new image
// reverted to "no new photo": show the record's own photo again
if (currentImage) { previewImg.src = currentImage; previewImg.hidden = false; }
else previewImg.hidden = true;
});
"Your photo comes from your listing and is not editable here — reply to the email " +
"that brought you here if it needs to change."));
form.appendChild(photoWrap);
var save = el("button", "btn", "Save my changes");
@@ -686,10 +670,10 @@
else if (had) payload[pair[1]] = "";
});
// An untouched photo rides back exactly as loaded — it came in with the record
// above and was never touched, so it stays byte-identical. Only a NEWLY picked
// photo (already validated and downscaled) replaces it.
if (newImage !== null) payload.Image = newImage;
// The photo does NOT ride in this record any more: the payload carries no
// `Image` key and this form sends none — not the value, not null, not "". The
// delete is the guarantee, whatever a reply may one day carry.
delete payload.Image;
fetch(C.webhook, {
method: C.saveMethod,
+56 -25
View File
@@ -1380,7 +1380,7 @@
<!-- DIRECTORY-LIST-START -->
<article class="dev-card" data-search="oliver - the oc guy i love to see how far i can push the odoo community version for my clients. besides the module implementation i am quite happy with adding to odoo using hermes agent, n8n and progressive web apps agent automation, manufacturing, e-commerce english german spanish" data-id="2" data-name="Oliver - The OC Guy" data-country="py" data-languages="english|german|spanish" data-oca="1" data-relay="1">
<header class="dev-head">
<img class="dev-photo" src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wgARCAMgAyADASIAAhEBAxEB/8QAHAAAAQUBAQEAAAAAAAAAAAAAAwECBAUGAAcI/8QAGwEAAwEBAQEBAAAAAAAAAAAAAAECAwQFBgf/2gAMAwEAAhADEAAAAd+J4/iP0bnsIPmmqHNqQgXm4CgWsg0Ga5lsI18oE4K3IAo3bMtrKDXGo2dJd6cKOYTl9RCPcSNXmcwSERuMp0mgPVrTxq0OXnAjXoHIThhFOEDMrrMjtxWt/n9E+YQ3B5fXIrOH0WaNh2vG5UZ+AahCyxFGe5BFNS6k+NGForCfRaaWhLWowqLn76p2zwerzWi9DwrWRVp5/uXEnN841Y88RzeDr2zU9jhzUl0NgXTKRxGjiUo27U1Giq9BVx6m+fRonex60oSa+TC6fI2MYjeP2JHP6RaC/rbzv4iz4KYsuM7CVSOJJKzjKyFVSWpkNiFSquzS84pVKZxHSImfZMPnpl4Wpq7nnMjxJC0lKCOKejEQaDCcyYWsGFuufiOdV2Ujs2bMqRu+zEuSSG7rWPKa6tLn1ymV8XTPTBiuzOA7rhjXwLxtoViyboa7Wtowcbfjswa7YxWMdukm8nV+idNecv8AQmswfbpHGGJuSBgW+gol58fdjToH3zEUJbiHWdTIh2WnMFb4/L6eXDrVKzB9GqrOw9Zw6Mtt03WPs2qoKyyp1RNLK08/Ct1cO7ohbUVxgNPj9vXJWi1acvdll06F5dNNnjYx3XRFOayQyq+tEHTAvIhrXDuCFVJrp1c9GDRjTziaIw8dXeg43XEltkdZpgtpFsubuAklZQKm8qndMOMXrzFHOGd7u5izOLYsdOUUzJUTpFq7ONbu5rJfIRzOUzg43c4rs8y/sau4x72IosurkahQ3kcAhyWIDxWAIFjHZDehhkchEmIRqG85ACkh7qBHmxdOfK3MGd2+NevezzvfVXuQikYJEIg2Kjgc17BMc5wWUqNKvzYdXbVF9Ngxg9o843Pnm504dByO8/01RioZm9RSV0Puay2cdyKc/NVg0BJGWxhGGknnK8Eezml5qg/FbXCbc8XZYvRaxpnify9D1EqS1dtUVWYNLm9M594SLfWyMtI550AcH156ePmpmtXBa2wnTQd3co5yNaj47WZXp8+5uKq1y7RNc3LsejQtGRGKpfRSkOe5rlAS444vKFaHdHelMSM5zwisVjJ0gBwraFtx5K1qNH1+VeCnR+H2YLorY3tQxuJlxxNKIVQBIYwYSUAMWmlVx785abm6a2YE658v3+O2WnHbDrpvD6748avdzYddM0u8lDiZ42b6B1Y3fU5wso1fXOr59CY00aUDXloOqGJXL6sZN15/o8rtzklpN1jQTcozn21fYdjW+yVVQawzM0FV6nmWQcwDfl0MWji7Y2saD15TCV6hc22RWdPXr7wbufp+hdT8nuy1+t4nzTr8dPey+cW3F26tubNnvcpTKy/ZnHub4dQ0q9dR8TZdCj1nZ1r3NTQtfNgkglojtmRxrNrhLS/ZTSlJWS21zpXWJFrmk0o2Uy3TWqCTauTrI984WfPfCrKiHMFry1pfRKXn9DIG9APWXmkrfvT88X0GtFhxy110GLW2mawj/Q7wy8Yr/a0a8Uf6P11gLuo01YVlP6eHD0fPm+oFeflRPUlF5070JirDJuFVYldsFPIrrnKsY3aqjAs2mNt1WIk4n0vKFTDB3eaVg1vNgTivNj06oTu4CorJtqp1Qvc8fPb0aTfafCyc/X9hSPkf2nyPf9XYZfP9ADZCjirJEEOpvai9JFFb1HX49hKhW2PU9wyc3U/n8KMGWF1FV4nohhkSO/iKGkR4RxSkHCSQAtXrJSCR7CVe19RiWrX+n8/67TXtJ5ntzzIpAWFSbK0cus/NRzAdtbOxqNByzGachmcg3PmpUWM+/wAv0Gdv+7yt69hOH1yK8jwFz+JG1XFB5ylvVznkFCtGxpGzUHzm4+bu7jSnfI9nwKzrCHeQ3iOxrSLLY55QhpKi1LSDcLkc1nK3hP5jyuVipmkQCTf0B618Z+seN7/ubszZeb69oJ3TUKvmV96uz+t8/wC3wtnaVs7k9CSisw6TEjyBDDIhOhxzDrRp4BAtXifEFcIpLStQGjWM7MUZkcNzSUc4N54mHYj9L531egvsd53vaKbS35nGadghyF5ThodovYpWjzGq52x7XzMrhiMayuvM8+3zLTZy67/K9KeMnD60gg31zk5nOFG8JQngfPRJcxjwL0Y6bop/JNM/MsUQPu+AEkx95N6eqedfolCi65iqgDlwmowjAvMbSdSdHnRkgq5LlFUiYFMwY1XnJCNLGmq9a8H7j9D67f4f6P5PsWcGFJz6W0NgPr8rTsg1fP1692MdGmyXDNc7uPi2s14co96aB+XMzclwZ4z3cfNx0aceWWovgVz27Z9K8V8tEWS/Zn5lTAknpejzthXYsM9G7m+dxw9FFh5RnqnZ+yjWYHo6cu1xgLjdH8vYn60DzUIvTa7Gnb5tpXaY6ONVPz3mNjFCbHbJVNZKInDbOIKH0wSRYkuuayPlU2H6fnwHdZ7ZRLzU3GPTh5OxkRpi5O3nTfnYfTEH5JU+5UbnxIHotLvy5NmgFc1INNJRjQ6ltLKm0YnFElwEVMWX1zWOlRag5oB5qb7T4jdYb+9ERnl+ujdZIxvKx9qirDRfRYtZ+fSbGPtkPrnXY6eeD9ADnvhJO1MGJZ6Od8/m8TS5Pq5bNbM3N6tWO/6VQvukKp+tkKrSTCKo4LdyViy9CcVUO4fNUjb96M67UtrHwv0DzP13qlsia3n6Y73tmjdxiGNTk+QvMEUYxyUiK3IaQKnpUEzZgGiNOMzqp3mXpHhW+Pl1ZLN6vkD9kfo+L1IoLF3P2w5ySExSulKnONJbr63QxayxeY9PodebzyN6DCucYzUPZm5GqkJ4yHu4TnARN9X3hiH6xGsHS+nZHbDL80XTxG5qNe57z5f+hPH+k3ZDj8j1mOKjBSQSHlhtDlydfL6hBr7TkcJ3LPUjm8E+fW3unm+ZUltkvQ4d2yUzg+gcVqyuVqDRhgunkgTBSFQYtcOW2vHiOOpSSBGeBY0uurLwb2HxX2bfotBShc28dziF8RXGURr2rZZIJjyhjlK3HdJcT0GzhExZQ5ho0E4V4xWzUd0fzB7z4D289P6Vm/YLXGNI4PXCQxhjdObUsPx2mnadykeajiriXZWqCv10RPCA1QWs7Ms2IqIVzHc0kC8FU0ke8hUV9BpAaZeW5/0TE93kwSKbTni6nMkWv17Pwe8+Y+wQ4yZ6MQpqyw0fQP6MzaCk0OEV6GbO4mmRU+1qJtcuPw26yvpePobKvsuH6F3K2AfNQowGxlrINDOKYsXnn6Cfx+/6fB9Bb5vbQ9i/ETU9f5/fZxrBeyeRbjY248iXGtUzMsdaVuLmLa/XJq3sZ2EK8dwvn7TLcMw4VtvExAg35sRHcegk8p6o9bXynqiR4p6J590vUb6vtOX0myHzc9xvnSLiCWWV5iIYmmMdZS1nHbIYiIUcw0iRpw5dHHtBzpBj2sQVGO0AFZHsIrVdFnw7UIMyNcUOB9SxHVwYfpCdfnDgzY9LX+ueA+q8PdtH1oOXe6NS2Sl6Q20rGMB4juisCYMMoOkhbLmuEQzmATo2GE/OYzizWVMm1eqrrGR0uK88pHm8b0iTpr5xG9Wcq8qkegqn55daZBZCH6Lw8NP2h5rAH2yp4t+w5LBSNpQtSS+Zeh3zwYGolx0YmRvSXHnZdzGjbGk2Dx4M235rLN1ZA8nzGmbtnrZaTOP0pEyOVwTmEqGlQ2kI16vNCtfUDe8LQjNWbEGQCdI7CCm2xCjHXqoRhC4TgNdOiXMSHYQLiLmdHU7c3n1doqbt8yE8jbyFuMXsYrYei+We/wDl+lSFtQcPo1WX9B8+2DWNJI0eyJNsObnz5bULdclicMvVXdM+0Tix9A1vT63POrlSCRmAhJBEWt0+QZMx2r807OP2RJLOf0wcQiqospAyRqVQA2SgRCSOGFy8kjxq0+jusazz31rHbHfjtJ0YuPWZpGViIBwTvykaNELJeYelRaz8X02Z25tYyCFw3BNC9o0cYLmaavDU3EmgfWd86pNWdgGEcTyxyDYwQJ0NHFFVEFBEOU2OND4zo1SWHIHSr402LWdXWWNftjmaLY53p8+oDMjbYRtXnNElefQXzf8ARnk+sRpA+b7cnA73D75wCBJ0Gz0WF2XEpEeQJc4ZccpVTSXdBfc+BLjasuwyGzyyiu7sV0uNMrJ2R0+Xt12Y2uX9DzfVZHdh0p3Na5idNu7kBFKgkR3UhK9UxoZwo+D3WZKoNZmtHpyX7+NPQxshjiGJ6Z9LCMkDfzG1lKGyDeXnO0zGox67IsQUuS3isAj0YzpbNJrmS21LLCtmNEVoxHINiBieAY489oquJZQRAGZGojSgJIwQmjwpYRUlfawd8aek09btzZiPYw9+OHdU+jEv0X4p7D5vpWgJqeZ7LMhuMjqs0SYDqmw22A1vLrfIj44ucrHNZmrmsfojBYw9GuvyGoyzMCwTPnrpksTQslpKCuh+S2+Q7fL3bcQkvaPxElrXtyMcnWhzxU7aRBgtWbMu640jsrCqd/L87JNeg1WbGFrqMJMee/Dj4U7bSvx4dM9SOk5OzCADUt9WNzeRWQprQXdfJ5PYEsGNeenPnrRu5PVTBSwk6piccTqBOjq5kRUGEkCIASBdTM4RZhsGXFJjRbOM1URrWqpOiiFUyGQuLine truncated
<span class="dev-avatar" aria-hidden="true">OG</span>
<div class="dev-id">
<h3 class="dev-name">Oliver - The OC Guy</h3>
<a class="dev-oca-badge" href="https://apps.odoo-community.org/community/oliver-arnold" target="_blank" rel="noopener nofollow">OCA Member Profile</a>
@@ -2052,6 +2052,7 @@
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
<script src="/static/focus-rule.js?v=22ac72a8f0"></script>
<script src="/static/photo-upload.js?v=c17638457b"></script>
<script src="/static/expert-image.js?v=e496d4a4e3"></script>
<script>
/* ============================================================================
Odoo freelancer directory — page script.
@@ -2093,6 +2094,12 @@
var ODOO_EXPERTS_EDIT_WEBHOOK = "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_edit";
var EDIT_EMAIL_KEY = "email"; // body sent to ODOO_EXPERTS_EDIT_WEBHOOK: { email: "..." }
// How many cards load their photo EAGERLY: the first visible ROW. The grid is 3
// columns at the widest layout (measured), so the first three cards. Every card below
// the fold loads lazily — a lazy image in the first row would delay LCP, and a photo
// off-screen is not worth its request.
var EAGER_CARDS = 3;
// ---- The shared constants (the same lists live in build-directory.py) --------
// The five self-assessed focus axes — each a SEPARATE numeric field (0-5) on the
// wire, no CSV, no encoding. They must total exactly FOCUS_TOTAL.
@@ -2356,10 +2363,6 @@
return v.indexOf("http://") === 0 || v.indexOf("https://") === 0 || v.indexOf("/") === 0;
}
function isImageSrc(v) {
return v.indexOf("https://") === 0 || v.indexOf("data:image/") === 0;
}
function liveEl(tag, cls, text) {
var node = document.createElement(tag);
if (cls) node.className = cls;
@@ -2460,9 +2463,25 @@
return liveValues(rec, field).map(fold).join("|");
}
/* The monogram — the initials treatment a card shows when there is no photo. It is
also the FALLBACK when the photo fails to load, so a missing photo looks exactly
like a card that never had one. */
function liveMonogram(name) {
var parts = name.split(/\s+/).filter(Boolean);
var mark = parts.length > 1
? parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
: (parts[0] || "?").slice(0, 2);
var mono = liveEl("span", "dev-avatar", mark.toUpperCase());
mono.setAttribute("aria-hidden", "true");
return mono;
}
/* One card, field for field the same as render_card() in build-directory.py.
Everything a freelancer wrote goes in as textContent, never as HTML. */
function liveCard(rec, active) {
Everything a freelancer wrote goes in as textContent, never as HTML.
`eager` is true only for the first visible row: those photos load eagerly because
a lazy image in the first row delays LCP. Everything below the fold loads lazily. */
function liveCard(rec, active, eager) {
var name = String(rec.Name || "").trim();
var card = liveEl("article", "dev-card");
card.setAttribute("data-name", name);
@@ -2474,23 +2493,33 @@
card.setAttribute("data-" + pair[2], liveAttrValue(rec, pair));
});
var photo = String(rec.Image || "").trim();
var avatar;
if (isImageSrc(photo)) {
avatar = liveEl("img", "dev-photo");
avatar.src = photo;
avatar.alt = "";
avatar.width = 72;
avatar.height = 72;
avatar.loading = "lazy";
avatar.decoding = "async";
} else {
var parts = name.split(/\s+/).filter(Boolean);
var mark = parts.length > 1
? parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
: (parts[0] || "?").slice(0, 2);
avatar = liveEl("span", "dev-avatar", mark.toUpperCase());
avatar.setAttribute("aria-hidden", "true");
// The photo is NOT in the profile payload — the webhook no longer sends `Image`
// (it used to ride as inline base64). It comes from its own endpoint, addressed by
// the record id: ONE <img>, ONE request (window.O4PExpertImage holds the route and
// its query-parameter name in one constant).
//
// The box is RESERVED (width/height + the .dev-photo CSS), so the card does not
// reflow when the photo arrives. A failure — no id, no workflow, a reply that is
// not an image — falls back to the SAME monogram a card with no photo already
// shows: no broken-image icon, no empty box, no alt text spilling into the layout.
var mono = liveMonogram(name);
var avatar = mono;
var rid = (rec.id === undefined || rec.id === null || rec.id === "") ? "" : String(rec.id);
if (rid) {
var photo = liveEl("img", "dev-photo");
photo.alt = "";
photo.width = 72;
photo.height = 72;
photo.decoding = "async";
photo.loading = eager ? "eager" : "lazy";
// On failure the <img> is REPLACED by the monogram — a node of the same 72x72
// size, so the card does not move. `mono` is captured by name, never the variable
// that is reassigned below.
photo.addEventListener("error", function () {
if (photo.parentNode) photo.parentNode.replaceChild(mono, photo);
}, { once: true });
photo.src = O4PExpertImage.url(rid, rec);
avatar = photo;
}
var head = liveEl("header", "dev-head");
var who = liveEl("div", "dev-id");
@@ -2960,7 +2989,9 @@
// NO sort: the endpoint's order is rendered exactly as received.
var active = liveFilters(records);
while (grid.firstChild) grid.removeChild(grid.firstChild);
records.forEach(function (rec) { grid.appendChild(liveCard(rec, active)); });
records.forEach(function (rec, i) {
grid.appendChild(liveCard(rec, active, i < EAGER_CARDS));
});
// The button is added after the cards exist — only while the route is set.
Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), enhanceAsk);
dirPage = 1;
+48
View File
@@ -0,0 +1,48 @@
/* The freelancer PHOTO — one endpoint, one constant.
The photo does NOT live in the profile payload any more. It used to ride there as
inline base64 under the key `Image`; the webhook no longer sends that key, so the
payload is small and the photo is fetched separately, per card, by record id.
ONE constant holds BOTH halves of the address: the route and the query-parameter
name. Changing either is a one-line edit here, and both pages pick it up.
The parameter name is `id`. It was recorded as UNVERIFIED in the brief (every probe
answered "workflow is not registered"), and it is now CONFIRMED by measurement
against the live route: `?id=<n>` answers 200 with the photo for that record, while
`/4`, `?profile_id=`, `?image_id=` and a missing id all fail. See
webmaster/out/2026-11-03-photo-endpoint/REPORT.md.
The endpoint currently answers `application/json` with `{"Image":"data:image/jpeg;
base64,…"}` — a JSON envelope, NOT image bytes — so an <img> cannot decode it and
every card falls back to its monogram. That is the measured state, not a guess; the
page is built for the shape the brief specifies (an <img> pointed at this route) and
starts showing photos the moment the route returns image bytes.
`versionParam`: appended ONLY when the payload carries a version token, so a
year-long immutable cache on the endpoint stays correct — the URL changes when the
photo does. Without a token the URL is stable and the endpoint's own headers decide
caching (see the report: it sends no Cache-Control and a weak ETag today). */
window.O4PExpertImage = {
endpoint: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_image",
idParam: "id",
versionParam: "v",
// Anything that changes when the photo changes. `updatedAt` used to be in the payload.
versionFields: ["updatedAt", "updated_at", "ImageVersion", "image_version"],
version: function (rec) {
if (!rec) return "";
for (var i = 0; i < this.versionFields.length; i++) {
var v = rec[this.versionFields[i]];
if (v !== undefined && v !== null && v !== "") return String(v);
}
return "";
},
url: function (id, rec) {
var u = this.endpoint + "?" + this.idParam + "=" + encodeURIComponent(id);
var v = this.version(rec);
if (v) u += "&" + this.versionParam + "=" + encodeURIComponent(v);
return u;
}
};