From 6484dd09c57b47c8a3ba39a56da0b99b9cc74210 Mon Sep 17 00:00:00 2001 From: Arthur Ria Date: Wed, 20 May 2026 09:47:25 +0200 Subject: [PATCH] v0.2 --- CLAUDE.md | 364 ++++++++++++++++++++++++++++++++++++++ content/content-script.js | 223 +++++++++++++++++++++++ content/injected.js | 155 ++++++++++++++++ icons/icon-128.png | Bin 0 -> 2529 bytes icons/icon-16.png | Bin 0 -> 465 bytes icons/icon-32.png | Bin 0 -> 825 bytes icons/icon-48.png | Bin 0 -> 1075 bytes icons/icon.svg | 23 +++ manifest.json | 44 +++++ popup/popup.css | 222 +++++++++++++++++++++++ popup/popup.html | 32 ++++ popup/popup.js | 277 +++++++++++++++++++++++++++++ 12 files changed, 1340 insertions(+) create mode 100644 CLAUDE.md create mode 100644 content/content-script.js create mode 100644 content/injected.js create mode 100644 icons/icon-128.png create mode 100644 icons/icon-16.png create mode 100644 icons/icon-32.png create mode 100644 icons/icon-48.png create mode 100644 icons/icon.svg create mode 100644 manifest.json create mode 100644 popup/popup.css create mode 100644 popup/popup.html create mode 100644 popup/popup.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8fc699b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,364 @@ +# CLAUDE.md - TakeID Browser Extension + +Ce fichier guide Claude Code pour le développement de l'extension TakeID. + +## Objectif + +Extension navigateur (Chrome + Firefox) qui récupère les **IDs internes (GUIDs)** des éléments sélectionnés dans les vues SmartUI du WMS Mecalux EasyWMS, et les copie dans le presse-papier. + +## Contexte + +SmartUI est l'interface web du WMS EasyWMS (Mecalux). Elle affiche des listes d'entités (articles, commandes, conteneurs...) dans des grilles. Quand l'utilisateur sélectionne des lignes via les checkboxes, il n'y a **aucun moyen visible** de récupérer l'ID interne (GUID) de ces éléments - il n'est pas dans le DOM. + +Cependant, l'investigation réseau a révélé que SmartUI charge les données via un appel XHR `POST getDataViewList` dont la réponse contient les IDs de chaque ligne. + +## Architecture + +### Principe + +1. L'extension intercepte les réponses XHR `getDataViewList` de SmartUI +2. Elle extrait et met en cache le mapping `code -> GUID` pour chaque page +3. Quand l'utilisateur clique sur le bouton de l'extension, elle lit les lignes sélectionnées dans le DOM +4. Elle résout les GUIDs via le cache et les copie dans le presse-papier (un par ligne) + +### Structure des fichiers + +``` +takeid-extension/ + manifest.json # Manifest V3 (Chrome) compatible Firefox + popup/ + popup.html # UI du popup (bouton copier, liste des IDs) + popup.js # Logique du popup + popup.css # Styles du popup + content/ + content-script.js # Content script - pont entre injected.js et popup + injected.js # Script injecté dans le contexte page - intercepte les XHR + icons/ + icon-16.png + icon-32.png + icon-48.png + icon-128.png + CLAUDE.md # Ce fichier +``` + +### Flux de données + +``` +[Page SmartUI] + | + v +SmartUI fait XHR POST vers getDataViewList + | + v +injected.js intercepte la réponse (monkey-patch XMLHttpRequest) + | Parse JSON -> extrait datos[].id.value et datos[].code.value + | + v (window.postMessage) +content-script.js reçoit le mapping et le stocke en mémoire + | + v (quand l'utilisateur clique sur l'icône de l'extension) +popup.js demande les IDs sélectionnés au content-script (chrome.tabs.sendMessage) + | + v +content-script.js lit le DOM pour identifier les lignes cochées + | Extrait le code de chaque ligne sélectionnée + | Résout code -> GUID via le cache + | + v +popup.js reçoit les GUIDs et les copie dans le presse-papier +``` + +## Données techniques SmartUI + +### URL Pattern + +Les vues SmartUI ont cette forme d'URL : +``` +https:///SmartUI/smartui?viewname=|&viewtype=ViewList +``` + +Exemples : +- `https://10.255.255.2/SmartUI/smartui?viewname=CustomApplication|ProductsVList&viewtype=ViewList` +- Le host peut varier (IP interne, domaine...) + +### Endpoint intercepté : getDataViewList + +**Requête :** POST vers une URL contenant `getDataViewList` +(L'URL exacte est relative au SmartUI Hub, ex: `https:///SmartUI/smartuihubcore?...` via SignalR ou direct XHR) + +**Réponse JSON :** +```json +{ + "datos": [ + { + "id": { + "isResource": false, + "keyResource": null, + "value": "add2a893-5364-4c8e-8683-6a611a475ad6", + "cssClass": "None", + "applicationBase": null + }, + "code": { + "url": "viewname=CustomApplication|ProductsVList&viewtype=ViewList&selectedKey=code&selectedValue=*000007713009750", + "isDataObjectUrl": 1, + "isResource": false, + "keyResource": null, + "value": "*000007713009750", + "cssClass": "None", + "applicationBase": null + }, + "description": { + "value": "sous-table Halo mela 1 niv L1400 2 tiroirs double" + } + } + ], + "file": null, + "count": 0 +} +``` + +**Points clés :** +- `datos` est un tableau de lignes (50 par page) +- Chaque champ est un objet avec une propriété `value` +- L'ID est dans `datos[i].id.value` (GUID, ex: `add2a893-5364-4c8e-8683-6a611a475ad6`) +- Le code est dans `datos[i].code.value` (ex: `*000007713009750`) +- Certains champs simples sont des scalaires directs (pas d'objet wrapper) +- Le champ `id` n'est pas toujours un objet - il peut être un scalaire string directement + +### Endpoint de métadonnées : view + +La réponse `view` (POST) fournit les métadonnées de la vue : +```json +{ + "primaryKeyName": "id", + "entity": { + "tableName": "ProductViewV2", + "pluralName": "Articles", + "properties": [...] + }, + "viewGrid": { + "viewFields": [...] + }, + "name": "ProductsVList", + "applicationName": "CustomApplication", + "title": "Articles" +} +``` + +**Utilité :** `primaryKeyName` confirme le nom du champ PK (toujours "id" pour les vues standard). L'extension peut intercepter ce call pour connaître le `primaryKeyName` dynamiquement. + +### Structure DOM de la grille SmartUI + +**IMPORTANT : La structure DOM exacte doit être vérifiée pendant le développement.** + +Indices connus : +- SmartUI utilise un framework JavaScript (probablement Knockout.js ou similaire) +- Les lignes sélectionnées ont des checkboxes cochées +- Le compteur "Sélectionné:3" est visible en haut de la grille +- Les codes sont des liens cliquables dans la première colonne de données +- La grille a des colonnes : checkbox, Code, Description courte, UdM de base, Type, Profil logistique, Classification... + +**Stratégie de détection des lignes sélectionnées :** + +L'approche recommandée est d'inspecter le DOM pour trouver les lignes sélectionnées. Chercher : +1. Des `` ou `
` avec une classe comme `selected`, `checked`, `active`, `k-state-selected` +2. Des `` cochés dans la colonne de sélection +3. Des attributs `aria-selected="true"` + +Puis extraire le code depuis la même ligne (première colonne de lien, ou cellule avec le texte du code). + +**Approche alternative (plus robuste) :** Au lieu de lire le code depuis le DOM, l'extension peut utiliser l'**index de la ligne** dans la grille pour le mapper aux données du cache `getDataViewList`. L'ordre des lignes dans le DOM correspond à l'ordre des éléments dans `datos[]`. + +## Compatibilité Chrome + Firefox + +### Manifest V3 + +Le `manifest.json` doit être compatible Chrome ET Firefox : + +```json +{ + "manifest_version": 3, + "name": "TakeID - WMS SmartUI ID Grabber", + "version": "1.0.0", + "description": "Copie les IDs internes des éléments sélectionnés dans les vues SmartUI du WMS EasyWMS", + "permissions": ["activeTab", "clipboardWrite", "scripting"], + "host_permissions": ["*://*/*"], + "action": { + "default_popup": "popup/popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "content_scripts": [ + { + "matches": ["*://*/*SmartUI/*"], + "js": ["content/content-script.js"], + "run_at": "document_start" + } + ], + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "takeid@mecalux.local", + "strict_min_version": "109.0" + } + } +} +``` + +### Différences Chrome vs Firefox + +- **API namespace :** Utiliser `browser` (Firefox natif) avec fallback sur `chrome` (Chrome). Ajouter un polyfill en haut de chaque script : + ```javascript + const browser = globalThis.browser || globalThis.chrome; + ``` +- **Manifest V3 :** Firefox supporte MV3 depuis v109. La clé `browser_specific_settings.gecko` est requise pour Firefox. +- **clipboardWrite :** Fonctionne dans les deux navigateurs via l'API `navigator.clipboard.writeText()` dans le popup (qui a un contexte sécurisé). +- **Content script injection :** `world: "MAIN"` n'est pas supporté partout sur Firefox. Utiliser l'injection via ` + + diff --git a/popup/popup.js b/popup/popup.js new file mode 100644 index 0000000..9268991 --- /dev/null +++ b/popup/popup.js @@ -0,0 +1,277 @@ +(function () { + 'use strict'; + + const browser = globalThis.browser || globalThis.chrome; + + const statusEl = document.getElementById('status'); + const countBadge = document.getElementById('count-badge'); + const previewEl = document.getElementById('preview'); + const copyBtn = document.getElementById('copy-btn'); + const copyLabel = copyBtn.querySelector('.btn-label'); + const viewNameEl = document.getElementById('view-name'); + + let currentIds = []; + + function parseViewName(url) { + if (!url) return null; + try { + const u = new URL(url); + const raw = u.searchParams.get('viewname'); + if (!raw) return null; + // Format attendu : AppName|ViewName + const parts = raw.split('|'); + const name = parts.length > 1 ? parts[1] : parts[0]; + return name ? name.trim() : null; + } catch (e) { + return null; + } + } + + function setViewName(name) { + if (!name) { + viewNameEl.hidden = true; + viewNameEl.textContent = ''; + viewNameEl.removeAttribute('title'); + return; + } + viewNameEl.textContent = name; + viewNameEl.title = name; + viewNameEl.hidden = false; + } + + function setStatus(text, level) { + statusEl.textContent = text; + statusEl.className = 'status status--' + (level || 'info'); + } + + function setBadge(n) { + countBadge.textContent = String(n); + countBadge.classList.toggle('badge--active', n > 0); + } + + function setPreview(pairs) { + // Vide le contenu existant + while (previewEl.firstChild) previewEl.removeChild(previewEl.firstChild); + + if (!pairs || pairs.length === 0) return; + + const MAX_PREVIEW = 50; + const visible = pairs.slice(0, MAX_PREVIEW); + + for (const pair of visible) { + const item = document.createElement('li'); + item.className = 'preview-item'; + + const codeEl = document.createElement('span'); + codeEl.className = 'preview-code'; + codeEl.textContent = pair.code || '(code inconnu)'; + codeEl.title = codeEl.textContent; + + const idEl = document.createElement('span'); + idEl.className = 'preview-id'; + idEl.textContent = pair.id; + idEl.title = pair.id; + + item.appendChild(codeEl); + item.appendChild(idEl); + previewEl.appendChild(item); + } + + if (pairs.length > MAX_PREVIEW) { + const more = document.createElement('li'); + more.className = 'preview-more'; + more.textContent = '... et ' + (pairs.length - MAX_PREVIEW) + ' de plus'; + previewEl.appendChild(more); + } + } + + function setButtonState(state) { + copyBtn.classList.remove('btn--primary', 'btn--ok', 'btn--error'); + switch (state) { + case 'ok': + copyBtn.classList.add('btn--ok'); + copyLabel.textContent = 'Copié'; + break; + case 'error': + copyBtn.classList.add('btn--error'); + copyLabel.textContent = 'Échec de la copie'; + break; + case 'idle': + default: + copyBtn.classList.add('btn--primary'); + copyLabel.textContent = 'Copier les IDs'; + break; + } + } + + function isSmartUiUrl(url) { + if (!url) return false; + try { + const u = new URL(url); + return /\/SmartUI\//i.test(u.pathname); + } catch (e) { + return /\/SmartUI\//i.test(url); + } + } + + function queryActiveTab() { + return new Promise((resolve) => { + try { + browser.tabs.query({ active: true, currentWindow: true }, (tabs) => { + resolve(tabs && tabs[0] ? tabs[0] : null); + }); + } catch (e) { + resolve(null); + } + }); + } + + function sendMessage(tabId, msg) { + return new Promise((resolve) => { + let settled = false; + const done = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + try { + browser.tabs.sendMessage(tabId, msg, (response) => { + const err = browser.runtime && browser.runtime.lastError; + if (err) { + done({ ok: false, error: err.message || String(err) }); + } else { + done(response || { ok: false, error: 'empty_response' }); + } + }); + } catch (e) { + done({ ok: false, error: String(e && e.message ? e.message : e) }); + } + setTimeout(() => done({ ok: false, error: 'timeout' }), 1500); + }); + } + + async function refresh() { + const tab = await queryActiveTab(); + if (!tab) { + setStatus("Impossible de lire l'onglet actif.", 'error'); + setBadge(0); + setPreview([]); + setViewName(null); + copyBtn.disabled = true; + return; + } + + if (!isSmartUiUrl(tab.url)) { + setStatus("Cette page n'est pas une vue SmartUI.", 'warn'); + setBadge(0); + setPreview([]); + setViewName(null); + copyBtn.disabled = true; + return; + } + + setViewName(parseViewName(tab.url)); + + let resp = await sendMessage(tab.id, { type: 'GET_SELECTED_IDS' }); + + // Si le content script ne repond pas, l'injecter programmatiquement et reessayer + if (!resp || resp.ok === false) { + setStatus("Injection du content script...", 'info'); + try { + await browser.scripting.executeScript({ + target: { tabId: tab.id, allFrames: true }, + files: ['content/content-script.js'] + }); + // Attendre que le script s'initialise + await new Promise((r) => setTimeout(r, 800)); + resp = await sendMessage(tab.id, { type: 'GET_SELECTED_IDS' }); + } catch (e) { + console.warn('[TakeID] Injection failed:', e); + } + } + + if (!resp || resp.ok === false) { + setStatus( + "Le content script ne répond pas. Rechargez la page SmartUI puis réouvrez le popup.", + 'error' + ); + setBadge(0); + setPreview([]); + copyBtn.disabled = true; + return; + } + + const pairs = Array.isArray(resp.pairs) + ? resp.pairs + : (Array.isArray(resp.ids) ? resp.ids.map((id) => ({ id, code: null })) : []); + currentIds = pairs.map((p) => p.id); + + setBadge(currentIds.length); + setPreview(pairs); + + if (currentIds.length === 0) { + if (!resp.cacheSize) { + setStatus( + "Aucune donnée en cache. Naviguez sur la grille (ou rafraîchissez la vue) pour que l'extension capture les IDs.", + 'warn' + ); + } else if (!resp.selectedCount) { + setStatus( + "Aucune ligne sélectionnée. Cochez les lignes dans la grille puis réouvrez le popup.", + 'warn' + ); + } else { + setStatus( + "Lignes sélectionnées détectées mais aucun ID associé trouvé dans le cache.", + 'warn' + ); + } + copyBtn.disabled = true; + } else { + setStatus( + currentIds.length + ' ID' + (currentIds.length > 1 ? 's' : '') + ' prêt' + (currentIds.length > 1 ? 's' : '') + ' à copier.', + 'ok' + ); + copyBtn.disabled = false; + } + } + + async function copyIds() { + if (!currentIds || currentIds.length === 0) return; + const text = currentIds.join('\n'); + try { + await navigator.clipboard.writeText(text); + setButtonState('ok'); + setStatus(currentIds.length + ' ID(s) copié(s) dans le presse-papier.', 'ok'); + setTimeout(() => setButtonState('idle'), 1400); + } catch (e) { + // Fallback : execCommand + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + if (ok) { + setButtonState('ok'); + setStatus(currentIds.length + ' ID(s) copié(s).', 'ok'); + setTimeout(() => setButtonState('idle'), 1400); + } else { + throw new Error('execCommand failed'); + } + } catch (e2) { + setButtonState('error'); + setStatus('Échec de la copie : ' + (e2.message || e2), 'error'); + setTimeout(() => setButtonState('idle'), 1800); + } + } + } + + copyBtn.addEventListener('click', copyIds); + + document.addEventListener('DOMContentLoaded', refresh); + if (document.readyState !== 'loading') refresh(); +})();