Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions apps/extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1266,6 +1266,15 @@ void settingsReleaseCoordinator.boot().catch((error) => {
});

// Message handler — profile management + scan orchestration

/**
* AbortControllers actifs pour les générations Form Assistant en cours, indexés
* par requestId. Permet au content script d'annuler une génération (transition
* `requesting CANCEL → armed` du modèle) via FORM_ASSIST_CANCEL. Un Map par
* requestId évite qu'un onglet n'annule la génération d'un autre.
*/
const formAssistRequestControllers = new Map<string, AbortController>();

chrome.runtime.onMessage.addListener((rawMessage: unknown, _sender, sendResponse) => {
// ── Input validation ──────────────────────────────────────────────────────
const validation = validateMessage(rawMessage);
Expand Down Expand Up @@ -2230,6 +2239,160 @@ chrome.runtime.onMessage.addListener((rawMessage: unknown, _sender, sendResponse
return true;
}

// ── Form Assistant (content script ↔ SW) ──
// Source de vérité : src/models/form-assistant.model.md (Machine B).
// Phase 1 : chemin local uniquement. Le moteur remote (Eve/Vercel) est Phase 2.
if (message.type === 'FORM_ASSIST_STATUS') {
import('../lib/shell/form-assistant/settings')
.then(({ getFormAssistSettings }) => getFormAssistSettings())
.then((settings) => {
sendResponse({
type: 'FORM_ASSIST_STATUS_RESULT',
payload: { enabled: settings.enabled, engine: settings.engine },
});
})
.catch((err) => {
console.warn('[MissionPulse] FORM_ASSIST_STATUS error:', err);
sendResponse({
type: 'FORM_ASSIST_STATUS_RESULT',
payload: { enabled: false, engine: 'local' },
});
});
return true;
}

if (message.type === 'FORM_ASSIST_ENABLE') {
const { enabled } = message.payload;
import('../lib/shell/form-assistant/settings')
.then(({ setFormAssistEnabled }) => setFormAssistEnabled(enabled))
.then((settings) => {
const enabledMessage = {
type: 'FORM_ASSIST_ENABLED',
payload: { enabled: settings.enabled, engine: settings.engine },
};
// Broadcast au side panel (runtime) et aux content scripts (tabs).
// `runtime.sendMessage` depuis le SW ne touche que les pages de
// l'extension ; les content scripts écoutent sur `tabs`.
chrome.runtime.sendMessage(enabledMessage).catch(() => {
/* Panel fermé — ignore */
});
chrome.tabs
.query({})
.then((tabs) => {
for (const tab of tabs) {
if (typeof tab.id === 'number') {
chrome.tabs.sendMessage(tab.id, enabledMessage).catch(() => {
/* Pas de content script sur cet onglet — ignore */
});
}
}
})
.catch(() => {
/* tabs API indisponible — ignore */
});
sendResponse({
type: 'FORM_ASSIST_ENABLED',
payload: { enabled: settings.enabled, engine: settings.engine },
});
})
.catch((err) => {
console.warn('[MissionPulse] FORM_ASSIST_ENABLE error:', err);
sendResponse({
type: 'FORM_ASSIST_ENABLED',
payload: { enabled: false, engine: 'local' },
});
});
return true;
}

if (message.type === 'FORM_ASSIST_REQUEST') {
const { requestId, field } = message.payload;
const controller = new AbortController();
formAssistRequestControllers.set(requestId, controller);

(async () => {
try {
const { generateFieldProposal } =
await import('../lib/shell/form-assistant/local-generator');
const { getFormAssistSettings } = await import('../lib/shell/form-assistant/settings');
const profile = await getProfile();

const settings = await getFormAssistSettings();
if (!settings.enabled) {
sendResponse({
type: 'FORM_ASSIST_ERROR',
payload: {
requestId,
code: 'unavailable',
message: 'Form Assistant désactivé',
},
});
return;
}

if (!profile) {
sendResponse({
type: 'FORM_ASSIST_ERROR',
payload: {
requestId,
code: 'failed',
message: 'Profil indisponible',
},
});
return;
}

// Phase 1 : selectFormAssistEngine() est la source de vérité Core pour
// le choix du moteur. Ici seul le chemin local est câblé ; le chemin
// remote (Eve) est Phase 2 et renverra 'unavailable' tant que non impl.
const proposal = await generateFieldProposal(field, profile, controller.signal);
if (!proposal || proposal.text.length === 0) {
sendResponse({
type: 'FORM_ASSIST_ERROR',
payload: {
requestId,
code: 'unavailable',
message: 'Aucune proposition disponible',
},
});
return;
}

sendResponse({
type: 'FORM_ASSIST_PROPOSAL',
payload: { requestId, text: proposal.text, engine: 'local' },
});
} catch (err) {
if (controller.signal.aborted) {
sendResponse({
type: 'FORM_ASSIST_ERROR',
payload: { requestId, code: 'cancelled', message: 'Génération annulée' },
});
return;
}
console.warn('[MissionPulse] FORM_ASSIST_REQUEST error:', err);
sendResponse({
type: 'FORM_ASSIST_ERROR',
payload: { requestId, code: 'failed', message: 'Échec de génération' },
});
} finally {
formAssistRequestControllers.delete(requestId);
}
})();
return true;
}

if (message.type === 'FORM_ASSIST_CANCEL') {
const { requestId } = message.payload;
const controller = formAssistRequestControllers.get(requestId);
if (controller) {
controller.abort();
formAssistRequestControllers.delete(requestId);
}
sendResponse({ type: 'FORM_ASSIST_CANCEL_ACK', payload: { requestId } });
return false;
}

// ── Toast handler (forward to side panel) ──

if (message.type === 'SHOW_TOAST') {
Expand Down
133 changes: 133 additions & 0 deletions apps/extension/src/content/form-assistant/field-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Content script — DOM → RawFieldInput → FieldDescriptor (sanitisé + classifié).
*
* Lit uniquement des métadonnées (label/placeholder/type/required), jamais la
* valeur courante du champ. Délègue la sanitisation + classification au Core
* (sanitizeFieldDescriptor). Aucune logique métier ici.
*/
import type {
FieldDescriptor,
FieldInputType,
RawFieldInput,
} from '../../lib/core/form-assistant/types';
import { sanitizeFieldDescriptor } from '../../lib/core/form-assistant';

const MAX_TEXT_LEN = 200;

function trimText(value: string, max = MAX_TEXT_LEN): string {
return value.replace(/\s+/g, ' ').trim().slice(0, max);
}

/**
* Mappe un élément DOM vers un FieldInputType connu, ou `null` si le champ
* n'est pas éligible (mot de passe, checkbox, hidden, date, range, …).
*/
function resolveInputType(el: HTMLElement): FieldInputType | null {
const tag = el.tagName.toLowerCase();
if (tag === 'textarea') {
return 'textarea';
}
if (el.isContentEditable) {
return 'contenteditable';
}
if (tag !== 'input') {
return null;
}

const rawType = (el as HTMLInputElement).type.toLowerCase();
switch (rawType) {
case 'email':
return 'email';
case 'tel':
return 'tel';
case 'url':
return 'url';
case 'search':
return 'search';
case 'text':
case '':
return 'text';
default:
// password, checkbox, radio, hidden, date, number, range, file, color, …
return null;
}
}

/**
* Résout le libellé humain d'un champ, par ordre de fiabilité décroissante.
* Ne lève jamais ; retourne une chaîne vide si rien n'est trouvé.
*/
function resolveLabel(el: HTMLElement): string {
if (el.id) {
const associated = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
if (associated?.textContent) {
return trimText(associated.textContent);
}
}
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) {
return trimText(ariaLabel);
}
const labelledBy = el.getAttribute('aria-labelledby');
if (labelledBy) {
// `aria-labelledby` peut référencer plusieurs IDs séparés par des espaces
// (ex : "field-label field-hint"). On concatène le texte de chacun.
const ids = labelledBy.trim().split(/\s+/);
const texts: string[] = [];
for (const id of ids) {
if (!id) {
continue;
}
const labeller = document.getElementById(id);
if (labeller?.textContent) {
texts.push(trimText(labeller.textContent));
}
}
if (texts.length > 0) {
return trimText(texts.join(' '));
}
}
const wrapping = el.closest('label');
if (wrapping?.textContent) {
return trimText(wrapping.textContent);
}
return '';
}

function resolvePlaceholder(el: HTMLElement): string {
const ph = (el as HTMLInputElement).placeholder;
return ph ? trimText(ph) : '';
}

function resolveRequired(el: HTMLElement): boolean {
return el.hasAttribute('required') || el.getAttribute('aria-required') === 'true';
}

/**
* Construit le FieldDescriptor sanit-isé pour un élément focalisé, ou `null`
* si le champ n'est pas éligible au Form Assistant.
*/
export function detectFieldDescriptor(target: HTMLElement): FieldDescriptor | null {
const inputType = resolveInputType(target);
if (!inputType) {
return null;
Comment thread
guyghost marked this conversation as resolved.
}
// Champs non modifiables : rien à proposer.
if (
(target as HTMLInputElement).readOnly ||
(target as HTMLInputElement).disabled ||
target.getAttribute('aria-readonly') === 'true' ||
target.getAttribute('aria-disabled') === 'true'
) {
return null;
}

const raw: RawFieldInput = {
label: resolveLabel(target),
placeholder: resolvePlaceholder(target),
inputType,
required: resolveRequired(target),
};

return sanitizeFieldDescriptor(raw);
}
Loading
Loading