From ae3732bb00653e33b2bf58276d6e0df0dadd25bd Mon Sep 17 00:00:00 2001 From: yvanfocsa Date: Fri, 31 Jul 2026 14:52:11 +0200 Subject: [PATCH] Harden installation and application workflow --- README.md | 173 ++++++-- UTILISATION_SYSTEME_CV.md | 65 ++- app/backend-guards.mjs | 149 +++++++ app/doctor.mjs | 301 ++++++++++--- app/job-sourcer.mjs | 117 ++--- app/offer-source.mjs | 4 +- app/public/app.js | 139 ++++-- app/public/index.html | 84 +++- app/public/styles.css | 412 +++++++++++++++++- app/server.mjs | 749 +++++++++++++++++++++++--------- package.json | 10 +- pnpm-lock.yaml | 9 - scripts/setup.mjs | 100 +++++ scripts/start.mjs | 178 ++++++++ start-openapply.cmd | 16 +- start-openapply.command | 53 ++- start-openapply.sh | 33 ++ test/backend-guards.test.mjs | 67 +++ test/doctor.test.mjs | 52 +++ test/frontend-contract.test.mjs | 37 ++ test/start.test.mjs | 30 ++ 21 files changed, 2314 insertions(+), 464 deletions(-) create mode 100644 app/backend-guards.mjs mode change 100644 => 100755 app/doctor.mjs delete mode 100644 pnpm-lock.yaml create mode 100755 scripts/setup.mjs create mode 100755 scripts/start.mjs create mode 100755 start-openapply.sh create mode 100644 test/backend-guards.test.mjs create mode 100644 test/doctor.test.mjs create mode 100644 test/frontend-contract.test.mjs create mode 100644 test/start.test.mjs diff --git a/README.md b/README.md index 91ab461..5ee60a1 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,148 @@ # OpenApply -OpenApply est une application locale et open source qui aide toute personne à préparer des candidatures ciblées, quel que soit son métier. Elle analyse une offre, signale les compétences à confirmer, adapte un CV et une lettre à partir de faits vérifiés, puis génère des fichiers DOCX et PDF. +OpenApply est une application locale et open source qui prépare des candidatures ciblées à partir de faits vérifiés. Elle analyse une offre, demande confirmation lorsqu’une compétence manque, adapte les copies d’un CV et d’une lettre, puis produit les fichiers DOCX et PDF. -## Principes - -- **Tous les métiers** : les catégories couvrent notamment le numérique, la santé, l’enseignement, l’industrie, le bâtiment, le commerce, l’hôtellerie, la logistique, l’administration et les fonctions support. -- **Aucune invention** : le moteur utilise seulement les faits saisis dans le profil actif. -- **Données locales** : profils, modèles, clés, offres et documents restent dans `.openapply/` et `generated/`, deux dossiers exclus de Git. -- **Profils isolés** : une candidature ne peut utiliser que les faits et modèles du profil actif. -- **Interface guidée** : le profil et le moteur IA se configurent dans l’application, sans passage obligatoire par le terminal. -- **Sources ouvertes** : licence MIT, tests automatiques et règles de contribution publiques. - -## Fonctionnalités +## Ce que fait OpenApply - Jusqu’à 10 offres ou candidatures spontanées par lot. -- Import de liens depuis un collage, un tableau ou une feuille Excel. +- Import de liens par collage, depuis un tableau ou une feuille Excel. - Classification locale par métier, contrat, niveau, langue, mode de travail et outils détectés. - Veille via Greenhouse, Lever, France Travail et La Bonne Alternance. -- Connexion visuelle à Codex, Google Antigravity, GitHub Copilot CLI, Hermes Agent, Ollama, LM Studio, llama.cpp ou à une API compatible. -- Confirmation des écarts de compétences avant adaptation. -- Génération d’un CV et d’une lettre en DOCX et PDF, avec contrôle LibreOffice sur une page. -- Reprise après interruption, cache local et relance ciblée des seuls postes en échec. +- Connexion à Codex, Google Antigravity, GitHub Copilot CLI, Hermes Agent, Ollama, LM Studio, llama.cpp ou à une API compatible. +- Confirmation rapide et honnête des écarts de compétences. +- Génération d’un CV et d’une lettre en DOCX et PDF. +- Contrôle LibreOffice pour conserver la mise en page et une page par document. +- Reprise après interruption, cache local et relance des seuls postes en échec. + +OpenApply ne modifie jamais les modèles importés. Les profils sont isolés et le moteur n’utilise que les faits du profil actif. + +## Installation rapide -## Installation +Il faut installer trois composants : -Prérequis : Node.js 20 ou plus récent, Python 3 et LibreOffice. +- Node.js 20 ou plus récent, avec npm ; +- Python 3, avec le module `venv` ; +- LibreOffice. -### Windows +Au premier `npm start` ou double-clic sur le lanceur, OpenApply crée automatiquement un environnement Python privé dans `.venv`, installe les modules documentaires et vérifie l’installation. `npm run setup` reste disponible pour relancer cette préparation manuellement. + +### Windows 10 et 11 + +Ouvre PowerShell puis exécute : ```powershell winget install OpenJS.NodeJS.LTS +winget install Python.Python.3.12 winget install TheDocumentFoundation.LibreOffice -py -3 -m venv .venv -.\.venv\Scripts\python.exe -m pip install -r requirements.txt -npm install -npm run doctor ``` -Double-clique ensuite sur `start-openapply.cmd`, ou lance `npm start` puis ouvre . +Ferme et rouvre PowerShell après l’installation de Node.js. Télécharge ou clone ensuite OpenApply : + +```powershell +git clone https://github.com/yvanfocsa/OpenApply.git +cd OpenApply +npm start +``` + +Tu peux aussi double-cliquer directement sur `start-openapply.cmd`. Le premier lancement prépare l’application, puis ouvre le navigateur. Les lancements suivants sont immédiats. ### macOS +Avec [Homebrew](https://brew.sh/) : + ```bash -brew install node +brew install node python brew install --cask libreoffice -python3 -m venv .venv -.venv/bin/python -m pip install -r requirements.txt -npm install -npm run doctor +git clone https://github.com/yvanfocsa/OpenApply.git +cd OpenApply +npm start ``` -Double-clique ensuite sur `start-openapply.command`, ou lance `npm start`. +Tu peux aussi double-cliquer directement sur `start-openapply.command`. Le premier lancement prépare l’application, puis ouvre le navigateur. Si macOS refuse le premier double-clic, ouvre le fichier avec clic droit, puis « Ouvrir ». + +### Linux, Debian et Ubuntu + +Installe d’abord Node.js 20 ou plus récent depuis [nodejs.org](https://nodejs.org/) ou avec le gestionnaire de versions de ton choix. Installe ensuite les dépendances système : + +```bash +sudo apt update +sudo apt install python3 python3-venv python3-pip libreoffice +git clone https://github.com/yvanfocsa/OpenApply.git +cd OpenApply +npm start +``` + +Pour les lancements suivants, utilise `npm start` ou `./start-openapply.sh`. + +### Installation depuis une archive ZIP + +Git n’est pas obligatoire. Télécharge l’archive du projet, décompresse-la, ouvre un terminal dans ce dossier puis exécute : + +```bash +npm start +``` + +Après avoir installé Node.js, Python et LibreOffice, `npm start` suffit : la préparation manquante est détectée et lancée automatiquement. + +L’application attend que le serveur soit prêt, puis ouvre . Si elle fonctionne déjà, la commande ouvre simplement l’instance existante. ## Premier lancement -OpenApply ouvre automatiquement la configuration du profil si les faits ou les modèles manquent. Importe : +OpenApply guide la création du premier profil. Prépare : 1. les faits vérifiés du candidat ; -2. un modèle de CV français et/ou anglais ; -3. un modèle de lettre DOCX ; -4. le moteur IA souhaité. +2. un modèle de CV français et/ou anglais au format DOCX ; +3. un modèle de lettre au format DOCX ; +4. un moteur IA local, un CLI connecté ou une clé API compatible. + +Un seul moteur IA est nécessaire. Les moteurs indiqués comme optionnels par le diagnostic n’empêchent pas le lancement. Chaque utilisateur apporte ses propres modèles. Aucun CV réel n’est fourni dans le dépôt public. -## Confidentialité +## Diagnostic et dépannage + +La première commande à lancer en cas de problème est : + +```bash +npm run doctor +``` + +Le diagnostic contrôle Node.js, npm, Python, les modules Python, LibreOffice, les droits d’écriture et les moteurs IA détectés. Il affiche une commande de correction adaptée à Windows, macOS ou Linux. + +Une version exploitable par un outil automatisé est disponible avec : + +```bash +npm run doctor:json +``` + +Problèmes courants : + +- **Node.js trop ancien** : installe la version LTS actuelle, ferme le terminal, puis rouvre-le. +- **Modules Python manquants** : relance `npm run setup`. L’installation reste isolée dans `.venv`. +- **LibreOffice introuvable** : installe l’application complète, puis relance le terminal. +- **Port 4173 occupé** : choisis un autre port. Sous PowerShell, utilise `$env:PORT=4174; npm start`. Sous macOS ou Linux, utilise `PORT=4174 npm start`. +- **Le navigateur ne doit pas s’ouvrir** : utilise `npm run start:server`, ou définis `OPENAPPLY_NO_BROWSER=1` avant `npm start`. +- **Python installé dans un emplacement particulier** : définis `OPENAPPLY_PYTHON` avec le chemin complet de l’exécutable, puis relance `npm run setup`. + +## Configuration locale + +Les réglages ordinaires se font dans l’interface. Ces variables restent disponibles pour les installations avancées : + +| Variable | Utilité | Valeur par défaut | +| --- | --- | --- | +| `PORT` | Port HTTP local | `4173` | +| `OPENAPPLY_DATA_DIR` | Dossier privé des profils et réglages | `.openapply/` | +| `OPENAPPLY_PYTHON` | Chemin d’un interpréteur Python précis | Détection automatique | +| `OPENAPPLY_NO_BROWSER` | Empêche l’ouverture automatique avec la valeur `1` | Désactivé | +| `OPENAPPLY_SKIP_SETUP` | Ignore la préparation automatique avec la valeur `1` | Désactivé | + +OpenApply écoute uniquement sur `127.0.0.1`. Il n’expose pas l’interface au réseau local. -Les données locales sont ignorées par Git. Les secrets saisis dans l’interface sont chiffrés au repos dans le coffre local ; la clé de coffre demeure sur le même appareil, ce qui protège contre une lecture accidentelle mais pas contre un compte système déjà compromis. OpenApply écoute uniquement sur `127.0.0.1` et applique des protections de navigateur strictes. +## Moteurs IA et confidentialité + +OpenApply n’envoie une offre et les faits du profil actif qu’au moteur choisi. Un moteur local comme Ollama, LM Studio ou llama.cpp peut être utilisé lorsque les données ne doivent pas quitter l’appareil. + +Les données privées sont enregistrées dans `.openapply/` et `generated/`, deux dossiers exclus de Git. Les secrets saisis dans l’interface sont chiffrés au repos dans le coffre local. La clé du coffre reste sur le même appareil, ce qui protège contre une lecture accidentelle mais pas contre un compte système déjà compromis. Avant un commit ou une publication : @@ -74,20 +151,16 @@ npm run privacy:check npm run check ``` -Le premier contrôle bloque les dossiers locaux, les formats de documents personnels et plusieurs formes courantes de secrets. Consulte aussi [PRIVACY.md](PRIVACY.md) et [SECURITY.md](SECURITY.md). - -## Moteurs IA et réseau - -OpenApply n’envoie une offre et les faits du profil actif qu’au moteur choisi. Un moteur local compatible peut être utilisé pour éviter un service distant. Les appels aux sources d’offres ne sont effectués que lorsque la veille correspondante est configurée et activée. +Consulte aussi [PRIVACY.md](PRIVACY.md) et [SECURITY.md](SECURITY.md). ## Sources d’offres - Greenhouse et Lever : pages carrières publiques, sans clé. -- France Travail : identifiant client et secret d’une application développeur autorisée pour `Offres d’emploi v2`. +- France Travail : identifiant client et secret d’une application autorisée pour `Offres d’emploi v2`. - La Bonne Alternance : jeton pour `Recherche d’opportunités d’emploi en alternance`. -- LinkedIn et Welcome to the Jungle : import manuel lorsque l’accès public automatisé n’est pas disponible. +- LinkedIn et Welcome to the Jungle : import manuel lorsqu’un accès public automatisé n’est pas disponible. -Les identifiants peuvent être saisis dans l’interface ou fournis par variables d’environnement : +Les identifiants peuvent être saisis dans l’interface. Les variables d’environnement restent disponibles pour une installation administrée : ```bash FRANCE_TRAVAIL_CLIENT_ID="..." \ @@ -96,14 +169,18 @@ LA_BONNE_ALTERNANCE_TOKEN="..." \ npm start ``` -## Stockage personnalisé +## Mise à jour -Par défaut, les données privées sont stockées dans `.openapply/`. Pour utiliser un autre emplacement local : +Depuis un clone Git : ```bash -OPENAPPLY_DATA_DIR="/chemin/prive/openapply" npm start +git pull --ff-only +npm run setup +npm run check ``` +Les profils et documents locaux ne sont pas remplacés par cette procédure. Une sauvegarde du dossier défini par `OPENAPPLY_DATA_DIR` reste recommandée avant une mise à jour importante. + ## Contribution -Les corrections, traductions et nouveaux métiers sont bienvenus. Lis [CONTRIBUTING.md](CONTRIBUTING.md). En participant, tu acceptes la licence [MIT](LICENSE). \ No newline at end of file +Les corrections, traductions et nouveaux métiers sont bienvenus. Lis [CONTRIBUTING.md](CONTRIBUTING.md). En participant, tu acceptes la licence [MIT](LICENSE). diff --git a/UTILISATION_SYSTEME_CV.md b/UTILISATION_SYSTEME_CV.md index fd8ae6b..da1c1b6 100644 --- a/UTILISATION_SYSTEME_CV.md +++ b/UTILISATION_SYSTEME_CV.md @@ -1,27 +1,72 @@ # Utiliser OpenApply -## 1. Créer le profil +## Avant de commencer -Ouvre les réglages du profil, indique un nom, les domaines recherchés et les faits vérifiés : expériences, études, compétences, langues et contraintes utiles. N’ajoute pas de secret ni de donnée sans rapport avec une candidature. +Le premier double-clic sur le lanceur Windows ou macOS prépare automatiquement les modules documentaires. Une installation fonctionnelle doit ensuite afficher uniquement des contrôles obligatoires en vert avec : -Importe ensuite les modèles DOCX du profil. OpenApply travaille sur des copies et ne modifie jamais ces sources. +```bash +npm run doctor +``` + +Les moteurs IA absents sont marqués comme optionnels. Un seul moteur prêt suffit. + +## 1. Créer un profil + +Au premier lancement, ouvre les réglages du profil et renseigne : + +- le nom du profil ; +- les domaines et contrats recherchés ; +- les expériences, études, compétences et langues vérifiées ; +- les contraintes utiles aux candidatures. + +Importe ensuite les modèles DOCX de ce profil. OpenApply travaille toujours sur des copies et ne modifie jamais les fichiers sources. + +Chaque personne doit disposer de son propre profil. Les faits et modèles d’un autre profil ne sont jamais réutilisés. ## 2. Connecter un moteur -Choisis un moteur dans les réglages. L’assistant visuel indique si une installation, une connexion de compte ou une clé API est nécessaire. Un moteur local est préférable lorsque les données ne doivent pas quitter l’appareil. +Choisis un moteur dans les réglages. L’assistant indique si une installation, une connexion de compte ou une clé API est nécessaire. Un moteur local est préférable lorsque les données ne doivent pas quitter l’appareil. + +Il n’est pas nécessaire de configurer tous les moteurs. Après une première connexion, utilise le bouton de vérification affiché dans l’interface. + +## 3. Ajouter les candidatures + +Tu peux coller : -## 3. Ajouter les cibles +- un lien d’offre ; +- plusieurs liens, avec un lien par ligne ; +- une colonne copiée depuis un tableur ; +- le texte complet d’une offre ; +- une cible de candidature spontanée. -Colle jusqu’à 10 liens, une colonne de tableur, le texte d’une offre ou une candidature spontanée. Le contrat et la langue peuvent rester en mode automatique ou être imposés manuellement. +Le contrat, la langue et le domaine peuvent rester en mode automatique ou être imposés. Un choix manuel l’emporte toujours sur la détection. ## 4. Confirmer les écarts -OpenApply compare les exigences de l’offre aux faits du profil. Confirme le niveau réel pour chaque compétence importante. Une réponse ne doit jamais exagérer l’expérience. +OpenApply compare les exigences de l’offre aux faits du profil. Pour chaque compétence importante, indique rapidement le niveau réel : professionnel, encadré, projet, notions ou jamais pratiqué. + +Les champs de commentaire sont facultatifs. Utilise-les seulement lorsqu’un contexte court peut améliorer la formulation sans exagérer l’expérience. ## 5. Générer et contrôler -Le moteur adapte les copies du CV et de la lettre. LibreOffice vérifie ensuite le rendu PDF et la limite d’une page. Les résultats se trouvent sous `generated//` et peuvent être téléchargés depuis l’interface. +Le moteur adapte une copie du CV et de la lettre. LibreOffice génère ensuite les PDF et vérifie que chaque document tient sur une page. + +Les résultats se trouvent sous `generated//` et peuvent être téléchargés depuis l’interface en DOCX ou PDF. + +En cas d’interruption ou de quota épuisé, reprends la candidature depuis l’étape enregistrée avec un autre moteur. Les offres déjà analysées et les documents déjà validés ne doivent pas être recalculés. + +## 6. Suivre les candidatures + +Classe les candidatures par catégorie et mets à jour leur statut : à préparer, envoyée, à relancer, entretien, acceptée, refusée ou archivée. La sélection multiple permet d’appliquer un statut ou une catégorie à plusieurs candidatures en une seule action. + +## 7. Protéger les données + +Ne place jamais `.openapply/`, `generated/`, un CV, une lettre, une clé ou un export de candidature dans Git. + +Avant une publication : -## 6. Protéger les données +```bash +npm run privacy:check +``` -Ne place jamais `.openapply/`, `generated/`, un CV, une lettre, une clé ou un export de candidature dans Git. Exécute `npm run privacy:check` avant toute publication. \ No newline at end of file +Pour changer l’emplacement des données privées, définis `OPENAPPLY_DATA_DIR` avant le lancement. Consulte le [guide d’installation](README.md#installation-rapide) pour les commandes Windows, macOS et Linux. diff --git a/app/backend-guards.mjs b/app/backend-guards.mjs new file mode 100644 index 0000000..c6fb577 --- /dev/null +++ b/app/backend-guards.mjs @@ -0,0 +1,149 @@ +import { randomUUID } from "node:crypto"; +import { lookup as dnsLookup } from "node:dns/promises"; +import { rename, unlink, writeFile } from "node:fs/promises"; +import { isIP } from "node:net"; +import path from "node:path"; + +function ipv4Parts(value) { + const parts = String(value || "").split(".").map(Number); + return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ? parts + : null; +} + +function privateIpv4(value) { + const parts = ipv4Parts(value); + if (!parts) return false; + const [first, second] = parts; + return first === 0 + || first === 10 + || first === 127 + || first >= 224 + || (first === 100 && second >= 64 && second <= 127) + || (first === 169 && second === 254) + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 198 && (second === 18 || second === 19)); +} + +function expandedIpv6(value) { + const address = String(value || "") + .replace(/^\[|\]$/g, "") + .split("%")[0] + .toLowerCase(); + if (!address.includes(":")) return null; + + let source = address; + const ipv4 = source.match(/(?:^|:)(\d{1,3}(?:\.\d{1,3}){3})$/)?.[1]; + if (ipv4) { + const parts = ipv4Parts(ipv4); + if (!parts) return null; + const high = ((parts[0] << 8) | parts[1]).toString(16); + const low = ((parts[2] << 8) | parts[3]).toString(16); + source = `${source.slice(0, -ipv4.length)}${high}:${low}`; + } + + const halves = source.split("::"); + if (halves.length > 2) return null; + const left = halves[0] ? halves[0].split(":") : []; + const right = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + const missing = 8 - left.length - right.length; + if (missing < 0 || (halves.length === 1 && missing !== 0)) return null; + const groups = [ + ...left, + ...Array(halves.length === 2 ? missing : 0).fill("0"), + ...right, + ].map((group) => Number.parseInt(group || "0", 16)); + return groups.length === 8 && groups.every((group) => Number.isInteger(group) && group >= 0 && group <= 0xffff) + ? groups + : null; +} + +function privateIpv6(value) { + const groups = expandedIpv6(value); + if (!groups) return false; + if (groups.every((group) => group === 0)) return true; + if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) return true; + if ((groups[0] & 0xfe00) === 0xfc00) return true; + if ((groups[0] & 0xffc0) === 0xfe80) return true; + if ((groups[0] & 0xff00) === 0xff00) return true; + if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) { + return privateIpv4(`${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`); + } + return false; +} + +export function isPrivateOrLocalHostname(value) { + const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase(); + if (!host) return true; + if ( + host === "localhost" + || host === "localdomain" + || host.endsWith(".localhost") + || host.endsWith(".local") + || host.endsWith(".lan") + || host.endsWith(".internal") + ) return true; + if (isIP(host) === 4) return privateIpv4(host); + if (isIP(host) === 6) return privateIpv6(host); + return false; +} + +export function validatePublicHttpUrl(value, { + label = "Le lien", + maxLength = 2_000, +} = {}) { + const input = String(value || "").trim(); + if (!input) return ""; + if (input.length > maxLength) throw new Error(`${label} est trop long.`); + let parsed; + try { + parsed = new URL(input); + } catch { + throw new Error(`${label} doit être une URL complète.`); + } + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error(`${label} doit commencer par http:// ou https://.`); + } + if (parsed.username || parsed.password) { + throw new Error(`${label} ne doit pas contenir d’identifiant ni de mot de passe.`); + } + if (isPrivateOrLocalHostname(parsed.hostname)) { + throw new Error(`${label} ne peut pas cibler une adresse locale ou privée.`); + } + return parsed.toString(); +} + +export async function resolvePublicAddress(hostname, lookupFunction = dnsLookup) { + const host = String(hostname || "").replace(/^\[|\]$/g, ""); + if (isPrivateOrLocalHostname(host)) { + throw new Error("Adresse locale ou privée non autorisée."); + } + const literalFamily = isIP(host); + if (literalFamily) return { address: host, family: literalFamily }; + const addresses = await lookupFunction(host, { all: true, verbatim: true }); + const entries = Array.isArray(addresses) ? addresses : [addresses]; + if (!entries.length || entries.some((entry) => isPrivateOrLocalHostname(entry?.address))) { + throw new Error("Le nom de domaine pointe vers une adresse locale ou privée."); + } + const selected = entries.find((entry) => entry?.family === 4) || entries[0]; + if (!selected?.address || ![4, 6].includes(selected.family)) { + throw new Error("Le nom de domaine n’a pas pu être résolu."); + } + return { address: selected.address, family: selected.family }; +} + +export async function atomicWriteFile(filePath, data, options = undefined) { + const directory = path.dirname(filePath); + const temporaryPath = path.join( + directory, + `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp` + ); + try { + await writeFile(temporaryPath, data, options); + await rename(temporaryPath, filePath); + } catch (error) { + await unlink(temporaryPath).catch(() => {}); + throw error; + } +} diff --git a/app/doctor.mjs b/app/doctor.mjs old mode 100644 new mode 100755 index d2cb673..8498cec --- a/app/doctor.mjs +++ b/app/doctor.mjs @@ -1,7 +1,9 @@ #!/usr/bin/env node +import { constants as fsConstants } from "node:fs"; +import { access, stat } from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { execPortable, platformLabel, @@ -12,17 +14,60 @@ import { resolvePythonRuntime, } from "./platform.mjs"; -const jsonOutput = process.argv.includes("--json"); -const environment = portableEnvironment({ ...process.env, NO_COLOR: "1" }); -const appDirectory = path.dirname(fileURLToPath(import.meta.url)); +const REQUIRED_PYTHON_MODULES = [ + { importName: "docx", packageName: "python-docx" }, + { importName: "pypdf", packageName: "pypdf" }, + { importName: "fitz", packageName: "PyMuPDF" }, +]; -async function commandVersion(command, args) { +const OPTIONAL_PROVIDERS = { + codex: "codex", + antigravity: "agy", + gemini: "gemini", + claude: "claude", + copilot: "copilot", + hermes: "hermes", + ollama: "ollama", +}; + +export function parseMajorVersion(value) { + const match = String(value || "").match(/(?:^|\s|v)(\d+)(?:\.|\s|$)/i); + return match ? Number.parseInt(match[1], 10) : 0; +} + +export function installationHints(platform = process.platform) { + if (platform === "win32") { + return { + node: "winget install OpenJS.NodeJS.LTS", + python: "winget install Python.Python.3.12", + libreOffice: "winget install TheDocumentFoundation.LibreOffice", + modules: "npm run setup", + }; + } + if (platform === "darwin") { + return { + node: "brew install node", + python: "brew install python", + libreOffice: "brew install --cask libreoffice", + modules: "npm run setup", + }; + } + return { + node: "Installe Node.js 20+ depuis https://nodejs.org/", + python: "sudo apt install python3 python3-venv python3-pip", + libreOffice: "sudo apt install libreoffice", + modules: "npm run setup", + }; +} + +async function commandVersion(command, args, { environment, platform }) { if (!command) return ""; try { const { stdout, stderr } = await execPortable(command, args, { env: environment, timeout: 10_000, maxBuffer: 128 * 1024, + platform, }); return `${stdout}\n${stderr}`.trim().split(/\r?\n/)[0] || ""; } catch { @@ -30,68 +75,206 @@ async function commandVersion(command, args) { } } -const pythonRuntime = await resolvePythonRuntime({ - projectDirectory: path.resolve(appDirectory, ".."), -}); -const python = pythonRuntime.command; - -const libreOffice = await resolveFirstCommand(["soffice", "libreoffice"]); -const providerCommands = { - codex: await resolveCommand("codex"), - antigravity: await resolveCommand("agy"), - gemini: await resolveCommand("gemini"), - claude: await resolveCommand("claude"), - copilot: await resolveCommand("copilot"), - hermes: await resolveCommand("hermes"), - ollama: await resolveCommand("ollama"), -}; -const report = { - platform: platformLabel(), - node: { - ready: Number(process.versions.node.split(".")[0]) >= 20, - version: process.versions.node, - }, - python: { - ready: Boolean(python), - modulesReady: pythonRuntime.modulesReady, - command: python, - version: await commandVersion(python, pythonCommandArgs(python, ["--version"])), - }, - libreOffice: { - ready: Boolean(libreOffice), - command: libreOffice, - version: await commandVersion(libreOffice, ["--version"]), - }, - providers: Object.fromEntries( - Object.entries(providerCommands).map(([id, command]) => [id, { - installed: Boolean(command), +async function inspectPythonModules(command, { environment, platform }) { + if (!command) { + return { + ready: false, + installed: [], + missing: REQUIRED_PYTHON_MODULES.map(({ packageName }) => packageName), + }; + } + const script = [ + "import importlib.util, json", + `names = ${JSON.stringify(REQUIRED_PYTHON_MODULES.map(({ importName }) => importName))}`, + "print(json.dumps({name: importlib.util.find_spec(name) is not None for name in names}))", + ].join("; "); + try { + const { stdout } = await execPortable( command, - }]) - ), -}; -report.ready = report.node.ready - && report.python.ready - && report.python.modulesReady - && report.libreOffice.ready; + pythonCommandArgs(command, ["-c", script], platform), + { + env: environment, + timeout: 15_000, + maxBuffer: 64 * 1024, + platform, + } + ); + const availability = JSON.parse(stdout.trim()); + const installed = REQUIRED_PYTHON_MODULES + .filter(({ importName }) => availability[importName]) + .map(({ packageName }) => packageName); + const missing = REQUIRED_PYTHON_MODULES + .filter(({ importName }) => !availability[importName]) + .map(({ packageName }) => packageName); + return { ready: missing.length === 0, installed, missing }; + } catch { + return { + ready: false, + installed: [], + missing: REQUIRED_PYTHON_MODULES.map(({ packageName }) => packageName), + }; + } +} -if (jsonOutput) { - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); -} else { - const line = (ready, label, detail = "") => { - process.stdout.write(`${ready ? "[OK]" : "[À INSTALLER]"} ${label}${detail ? ` : ${detail}` : ""}\n`); +async function nearestExistingDirectory(target) { + let current = path.resolve(target); + while (true) { + try { + const information = await stat(current); + if (information.isDirectory()) return current; + } catch {} + const parent = path.dirname(current); + if (parent === current) return ""; + current = parent; + } +} + +async function writableTarget(target) { + const existingDirectory = await nearestExistingDirectory(target); + if (!existingDirectory) return false; + try { + await access(existingDirectory, fsConstants.W_OK); + return true; + } catch { + return false; + } +} + +export async function createDoctorReport({ + platform = process.platform, + env = process.env, + projectDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), + homeDir, +} = {}) { + const environment = portableEnvironment({ ...env, NO_COLOR: "1" }, { platform, homeDir }); + const resolveOptions = { platform, env: environment, ...(homeDir ? { homeDir } : {}) }; + const npm = await resolveCommand("npm", resolveOptions); + const pythonRuntime = await resolvePythonRuntime({ + platform, + env: environment, + projectDirectory, + ...(homeDir ? { homeDir } : {}), + }); + const python = pythonRuntime.command; + const libreOffice = await resolveFirstCommand(["soffice", "libreoffice"], resolveOptions); + const nodeVersion = process.versions.node; + const npmVersion = await commandVersion(npm, ["--version"], { environment, platform }); + const pythonVersion = await commandVersion( + python, + pythonCommandArgs(python, ["--version"], platform), + { environment, platform } + ); + const pythonModules = await inspectPythonModules(python, { environment, platform }); + const dataDirectory = path.resolve(env.OPENAPPLY_DATA_DIR || path.join(projectDirectory, ".openapply")); + const generatedDirectory = path.resolve(projectDirectory, "generated"); + const providers = {}; + for (const [id, executable] of Object.entries(OPTIONAL_PROVIDERS)) { + const command = await resolveCommand(executable, resolveOptions); + providers[id] = { installed: Boolean(command), command }; + } + const report = { + platform: platformLabel(platform), + platformId: platform, + projectDirectory, + node: { + ready: parseMajorVersion(nodeVersion) >= 20, + minimum: "20", + version: nodeVersion, + command: process.execPath, + }, + npm: { + ready: Boolean(npm), + version: npmVersion, + command: npm, + }, + python: { + ready: Boolean(python) && parseMajorVersion(pythonVersion) >= 3, + modulesReady: pythonModules.ready, + installedModules: pythonModules.installed, + missingModules: pythonModules.missing, + command: python, + version: pythonVersion, + }, + libreOffice: { + ready: Boolean(libreOffice), + command: libreOffice, + version: await commandVersion(libreOffice, ["--version"], { environment, platform }), + }, + storage: { + ready: await writableTarget(dataDirectory) && await writableTarget(generatedDirectory), + dataDirectory, + generatedDirectory, + }, + providers, + hints: installationHints(platform), + }; + report.ready = report.node.ready + && report.npm.ready + && report.python.ready + && report.python.modulesReady + && report.libreOffice.ready + && report.storage.ready; + return report; +} + +export function formatDoctorReport(report, { colors = false } = {}) { + const output = []; + const icon = (ready, optional = false) => { + if (ready) return colors ? "\u001b[32m[OK]\u001b[0m" : "[OK]"; + if (optional) return colors ? "\u001b[36m[OPTIONNEL]\u001b[0m" : "[OPTIONNEL]"; + return colors ? "\u001b[31m[À CORRIGER]\u001b[0m" : "[À CORRIGER]"; }; - process.stdout.write(`OpenApply, diagnostic ${report.platform}\n\n`); - line(report.node.ready, "Node.js", report.node.version); + const line = (ready, label, detail = "", optional = false) => { + output.push(`${icon(ready, optional)} ${label}${detail ? ` : ${detail}` : ""}`); + }; + output.push(`OpenApply, diagnostic ${report.platform}`); + output.push(""); + line(report.node.ready, "Node.js", `${report.node.version} (minimum ${report.node.minimum})`); + line(report.npm.ready, "npm", report.npm.version || report.npm.command); line(report.python.ready, "Python 3", report.python.version || report.python.command); - line(report.python.modulesReady, "Modules Python", "python-docx, pypdf et PyMuPDF"); + line( + report.python.modulesReady, + "Modules Python", + report.python.modulesReady + ? "python-docx, pypdf et PyMuPDF" + : `manquants : ${report.python.missingModules.join(", ")}` + ); line(report.libreOffice.ready, "LibreOffice", report.libreOffice.version || report.libreOffice.command); - process.stdout.write("\nMoteurs détectés\n"); + line(report.storage.ready, "Dossiers de travail", report.storage.dataDirectory); + output.push(""); + output.push("Moteurs IA détectés (un seul suffit)"); for (const [id, provider] of Object.entries(report.providers)) { - line(provider.installed, id, provider.installed ? provider.command : ""); + line(provider.installed, id, provider.installed ? provider.command : "non détecté", true); } if (!report.ready) { - process.stdout.write("\nInstalle les dépendances manquantes puis relance npm run doctor.\n"); + output.push(""); + output.push("Corrections recommandées"); + if (!report.node.ready || !report.npm.ready) output.push(`- Node.js/npm : ${report.hints.node}`); + if (!report.python.ready) output.push(`- Python : ${report.hints.python}`); + if (!report.python.modulesReady) output.push(`- Modules Python : ${report.hints.modules}`); + if (!report.libreOffice.ready) output.push(`- LibreOffice : ${report.hints.libreOffice}`); + if (!report.storage.ready) output.push("- Vérifie les droits d’écriture du dossier du projet et de OPENAPPLY_DATA_DIR."); + output.push("Puis relance npm run doctor."); + } else { + output.push(""); + output.push("Installation prête. Lance OpenApply avec npm start."); } + return `${output.join("\n")}\n`; +} + +async function main() { + const jsonOutput = process.argv.includes("--json"); + const noColor = process.argv.includes("--no-color") || !process.stdout.isTTY || process.env.NO_COLOR; + const report = await createDoctorReport(); + process.stdout.write( + jsonOutput + ? `${JSON.stringify(report, null, 2)}\n` + : formatDoctorReport(report, { colors: !noColor }) + ); + process.exitCode = report.ready ? 0 : 1; } -process.exitCode = report.ready ? 0 : 1; +const entryPoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === entryPoint) { + await main(); +} diff --git a/app/job-sourcer.mjs b/app/job-sourcer.mjs index 727afd2..4acad0a 100644 --- a/app/job-sourcer.mjs +++ b/app/job-sourcer.mjs @@ -2,6 +2,10 @@ import { createHash } from "node:crypto"; import http from "node:http"; import https from "node:https"; import { URL } from "node:url"; +import { + resolvePublicAddress, + validatePublicHttpUrl, +} from "./backend-guards.mjs"; import { classifyJob } from "./job-classifier.mjs"; export const SOURCING_PROVIDERS = [ @@ -774,62 +778,77 @@ function structuredJobText(html) { return ""; } -function isPrivateHostname(hostname) { - const host = String(hostname || "").replace(/^\[|\]$/g, "").toLowerCase(); - if (!host || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true; - if (host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true; - const parts = host.split(".").map(Number); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; - return parts[0] === 10 - || parts[0] === 127 - || parts[0] === 0 - || parts[0] === 169 && parts[1] === 254 - || parts[0] === 192 && parts[1] === 168 - || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31; -} +export async function fetchUrlText(urlString, timeoutMs = 7_000, redirectCount = 0) { + if (redirectCount > 4) throw new Error("Trop de redirections."); + const safeUrl = validatePublicHttpUrl(urlString, { label: "Le lien de l’offre" }); + const parsed = new URL(safeUrl); + const resolved = await resolvePublicAddress(parsed.hostname); + const transport = parsed.protocol === "https:" ? https : http; + const maximumBytes = 2_000_000; -function fetchUrlText(urlString, timeoutMs = 7_000, redirectCount = 0) { return new Promise((resolve, reject) => { - try { - if (redirectCount > 4) throw new Error("Trop de redirections."); - const parsed = new URL(urlString); - if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Protocole non autorisé."); - if (isPrivateHostname(parsed.hostname)) throw new Error("Adresse locale ou privée non autorisée."); - const transport = parsed.protocol === "https:" ? https : http; - const request = transport.get( - parsed, - { - headers: { - Accept: "text/html,application/xhtml+xml", - "User-Agent": "OpenApply/1.0", - }, - timeout: timeoutMs, + let settled = false; + const finish = (error, value = "") => { + if (settled) return; + settled = true; + if (error) reject(error); + else resolve(value); + }; + const request = transport.get( + parsed, + { + headers: { + Accept: "text/html,application/xhtml+xml", + "User-Agent": "OpenApply/1.0", }, - (response) => { - if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { - response.resume(); - const target = new URL(response.headers.location, parsed).toString(); - fetchUrlText(target, timeoutMs, redirectCount + 1).then(resolve, reject); + lookup: (_hostname, _options, callback) => callback(null, resolved.address, resolved.family), + timeout: timeoutMs, + }, + (response) => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume(); + let target; + try { + target = new URL(response.headers.location, parsed).toString(); + } catch { + finish(new Error("Redirection invalide.")); return; } - if (response.statusCode < 200 || response.statusCode >= 300) { - response.resume(); - reject(new Error(`Réponse HTTP ${response.statusCode}.`)); + fetchUrlText(target, timeoutMs, redirectCount + 1).then( + (value) => finish(null, value), + (error) => finish(error) + ); + return; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + response.resume(); + finish(new Error(`Réponse HTTP ${response.statusCode}.`)); + return; + } + const announcedLength = Number(response.headers["content-length"] || 0); + if (Number.isFinite(announcedLength) && announcedLength > maximumBytes) { + response.destroy(); + finish(new Error("Page de l’offre trop volumineuse.")); + return; + } + let receivedBytes = 0; + const chunks = []; + response.on("data", (chunk) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + receivedBytes += buffer.length; + if (receivedBytes > maximumBytes) { + response.destroy(); + finish(new Error("Page de l’offre trop volumineuse.")); return; } - let body = ""; - response.setEncoding("utf8"); - response.on("data", (chunk) => { - if (body.length < 2_000_000) body += chunk; - }); - response.on("end", () => resolve(body)); - } - ); - request.on("error", reject); - request.on("timeout", () => request.destroy(new Error("Délai réseau dépassé."))); - } catch (error) { - reject(error); - } + chunks.push(buffer); + }); + response.on("error", (error) => finish(error)); + response.on("end", () => finish(null, Buffer.concat(chunks).toString("utf8"))); + } + ); + request.on("error", (error) => finish(error)); + request.on("timeout", () => request.destroy(new Error("Délai réseau dépassé."))); }); } diff --git a/app/offer-source.mjs b/app/offer-source.mjs index 3948e15..65ce78d 100644 --- a/app/offer-source.mjs +++ b/app/offer-source.mjs @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { validatePublicHttpUrl } from "./backend-guards.mjs"; import { fetchOfferDetails } from "./job-sourcer.mjs"; const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000; @@ -11,8 +12,7 @@ export function singleHttpUrl(value) { const input = String(value || "").trim(); if (!/^https?:\/\/\S+$/i.test(input)) return ""; try { - const parsed = new URL(input); - return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : ""; + return validatePublicHttpUrl(input, { label: "Le lien de l’offre" }); } catch { return ""; } diff --git a/app/public/app.js b/app/public/app.js index 3f4e0e4..91c78cd 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -4,12 +4,13 @@ import { mergePastedOfferLinks, } from "./link-import.js"; +const appBoot = document.querySelector("#appBoot"); const form = document.querySelector("#jobForm"); const topNewApplicationButton = document.querySelector("#topNewApplicationButton"); -const libraryNewApplicationButton = document.querySelector("#libraryNewApplicationButton"); const librarySearchField = document.querySelector("#librarySearchField"); const applicationsTotal = document.querySelector("#applicationsTotal"); const applicationsSummary = document.querySelector("#applicationsSummary"); +const applicationsMobileToggle = document.querySelector("#applicationsMobileToggle"); const libraryStatusFilters = document.querySelector("#libraryStatusFilters"); const filterAllCount = document.querySelector("#filterAllCount"); const filterActiveCount = document.querySelector("#filterActiveCount"); @@ -29,6 +30,8 @@ const libraryBulkMessage = document.querySelector("#libraryBulkMessage"); const applicationGroups = document.querySelector("#applicationGroups"); const workspace = document.querySelector(".workspace"); const profileSettings = document.querySelector("#profileSettings"); +const setupGuideSummary = document.querySelector("#setupGuideSummary"); +const setupGuideSteps = [...document.querySelectorAll("[data-setup-step]")]; const previewPane = document.querySelector(".preview-pane"); const previewStage = document.querySelector("#previewStage"); const sourceComposer = document.querySelector("#sourceComposer"); @@ -71,6 +74,7 @@ const progressRegion = document.querySelector("#progressRegion"); const progressTitle = document.querySelector("#progressTitle"); const progressMessage = document.querySelector("#progressMessage"); const progressPercent = document.querySelector("#progressPercent"); +const progressTrack = document.querySelector("#progressTrack"); const progressBar = document.querySelector("#progressBar"); const automationToggleButton = document.querySelector("#automationToggleButton"); const soundToggleButton = document.querySelector("#soundToggleButton"); @@ -174,6 +178,7 @@ const resultCategoryBadge = document.querySelector("#resultCategoryBadge"); const docxPackDownload = document.querySelector("#docxPackDownload"); const pdfPackDownload = document.querySelector("#pdfPackDownload"); const bundleResults = document.querySelector("#bundleResults"); +const bundleResultsTitle = document.querySelector("#bundleResultsTitle"); const bundleResultsStatus = document.querySelector("#bundleResultsStatus"); const bundleResultList = document.querySelector("#bundleResultList"); const bundleRetryFailuresButton = document.querySelector("#bundleRetryFailuresButton"); @@ -572,6 +577,19 @@ function setSystemState(kind, text) { systemState.classList.remove("ready", "error"); if (kind) systemState.classList.add(kind); systemStateText.textContent = text; + systemState.title = text; +} + +function updateProgressValue(value) { + const normalized = Math.max(0, Math.min(100, Number(value) || 0)); + progressPercent.textContent = `${normalized} %`; + progressBar.style.transform = `scaleX(${normalized / 100})`; + progressTrack.setAttribute("aria-valuenow", String(normalized)); +} + +function revealApplication() { + document.body.classList.remove("is-booting"); + appBoot.hidden = true; } function providerForConnection(providerId = connectionProviderId) { @@ -828,7 +846,7 @@ async function checkHealth({ autoPrompt = false } = {}) { : "Complète le profil candidat avant de lancer une candidature."; if (autoPrompt && !hasAutoOpenedProfileSetup) { hasAutoOpenedProfileSetup = true; - window.setTimeout(showProfileSettings, 180); + showProfileSettings(); } } else { promptTitle.textContent = "Le moteur IA attend ta connexion"; @@ -837,7 +855,7 @@ async function checkHealth({ autoPrompt = false } = {}) { formError.textContent = `${health.engine?.label || "Le moteur IA"} n’est pas encore connecté. Utilise le bouton ci-dessous pour terminer la configuration.`; if (autoPrompt && !hasAutoOpenedConnection) { hasAutoOpenedConnection = true; - window.setTimeout(() => openProviderConnectionDialog(health.engine?.id || activeProfile?.provider, { automatic: true }), 150); + openProviderConnectionDialog(health.engine?.id || activeProfile?.provider, { automatic: true }); } } submitButton.disabled = true; @@ -981,10 +999,11 @@ async function loadProviderUsage({ force = false } = {}) { quotaSummaryValue.textContent = "Lecture indisponible"; } finally { refreshUsageButton.disabled = false; + const usageDelay = busyPhase || modelUsagePanel.matches(":popover-open") ? 30_000 : 120_000; usagePollTimer = window.setTimeout(() => { usagePollTimer = null; if (document.visibilityState === "visible") void loadProviderUsage(); - }, 30_000); + }, usageDelay); } } @@ -1730,8 +1749,23 @@ function updateProfilePreview() { const letterReady = uploadedOrSaved(profileCoverLetterField, "coverLetter"); const requiredDocuments = Number(cvReady) + Number(letterReady); const providerReady = providerStatuses[provider?.id]?.ready || (profile?.provider === provider?.id && activeProviderStatus?.ready); - const checks = [name.length >= 2, headline.length >= 3, domains.length > 0, factsReady, cvReady, letterReady, Boolean(providerReady)]; - const readiness = Math.round((checks.filter(Boolean).length / checks.length) * 100); + const setupState = { + profile: name.length >= 2 && headline.length >= 3 && domains.length > 0 && factsReady, + documents: cvReady && letterReady, + provider: Boolean(providerReady), + }; + const completedSteps = Object.values(setupState).filter(Boolean).length; + + setupGuideSteps.forEach((step) => { + const complete = Boolean(setupState[step.dataset.setupStep]); + step.dataset.state = complete ? "complete" : "pending"; + step.querySelector("a")?.setAttribute("aria-label", `${step.querySelector("strong")?.textContent || "Étape"} : ${complete ? "prête" : "à compléter"}`); + }); + const setupSummary = completedSteps === 3 + ? "Ton espace est prêt. Tu peux enregistrer puis lancer une candidature." + : `${completedSteps} étape${completedSteps > 1 ? "s" : ""} sur 3 prête${completedSteps > 1 ? "s" : ""}. Complète uniquement les éléments marqués à faire.`; + if (setupGuideSummary.textContent !== setupSummary) setupGuideSummary.textContent = setupSummary; + setupGuideSummary.parentElement.parentElement.style.setProperty("--setup-progress", `${completedSteps / 3}`); previewDraftLabel.textContent = isNew ? "Nouveau profil" : profile.id === activeProfile?.id ? "Profil actif en modification" : "Profil enregistré"; previewDraftName.textContent = name || "Nom du candidat"; @@ -1740,10 +1774,11 @@ function updateProfilePreview() { previewProvider.textContent = provider?.label || "À choisir"; previewDocuments.textContent = `${requiredDocuments} sur 2 requis`; previewFacts.textContent = factsReady ? "Base prête" : "À renseigner"; - previewReadiness.textContent = readiness === 100 ? "Prêt" : `${readiness} %`; - previewReadiness.classList.toggle("is-ready", readiness === 100); - previewProgressBar.style.transform = `scaleX(${readiness / 100})`; - previewDraftProfile.classList.toggle("is-ready", readiness === 100); + const profileReady = completedSteps === 3; + previewReadiness.textContent = profileReady ? "Prêt" : "À compléter"; + previewReadiness.classList.toggle("is-ready", profileReady); + previewProgressBar.style.transform = `scaleX(${completedSteps / 3})`; + previewDraftProfile.classList.toggle("is-ready", profileReady); previewDomains.replaceChildren(...(domains.length ? domains : ["Domaine"]).map((domain) => createTextElement("span", "preview-domain", domain))); profilePreviewNote.textContent = isNew ? "À l’enregistrement, ce profil apparaîtra sous le profil actif et gardera ses candidatures séparées." @@ -1919,11 +1954,16 @@ async function filePayload(input) { async function initialize() { await loadProfiles(); restoreIntakeDraft(); - await checkHealth({ autoPrompt: true }); - await loadApplicationLibrary(); - await restoreLatestJob(); - await loadProviderUsage(); - await loadJobWatch({ hydrateSettings: true }); + await Promise.all([ + checkHealth({ autoPrompt: true }), + loadApplicationLibrary(), + restoreLatestJob(), + ]); + revealApplication(); + await Promise.allSettled([ + loadProviderUsage(), + loadJobWatch({ hydrateSettings: true }), + ]); } function updateSubmitLabel() { @@ -2222,7 +2262,7 @@ function syncControlState() { document.querySelectorAll('input[name="category"], input[name="mode"], input[name="language"]').forEach((input) => { input.disabled = busy || intakeLocked; }); - document.querySelectorAll(".application-item-button, #topNewApplicationButton, #libraryNewApplicationButton, #profileMenuButton").forEach((control) => { + document.querySelectorAll(".application-item-button, #topNewApplicationButton, #profileMenuButton").forEach((control) => { control.disabled = busy; }); document.querySelectorAll(".move-application-select, .application-status-select").forEach((control) => { @@ -2241,7 +2281,7 @@ function syncControlState() { function updateReviewActionLabel() { if (busyPhase === "generation") { - generateButton.textContent = busyScope === "bundle" ? "Génération du bundle…" : "Génération en cours…"; + generateButton.textContent = busyScope === "bundle" ? "Génération du lot…" : "Génération en cours…"; return; } if (currentBundle && bundleReviewItems.length) { @@ -2364,8 +2404,7 @@ function setProgress(job, options = {}) { job.tokenUsage?.totalTokens ? `${formatTokenCount(job.tokenUsage.totalTokens)} tokens` : "", ].filter(Boolean); processMetadata.textContent = metadata.join(" · "); - progressPercent.textContent = `${job.progress} %`; - progressBar.style.transform = `scaleX(${job.progress / 100})`; + updateProgressValue(job.progress); notifyProgressMilestones(job.stage, options); const order = ["analyzing", "review", "drafting", "verifying", "completed"]; const visibleStage = job.stage === "packaging" ? "completed" : job.stage; @@ -2373,6 +2412,8 @@ function setProgress(job, options = {}) { steps.forEach((step, index) => { step.classList.toggle("done", activeIndex > index || visibleStage === "completed" && job.stage === "completed"); step.classList.toggle("active", activeIndex === index && job.stage !== "completed"); + if (activeIndex === index && job.stage !== "completed") step.setAttribute("aria-current", "step"); + else step.removeAttribute("aria-current"); }); if (job.state === "failed") renderRecovery(job); else if (job.state !== "canceled") hideRecovery(); @@ -2625,10 +2666,11 @@ async function loadJobWatch({ refresh = false, hydrateSettings = false } = {}) { if (refresh) jobWatchSettingsStatus.textContent = message; } finally { refreshJobWatchButton.disabled = false; + const watchDelay = jobWatchPanel.matches(":popover-open") ? 60_000 : 300_000; jobWatchPollTimer = window.setTimeout(() => { jobWatchPollTimer = null; if (document.visibilityState === "visible") void loadJobWatch(); - }, 60_000); + }, watchDelay); } } @@ -2901,6 +2943,8 @@ function showResult(job, { autoDownload = false } = {}) { resetButton.hidden = false; renderApplicationLibrary(); void loadApplicationLibrary(); + resultBar.focus({ preventScroll: true }); + resultBar.scrollIntoView({ behavior: "smooth", block: "nearest" }); if (autoDownload) runCompletionAction(`job:${job.id}`, result); } @@ -2921,7 +2965,9 @@ function selectPreview(kind) { const languageLabel = currentResult.language === "en" ? "EN" : "FR"; previewSubtitle.textContent = `${isLetter ? (currentResult.language === "en" ? "Cover letter" : "Lettre de motivation") : "CV"} ${languageLabel} · ${currentResult.contractType.toUpperCase()} · 1 page contrôlée avec LibreOffice`; previewTabButtons.forEach((button) => { - button.setAttribute("aria-selected", String(button.dataset.document === kind)); + const selected = button.dataset.document === kind; + button.setAttribute("aria-selected", String(selected)); + button.tabIndex = selected ? 0 : -1; }); } @@ -2956,12 +3002,12 @@ function renderBundleResults(bundle) { workspace.classList.add("bundle-result-mode"); pageBadge.textContent = `${bundle.items.length} × 2 documents`; bundleResultsStatus.textContent = bundle.state === "completed" - ? "Bundle terminé" + ? "Lot terminé" : bundle.state === "canceled" - ? "Bundle annulé" + ? "Lot annulé" : bundle.state === "failed" - ? "Bundle interrompu" - : "Bundle en cours"; + ? "Lot interrompu" + : "Lot en cours"; const packsReady = Boolean(bundle.docxPackUrl && bundle.pdfPackUrl); const extensionReady = Boolean(bundle.extensionPackUrl); bundleRetryFailuresButton.hidden = !bundle.retryableFailures || !["completed", "failed"].includes(bundle.state); @@ -3069,6 +3115,10 @@ function showBundleComplete(bundle, { autoDownload = false } = {}) { resetButton.hidden = false; renderBundleResults(bundle); void loadApplicationLibrary(); + if (bundle.state === "completed") { + bundleResultsTitle.focus({ preventScroll: true }); + bundleResultsTitle.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } if (autoDownload) runCompletionAction(`bundle:${bundle.id}`, bundle); } @@ -3076,7 +3126,7 @@ function showFailure(message, job = null) { formError.textContent = message; reviewError.textContent = message; previewSubtitle.textContent = job?.canResume || job?.retryableFailures - ? "Le traitement est interrompu, mais le dernier checkpoint est prêt à reprendre." + ? "Le traitement est interrompu, mais la dernière étape sauvegardée est prête à reprendre." : "Le traitement n’a pas abouti. Corrige la saisie puis relance."; if (job) renderRecovery(job); resetButton.hidden = false; @@ -3171,7 +3221,7 @@ function resetInterface({ keepOffer = false } = {}) { reviewError.textContent = ""; progressRegion.hidden = true; progressTitle.textContent = "Traitement en cours"; - progressBar.style.transform = "scaleX(0)"; + updateProgressValue(0); processMetadata.textContent = ""; hideRecovery(); steps.forEach((step) => step.classList.remove("active", "done")); @@ -3598,8 +3648,7 @@ form.addEventListener("submit", async (event) => { : isBundle ? `Préparation de ${uniqueLinks.length} offres` : "Lecture des outils et compétences demandés"; - progressPercent.textContent = "6 %"; - progressBar.style.transform = "scaleX(0.06)"; + updateProgressValue(6); try { const requestBody = isBundle @@ -3649,9 +3698,8 @@ skillsReviewForm.addEventListener("submit", async (event) => { form.hidden = false; submitButton.hidden = true; progressRegion.hidden = false; - progressMessage.textContent = "Lancement des candidatures du bundle"; - progressPercent.textContent = "55 %"; - progressBar.style.transform = "scaleX(0.55)"; + progressMessage.textContent = "Lancement des candidatures du lot"; + updateProgressValue(55); renderBundleResults(currentBundle); try { const response = await fetch(`/api/bundles/${currentBundle.id}/generate`, { @@ -3664,7 +3712,7 @@ skillsReviewForm.addEventListener("submit", async (event) => { }), }); const bundle = await response.json(); - if (!response.ok) throw new Error(bundle.error || "Impossible de démarrer le bundle."); + if (!response.ok) throw new Error(bundle.error || "Impossible de démarrer le lot."); currentBundle = bundle; activeRequest = { kind: "bundle", id: bundle.id }; setProgress(bundle); @@ -3692,8 +3740,7 @@ skillsReviewForm.addEventListener("submit", async (event) => { previewTabs.hidden = true; progressRegion.hidden = false; progressMessage.textContent = "Application de tes réponses au CV et à la lettre"; - progressPercent.textContent = "35 %"; - progressBar.style.transform = "scaleX(0.35)"; + updateProgressValue(35); try { const response = await fetch("/api/jobs", { @@ -3783,7 +3830,6 @@ async function beginNewApplication() { resetButton.addEventListener("click", () => void beginNewApplication()); topNewApplicationButton.addEventListener("click", () => void beginNewApplication()); -libraryNewApplicationButton?.addEventListener("click", () => void beginNewApplication()); document.querySelector(".applications-pane").addEventListener("click", (event) => { const filterButton = event.target.closest("[data-library-status-filter]"); @@ -3864,6 +3910,12 @@ librarySearchField?.addEventListener("input", () => { renderApplicationLibrary(); }); +applicationsMobileToggle.addEventListener("click", () => { + const open = applicationsPane.classList.toggle("is-mobile-open"); + applicationsMobileToggle.setAttribute("aria-expanded", String(open)); + applicationsMobileToggle.textContent = open ? "Réduire" : "Voir"; +}); + document.querySelector(".applications-pane").addEventListener("change", (event) => { const selectionCheckbox = event.target.closest("[data-select-application-id]"); if (selectionCheckbox) { @@ -4015,8 +4067,20 @@ profileForm.addEventListener("submit", async (event) => { } }); -previewTabButtons.forEach((button) => { +previewTabButtons.forEach((button, index) => { button.addEventListener("click", () => selectPreview(button.dataset.document)); + button.addEventListener("keydown", (event) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const nextIndex = event.key === "Home" + ? 0 + : event.key === "End" + ? previewTabButtons.length - 1 + : (index + (event.key === "ArrowRight" ? 1 : -1) + previewTabButtons.length) % previewTabButtons.length; + const nextButton = previewTabButtons[nextIndex]; + nextButton.focus(); + selectPreview(nextButton.dataset.document); + }); }); soundToggleButton.addEventListener("click", () => { @@ -4356,6 +4420,7 @@ document.addEventListener("visibilitychange", () => { syncUtilityToggles(); renderOfferLinks(); void initialize().catch((error) => { + revealApplication(); setSystemState("error", "Initialisation impossible"); formError.textContent = error instanceof Error ? error.message : "Impossible de charger OpenApply."; submitButton.disabled = true; diff --git a/app/public/index.html b/app/public/index.html index 64e748b..1115053 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -5,10 +5,21 @@ OpenApply | Candidatures ciblées - - + + - + +
+ +
+ Ouverture d’OpenApply + Vérification du profil et des outils locaux +
+ +
+
@@ -155,8 +166,12 @@

Veille d’offres

Suivi personnel

Mes candidatures

- 0 +
+ 0 + +
+

Aucune candidature à suivre.

Le brouillon est sauvegardé automatiquement sur cet appareil.

+ +
@@ -478,7 +536,7 @@

Offres à analyser

Ajouter un poste -
+ @@ -603,13 +661,13 @@

Traitement en cours

0 %
- +
  1. 1Analyse
  2. 2Validation
  3. 3Adaptation
  4. -
  5. 4LibreOffice
  6. +
  7. 4Mise en page
  8. 5Fichiers