@noeva/analytics-core 0.3.3 → 0.4.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/CHANGELOG.md +29 -0
- package/contenuto/assets/agenti.js +27 -10
- package/contenuto/assets/base.css +23 -0
- package/contenuto/assets/chat.js +13 -7
- package/contenuto/assets/commento.js +52 -15
- package/contenuto/assets/risposte.js +166 -0
- package/contenuto/assets/tema.js +4 -1
- package/contenuto/assets/vista.js +252 -0
- package/contenuto/assets/voce.js +127 -0
- package/contenuto/lib/mcp-locale/cartella/CLAUDE.md +17 -31
- package/contenuto/lib/mcp-locale/costruisci.py +14 -23
- package/contenuto/lib/modello/consegna.py +10 -1
- package/contenuto/lib/modello/istruzioni.json +14 -0
- package/contenuto/lib/modello/istruzioni.py +48 -0
- package/contenuto/lib/modello/pubblica.py +8 -2
- package/contenuto/lib/modello/scheda.py +146 -0
- package/contenuto/lib/snapshot/ai.py +10 -2
- package/contenuto/lib/snapshot/esporta.py +4 -1
- package/contenuto/lib/snapshot/manifesto.py +23 -1
- package/core.js +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,35 @@
|
|
|
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.0 — 10 novembre 2026
|
|
7
|
+
|
|
8
|
+
- **Le risposte alle domande si registrano in ogni progetto allo stesso modo**
|
|
9
|
+
(standard §6.4 e §7). Modulo nuovo `apps/assets/risposte.js`: `montaRisposte` sceglie
|
|
10
|
+
da solo fra il Mac (`serve-app.py`), il salvataggio online del contenitore e
|
|
11
|
+
l'esportazione su file, tiene la bozza nel browser e scrive la riga che dice dove si
|
|
12
|
+
salvano. La pagina del progetto disegna le domande e chiama `R.collega()`; il `POST`
|
|
13
|
+
a `kb/risposte` scritto a mano in `apps/progetto/pagina.js`, con il controllo
|
|
14
|
+
«le risposte si registrano dalla versione sul Mac», si toglie.
|
|
15
|
+
- `analytics ci` si ferma se `apps/progetto/` mostra le domande (`lista-domande`) senza
|
|
16
|
+
`montaRisposte`. Prima di aggiornare: la pagina del progetto usa il modulo.
|
|
17
|
+
- Modulo nuovo `apps/assets/vista.js`, la Vista dichiarata (standard §6.2 punto 9,
|
|
18
|
+
NW-1288): `montaVista` scrive la riga di provenienza e il blocco `noeva-vista`, con
|
|
19
|
+
«Copia la citazione». `tema.js` manda l'evento `noeva-vista` a ogni cambio di filtri o
|
|
20
|
+
di fuoco e `commento.js` legge da li' filtri e fuoco. Una pagina che non chiama
|
|
21
|
+
`montaVista` resta com'e'. Le frasi nuove da tradurre in `en.js` sono in
|
|
22
|
+
`lib/pagina/README.md` di NOEVA Analytics.
|
|
23
|
+
- La chat su NOEVA Agents chiede `ai/token` solo quando si apre (NW-1279): al
|
|
24
|
+
caricamento della pagina chiede solo `ai/token?disponibile`, senza token.
|
|
25
|
+
- `snapshot.json` porta in `progetto` l'id del progetto nel contenitore, non il nome
|
|
26
|
+
della cartella (`esporta.py`, `manifesto.py`).
|
|
27
|
+
- Il modello di consegna porta la scheda del progetto e le istruzioni di `initialize`,
|
|
28
|
+
uguali in locale e dal gateway (NW-1286): `lib/modello/scheda.py`, `istruzioni.py`.
|
|
29
|
+
- La cartella consegnata del servizio locale ha un `CLAUDE.md` con il solo
|
|
30
|
+
collegamento e i divieti di sicurezza, fra marcatori (NW-1287).
|
|
31
|
+
- `lib/snapshot/ai.py` scrive i prompt della chat anche per un progetto senza commento
|
|
32
|
+
guidato: `commento.py` deve avere solo `conoscenza` e `LINGUE`; istruzioni, modi e
|
|
33
|
+
schema servono solo per i prompt del tour.
|
|
34
|
+
|
|
6
35
|
## 0.3.3 — 6 novembre 2026
|
|
7
36
|
|
|
8
37
|
- 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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
28
|
-
/**
|
|
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
|
-
|
|
31
|
-
|
|
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 (
|
|
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 :
|
|
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
|
-
|
|
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; } }
|
package/contenuto/assets/chat.js
CHANGED
|
@@ -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(
|
|
135
|
-
else if ((await motori()).length) costruisci(
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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) {
|
|
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
|
-
|
|
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,
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
}
|
package/contenuto/assets/tema.js
CHANGED
|
@@ -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
|
|