@noeva/analytics-core 0.3.3 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -3,6 +3,49 @@
3
3
  Le versioni del pacchetto che i repository dei progetti installano (ADR 0005). Prima di
4
4
  `analytics upgrade` si legge cosa cambia fra la versione installata e la nuova.
5
5
 
6
+ ## 0.4.1 — 10 novembre 2026
7
+
8
+ - `voce.js`: `montaSceltaVoce("#voce", { lingue: ["it"] })` mostra solo le lingue in cui
9
+ il progetto ha pagine con il commento; senza `lingue` restano tutte quelle del
10
+ catalogo. La 0.4.0 su npm aveva il modulo senza questa opzione.
11
+
12
+ ## 0.4.0 — 10 novembre 2026
13
+
14
+ - **Le risposte alle domande si registrano in ogni progetto allo stesso modo**
15
+ (standard §6.4 e §7). Modulo nuovo `apps/assets/risposte.js`: `montaRisposte` sceglie
16
+ da solo fra il Mac (`serve-app.py`), il salvataggio online del contenitore e
17
+ l'esportazione su file, tiene la bozza nel browser e scrive la riga che dice dove si
18
+ salvano. La pagina del progetto disegna le domande e chiama `R.collega()`; il `POST`
19
+ a `kb/risposte` scritto a mano in `apps/progetto/pagina.js`, con il controllo
20
+ «le risposte si registrano dalla versione sul Mac», si toglie.
21
+ - `analytics ci` si ferma se `apps/progetto/` mostra le domande (`lista-domande`) senza
22
+ `montaRisposte`. Prima di aggiornare: la pagina del progetto usa il modulo.
23
+ - Modulo nuovo `apps/assets/vista.js`, la Vista dichiarata (standard §6.2 punto 9,
24
+ NW-1288): `montaVista` scrive la riga di provenienza e il blocco `noeva-vista`, con
25
+ «Copia la citazione». `tema.js` manda l'evento `noeva-vista` a ogni cambio di filtri o
26
+ di fuoco e `commento.js` legge da li' filtri e fuoco. Una pagina che non chiama
27
+ `montaVista` resta com'e'. Le frasi nuove da tradurre in `en.js` sono in
28
+ `lib/pagina/README.md` di NOEVA Analytics.
29
+ - **La voce del commento anche sul sito pubblicato** (NW-1091). Modulo nuovo
30
+ `apps/assets/voce.js`: `montaSceltaVoce("#voce")` nella home del progetto offre, per
31
+ italiano e inglese, le voci del catalogo del contenitore (`ai/voci`, ElevenLabs con la
32
+ chiave in un secret del Worker) con «Ascolta»; la scelta resta nel browser. Dove il
33
+ contenitore non fa la voce il riquadro resta nascosto. `commento.js` usa la voce
34
+ scelta; chi non sceglie sente quella di prima. La home monta il modulo con un
35
+ `import()` dinamico, cosi' resta buona anche prima dell'aggiornamento. Le frasi nuove
36
+ da tradurre in `en.js` sono in `lib/pagina/README.md` di NOEVA Analytics.
37
+ - La chat su NOEVA Agents chiede `ai/token` solo quando si apre (NW-1279): al
38
+ caricamento della pagina chiede solo `ai/token?disponibile`, senza token.
39
+ - `snapshot.json` porta in `progetto` l'id del progetto nel contenitore, non il nome
40
+ della cartella (`esporta.py`, `manifesto.py`).
41
+ - Il modello di consegna porta la scheda del progetto e le istruzioni di `initialize`,
42
+ uguali in locale e dal gateway (NW-1286): `lib/modello/scheda.py`, `istruzioni.py`.
43
+ - La cartella consegnata del servizio locale ha un `CLAUDE.md` con il solo
44
+ collegamento e i divieti di sicurezza, fra marcatori (NW-1287).
45
+ - `lib/snapshot/ai.py` scrive i prompt della chat anche per un progetto senza commento
46
+ guidato: `commento.py` deve avere solo `conoscenza` e `LINGUE`; istruzioni, modi e
47
+ schema servono solo per i prompt del tour.
48
+
6
49
  ## 0.3.3 — 6 novembre 2026
