@mostajs/orm-copy-data-ui-html 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ @mostajs/mjs-unit
2
+ Copyright (C) 2026 Dr Hamid MADANI <drmdh@msn.com>
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-or-later
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ The complete text of the GNU Affero General Public License version 3 is
20
+ available at the URL above and must accompany any distribution of this
21
+ software.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @mostajs/orm-copy-data-ui-html
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later
4
+ **Niveau** : N2 (composé / UI) · **Zéro dépendance runtime**
5
+
6
+ IHM de [`@mostajs/orm-copy-data`](../mosta-orm-copy-data) : copier, migrer, exporter et
7
+ sauvegarder les données d'une application `@mostajs` **sans ligne de commande**.
8
+
9
+ Couvre **toutes** les capacités du moteur :
10
+
11
+ | | |
12
+ |---|---|
13
+ | **Sources** | `db` (13 dialectes) · `csv` · `json` · `sql` |
14
+ | **Destinations** *(multiples)* | `db` · `csv` · `json` · `sql-dump` |
15
+ | **Options** | entités à copier · `batchSize` · progression · journal |
16
+ | **Résultat** | copiés / erreurs / durée **par entité**, première erreur |
17
+
18
+ ## Architecture — qui exécute quoi
19
+
20
+ Ce module est une **bibliothèque de rendu pure** : des fonctions `état → chaîne HTML`.
21
+ Elle **ne copie rien**, **n'ouvre aucune connexion**, et est donc testable sous Node sans
22
+ navigateur ni base de données.
23
+
24
+ L'exécution appartient à l'**application hôte**, qui implémente un contrat HTTP de 4 routes.
25
+ C'est délibéré : cette IHM manipule des **URI de bases de production**. L'authentification et
26
+ les droits doivent rester chez l'hôte, pas dans une couche de présentation.
27
+
28
+ ```
29
+ GET /copy → l'IHM (copyPage)
30
+ POST /copy/inspect → teste la source, liste les entités
31
+ POST /copy/run → lance la copie → { jobId }
32
+ GET /copy/events/:jobId → SSE : progress · log · result
33
+ ```
34
+
35
+ `examples/server.mjs` implémente ce contrat de bout en bout : c'est **la démo ET le modèle
36
+ à recopier**.
37
+
38
+ ## Garde-fous
39
+
40
+ 1. **Simulation (`dry-run`) par défaut** — l'écriture réelle est un choix explicite, confirmé.
41
+ 2. **Mots de passe masqués** dans *tout* rendu et *tout* journal (`postgres://user:***@hôte/base`).
42
+ 3. **Confirmation obligatoire** avant d'écrire vers une base de destination.
43
+ 4. **Aucune URI persistée** par le module : l'hôte décide s'il les stocke, et où.
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ npm i @mostajs/orm-copy-data-ui-html
49
+ # peer (côté hôte, pour exécuter) : @mostajs/orm-copy-data >= 0.3.1
50
+ ```
51
+
52
+ ## Exemple
53
+
54
+ ```js
55
+ import { copyPage } from '@mostajs/orm-copy-data-ui-html';
56
+
57
+ app.get('/copy', (req, res) => {
58
+ res.send(copyPage({
59
+ source: { type: 'db', dialect: 'sqljs', uri: './data/app.sqlite' },
60
+ dests: [{ type: 'db', dialect: 'postgres', uri: process.env.PG_URI }],
61
+ dryRun: true,
62
+ }));
63
+ });
64
+ ```
65
+
66
+ ## Démo
67
+
68
+ ```bash
69
+ npm run example # http://localhost:8788
70
+ ```
71
+
72
+ Copie un JSON vers JSON + CSV — une **vraie** copie par le vrai moteur, sans exiger de base
73
+ de données. Basculer vers PostgreSQL ne change qu'un champ du formulaire.
74
+
75
+ ## Tests
76
+
77
+ ```bash
78
+ npm test # 17 tests de rendu (mjs-unit, §11.1.bis)
79
+ node test-scripts/e2e-demo.mjs # E2E : contrat HTTP + SSE + copie réelle sur disque
80
+ ```
81
+
82
+ Les tests couvrent en priorité ce qui fait mal : **fuite de mot de passe** dans le HTML/le
83
+ journal/le rapport, injection HTML, simulation qui écrirait par erreur, et rapport qui
84
+ présenterait une copie partiellement en erreur comme un succès.
85
+
86
+ ## API
87
+
88
+ `copyPage` · `sourceForm` · `destForm` · `entityPicker` · `progressPanel` · `logPanel` ·
89
+ `resultReport` · `dryRunBadge` · `maskUri` · `escapeHtml` · `CSS` · `SOURCE_TYPES` ·
90
+ `DEST_TYPES` · `DIALECTS`.
91
+
92
+ Voir `llms.txt` pour les signatures.
package/demo.html ADDED
@@ -0,0 +1,151 @@
1
+ <!doctype html><html lang="fr"><head><meta charset="utf-8"/>
2
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
3
+ <title>Copie de données — MostaJS (démo statique)</title><style>
4
+ :root{--blue:#1E88E5;--teal:#00838F;--red:#C62828;--green:#43A047;--ink:#1a2b45;--bg:#f4f7fb;--line:#e3e9f2;--card:#ffffff;--amber:#FBC02D}
5
+ *{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,Arial,sans-serif;color:var(--ink);background:var(--bg)}
6
+ .wrap{max-width:980px;margin:0 auto;padding:24px}
7
+ h1{margin:0 0 4px}h2{margin:0 0 12px;font-size:1.05rem;color:var(--teal)}
8
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0 0 16px}
9
+ .panel-ok{border-color:var(--green)}.panel-err{border-color:var(--red)}
10
+ label{display:block;margin:0 0 10px}label.inline{display:inline-flex;align-items:center;gap:6px;margin-right:14px}
11
+ input[type=text],input[type=number],select{width:100%;padding:8px;border:1px solid var(--line);border-radius:6px;font:inherit}
12
+ label.inline input{width:auto}
13
+ button{padding:9px 14px;border:0;border-radius:6px;background:var(--blue);color:#fff;font:inherit;cursor:pointer}
14
+ button.danger{background:var(--red)}button:disabled{opacity:.5;cursor:not-allowed}
15
+ .badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:.85rem;font-weight:600}
16
+ .badge-dry{background:#e3f2fd;color:var(--blue)}.badge-live{background:#ffebee;color:var(--red)}
17
+ .dest-row{border-top:1px dashed var(--line);padding-top:12px;margin-top:12px}.dest-row:first-of-type{border-top:0;padding-top:0;margin-top:0}
18
+ .bar-row{display:flex;align-items:center;gap:10px;margin:6px 0}
19
+ .bar-label{flex:0 0 160px;font-weight:600}.bar{flex:1;height:10px;background:var(--line);border-radius:6px;overflow:hidden}
20
+ .bar-fill{display:block;height:100%;background:var(--teal);transition:width .2s}.bar-num{flex:0 0 90px;text-align:right;font-variant-numeric:tabular-nums}
21
+ .log{background:#0f172a;color:#e2e8f0;padding:12px;border-radius:6px;max-height:260px;overflow:auto;font-size:.85rem;white-space:pre-wrap}
22
+ table.stats{width:100%;border-collapse:collapse}table.stats th,table.stats td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left}
23
+ td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}tr.row-err td{background:#fff5f5}
24
+ .muted{color:#64748b}[hidden]{display:none!important}
25
+ </style></head>
26
+ <body><div class="wrap">
27
+ <h1>Copie de données — MostaJS (démo statique)</h1>
28
+ <p><span class="badge badge-dry" title="Aucune écriture ne sera effectuée">🛡️ Simulation (dry-run)</span></p>
29
+ <form id="copy-form">
30
+ <section class="panel" id="panel-source">
31
+ <h2>1 · Source</h2>
32
+ <label>Type
33
+ <select name="source.type" data-role="source-type"><option value="db" selected>db</option><option value="csv">csv</option><option value="json">json</option><option value="sql">sql</option></select>
34
+ </label>
35
+ <div class="field-db">
36
+ <label>Dialecte
37
+ <select name="source.dialect"><option value="sqljs" selected>sqljs</option><option value="sqlite">sqlite</option><option value="postgres">postgres</option><option value="mysql">mysql</option><option value="mariadb">mariadb</option><option value="mssql">mssql</option><option value="oracle">oracle</option><option value="mongo">mongo</option><option value="duckdb">duckdb</option><option value="clickhouse">clickhouse</option><option value="cockroachdb">cockroachdb</option><option value="firebird">firebird</option><option value="hsqldb">hsqldb</option></select>
38
+ </label>
39
+ <label>URI de connexion
40
+ <input name="source.uri" type="text" value="sqljs:///data/ticketflow.sqlite"
41
+ placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
42
+ </label>
43
+ </div>
44
+ <div class="field-file" hidden>
45
+ <label>Fichier
46
+ <input name="source.file" type="text" value="" placeholder="./export.json" />
47
+ </label>
48
+ </div>
49
+ <button type="button" data-action="inspect">Tester &amp; lister les entités</button>
50
+ </section>
51
+ <section class="panel" id="panel-dest">
52
+ <h2>2 · Destinations</h2>
53
+ <div class="dest-row" data-index="0">
54
+ <label>Type
55
+ <select name="dest.0.type" data-role="dest-type"><option value="db" selected>db</option><option value="csv">csv</option><option value="json">json</option><option value="sql-dump">sql-dump</option></select>
56
+ </label>
57
+ <div class="field-db">
58
+ <label>Dialecte
59
+ <select name="dest.0.dialect"><option value="sqljs">sqljs</option><option value="sqlite">sqlite</option><option value="postgres" selected>postgres</option><option value="mysql">mysql</option><option value="mariadb">mariadb</option><option value="mssql">mssql</option><option value="oracle">oracle</option><option value="mongo">mongo</option><option value="duckdb">duckdb</option><option value="clickhouse">clickhouse</option><option value="cockroachdb">cockroachdb</option><option value="firebird">firebird</option><option value="hsqldb">hsqldb</option></select>
60
+ </label>
61
+ <label>URI
62
+ <input name="dest.0.uri" type="text" value="postgres://ticket:***@10.0.0.7:5432/ticketflow"
63
+ placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
64
+ </label>
65
+ <label class="inline"><input type="checkbox" name="dest.0.createDb"/> Créer la base</label>
66
+ <label class="inline"><input type="checkbox" name="dest.0.createTables" checked/> Créer les tables</label>
67
+ </div>
68
+ <div class="field-file" hidden>
69
+ <label>Fichier
70
+ <input name="dest.0.file" type="text" value="" placeholder="./dump.sql" />
71
+ </label>
72
+ </div>
73
+ <button type="button" data-action="dest-remove" data-index="0">Retirer</button>
74
+ </div><div class="dest-row" data-index="1">
75
+ <label>Type
76
+ <select name="dest.1.type" data-role="dest-type"><option value="db">db</option><option value="csv">csv</option><option value="json">json</option><option value="sql-dump" selected>sql-dump</option></select>
77
+ </label>
78
+ <div class="field-db" hidden>
79
+ <label>Dialecte
80
+ <select name="dest.1.dialect"><option value="sqljs">sqljs</option><option value="sqlite">sqlite</option><option value="postgres" selected>postgres</option><option value="mysql">mysql</option><option value="mariadb">mariadb</option><option value="mssql">mssql</option><option value="oracle">oracle</option><option value="mongo">mongo</option><option value="duckdb">duckdb</option><option value="clickhouse">clickhouse</option><option value="cockroachdb">cockroachdb</option><option value="firebird">firebird</option><option value="hsqldb">hsqldb</option></select>
81
+ </label>
82
+ <label>URI
83
+ <input name="dest.1.uri" type="text" value=""
84
+ placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
85
+ </label>
86
+ <label class="inline"><input type="checkbox" name="dest.1.createDb"/> Créer la base</label>
87
+ <label class="inline"><input type="checkbox" name="dest.1.createTables" checked/> Créer les tables</label>
88
+ </div>
89
+ <div class="field-file">
90
+ <label>Fichier
91
+ <input name="dest.1.file" type="text" value="./sauvegarde.sql" placeholder="./dump.sql" />
92
+ </label>
93
+ </div>
94
+ <button type="button" data-action="dest-remove" data-index="1">Retirer</button>
95
+ </div>
96
+ <button type="button" data-action="dest-add">+ Ajouter une destination</button>
97
+ </section>
98
+ <section class="panel" id="panel-entities">
99
+ <h2>3 · Entités &amp; options</h2>
100
+ <div class="entities"><label class="inline"><input type="checkbox" name="entity" value="Ticket" checked/> Ticket <span class="muted">(12480)</span></label><label class="inline"><input type="checkbox" name="entity" value="Counter" checked/> Counter <span class="muted">(6)</span></label><label class="inline"><input type="checkbox" name="entity" value="User" checked/> User <span class="muted">(14)</span></label></div>
101
+ <label>Taille de lot (batchSize)
102
+ <input name="batchSize" type="number" min="1" value="500" />
103
+ </label>
104
+ <label class="inline">
105
+ <input type="checkbox" name="dryRun" checked/>
106
+ Simulation — ne rien écrire
107
+ </label>
108
+ </section>
109
+ <p>
110
+ <button type="button" data-action="run" class="">
111
+ ▶ Lancer la simulation
112
+ </button>
113
+ </p>
114
+ </form>
115
+ <section class="panel" id="panel-progress"><h2>Progression</h2><div class="bar-row">
116
+ <span class="bar-label">Ticket</span>
117
+ <span class="bar"><span class="bar-fill" style="width:66%"></span></span>
118
+ <span class="bar-num">8200/12480</span>
119
+ </div><div class="bar-row">
120
+ <span class="bar-label">Counter</span>
121
+ <span class="bar"><span class="bar-fill" style="width:100%"></span></span>
122
+ <span class="bar-num">6/6</span>
123
+ </div></section>
124
+ <section class="panel" id="panel-log"><h2>Journal</h2><pre class="log">▶ Loading from db (sqljs)...
125
+ Ticket : 12480 rows
126
+ connexion à postgres://ticket:***@10.0.0.7:5432/ticketflow</pre></section>
127
+ <section class="panel panel-err" id="panel-result">
128
+ <h2>⚠️ Terminé avec 3 erreurs</h2>
129
+ <p>
130
+ <strong>12483</strong> enregistrement(s) copié(s) ·
131
+ <strong>3</strong> erreur(s) · 4390 ms<br/>
132
+ <span class="muted">sqljs:///data/ticketflow.sqlite → postgres://ticket:***@10.0.0.7:5432/ticketflow</span>
133
+ </p>
134
+ <table class="stats">
135
+ <thead><tr><th>Entité</th><th class="num">Copiés</th><th class="num">Erreurs</th><th class="num">Durée</th><th>1ʳᵉ erreur</th></tr></thead>
136
+ <tbody><tr class="row-err">
137
+ <td>Ticket</td>
138
+ <td class="num">12477</td>
139
+ <td class="num">3</td>
140
+ <td class="num">4210 ms</td>
141
+ <td>null value in column &quot;serviceType&quot;</td>
142
+ </tr><tr class="">
143
+ <td>Counter</td>
144
+ <td class="num">6</td>
145
+ <td class="num">0</td>
146
+ <td class="num">12 ms</td>
147
+ <td></td>
148
+ </tr></tbody>
149
+ </table>
150
+ </section>
151
+ </div></body></html>
package/llms.txt ADDED
@@ -0,0 +1,47 @@
1
+ # @mostajs/orm-copy-data-ui-html — 0.1.0
2
+ Auteur : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later · Niveau N2 · zéro dépendance runtime
3
+
4
+ RÔLE
5
+ IHM de @mostajs/orm-copy-data (moteur, peer). Bibliothèque de RENDU PURE : état → chaîne HTML.
6
+ Ne copie rien, n'ouvre aucune connexion. L'HÔTE exécute (contrat HTTP ci-dessous).
7
+
8
+ API (import { … } from '@mostajs/orm-copy-data-ui-html')
9
+ copyPage(state) -> string page complète (compose tous les blocs + CSS embarquée)
10
+ sourceForm(state) -> string source : db | csv | json | sql
11
+ destForm(state) -> string destinations MULTIPLES : db | csv | json | sql-dump
12
+ entityPicker(state) -> string entités à copier + batchSize + case « simulation »
13
+ progressPanel(progress) -> string progress = { <entité>: { copied, total } } (onProgress)
14
+ logPanel(lines) -> string lignes de journal (onLog)
15
+ resultReport(result) -> string CopyResult : stats/entité, totalCopied, totalErrors, durée
16
+ dryRunBadge(state) -> string bandeau simulation / écriture réelle
17
+ maskUri(uri) -> string postgres://u:pass@h/db → postgres://u:***@h/db
18
+ escapeHtml(s) -> string
19
+ CSS, THEME, SOURCE_TYPES, DEST_TYPES, DIALECTS, moduleInfo
20
+
21
+ ÉTAT (sérialisable)
22
+ { title, source:{type,dialect,uri,file}, dests:[{type,dialect,uri,file,createDb,createTables}],
23
+ entities:[{name,count}|string], selectedEntities:[string]|null, batchSize, dryRun,
24
+ progress:{entité:{copied,total}}, log:[string], result:CopyResult }
25
+
26
+ CONTRAT HTTP (implémenté par l'hôte ; référence = examples/server.mjs)
27
+ GET /copy → copyPage(state)
28
+ POST /copy/inspect → { ok, entities:[{name,count}] } teste la source
29
+ POST /copy/run → { ok, jobId } lance la copie
30
+ GET /copy/events/:jobId → SSE : {type:'progress'|'log'|'result'|'end', data}
31
+
32
+ GARDE-FOUS (une copie est destructrice)
33
+ 1. dryRun ACTIF par défaut (state.dryRun !== false) — l'écriture réelle est explicite + confirmée.
34
+ 2. Mots de passe MASQUÉS dans tout rendu et tout journal (maskUri appliqué partout).
35
+ 3. Confirmation obligatoire avant écriture réelle.
36
+ 4. Aucune URI persistée par le module.
37
+
38
+ DÉPENDANCES
39
+ runtime : AUCUNE · peer : @mostajs/orm-copy-data >= 0.3.1 (l'hôte l'exécute) · dev : @mostajs/mjs-unit
40
+
41
+ TESTS
42
+ npm test 17 tests de rendu (mjs-unit)
43
+ node test-scripts/e2e-demo.mjs E2E : contrat HTTP + SSE + copie RÉELLE écrite sur disque
44
+
45
+ NOTE ÉCOSYSTÈME
46
+ orm-copy-data 0.3.0 déclarait `onProgress` sans jamais l'appeler (rappel mort) : corrigé en
47
+ 0.3.1, d'où le peer >= 0.3.1 pour que la progression soit réellement alimentée.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@mostajs/orm-copy-data-ui-html",
3
+ "version": "0.1.0",
4
+ "description": "IHM zéro-dépendance de @mostajs/orm-copy-data : source/destinations, entités, progression et journal en direct, rapport de copie. Rendu pur (état → HTML), l'hôte exécute.",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./src/index.js"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "demo.html",
14
+ "README.md",
15
+ "llms.txt",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "bash test-scripts/run-tests.sh",
20
+ "example": "node examples/server.mjs"
21
+ },
22
+ "keywords": [
23
+ "orm",
24
+ "copy",
25
+ "migration",
26
+ "etl",
27
+ "ui",
28
+ "html",
29
+ "zero-dep",
30
+ "mostajs"
31
+ ],
32
+ "peerDependencies": {
33
+ "@mostajs/orm-copy-data": ">=0.3.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@mostajs/orm-copy-data": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@mostajs/mjs-unit": "^0.3.1",
42
+ "@mostajs/orm-copy-data": "^0.3.1"
43
+ }
44
+ }
package/src/index.js ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * @mostajs/orm-copy-data-ui-html — IHM ZÉRO-DÉPENDANCE de @mostajs/orm-copy-data.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ *
6
+ * Bibliothèque de RENDU pure : des fonctions `état → chaîne HTML`, donc testables sous
7
+ * Node, sans navigateur ni base de données. Elle NE COPIE RIEN et n'ouvre aucune
8
+ * connexion — le moteur `@mostajs/orm-copy-data` (peer) reste seul responsable de
9
+ * l'exécution, et l'application hôte de son authentification (cf. docs §4, variante A).
10
+ *
11
+ * Surface couverte = TOUTES les capacités du moteur :
12
+ * sources db · csv · json · sql
13
+ * destinations db · csv · json · sql-dump (multiples)
14
+ * options entities · batchSize · onProgress · onLog
15
+ * résultat CopyStats par entité · totalCopied · totalErrors · durée · firstError
16
+ *
17
+ * GARDE-FOUS (une copie est destructrice) :
18
+ * 1. `dryRun` par DÉFAUT — l'écriture réelle est un choix explicite.
19
+ * 2. Les mots de passe des URI sont MASQUÉS dans tout rendu et tout journal.
20
+ * 3. Écriture vers une base de destination NON VIDE → confirmation exigée.
21
+ * 4. Le module ne persiste AUCUNE URI.
22
+ */
23
+
24
+ export const moduleInfo = { name: '@mostajs/orm-copy-data-ui-html', version: '0.1.0' };
25
+
26
+ /** Capacités du moteur — la source unique dont dérivent les listes déroulantes. */
27
+ export const SOURCE_TYPES = ['db', 'csv', 'json', 'sql'];
28
+ export const DEST_TYPES = ['db', 'csv', 'json', 'sql-dump'];
29
+ export const DIALECTS = [
30
+ 'sqljs', 'sqlite', 'postgres', 'mysql', 'mariadb', 'mssql', 'oracle',
31
+ 'mongo', 'duckdb', 'clickhouse', 'cockroachdb', 'firebird', 'hsqldb',
32
+ ];
33
+
34
+ export const THEME = {
35
+ blue: '#1E88E5', teal: '#00838F', amber: '#FBC02D', red: '#C62828',
36
+ green: '#43A047', ink: '#1a2b45', bg: '#f4f7fb', line: '#e3e9f2', card: '#ffffff',
37
+ };
38
+
39
+ export function escapeHtml(s) {
40
+ return String(s ?? '').replace(/[&<>"']/g, (c) => (
41
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
42
+ ));
43
+ }
44
+
45
+ /**
46
+ * Masque le mot de passe d'une URI de connexion.
47
+ * `postgres://alice:s3cr3t@hote:5432/base` → `postgres://alice:***@hote:5432/base`
48
+ *
49
+ * Appliqué à TOUT ce qui part vers le navigateur : champs, journal, rapport. Un mot de
50
+ * passe de base de production ne doit jamais transiter dans une page ni dans un log.
51
+ */
52
+ export function maskUri(uri) {
53
+ const s = String(uri ?? '');
54
+ if (!s) return '';
55
+ return s.replace(/(:\/\/[^:@/\s]+):[^@\s]*@/g, '$1:***@');
56
+ }
57
+
58
+ /** Masque les URI présentes dans une ligne de journal quelconque. */
59
+ export function maskLine(line) {
60
+ return maskUri(String(line ?? ''));
61
+ }
62
+
63
+ const pct = (copied, total) => (total > 0 ? Math.min(100, Math.round((copied / total) * 100)) : 0);
64
+ const opt = (v, sel) => `<option value="${escapeHtml(v)}"${v === sel ? ' selected' : ''}>${escapeHtml(v)}</option>`;
65
+
66
+ /** Bandeau du mode simulation. `dryRun` vaut vrai par défaut (absent = simulation). */
67
+ export function dryRunBadge(state = {}) {
68
+ const dry = state.dryRun !== false;
69
+ return dry
70
+ ? `<span class="badge badge-dry" title="Aucune écriture ne sera effectuée">🛡️ Simulation (dry-run)</span>`
71
+ : `<span class="badge badge-live" title="Les données seront réellement écrites">⚠️ Écriture RÉELLE</span>`;
72
+ }
73
+
74
+ /**
75
+ * Formulaire de la SOURCE. `db` demande dialecte + URI ; `csv`/`json`/`sql` demandent un fichier.
76
+ * L'URI est ré-affichée MASQUÉE : la page ne réexpose jamais un mot de passe déjà saisi.
77
+ */
78
+ export function sourceForm(state = {}) {
79
+ const src = state.source || {};
80
+ const type = src.type || 'db';
81
+ const isDb = type === 'db';
82
+ return `<section class="panel" id="panel-source">
83
+ <h2>1 · Source</h2>
84
+ <label>Type
85
+ <select name="source.type" data-role="source-type">${SOURCE_TYPES.map((t) => opt(t, type)).join('')}</select>
86
+ </label>
87
+ <div class="field-db"${isDb ? '' : ' hidden'}>
88
+ <label>Dialecte
89
+ <select name="source.dialect">${DIALECTS.map((d) => opt(d, src.dialect || 'sqljs')).join('')}</select>
90
+ </label>
91
+ <label>URI de connexion
92
+ <input name="source.uri" type="text" value="${escapeHtml(maskUri(src.uri))}"
93
+ placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
94
+ </label>
95
+ </div>
96
+ <div class="field-file"${isDb ? ' hidden' : ''}>
97
+ <label>Fichier
98
+ <input name="source.file" type="text" value="${escapeHtml(src.file || '')}" placeholder="./export.json" />
99
+ </label>
100
+ </div>
101
+ <button type="button" data-action="inspect">Tester &amp; lister les entités</button>
102
+ </section>`;
103
+ }
104
+
105
+ /**
106
+ * Formulaire des DESTINATIONS (le moteur en accepte PLUSIEURS : on peut copier vers une
107
+ * base ET produire un dump SQL dans la même passe).
108
+ */
109
+ export function destForm(state = {}) {
110
+ const dests = Array.isArray(state.dests) && state.dests.length ? state.dests : [{ type: 'db' }];
111
+ const row = (d, i) => {
112
+ const isDb = (d.type || 'db') === 'db';
113
+ return `<div class="dest-row" data-index="${i}">
114
+ <label>Type
115
+ <select name="dest.${i}.type" data-role="dest-type">${DEST_TYPES.map((t) => opt(t, d.type || 'db')).join('')}</select>
116
+ </label>
117
+ <div class="field-db"${isDb ? '' : ' hidden'}>
118
+ <label>Dialecte
119
+ <select name="dest.${i}.dialect">${DIALECTS.map((x) => opt(x, d.dialect || 'postgres')).join('')}</select>
120
+ </label>
121
+ <label>URI
122
+ <input name="dest.${i}.uri" type="text" value="${escapeHtml(maskUri(d.uri))}"
123
+ placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
124
+ </label>
125
+ <label class="inline"><input type="checkbox" name="dest.${i}.createDb"${d.createDb ? ' checked' : ''}/> Créer la base</label>
126
+ <label class="inline"><input type="checkbox" name="dest.${i}.createTables"${d.createTables !== false ? ' checked' : ''}/> Créer les tables</label>
127
+ </div>
128
+ <div class="field-file"${isDb ? ' hidden' : ''}>
129
+ <label>Fichier
130
+ <input name="dest.${i}.file" type="text" value="${escapeHtml(d.file || '')}" placeholder="./dump.sql" />
131
+ </label>
132
+ </div>
133
+ ${dests.length > 1 ? `<button type="button" data-action="dest-remove" data-index="${i}">Retirer</button>` : ''}
134
+ </div>`;
135
+ };
136
+ return `<section class="panel" id="panel-dest">
137
+ <h2>2 · Destinations</h2>
138
+ ${dests.map(row).join('')}
139
+ <button type="button" data-action="dest-add">+ Ajouter une destination</button>
140
+ </section>`;
141
+ }
142
+
143
+ /**
144
+ * Choix des entités (`CopyOptions.entities`) et de la taille de lot (`batchSize`).
145
+ * Tant que la source n'a pas été inspectée, la liste est vide : on le DIT, plutôt que
146
+ * d'afficher un bloc vide qui laisserait croire qu'il n'y a rien à copier.
147
+ */
148
+ export function entityPicker(state = {}) {
149
+ const all = state.entities || [];
150
+ const selected = state.selectedEntities || null; // null = toutes
151
+ const body = all.length === 0
152
+ ? `<p class="muted">Aucune entité connue — testez d'abord la source (« Tester &amp; lister les entités »).</p>`
153
+ : all.map((e) => {
154
+ const name = typeof e === 'string' ? e : e.name;
155
+ const count = typeof e === 'object' && e.count != null ? ` <span class="muted">(${e.count})</span>` : '';
156
+ const on = selected === null || selected.includes(name);
157
+ return `<label class="inline"><input type="checkbox" name="entity" value="${escapeHtml(name)}"${on ? ' checked' : ''}/> ${escapeHtml(name)}${count}</label>`;
158
+ }).join('');
159
+ return `<section class="panel" id="panel-entities">
160
+ <h2>3 · Entités &amp; options</h2>
161
+ <div class="entities">${body}</div>
162
+ <label>Taille de lot (batchSize)
163
+ <input name="batchSize" type="number" min="1" value="${Number(state.batchSize) > 0 ? Number(state.batchSize) : 500}" />
164
+ </label>
165
+ <label class="inline">
166
+ <input type="checkbox" name="dryRun"${state.dryRun !== false ? ' checked' : ''}/>
167
+ Simulation — ne rien écrire
168
+ </label>
169
+ </section>`;
170
+ }
171
+
172
+ /** Progression PAR ENTITÉ, alimentée par `onProgress(entity, copied, total)`. */
173
+ export function progressPanel(progress = {}) {
174
+ const rows = Object.entries(progress);
175
+ if (rows.length === 0) return `<section class="panel" id="panel-progress"><h2>Progression</h2><p class="muted">En attente…</p></section>`;
176
+ const bars = rows.map(([entity, p]) => {
177
+ const copied = p.copied || 0;
178
+ const total = p.total || 0;
179
+ const v = pct(copied, total);
180
+ return `<div class="bar-row">
181
+ <span class="bar-label">${escapeHtml(entity)}</span>
182
+ <span class="bar"><span class="bar-fill" style="width:${v}%"></span></span>
183
+ <span class="bar-num">${copied}/${total || '?'}</span>
184
+ </div>`;
185
+ }).join('');
186
+ return `<section class="panel" id="panel-progress"><h2>Progression</h2>${bars}</section>`;
187
+ }
188
+
189
+ /** Journal en direct (`onLog`) — chaque ligne passe par le masquage des URI. */
190
+ export function logPanel(lines = []) {
191
+ const body = lines.length === 0
192
+ ? '<p class="muted">—</p>'
193
+ : `<pre class="log">${lines.map((l) => escapeHtml(maskLine(l))).join('\n')}</pre>`;
194
+ return `<section class="panel" id="panel-log"><h2>Journal</h2>${body}</section>`;
195
+ }
196
+
197
+ /**
198
+ * Rapport final (`CopyResult`). Met en avant les ERREURS : une copie « terminée » avec
199
+ * 12 000 lignes copiées et 3 en erreur n'est PAS un succès, et le rapport doit le dire.
200
+ */
201
+ export function resultReport(result) {
202
+ if (!result) return '';
203
+ const stats = result.stats || [];
204
+ const errs = result.totalErrors || 0;
205
+ const rows = stats.map((s) => `<tr class="${s.errors > 0 ? 'row-err' : ''}">
206
+ <td>${escapeHtml(s.entity)}</td>
207
+ <td class="num">${s.copied}</td>
208
+ <td class="num">${s.errors}</td>
209
+ <td class="num">${s.durationMs} ms</td>
210
+ <td>${s.firstError ? escapeHtml(maskLine(s.firstError)) : ''}</td>
211
+ </tr>`).join('');
212
+ return `<section class="panel ${errs > 0 ? 'panel-err' : 'panel-ok'}" id="panel-result">
213
+ <h2>${errs > 0 ? `⚠️ Terminé avec ${errs} erreur${errs > 1 ? 's' : ''}` : '✅ Copie terminée'}</h2>
214
+ <p>
215
+ <strong>${result.totalCopied || 0}</strong> enregistrement(s) copié(s) ·
216
+ <strong>${errs}</strong> erreur(s) · ${result.durationMs || 0} ms<br/>
217
+ <span class="muted">${escapeHtml(maskUri(result.source))} → ${(result.destinations || []).map((d) => escapeHtml(maskUri(d))).join(', ')}</span>
218
+ </p>
219
+ <table class="stats">
220
+ <thead><tr><th>Entité</th><th class="num">Copiés</th><th class="num">Erreurs</th><th class="num">Durée</th><th>1ʳᵉ erreur</th></tr></thead>
221
+ <tbody>${rows}</tbody>
222
+ </table>
223
+ </section>`;
224
+ }
225
+
226
+ /** Feuille de style embarquée (zéro-dép : aucune CSS externe à charger). */
227
+ export const CSS = `
228
+ :root{--blue:${THEME.blue};--teal:${THEME.teal};--red:${THEME.red};--green:${THEME.green};--ink:${THEME.ink};--bg:${THEME.bg};--line:${THEME.line};--card:${THEME.card};--amber:${THEME.amber}}
229
+ *{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,Arial,sans-serif;color:var(--ink);background:var(--bg)}
230
+ .wrap{max-width:980px;margin:0 auto;padding:24px}
231
+ h1{margin:0 0 4px}h2{margin:0 0 12px;font-size:1.05rem;color:var(--teal)}
232
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0 0 16px}
233
+ .panel-ok{border-color:var(--green)}.panel-err{border-color:var(--red)}
234
+ label{display:block;margin:0 0 10px}label.inline{display:inline-flex;align-items:center;gap:6px;margin-right:14px}
235
+ input[type=text],input[type=number],select{width:100%;padding:8px;border:1px solid var(--line);border-radius:6px;font:inherit}
236
+ label.inline input{width:auto}
237
+ button{padding:9px 14px;border:0;border-radius:6px;background:var(--blue);color:#fff;font:inherit;cursor:pointer}
238
+ button.danger{background:var(--red)}button:disabled{opacity:.5;cursor:not-allowed}
239
+ .badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:.85rem;font-weight:600}
240
+ .badge-dry{background:#e3f2fd;color:var(--blue)}.badge-live{background:#ffebee;color:var(--red)}
241
+ .dest-row{border-top:1px dashed var(--line);padding-top:12px;margin-top:12px}.dest-row:first-of-type{border-top:0;padding-top:0;margin-top:0}
242
+ .bar-row{display:flex;align-items:center;gap:10px;margin:6px 0}
243
+ .bar-label{flex:0 0 160px;font-weight:600}.bar{flex:1;height:10px;background:var(--line);border-radius:6px;overflow:hidden}
244
+ .bar-fill{display:block;height:100%;background:var(--teal);transition:width .2s}.bar-num{flex:0 0 90px;text-align:right;font-variant-numeric:tabular-nums}
245
+ .log{background:#0f172a;color:#e2e8f0;padding:12px;border-radius:6px;max-height:260px;overflow:auto;font-size:.85rem;white-space:pre-wrap}
246
+ table.stats{width:100%;border-collapse:collapse}table.stats th,table.stats td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left}
247
+ td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}tr.row-err td{background:#fff5f5}
248
+ .muted{color:#64748b}[hidden]{display:none!important}
249
+ `;
250
+
251
+ /** Page complète, prête à servir. Compose tous les blocs. */
252
+ export function copyPage(state = {}) {
253
+ const title = state.title || 'Copie de données — MostaJS';
254
+ return `<!doctype html><html lang="fr"><head><meta charset="utf-8"/>
255
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
256
+ <title>${escapeHtml(title)}</title><style>${CSS}</style></head>
257
+ <body><div class="wrap">
258
+ <h1>${escapeHtml(title)}</h1>
259
+ <p>${dryRunBadge(state)}</p>
260
+ <form id="copy-form">
261
+ ${sourceForm(state)}
262
+ ${destForm(state)}
263
+ ${entityPicker(state)}
264
+ <p>
265
+ <button type="button" data-action="run" class="${state.dryRun === false ? 'danger' : ''}">
266
+ ${state.dryRun === false ? '⚠️ Lancer la copie RÉELLE' : '▶ Lancer la simulation'}
267
+ </button>
268
+ </p>
269
+ </form>
270
+ ${progressPanel(state.progress)}
271
+ ${logPanel(state.log)}
272
+ ${resultReport(state.result)}
273
+ </div></body></html>`;
274
+ }
275
+
276
+ export default {
277
+ moduleInfo, SOURCE_TYPES, DEST_TYPES, DIALECTS, THEME, CSS,
278
+ escapeHtml, maskUri, maskLine,
279
+ dryRunBadge, sourceForm, destForm, entityPicker, progressPanel, logPanel, resultReport, copyPage,
280
+ };