This commit is contained in:
Arthur Ria
2026-05-20 09:47:25 +02:00
commit 6484dd09c5
12 changed files with 1340 additions and 0 deletions
+364
View File
@@ -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://<HOST>/SmartUI/smartui?viewname=<AppName>|<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://<HOST>/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 `<tr>` ou `<div>` avec une classe comme `selected`, `checked`, `active`, `k-state-selected`
2. Des `<input type="checkbox">` 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 `<script>` tag dans le content script pour injecter `injected.js` dans le contexte page.
## Implémentation détaillée
### injected.js - Interception XHR
Ce script est injecté dans le contexte de la page (pas le monde isolé du content script).
```javascript
// Monkey-patch XMLHttpRequest pour intercepter les réponses getDataViewList
(function() {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...args) {
this._takeid_url = url;
return originalOpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function(body) {
if (this._takeid_url && this._takeid_url.includes('getDataViewList')) {
this.addEventListener('load', function() {
try {
const data = JSON.parse(this.responseText);
if (data && data.datos && Array.isArray(data.datos)) {
// Extraire le mapping
const items = data.datos.map((row, index) => {
const id = extractValue(row.id);
const code = extractValue(row.code);
return { index, id, code };
}).filter(item => item.id);
// Envoyer au content script
window.postMessage({
type: 'TAKEID_DATA',
items: items
}, '*');
}
} catch (e) {
// Silently ignore parse errors
}
});
}
return originalSend.call(this, body);
};
function extractValue(field) {
if (!field) return null;
if (typeof field === 'string') return field;
if (typeof field === 'object' && field.value !== undefined) return field.value;
return null;
}
})();
```
**Note :** Il faut aussi patcher `fetch()` si SmartUI l'utilise (vérifier pendant le développement). Il faut aussi surveiller les réponses qui arrivent via SignalR/WebSocket (le endpoint `smartuihubcore`). Dans ce cas, intercepter les messages WebSocket.
### content-script.js - Cache et lecture DOM
```javascript
// Cache des données
let dataCache = []; // [{index, id, code}, ...]
// Écouter les messages de injected.js
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'TAKEID_DATA') {
dataCache = event.data.items;
}
});
// Injecter injected.js dans le contexte page
const script = document.createElement('script');
script.src = browser.runtime.getURL('content/injected.js');
document.documentElement.appendChild(script);
// Répondre aux messages du popup
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'GET_SELECTED_IDS') {
const ids = getSelectedIds();
sendResponse({ ids });
}
});
function getSelectedIds() {
// TODO: Implémenter la lecture des lignes sélectionnées dans le DOM
// Stratégie: trouver les checkboxes cochées, identifier leur index de ligne,
// mapper vers dataCache[index].id
}
```
### popup.js - Interface utilisateur
Le popup affiche :
- Nombre d'éléments sélectionnés
- Liste des IDs (preview)
- Bouton "Copier"
- Message de confirmation
## Commandes de développement
```bash
# Pas de build nécessaire - vanilla JS
# Tester sur Chrome : chrome://extensions -> Mode développeur -> Charger l'extension non empaquetée
# Tester sur Firefox : about:debugging -> Ce Firefox -> Charger un module temporaire -> manifest.json
```
## Contraintes
1. **Pas de dépendances externes** - Vanilla JS uniquement, pas de npm/build
2. **Pas de credentials** - L'extension n'a pas besoin de s'authentifier, elle intercepte les données qui transitent déjà
3. **Pagination** - Le cache est rafraîchi à chaque chargement de page de données (50 lignes). Quand l'utilisateur change de page dans la grille, un nouveau `getDataViewList` est émis et le cache est remplacé
4. **Multi-vues** - L'extension doit fonctionner sur n'importe quelle vue SmartUI (Products, OutboundOrders, Containers...), pas seulement les Articles
5. **Performance** - Le monkey-patching ne doit pas ralentir SmartUI. Ne parser que les réponses `getDataViewList`
6. **Icônes** - Générer des icônes SVG simples en placeholder (un "ID" stylisé ou un presse-papier)
## Bugs corrigés (post-MVP)
### Bug 1 - getDataViewList via SignalR (body, pas URL)
SmartUI envoie les appels `getDataViewList` via un hub SignalR (`smartuihubcore`). Le nom de méthode `getDataViewList` est dans le **body** de la requête, pas dans l'URL. Le check initial `url.indexOf('getDataViewList')` échouait silencieusement.
**Fix dans injected.js :** La fonction `isTargetRequest(url, body)` vérifie maintenant l'URL ET le body de la requête pour détecter `getDataViewList`.
### Bug 2 - Cache écrasé par une réponse vide
SmartUI fait **deux** appels `getDataViewList` : un avec les 50 lignes de données, et un second (count/metadata) qui retourne `datos: []`. Le second écrasait le cache avec 0 items.
**Fix dans content-script.js :** On ignore les réponses avec `items.length === 0` pour ne pas écraser un cache valide.
### Bug 3 - event.source !== window sur Firefox
Les content scripts Firefox utilisent un "Xray wrapper". `event.source` (page réelle) n'est pas strictement égal à `window` (wrapper), donc le check `event.source !== window` bloquait la réception des postMessage.
**Fix dans content-script.js :** Suppression du check `event.source !== window`. Le filtrage se fait uniquement via `data.type === 'TAKEID_DATA'`.
## Priorités de développement
1. **Phase 1 - MVP :** ✅ Interception XHR + cache + lecture DOM + copie clipboard. Testé sur les vues Articles et Stock.
2. **Phase 2 - Polish :** Icônes, messages d'état, gestion des cas limites (pas de sélection, cache vide, page sans SmartUI).
3. **Phase 3 - Robustesse :** Support WebSocket/SignalR si certaines vues chargent les données autrement. Support multi-onglets.
+223
View File
@@ -0,0 +1,223 @@
(function () {
'use strict';
const browser = globalThis.browser || globalThis.chrome;
if (window.__takeid_content_script__) return;
window.__takeid_content_script__ = true;
// Cache des items recus du script injecte: [{index, id, code}]
let dataCache = [];
let lastCacheTimestamp = 0;
// ----- Injection de injected.js dans le contexte page -----
function injectPageScript() {
try {
const script = document.createElement('script');
script.src = browser.runtime.getURL('content/injected.js');
script.async = false;
script.onload = function () {
// Une fois execute, on retire l'element pour ne pas polluer le DOM
if (script.parentNode) script.parentNode.removeChild(script);
};
(document.head || document.documentElement).appendChild(script);
} catch (e) {
// swallow
}
}
injectPageScript();
// ----- Reception des donnees de injected.js -----
// Note: on ne filtre PAS sur event.source car sur Firefox le content script
// utilise un Xray wrapper et event.source !== window meme si c'est la meme page.
window.addEventListener('message', (event) => {
const data = event.data;
if (!data || typeof data !== 'object') return;
if (data.type !== 'TAKEID_DATA') return;
if (!Array.isArray(data.items)) return;
// Ne pas ecraser le cache avec une reponse vide (certains appels getDataViewList
// sont des requetes de count/metadata qui retournent datos=[])
if (data.items.length === 0) {
console.log('[TakeID] Ignoring empty getDataViewList response (keeping', dataCache.length, 'items in cache)');
return;
}
dataCache = data.items;
lastCacheTimestamp = data.timestamp || Date.now();
console.log('[TakeID] Cache updated:', dataCache.length, 'items');
});
// ----- Lecture du DOM : trouver les lignes selectionnees -----
function normalizeCode(s) {
if (s === null || s === undefined) return '';
return String(s).trim();
}
function findGridRoot() {
// Heuristique : chercher un conteneur de grille
const candidates = [
'.k-grid',
'[role="grid"]',
'table.grid',
'.smartui-grid',
'table'
];
for (const sel of candidates) {
const el = document.querySelector(sel);
if (el) return el;
}
return document;
}
function getAllDataRows(root) {
// Recupere toutes les lignes "data" (potentiellement tr ou divs)
let rows = root.querySelectorAll('tr[role="row"]');
if (rows && rows.length) return Array.from(rows);
rows = root.querySelectorAll('tbody > tr');
if (rows && rows.length) return Array.from(rows);
rows = root.querySelectorAll('[role="row"]');
if (rows && rows.length) return Array.from(rows);
rows = root.querySelectorAll('.k-grid-content tr');
if (rows && rows.length) return Array.from(rows);
return [];
}
function isHeaderRow(row) {
if (!row) return false;
if (row.matches && (row.matches('thead tr') || row.matches('[role="row"][aria-rowindex="1"]'))) return true;
// ligne sans cellules <td>, juste des <th>
if (row.querySelector && row.querySelector('th') && !row.querySelector('td')) return true;
return false;
}
function rowIsSelected(row) {
if (!row) return false;
// 1. classes communes
const selectedClasses = ['selected', 'k-state-selected', 'k-selected', 'is-selected', 'row-selected', 'active'];
if (row.classList) {
for (const cls of selectedClasses) {
if (row.classList.contains(cls)) return true;
}
}
// 2. aria-selected
if (row.getAttribute && row.getAttribute('aria-selected') === 'true') return true;
// 3. checkbox cochee a l'interieur de la ligne
const cb = row.querySelector && row.querySelector('input[type="checkbox"]');
if (cb && cb.checked) return true;
return false;
}
function extractCodeFromRow(row) {
if (!row) return null;
// 1. data-attribute eventuel
const dataAttrs = ['data-code', 'data-key', 'data-id'];
for (const a of dataAttrs) {
const v = row.getAttribute && row.getAttribute(a);
if (v) return normalizeCode(v);
}
// 2. premier lien clickable (souvent la colonne Code dans SmartUI)
const link = row.querySelector && row.querySelector('a');
if (link && link.textContent) {
const t = normalizeCode(link.textContent);
if (t) return t;
}
// 3. premiere cellule "non checkbox"
const cells = row.querySelectorAll ? row.querySelectorAll('td') : [];
for (const cell of cells) {
if (cell.querySelector('input[type="checkbox"]')) continue;
const t = normalizeCode(cell.textContent);
if (t) return t;
}
return null;
}
function indexOfDataRow(row, allRows) {
// Trouve l'index de la ligne dans la sequence des lignes "data" (hors header)
const dataRows = allRows.filter((r) => !isHeaderRow(r));
return dataRows.indexOf(row);
}
function getSelectedIds() {
const root = findGridRoot();
const allRows = getAllDataRows(root);
const dataRows = allRows.filter((r) => !isHeaderRow(r));
const selectedRows = dataRows.filter(rowIsSelected);
// Strategie A : trouver via le code (lookup dans le cache)
const cacheByCode = new Map();
for (const item of dataCache) {
if (item.code) cacheByCode.set(normalizeCode(item.code), item.id);
}
const pairs = [];
const seen = new Set();
for (const row of selectedRows) {
let id = null;
let resolvedCode = null;
const code = extractCodeFromRow(row);
if (code && cacheByCode.has(code)) {
id = cacheByCode.get(code);
resolvedCode = code;
}
// Strategie B : par index si le code n'a pas matche
if (!id) {
const idx = dataRows.indexOf(row);
if (idx >= 0 && idx < dataCache.length) {
id = dataCache[idx].id;
resolvedCode = dataCache[idx].code || code || null;
}
}
if (id && !seen.has(id)) {
seen.add(id);
pairs.push({ id, code: resolvedCode });
}
}
return {
pairs,
ids: pairs.map((p) => p.id),
selectedCount: selectedRows.length,
cacheSize: dataCache.length,
cacheTimestamp: lastCacheTimestamp
};
}
// ----- Reception des messages du popup -----
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!msg || typeof msg !== 'object') return false;
if (msg.type === 'TAKEID_PING') {
sendResponse({ ok: true, cacheSize: dataCache.length });
return false;
}
if (msg.type === 'GET_SELECTED_IDS') {
try {
const result = getSelectedIds();
sendResponse({ ok: true, ...result });
} catch (e) {
sendResponse({ ok: false, error: String(e && e.message ? e.message : e) });
}
return false;
}
return false;
});
})();
+155
View File
@@ -0,0 +1,155 @@
(function () {
'use strict';
if (window.__takeid_injected__) return;
window.__takeid_injected__ = true;
const TARGET_SUBSTR = 'getDataViewList';
const LOG_PREFIX = '[TakeID]';
function extractValue(field) {
if (field === null || field === undefined) return null;
if (typeof field === 'string') return field;
if (typeof field === 'number' || typeof field === 'boolean') return String(field);
if (typeof field === 'object') {
if (Object.prototype.hasOwnProperty.call(field, 'value')) {
return extractValue(field.value);
}
}
return null;
}
function parseAndPost(responseText) {
if (!responseText || typeof responseText !== 'string') return;
let data;
try {
data = JSON.parse(responseText);
} catch (e) {
return;
}
if (!data || !Array.isArray(data.datos)) return;
const items = data.datos
.map((row, index) => {
if (!row || typeof row !== 'object') return null;
const id = extractValue(row.id);
const code = extractValue(row.code);
return { index, id, code };
})
.filter((item) => item && item.id);
console.log(LOG_PREFIX, 'Posting', items.length, 'items to content script');
window.postMessage(
{
type: 'TAKEID_DATA',
items: items,
timestamp: Date.now()
},
'*'
);
}
// Verifie si l'URL OU le body contient getDataViewList
function isTargetRequest(url, body) {
if (url && url.indexOf(TARGET_SUBSTR) !== -1) return true;
if (body && typeof body === 'string' && body.indexOf(TARGET_SUBSTR) !== -1) return true;
return false;
}
// --- Monkey-patch XMLHttpRequest ---
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
try {
this.__takeid_url = typeof url === 'string' ? url : (url && url.toString ? url.toString() : '');
} catch (e) {
this.__takeid_url = '';
}
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
try {
// Convertir le body en string pour la recherche
let bodyStr = '';
if (body) {
if (typeof body === 'string') {
bodyStr = body;
} else if (body instanceof FormData) {
// FormData: on ne peut pas facilement chercher dedans, skip
bodyStr = '';
} else {
try { bodyStr = JSON.stringify(body); } catch (e) { bodyStr = ''; }
}
}
if (isTargetRequest(this.__takeid_url, bodyStr)) {
console.log(LOG_PREFIX, 'Intercepted getDataViewList - URL:', this.__takeid_url);
this.addEventListener('load', function () {
try {
let text = null;
if (this.responseType === '' || this.responseType === 'text') {
text = this.responseText;
} else if (this.responseType === 'json' && this.response) {
try {
text = JSON.stringify(this.response);
} catch (e) {
text = null;
}
}
if (text) {
console.log(LOG_PREFIX, 'Response received, length:', text.length);
parseAndPost(text);
} else {
console.warn(LOG_PREFIX, 'Could not read response text, responseType:', this.responseType);
}
} catch (e) {
console.warn(LOG_PREFIX, 'Error reading response:', e);
}
});
}
} catch (e) {
// swallow
}
return originalSend.apply(this, arguments);
};
// --- Monkey-patch fetch (au cas ou SmartUI l'utilise) ---
if (typeof window.fetch === 'function') {
const originalFetch = window.fetch;
window.fetch = function (input, init) {
let url = '';
try {
if (typeof input === 'string') {
url = input;
} else if (input && typeof input.url === 'string') {
url = input.url;
}
} catch (e) {
url = '';
}
const promise = originalFetch.apply(this, arguments);
if (url && url.indexOf(TARGET_SUBSTR) !== -1) {
promise
.then((response) => {
try {
const clone = response.clone();
clone
.text()
.then((text) => parseAndPost(text))
.catch(() => {});
} catch (e) {
// swallow
}
return response;
})
.catch(() => {});
}
return promise;
};
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
</defs>
<!-- Fond arrondi -->
<rect x="0" y="0" width="128" height="128" rx="24" ry="24" fill="url(#bg)"/>
<!-- Pince de presse-papier (haut) -->
<rect x="48" y="14" width="32" height="14" rx="4" ry="4" fill="#ffffff" opacity="0.92"/>
<!-- Texte "ID" -->
<text x="64" y="92"
font-family="-apple-system, 'Segoe UI', Roboto, sans-serif"
font-weight="700"
font-size="64"
text-anchor="middle"
fill="#ffffff">ID</text>
</svg>

After

Width:  |  Height:  |  Size: 789 B

+44
View File
@@ -0,0 +1,44 @@
{
"manifest_version": 3,
"name": "TakeID - WMS SmartUI ID Grabber",
"version": "1.0.0",
"description": "Copie les IDs internes (GUIDs) des elements selectionnes dans les vues SmartUI du WMS EasyWMS.",
"permissions": ["activeTab", "clipboardWrite", "scripting"],
"host_permissions": ["*://*/*"],
"action": {
"default_popup": "popup/popup.html",
"default_title": "TakeID",
"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",
"all_frames": true
}
],
"web_accessible_resources": [
{
"resources": ["content/injected.js"],
"matches": ["*://*/*"]
}
],
"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"
}
}
}
+222
View File
@@ -0,0 +1,222 @@
:root {
--bg: #ffffff;
--fg: #1f2937;
--muted: #6b7280;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--success: #16a34a;
--warning: #d97706;
--error: #dc2626;
--border: #e5e7eb;
--surface: #f9fafb;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
width: 360px;
min-height: 260px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color: var(--fg);
background: var(--bg);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px 10px 14px;
border-bottom: 1px solid var(--border);
}
.header-text {
display: flex;
flex-direction: column;
min-width: 0;
gap: 2px;
}
.title {
margin: 0;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.2px;
}
.view-name {
display: block;
font-size: 11.5px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
}
.view-name::before {
content: "Vue : ";
color: #9ca3af;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 22px;
padding: 0 8px;
font-size: 12px;
font-weight: 600;
color: #fff;
background: var(--muted);
border-radius: 999px;
}
.badge.badge--active {
background: var(--primary);
}
.status {
margin: 10px 14px 6px 14px;
padding: 8px 10px;
font-size: 12px;
border-radius: 6px;
border: 1px solid transparent;
line-height: 1.35;
}
.status--info {
color: #1e3a8a;
background: #eff6ff;
border-color: #bfdbfe;
}
.status--ok {
color: #166534;
background: #f0fdf4;
border-color: #bbf7d0;
}
.status--warn {
color: #92400e;
background: #fffbeb;
border-color: #fde68a;
}
.status--error {
color: #991b1b;
background: #fef2f2;
border-color: #fecaca;
}
.preview-wrapper {
padding: 4px 14px 0 14px;
}
.preview-label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted);
margin: 6px 0 4px 0;
}
.preview {
list-style: none;
margin: 0;
padding: 4px;
height: 140px;
overflow: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 11.5px;
color: #374151;
}
.preview-item {
display: flex;
flex-direction: column;
padding: 5px 8px;
border-radius: 4px;
gap: 1px;
}
.preview-item + .preview-item {
margin-top: 2px;
border-top: 1px solid var(--border);
}
.preview-code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 11.5px;
color: #111827;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-id {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 10.5px;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-more {
padding: 6px 8px;
font-size: 11px;
font-style: italic;
color: var(--muted);
text-align: center;
}
.footer {
padding: 10px 14px 14px 14px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 9px 12px;
font-size: 13px;
font-weight: 600;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn--primary {
color: #fff;
background: var(--primary);
}
.btn--primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.btn--ok {
color: #fff;
background: var(--success);
}
.btn--error {
color: #fff;
background: var(--error);
}
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title>TakeID</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<header class="header">
<div class="header-text">
<h1 class="title">TakeID - SmartUI</h1>
<span id="view-name" class="view-name" hidden></span>
</div>
<span id="count-badge" class="badge">0</span>
</header>
<section id="status" class="status status--info">Lecture de la page...</section>
<section class="preview-wrapper">
<label class="preview-label">Sélection</label>
<ul id="preview" class="preview" aria-live="polite"></ul>
</section>
<footer class="footer">
<button id="copy-btn" class="btn btn--primary" type="button" disabled>
<span class="btn-label">Copier les IDs</span>
</button>
</footer>
<script src="popup.js"></script>
</body>
</html>
+277
View File
@@ -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();
})();