7
50
 
8
51
  - Il contenitore parla Oracle: l'extra `oracle` di wrenai (oracledb in modalita' thin,
@@ -3,11 +3,13 @@
3
3
  /* La chat su NOEVA Agents (ADR 0006, in prova): agents UI in un iframe dentro il
4
4
  * pannello della chat, che conserva le sue quattro viste.
5
5
  *
6
- * const a = await agentiDisponibili(); // null, o { agenti, token, ... }
7
- * if (a) montaAgenti(contenitore, { pagina, accesso: a });
6
+ * if (await agentiDisponibili()) ... // true o false, senza token
7
+ * montaAgenti(contenitore, { pagina }); // quando l'utente apre la chat
8
8
  *
9
9
  * Il Worker custodisce i token NOEVA dell'utente e da' alla pagina solo
10
10
  * l'access token, da ai/token, con l'indirizzo di agents UI (AGENTI_URL). La
11
+ * pagina lo chiede solo all'apertura della chat: al caricamento le basta
12
+ * ai/token?disponibile, che il token non lo porta (NW-1279). La
11
13
  * pagina lo passa all'iframe con INIT e, prima che scada, con AUTH_REFRESH: lo
12
14
  * stesso protocollo del client NOEVA. Progetto, pagina, lingua e lo stato della
13
15
  * pagina (contesto() del commento) viaggiano in customMetadata, aggiornato con
@@ -24,25 +26,39 @@ const AGENTE = "analyticsChatAgent";
24
26
  // arriva gia' rinnovato.
25
27
  const ANTICIPO_MS = 6 * 60 * 1000;
26
28
 
27
- let _accesso = null;
28
- /** L'accesso ad agents UI, o null se il contenitore non lo offre (niente SESSIONI o AGENTI_URL). */
29
+ let _disponibile = null;
30
+ /** Se il contenitore offre agents UI (SESSIONI e AGENTI_URL) a questa sessione. Senza token. */
29
31
  export function agentiDisponibili() {
30
- _accesso ||= chiediToken().catch(() => null);
31
- return _accesso;
32
+ _disponibile ||= fetch(rotta("ai/token?disponibile"), { cache: "no-store" })
33
+ .then(async (r) => r.ok && (await r.json())?.disponibile === true).catch(() => false);
34
+ return _disponibile;
32
35
  }
33
36
 
37
+ // null: sessione NOEVA finita (401). undefined: il Worker non risponde.
34
38
  async function chiediToken() {
35
39
  const r = await fetch(rotta("ai/token"), { cache: "no-store" });
36
- if (!r.ok) return null;
40
+ if (r.status === 401) return null;
41
+ if (!r.ok) return undefined;
37
42
  const j = await r.json();
38
- return j?.token && j?.agenti ? j : null;
43
+ return j?.token && j?.agenti ? j : undefined;
39
44
  }
40
45
 
41
46
  const tema = () => {
42
47
  try { return { light: "light", dark: "dark" }[localStorage.getItem("noeva-tema")] || "system"; } catch { return "system"; }
43
48
  };
44
49
 
45
- export function montaAgenti(contenitore, { pagina, accesso, suggerimenti = [] }) {
50
+ /** Monta agents UI nel pannello: chiede il token adesso, all'apertura della chat. */
51
+ export async function montaAgenti(contenitore, { pagina, suggerimenti = [] }) {
52
+ contenitore.querySelector(".ch-agenti-errore")?.remove();
53
+ const accesso = await chiediToken().catch(() => undefined);
54
+ if (accesso === null) { location.reload(); return null; } // sessione finita: il cancello porta al login
55
+ if (!accesso) {
56
+ const errore = document.createElement("p");
57
+ errore.className = "ch-stato errore ch-agenti-errore";
58
+ errore.textContent = t("La chat non risponde: riprova fra poco.");
59
+ contenitore.append(errore);
60
+ return null;
61
+ }
46
62
  const origine = new URL(accesso.agenti).origin;
47
63
  const f = document.createElement("iframe");
48
64
  f.src = accesso.agenti;
@@ -104,7 +120,8 @@ export function montaAgenti(contenitore, { pagina, accesso, suggerimenti = [] })
104
120
  attesa = setTimeout(() => manda({ type: "UPDATE_METADATA", payload: metadata() }), 400);
105
121
  };
106
122
  document.addEventListener("change", aggiorna, true);
123
+ document.addEventListener("noeva-vista", aggiorna);
107
124
  document.addEventListener("click", (e) => { if (!contenitore.contains(e.target)) aggiorna(); }, true);
108
125
  addEventListener("popstate", aggiorna);
109
- return { aggiorna };
126
+ return { aggiorna, iframe: f };
110
127
  }
@@ -255,6 +255,15 @@ th[data-col]:focus-visible { outline: 2px solid var(--primary); outline-offset:
255
255
  *, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
256
256
  }
257
257
 
258
+ /* --- la riga di provenienza (montaVista in vista.js) ---------------------- */
259
+ .provenienza { font-size: 12px; color: var(--ink-3); line-height: 1.7; margin: 8px 0 0; }
260
+ .provenienza b { font-weight: 500; color: var(--ink-2); }
261
+ .provenienza .pv-eta.stantio { color: var(--avviso); font-weight: 500; }
262
+ .provenienza .pv-frase code { font-size: 11.5px; }
263
+ .provenienza .pv-copia { font: inherit; font-size: 11.5px; margin-left: 4px; padding: 2px 9px; min-height: 24px;
264
+ border: 1px solid var(--linea-controllo); background: transparent; color: var(--ink-2); cursor: pointer; }
265
+ .provenienza .pv-copia:hover { color: var(--ink); border-color: var(--ink); }
266
+
258
267
  /* --- piede di pagina (montaPiede in tema.js) ------------------------------ */
259
268
  footer.piede { max-width: 1240px; margin: 0 auto; padding: 18px 22px 28px; border-top: 1px solid var(--line);
260
269
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; color: var(--ink-3); }
@@ -367,3 +376,17 @@ tbody tr.altro > td { opacity: .55; }
367
376
  .menu-utente .chi span { font-size: 12px; color: var(--ink-3); word-break: break-all; }
368
377
  .menu-utente a[role="menuitem"] { display: block; padding: 10px 14px; font-size: 13px; color: var(--ink-2); text-decoration: none; }
369
378
  .menu-utente a[role="menuitem"]:hover, .menu-utente a[role="menuitem"]:focus-visible { background: var(--surface-2); color: var(--ink); outline: 0; }
379
+
380
+ /* --- la voce del commento, nella home del progetto (montaSceltaVoce in voce.js) --- */
381
+ .scelta-voce[hidden] { display: none; }
382
+ .scelta-voce { margin: 24px 0; }
383
+ .scelta-voce h3 { font-weight: 500; font-size: 15px; margin: 0 0 4px; }
384
+ .scelta-voce .riga-voce { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 10px; }
385
+ .scelta-voce label { min-width: 72px; font-size: 13px; color: var(--ink-2); }
386
+ .scelta-voce select { min-height: 32px; max-width: 100%; padding: 4px 8px; font: inherit; font-size: 13px;
387
+ background: var(--surface); color: var(--ink); border: 1px solid var(--linea-controllo); }
388
+ .scelta-voce button { min-height: 32px; padding: 4px 12px; font: inherit; font-size: 13px; cursor: pointer;
389
+ border: 1px solid var(--linea-controllo); background: transparent; color: var(--ink-2); }
390
+ .scelta-voce button:hover { color: var(--ink); border-color: var(--ink); }
391
+ .scelta-voce .stato-voce { font-size: 12px; color: var(--ink-3); }
392
+ @media (max-width: 1099.98px) { .scelta-voce select, .scelta-voce button { min-height: 44px; } }
@@ -130,9 +130,11 @@ export function montaChat({ pagina }) {
130
130
  // Il pannello compare dove qualcuno puo' rispondere: NOEVA Agents (ADR 0006,
131
131
  // dove il contenitore lo offre), l'inferenza NOEVA del contenitore (anche sul
132
132
  // sito pubblicato) o, senza, il server sul Mac.
133
+ // Al caricamento basta sapere se NOEVA Agents c'e': il token si chiede quando
134
+ // la chat si apre (NW-1279).
133
135
  agentiDisponibili().then(async (a) => {
134
- if (a) costruisci(a);
135
- else if ((await motori()).length) costruisci(null);
136
+ if (a) costruisci(true);
137
+ else if ((await motori()).length) costruisci(false);
136
138
  });
137
139
 
138
140
  function costruisci(agenti) {
@@ -162,10 +164,9 @@ export function montaChat({ pagina }) {
162
164
  const $ = (s) => p.querySelector(s);
163
165
  // Con NOEVA Agents il corpo del pannello e' agents UI: conversazione,
164
166
  // storico, «nuova» e consumi sono suoi. Del pannello restano testa e viste.
165
- if (agenti) {
166
- for (const s of ["#ch-msgs", "#ch-stato", "#ch-form", "#ch-nuova"]) $(s).remove();
167
- montaAgenti(p, { pagina, accesso: agenti, suggerimenti: SUGGERIMENTI[pagina] || [] });
168
- }
167
+ // Il token e l'iframe arrivano alla prima apertura.
168
+ let montato = null;
169
+ if (agenti) for (const s of ["#ch-msgs", "#ch-stato", "#ch-form", "#ch-nuova"]) $(s).remove();
169
170
  const CHIAVE = `${PROGETTO.id}-chat-${pagina}-${LINGUA}`;
170
171
  const st = { sessione: null, inCorso: null, msgs: [] };
171
172
  try { st.sessione = localStorage.getItem(CHIAVE); } catch { /* niente memoria */ }
@@ -284,7 +285,12 @@ export function montaChat({ pagina }) {
284
285
  const apri = (si) => {
285
286
  p.hidden = !si; btn.setAttribute("aria-expanded", String(si));
286
287
  applica();
287
- if (si) { if (agenti) p.querySelector("iframe")?.focus(); else { if (!$("#ch-msgs").children.length) riprendi(); $("#ch-testo").focus(); } } else btn.focus();
288
+ if (si) {
289
+ if (agenti) {
290
+ montato ||= montaAgenti(p, { pagina, suggerimenti: SUGGERIMENTI[pagina] || [] }).then((m) => { if (!m) montato = null; return m; });
291
+ montato.then((m) => m?.iframe.focus());
292
+ } else { if (!$("#ch-msgs").children.length) riprendi(); $("#ch-testo").focus(); }
293
+ } else btn.focus();
288
294
  };
289
295
  btn.onclick = () => apri(p.hidden);
290
296
  $("#ch-chiudi").onclick = () => apri(false);
@@ -16,12 +16,14 @@
16
16
  * Tour e voce li fa serve-app.py (/ai/tour, /ai/voce, vedi commento.py), con la
17
17
  * cache su disco: la vista iniziale, preparata prima, parte subito e senza rete.
18
18
  * Dietro il cancello il server non c'e': restano i tour e le voci gia' pronti,
19
- * trovati con la stessa chiave del server (servizio.js).
19
+ * trovati con la stessa chiave del server (servizio.js), e la voce la fa il
20
+ * contenitore (ai/voci, voce.js) con la voce scelta nella home del progetto.
20
21
  */
21
22
  import { conServer, chiaveServer, sha256, fileDati, urlDati, conLingua, rotta } from "./servizio.js";
22
23
  import { MESI, MESI_LUNGHI } from "./formati.js";
23
24
  import { t, LINGUA } from "./lingua.js";
24
25
  import { giro, nuovaSessione, motoreScelto, selettoreMotore } from "./inferenza.js";
26
+ import { voceOnline, voceScelta, parla } from "./voce.js";
25
27
 
26
28
  export const pulito = (s) => String(s ?? "").replace(/[▸▾]/g, "").replace(/\s+/g, " ").trim();
27
29
  /* il testo di un elemento senza i pulsanti che contiene (la «i» della guida, i CSV) */
@@ -98,13 +100,36 @@ function filtri() {
98
100
  return f;
99
101
  }
100
102
 
103
+ /* La vista dichiarata dalla pagina (vista.js, blocco noeva-vista), o null. */
104
+ function vistaDichiarata() {
105
+ try { return JSON.parse(document.getElementById("noeva-vista")?.textContent || "null"); } catch { return null; }
106
+ }
107
+
108
+ /* Dove la pagina dichiara la vista, filtri e fuoco vengono da li': chat, commento
109
+ * e presentazione citano la stessa vista di Claude nel browser. Della vista
110
+ * restano fuori l'indirizzo e l'eta' dello snapshot, che cambiano fra il Mac e il
111
+ * contenitore e col passare delle ore: il contesto e' anche la chiave dei tour e
112
+ * delle scalette gia' pronti. Le pagine senza blocco restano come prima. */
101
113
  export function contesto() {
102
- return {
114
+ const v = vistaDichiarata();
115
+ const ctx = {
103
116
  titolo: pulito(document.querySelector(".topbar h1")?.textContent),
104
- filtri: filtri(),
117
+ filtri: v ? filtriDellaVista(v) : filtri(),
105
118
  sintesi: pulito(document.querySelector("#tesi, .tesi")?.textContent),
106
119
  riquadri: riquadri(),
107
120
  };
121
+ if (v) {
122
+ const { url, snapshot, ...resto } = v;
123
+ ctx.vista = { ...resto, snapshot: { generato: snapshot?.generato ?? null, data_ora: snapshot?.data_ora ?? null, fuso: snapshot?.fuso ?? null } };
124
+ }
125
+ return ctx;
126
+ }
127
+
128
+ function filtriDellaVista(v) {
129
+ const f = {};
130
+ for (const x of v.filtri || []) f[x.nome] = x.etichetta;
131
+ if (v.fuoco) f[t("A fuoco")] = v.fuoco.etichetta;
132
+ return f;
108
133
  }
109
134
 
110
135
  /* --- evidenziare -------------------------------------------------------------------- */
@@ -404,6 +429,8 @@ export function montaCommento({ pagina }) {
404
429
  j._da_cache = true;
405
430
  }
406
431
  if (gen !== st.gen) return;
432
+ // gli mp3 del contenitore sono blob: del tour di prima, si liberano
433
+ for (const v of st.voci.values()) v.then((u) => u?.startsWith("blob:") && URL.revokeObjectURL(u), () => {});
407
434
  st.tour = j; st.voci.clear();
408
435
  const sec = Math.round((performance.now() - t0) / 1000);
409
436
  stato(j._da_cache ? t("dalla cache") : t("pronto in {sec} s", { sec }) + (j._motore?.turni ? ` · ${t("{n} passaggi", { n: j._motore.turni })}` : ""));
@@ -420,23 +447,33 @@ export function montaCommento({ pagina }) {
420
447
  const tappa = st.tour?.tappe[i];
421
448
  if (!tappa || !st.voce) return Promise.resolve(null);
422
449
  if (!st.voci.has(i)) {
423
- st.voci.set(i, conServer().then(async (vivo) => {
424
- if (vivo) {
425
- const j = await fetch(rotta("ai/voce"), { method: "POST", headers: { "content-type": "application/json" },
426
- body: JSON.stringify({ testo: tappa.testo, pagina, lingua: LINGUA }) }).then((r) => r.json());
427
- if (j.errore) throw new Error(j.errore);
428
- return urlDati(j.url.replace(/^dati\//, ""));
450
+ st.voci.set(i, (async () => {
451
+ // Chi ha scelto una voce nella home la sente dal contenitore; chi non ha
452
+ // scelto, prima la voce del progetto (serve-app.py o gli mp3 gia' pronti),
453
+ // poi il contenitore con la predefinita.
454
+ const scelta = voceScelta();
455
+ const vivo = await conServer();
456
+ if (!scelta && vivo) return voceDalServer(tappa);
457
+ if (!scelta) {
458
+ const voci = await (st.manifesto ||= fileDati("commento/voci.json").then((m) => m || {}));
459
+ const url = voci[await sha256(`${pagina}\0${tappa.testo}`)];
460
+ if (url) return urlDati(url.replace(/^dati\//, ""));
429
461
  }
430
- // dietro il cancello: l'mp3 gia' generato, dal manifesto di export-kb.py
431
- const voci = await (st.manifesto ||= fileDati("commento/voci.json").then((m) => m || {}));
432
- const url = voci[await sha256(`${pagina}\0${tappa.testo}`)];
433
- if (!url) throw new Error(t("voce non pronta per questa tappa"));
434
- return urlDati(url.replace(/^dati\//, ""));
435
- }));
462
+ if (await voceOnline()) return parla(tappa.testo, { pagina });
463
+ if (vivo) return voceDalServer(tappa);
464
+ throw new Error(t("voce non pronta per questa tappa"));
465
+ })());
436
466
  }
437
467
  return st.voci.get(i);
438
468
  }
439
469
 
470
+ async function voceDalServer(tappa) {
471
+ const j = await fetch(rotta("ai/voce"), { method: "POST", headers: { "content-type": "application/json" },
472
+ body: JSON.stringify({ testo: tappa.testo, pagina, lingua: LINGUA }) }).then((r) => r.json());
473
+ if (j.errore) throw new Error(j.errore);
474
+ return urlDati(j.url.replace(/^dati\//, ""));
475
+ }
476
+
440
477
  function prossima() { if (st.tour && st.i < st.tour.tappe.length - 1) vai(st.i + 1); else fine(); }
441
478
 
442
479
  function fine() {
@@ -0,0 +1,166 @@
1
+ // Modulo condiviso delle dashboard NOEVA Analytics. La sorgente e' lib/pagina/risposte.js;
2
+ // la copia in apps/assets/ la scrive lib/snapshot/installa.sh --pagina. Non modificare la copia.
3
+ /* La registrazione delle risposte alle domande, nella pagina «Il progetto»
4
+ * (standard §6.4 e §7). Una sola strada per tutti i progetti: la pagina disegna
5
+ * le domande come vuole, questo modulo decide dove si salvano e le salva.
6
+ *
7
+ * Tre modi, scelti da soli:
8
+ *
9
+ * mac c'e' serve-app.py: POST kb/risposte scrive knowledge/risposte-cliente.json
10
+ * online dietro il cancello: la stessa rotta la serve il contenitore, che scrive
11
+ * su R2 le risposte condivise fra gli utenti del progetto, con chi le ha
12
+ * scritte e lo storico (lib/analytics/risposte.js)
13
+ * file nessuno dei due (un server statico, un contenitore senza il bucket):
14
+ * le risposte non si salvano, si esportano in un file da mandare a 4D
15
+ *
16
+ * In tutti e tre la bozza di ogni risposta resta nel browser finche' non si
17
+ * salva, e «Esporta le risposte» scarica quelle scritte, salvate o no.
18
+ *
19
+ * const R = montaRisposte({
20
+ * domande: () => DOMANDE, // [{ id, domanda }]
21
+ * alSalvataggio: (risposte) => { ...ridisegna },
22
+ * });
23
+ * RISPOSTE = await R.carica(); // e R.modo
24
+ * ...la pagina scrive in #lista-domande, per ogni domanda:
25
+ * <article class="domanda" id="d-<id>" data-id="<id>">
26
+ * <textarea>la risposta salvata</textarea>
27
+ * <button data-a="risposta|rimandata|aperta"> <span class="salvata">
28
+ * R.collega(); // dopo ogni disegno
29
+ *
30
+ * `collega` mette la bozza nel campo, i pulsanti, il nome di chi risponde
31
+ * (#autore, ricordato nel browser) e la riga che dice dove si salvano le
32
+ * risposte, con il pulsante dell'esportazione: se la pagina non ha
33
+ * #nota-risposte, la crea dopo `accanto`.
34
+ */
35
+
36
+ import { kb, conServer, salvaRisposta } from "./servizio.js";
37
+ import { t } from "./lingua.js";
38
+ import { dataLunga } from "./formati.js";
39
+ import { PROGETTO } from "./progetto.js";
40
+
41
+ const memoria = {
42
+ leggi(k) { try { return localStorage.getItem(k); } catch { return null; } },
43
+ scrivi(k, v) {
44
+ try { if (v === null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* nessuna memoria locale: resta il campo */ }
45
+ },
46
+ };
47
+
48
+ const NOTE = {
49
+ mac: () => t("Le risposte si salvano nel progetto, in knowledge/risposte-cliente.json."),
50
+ online: () => t("Le risposte si salvano online e le vedono tutti gli utenti del progetto, con il nome di chi le ha scritte e lo storico delle versioni."),
51
+ file: () => t("Qui le risposte non si salvano: scrivetele e usate «Esporta le risposte», poi mandate il file a 4D."),
52
+ };
53
+
54
+ export function montaRisposte({ lista = "#lista-domande", accanto = "#cont-domande", domande = () => [], alSalvataggio = () => {} } = {}) {
55
+ const CHIAVE_AUTORE = `${PROGETTO.id}.autore`;
56
+ const chiaveBozza = (id) => `${PROGETTO.id}.bozza.${id}`;
57
+ let risposte = {}, modo = "file", autore = memoria.leggi(CHIAVE_AUTORE) || "";
58
+
59
+ const bozza = (id) => memoria.leggi(chiaveBozza(id));
60
+ const scriviBozza = (id, testo) => memoria.scrivi(chiaveBozza(id), testo === (risposte[id]?.risposta || "") ? null : testo);
61
+ const campo = (id) => document.querySelector(`#d-${id} textarea`);
62
+
63
+ /* Le risposte, dal server, dal contenitore o dal file pubblicato; e il modo. */
64
+ async function carica() {
65
+ const r = await kb("risposte").catch(() => ({ risposte: {} }));
66
+ risposte = r.risposte || {};
67
+ modo = (await conServer()) ? "mac" : r.condivise ? "online" : "file";
68
+ return risposte;
69
+ }
70
+
71
+ /* La riga sotto i contatori: dove si salvano le risposte, e l'esportazione. */
72
+ function riga() {
73
+ let nota = document.querySelector("#nota-risposte");
74
+ if (!nota) {
75
+ const dopo = document.querySelector(accanto);
76
+ if (!dopo) return;
77
+ const blocco = document.createElement("div");
78
+ blocco.className = "nota-risposte";
79
+ Object.assign(blocco.style, { display: "flex", flexWrap: "wrap", alignItems: "center", gap: "8px 14px", margin: "0 0 14px", fontSize: "13px" });
80
+ nota = document.createElement("span"); nota.id = "nota-risposte"; nota.className = "chi";
81
+ const pulsante = document.createElement("button"); pulsante.type = "button"; pulsante.id = "esporta-risposte"; pulsante.className = "btn";
82
+ const esito = document.createElement("span"); esito.id = "nota-esporta"; esito.className = "chi";
83
+ blocco.append(nota, pulsante, esito);
84
+ dopo.after(blocco);
85
+ }
86
+ nota.textContent = NOTE[modo]();
87
+ const pulsante = document.querySelector("#esporta-risposte");
88
+ if (pulsante) { pulsante.textContent = t("Esporta le risposte"); pulsante.onclick = esporta; }
89
+ }
90
+
91
+ function collega() {
92
+ riga();
93
+ const chi = document.querySelector("#autore");
94
+ if (chi) {
95
+ chi.value = autore;
96
+ chi.oninput = () => { autore = chi.value; memoria.scrivi(CHIAVE_AUTORE, autore); };
97
+ }
98
+ document.querySelectorAll(`${lista} .domanda`).forEach((el) => {
99
+ const id = Number(el.dataset.id);
100
+ el.querySelectorAll("button[data-a]").forEach((b) => (b.onclick = () => salva(el, b.dataset.a)));
101
+ const testo = el.querySelector("textarea");
102
+ if (!testo) return;
103
+ // la bozza resta nel browser finche' non si salva: un ricaricamento non la perde
104
+ const b = bozza(id);
105
+ if (b !== null) testo.value = b;
106
+ testo.oninput = () => scriviBozza(id, testo.value);
107
+ });
108
+ }
109
+
110
+ async function salva(el, stato) {
111
+ const id = Number(el.dataset.id), risposta = el.querySelector("textarea").value;
112
+ const nota = el.querySelector(".salvata");
113
+ if (nota) { nota.textContent = t("salvo…"); nota.style.color = ""; }
114
+ try {
115
+ await salvaRisposta({ id, stato, risposta, autore });
116
+ memoria.scrivi(chiaveBozza(id), null);
117
+ await carica();
118
+ await alSalvataggio(risposte);
119
+ document.getElementById(`d-${id}`)?.scrollIntoView?.({ block: "nearest" });
120
+ return true;
121
+ } catch (e) {
122
+ if (nota) { nota.textContent = t("non salvata: {errore}", { errore: e.message }); nota.style.color = "var(--male)"; }
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /* Le risposte scritte nella pagina, salvate o no, in Markdown. */
128
+ function testoEsportato(oggi = new Date().toISOString().slice(0, 10)) {
129
+ const voci = domande().map((q) => {
130
+ const testo = (campo(q.id)?.value ?? bozza(q.id) ?? risposte[q.id]?.risposta ?? "").trim();
131
+ return { q, testo, salvata: testo === (risposte[q.id]?.risposta || "").trim() };
132
+ }).filter((v) => v.testo);
133
+ if (!voci.length) return { n: 0, testo: "" };
134
+ const righe = [`# ${t("Risposte alle domande di {nome}", { nome: PROGETTO.nome || PROGETTO.id })}`, "",
135
+ t("Esportate il {data}{da}. {n} risposte.", { data: dataLunga(oggi), da: autore ? t(" da {autore}", { autore }) : "", n: voci.length }), ""];
136
+ for (const { q, testo, salvata } of voci) {
137
+ const r = risposte[q.id] || {};
138
+ righe.push(`## ${q.id}. ${q.domanda}`, "", testo, "",
139
+ salvata && r.aggiornata
140
+ ? `_${t("salvata il {data}", { data: r.aggiornata.slice(0, 10) })}${r.autore ? t(" da {autore}", { autore: r.autore }) : ""}_`
141
+ : `_${t("non ancora salvata")}_`, "");
142
+ }
143
+ return { n: voci.length, testo: righe.join("\n") };
144
+ }
145
+
146
+ function esporta() {
147
+ const oggi = new Date().toISOString().slice(0, 10);
148
+ const { n, testo } = testoEsportato(oggi);
149
+ const esito = document.querySelector("#nota-esporta");
150
+ if (!n) { if (esito) esito.textContent = t("nessuna risposta scritta da esportare"); return; }
151
+ const a = document.createElement("a");
152
+ a.href = URL.createObjectURL(new Blob([testo], { type: "text/markdown;charset=utf-8" }));
153
+ a.download = `risposte-${PROGETTO.id}-${oggi}.md`;
154
+ document.body.append(a); a.click(); a.remove();
155
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
156
+ if (esito) esito.textContent = t("{n} risposte esportate in {file}", { n, file: a.download });
157
+ }
158
+
159
+ return {
160
+ carica, collega, salva, esporta, testoEsportato,
161
+ get risposte() { return risposte; },
162
+ get modo() { return modo; },
163
+ get autore() { return autore; },
164
+ statoDi: (q) => risposte[q.id]?.stato || "aperta",
165
+ };
166
+ }
@@ -297,6 +297,8 @@ export function sincronizzaUrl(stato, campi, ammessi = {}) {
297
297
  }
298
298
  const q = p.toString();
299
299
  history.replaceState(null, "", q ? `?${q}` : location.pathname);
300
+ // la vista dichiarata (vista.js) si riscrive con i filtri nuovi
301
+ document.dispatchEvent(new Event("noeva-vista"));
300
302
  };
301
303
 
302
304
  return { leggi, scrivi };
@@ -645,12 +647,13 @@ export function montaFuoco(tabella, scegli) {
645
647
  if (!tr) return;
646
648
  if (tr.classList.contains("apribile") && e.target.closest("td")?.cellIndex === 0) return;
647
649
  scegli(tr.dataset.f);
650
+ document.dispatchEvent(new Event("noeva-vista"));
648
651
  });
649
652
  tabella.addEventListener("keydown", (e) => {
650
653
  const k = chiave(e.target);
651
654
  if (k === undefined) return;
652
655
  const apribile = e.target.closest("tr").classList.contains("apribile");
653
- if (e.key === " " || (e.key === "Enter" && !apribile)) { e.preventDefault(); scegli(k); }
656
+ if (e.key === " " || (e.key === "Enter" && !apribile)) { e.preventDefault(); scegli(k); document.dispatchEvent(new Event("noeva-vista")); }
654
657
  });
655
658
  }
656
659