sagadeck 1.11.2 → 1.11.4

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/README.md CHANGED
@@ -267,6 +267,8 @@ embutido em iframe de outra origem.
267
267
 
268
268
  **Comandos da IA (para testar APIs de verdade):** no chat do Studio, a IA pode pedir para rodar um trecho de JavaScript, Python, PowerShell ou shell — por exemplo, testar a API cuja documentação você colou, ajustar o contrato e só então montar o slide. Nada roda sem você ver o código e clicar em **Executar** (ou em *Executar e liberar os próximos*, que vale até recarregar o Studio ou trocar de apresentação). O comando roda na pasta da apresentação e recebe as variáveis do ambiente ativo (`SAGA_VAR_*`, `SAGA_SECRET_*`, `SAGA_TOKEN`); a IA só vê os nomes, e a saída volta para ela com os segredos mascarados. No servidor multiusuário, só quem estiver em `--agentes=usuario1,usuario2` (ou `SAGADECK_AGENTES`) tem comandos, e eles rodam **na máquina do servidor**; os demais usam o resto normalmente.
269
269
 
270
+ **Enviar cópia para outra pessoa (servidor multiusuário):** quem administra (`--admins=usuario1` ou `SAGADECK_ADMINS`; sem lista, os de `--agentes`) tem, no menu do cartão da biblioteca, **Enviar cópia para…**: a apresentação chega como nova na biblioteca da pessoa escolhida, com "(versão revisada)" no título, sem substituir nada dela. Vão imagens, `contexto/` e o resto da pasta; ficam de fora a conversa com a IA, as versões e os avisos da criação.
271
+
270
272
  **Vários usuários (servidor):** `sagadeck studio --multiuser --library=/srv/sagadeck` dá uma biblioteca por pessoa
271
273
  (`usuarios/<nome>/`), identificada pelo cabeçalho `X-Sagadeck-User` (ou `--user-header=...`) que o proxy de login
272
274
  (nginx) coloca. Sem o cabeçalho, nada é servido. Por confiar nesse cabeçalho, só roda escutando em `127.0.0.1`,
package/bin/sagadeck.js CHANGED
@@ -67,6 +67,7 @@ const HELP = `sagadeck — YAML -> apresentação (HTML animado + PowerPoint edi
67
67
  --host=0.0.0.0 abre para a rede (padrão: só esta máquina)
68
68
  --multiuser: uma biblioteca por usuário, atrás de um proxy que envia X-Sagadeck-User
69
69
  --agentes=naru,ana: no multiusuário, quem pode deixar a IA rodar comandos (padrão: ninguém)
70
+ --admins=naru: no multiusuário, quem envia cópias para a biblioteca dos outros (padrão: os de --agentes)
70
71
  sagadeck ensaio-api [--port=3000] ensaio do slide "api": deck de exemplo contra uma API de mentira (sem VPN)
71
72
  o deck fica na biblioteca (Sem tópico/Ensaio APIs de IA ao vivo); se já existir, reabre
72
73
  sagadeck pack <deck.yaml> [saida.sagadeck] a apresentação inteira num arquivo (YAML + imagens, CSS, widgets)
@@ -406,7 +407,8 @@ async function main() {
406
407
  process.exit(1);
407
408
  }
408
409
  const agentUsers = typeof flags.agentes === "string" ? flags.agentes.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
409
- const server = createStudioServer(deckFile, { port, host, library, multiuser, userHeader: flags["user-header"], agentUsers });
410
+ const adminUsers = typeof flags.admins === "string" ? flags.admins.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
411
+ const server = createStudioServer(deckFile, { port, host, library, multiuser, userHeader: flags["user-header"], agentUsers, adminUsers });
410
412
  server.on("error", (e) => {
411
413
  if (e.code !== "EADDRINUSE") throw e;
412
414
  console.error(`A porta ${port} está ocupada por outro programa. Use --port=OUTRA.`);
package/docs/ANDAMENTO.md CHANGED
@@ -8,7 +8,85 @@ começar. Trabalho em curso vai para o GitHub (commit + push) a cada etapa, nunc
8
8
 
9
9
  ---
10
10
 
11
- ## 1.11.2 — 23/10/2026 (branch `claude/ritmo-da-geracao`)
11
+ ## 1.11.4: CI mais rápida, implantação por roteiro, diário em dia — 23/10/2026 (branch `claude/melhorias-infra`)
12
+
13
+ - **Antivírus do Windows segurava a abertura do deck** (exportação, fotos, PDF, PPTX e conferência): o Node lia o
14
+ HTML recém-gravado só para ver se tinha mapa, e no Windows essa primeira leitura de um HTML grande fica parada 15 a
15
+ 20 s (o Chrome abre na hora). Agora quem grava usa `writeDeckHTML` (`src/export/recent-html.js`: build, exportação
16
+ do Studio) e a abertura usa o texto da memória; sem ele, quem lê é o Chrome, e o Node só relê se houver mapa (o
17
+ mapa abre por endereço local, servido da memória). `lengths.test.js` caiu de 84 s para 18 s nesta máquina; cada
18
+ exportação no Windows de quem usa deixa de esperar. Teste: `test/open-deck.test.js` (conta as leituras do arquivo
19
+ pelo Node; falha com o código antigo).
20
+ - **CI em 3 partes por sistema**, ao mesmo tempo: `scripts/test-parte.mjs` reparte os arquivos pelo peso (os lentos
21
+ numa tabela lá dentro; dividir pela ordem dos arquivos dava 22 min × 13 min). Os checks exigidos na main continuam
22
+ `test (ubuntu-latest, 24.x)` e `test (windows-latest, 24.x)`: o job `test` de cada sistema confere pela API do run
23
+ que as 3 partes dele passaram. Antes: 26 min no Ubuntu e 37 no Windows por PR. Teste: `test/test-parte.test.js`
24
+ (todo arquivo em exatamente uma parte, partes equilibradas, pesos só de arquivos que existem).
25
+ - **Implantação no Oracle por roteiro**: `node scripts/implantar-oracle.mjs` (dev primeiro, estável só se o dev subiu
26
+ na versão certa; git como root; npm ci só quando o lock muda além do número da versão; confere `/api/instance`).
27
+ Hoje à mão o git sem sudo falhou calado e o npm ci rodou sem precisar. Teste: `test/implantar-oracle.test.js` roda o
28
+ roteiro de verdade com git real e sudo/systemctl/curl/npm de mentira (no Linux; conferido no próprio Oracle).
29
+ - `SAGADECK_ADMINS=305cc6c7416383c8` (a Narumi) em `/etc/sagadeck.env` no Oracle: vale para os dois serviços.
30
+ - Diário: as pendências "Falta: PR…" do Painel Conferência (#204), do Pedido da criação (#203), da Conferência das
31
+ contas (#201, 1.11.0) e da Frente E (Frente F) viraram "Concluído".
32
+
33
+ ## 1.11.3 — 23/10/2026 (branch `claude/versao-1.11.3`)
34
+
35
+ Junta o envio de cópia para outra pessoa (#217), o Corrigir tudo (#218) e os testes ao vivo a cada 3 versões (#216).
36
+ Primeira versão múltipla de 3: `node scripts/ai-live-ci.mjs --na-release` rodou antes de publicar, 27 de 27 de primeira
37
+ (16 min). Suíte completa: 1.159 testes, uma falha intermitente (teste de fórmulas esperava 900 ms fixos pelo
38
+ salvamento; passou 3 de 3 isolado; agora espera o arquivo salvo). No Oracle, a Narumi entra em `--admins` do
39
+ sagadeck e do sagadeck-dev para ver o Enviar cópia para….
40
+
41
+ ## Corrigir tudo com IA no painel Conferência — 23/10/2026 (branch `claude/jovial-hellman-4b4379`)
42
+
43
+ Na aula de hidráulica a criação deixou 24 avisos; corrigir um por um pelo painel dava trabalho, e na prática a IA
44
+ resolveu tudo quando recebeu um pedido único ("corrija os avisos da criação"). O cartão da biblioteca já mostrava
45
+ "N a conferir"; faltava o atalho.
46
+ - Revisar › Conferência ganhou **Corrigir tudo com IA** no cabeçalho: um pedido só ao chat com todas as pendências que
47
+ ainda valem (sem as ignoradas e sem os avisos de "o slide mudou"), agrupadas por slide ("Slide 2:", "Deck todo:"),
48
+ cada uma com o `fix` do item. O servidor monta (`conferenceBatch` em `src/studio/conference.js`, rota
49
+ `POST /api/conferencia/corrigir-tudo`) e marca como `enviado` de uma vez (`decideItems`) o que não se confere
50
+ sozinho; contas, contas do material e slides `derivado` vão com `contas: [n]` e saem do painel pela conferência
51
+ depois da resposta, como no botão por item.
52
+ - O botão fica desabilitado enquanto a IA responde; o painel se atualiza no fim do pedido (`sagadeck:chat-done`).
53
+ Testes: `ai.test.js` (agrupamento, aviso que mudou fora, contas, enviados, decisão em lote) e `studio-ai.test.js`
54
+ (três pendências, clique, um pedido só ao modelo com as três, `contas: [2]`, botão desabilitado durante a resposta,
55
+ deck salvo corrigido, painel vazio, zero erros JS). O teste do Studio falha sem o botão. `npm test`: 1.153 testes,
56
+ 1.122 aprovados, 31 condicionais pulados, zero falhas.
57
+
58
+ ## Enviar cópia para outra pessoa — 23/10/2026 (branch `claude/cranky-bell-62ec04`)
59
+
60
+ A versão revisada da aula da Mary foi posta na biblioteca dela à mão, por SSH como root (copiar a pasta, trocar o
61
+ título, `chown`). Agora quem administra o servidor multiusuário faz isso pelo navegador: menu do cartão › **Enviar cópia
62
+ para…** › escolhe a pessoa. Chega como apresentação NOVA, no tópico de mesmo nome, com "(versão revisada)" no título
63
+ (sem dobrar o sufixo) e nome livre (nunca por cima da dela). Vai a pasta inteira (imagens, `contexto/`, `geracao.json`);
64
+ ficam de fora `.sagadeck/conversa.json`, `.sagadeck/versoes/`, `.sagadeck/avaliacao-geracao.json`, `.sagadeck/lixeira/`
65
+ e a conversa antiga `<deck>.conversa.json` (`sendDeckTo` em `src/library.js`; `copyRetry` ganhou `skip`).
66
+ - Quem administra: `--admins=…` / `SAGADECK_ADMINS`; sem lista, os de `--agentes` (já mandam na máquina). Rotas
67
+ `GET /api/library/users` e `POST /api/library/decks/send` respondem 403 para os outros; destino só pasta existente de
68
+ `usuarios/` (nada de `../`, nem a própria). O servidor registra no log quem enviou o quê para quem.
69
+ - O id do portal não diz quem é: a biblioteca grava o username/e-mail do `/whoami` em `usuarios/<id>/.perfil.json` ao
70
+ abrir (`POST /api/library/me`); a lista mostra nome, e-mail, id, quantas apresentações e a mais recente.
71
+ - Arquivos criados pelo próprio processo do Studio: o dono já é o usuário do serviço, sem `chown`.
72
+ - Testes: `library.test.js` (cópia completa, exclusões, sufixo, nunca sobrescreve) e `library-ui.test.js` (admin envia
73
+ pela tela duas vezes; Mary não vê o item e leva 403; destino inválido dá 400; zero erros de JavaScript).
74
+ - Falta: no Oracle, conferir se a Narumi está em `--agentes` ou ligar `--admins` no serviço depois do deploy.
75
+
76
+ ## Testes com o modelo de verdade a cada 3 versões — 23/10/2026 (branch `claude/testes-ao-vivo`)
77
+
78
+ Os testes ao vivo (`ai-live.test.js`) não rodam na CI comum, e dois deles falhavam havia tempo sem ninguém ver. Decisão
79
+ da Narumi: rodar a cada release custa demais e agendado roda à toa; **a cada versão múltipla de 3** (1.11.3, 1.11.6,
80
+ 1.12.0…), antes de publicar, na máquina de quem publica (com a IA de `~/.sagadeck/ia.json`), e o que falhar duas vezes
81
+ se corrige antes de publicar. Regra no `CLAUDE.md`.
82
+ - `scripts/ai-live-ci.mjs`: roda a bateria, repete uma vez o que falhar (instável ≠ quebrado), resume em Markdown;
83
+ `--na-release` pula versões que não são múltiplas de 3 e diz qual é a próxima; `AI_LIVE_FILTRO` roda só um grupo.
84
+ - `.github/workflows/ai-live.yml`: só pelo botão (Run workflow, com filtro opcional); usa o segredo
85
+ `SAGADECK_IA_JSON` (conteúdo de um ia.json) e, sem ele, avisa e termina verde. O segredo não foi cadastrado: chave é
86
+ da Narumi (`gh secret set SAGADECK_IA_JSON < ~/.sagadeck/ia.json`, se ela quiser o botão funcionando).
87
+ - Testes em `ai-live-ci.test.js`; o roteiro rodou de verdade com filtro (contas e refinamento): 5 de 5.
88
+
89
+ 1.11.2 — 23/10/2026 (branch `claude/ritmo-da-geracao`)
12
90
 
13
91
  Rodada completa dos testes ao vivo (`SAGADECK_LIVE=1`) depois da 1.11.1: 24 de 27. As três falhas foram comparadas com o
14
92
  código de antes (`3c18855`): "refinamento" é variação do modelo (2 de 3 nos dois); "deck variado" e "sem falar de
@@ -160,7 +238,7 @@ Testes: `ai.test.js` (itens, ignorar, recordMath, Conferir de novo com e sem IA)
160
238
  usado como a pessoa usaria: selo, Conferir de novo, Ignorar, Corrigir com IA numa conta e num slide da IA,
161
239
  conferindo deck salvo, relatório e o que foi ao modelo; zero erros JS). Ícones Lucide novos: list-checks,
162
240
  calculator, hash, image-off. Suíte completa: `npm test`, 1.116 testes, 1.087 aprovados, 29 condicionais pulados, zero falhas.
163
- Falta: PR empilhado sobre o #201 (`claude/conferir-contas`), CI verde, merge.
241
+ Concluído: entrou no PR #204 (depois do #201) e saiu na 1.11.0.
164
242
 
165
243
  ## Pedido da criação gravado — 22/10/2026 (branch `claude/pedido-da-criacao-0d34bc`, sobre `claude/conferir-contas`)
166
244
 
@@ -178,7 +256,7 @@ pasta inteira; o pacote `.sagadeck` agora leva `.sagadeck/geracao.json`.
178
256
  Testes: `storage.test.js` (biblioteca com anexo; segredos e `anteriores`), `library-ui.test.js` (pedido guiado
179
257
  digitado; só com anexo grava o texto padrão marcado, duplicar e o pacote levam o arquivo, zero erros JS),
180
258
  `studio-ai.test.js` (Deck com IA do editor e criação pelo chat com docx). Conferido que falham sem a gravação.
181
- Falta: PR com CI verde e merge (depois do PR #201, que é a base).
259
+ Concluído: entrou no PR #203 (depois do #201) e saiu na 1.11.0.
182
260
 
183
261
  ## Modelo para conferir — 22/10/2026 (branch `claude/modelo-por-etapa-d74f41`, parte de `claude/conferir-contas`)
184
262
 
@@ -236,9 +314,8 @@ erros que ninguém tinha visto (velocidades da tabela do slide 26). A correção
236
314
  corrigir o material" da pessoa vale sobre a regra da autoria.
237
315
  Suíte completa: `npm test`, 1.108 testes, 1.079 aprovados, 29 condicionais pulados, zero falhas. Studio local na
238
316
  3517 rodando `c65772e` a partir desta worktree, para validação dela.
239
- Falta (depois do ok dela): PR, CI verde, merge, deploy na Oracle (produção estava na 1.9.0) e, no deck da conta da
240
- Mary, pedir à IA do sagadeck pelo chat que confira e corrija as contas (inclusive as do material: a aula é dela e
241
- ela autorizou caprichar), corrija os avisos da criação e melhore a aula. A correção é da IA, não à mão.
317
+ Concluído: entrou no PR #201 e saiu na 1.11.0 (Oracle e npm); a IA do sagadeck corrigiu o deck da conta da Mary pelo
318
+ chat, e a versão revisada foi para a pasta dela como outra apresentação.
242
319
 
243
320
  ## UX para programação e APIs com cURL — 12/10/2026 (branch `codex/ux-dev-slides`, versão 1.10.0)
244
321
 
@@ -2186,8 +2263,8 @@ raciocínio, a mesma conferência sai em 1–2 s e acha o que importa (título r
2186
2263
  "a partir de um arquivo" só com o anexo), `studio-themes.test.js` (prévia sem gravar, clique aplica),
2187
2264
  `studio.test.js` (Baixar tudo: zip com pptx com notas, pdf e roteiro), `storage.test.js` (gerado na biblioteca).
2188
2265
 
2189
- Falta da lista: modo simples por padrão (avançado dobrável) e as fontes do Google embutidas (a rede do banco barra
2190
- `fonts.googleapis.com`: temas perdem a letra, abrir/exportar pode esperar, e cada abertura avisa o Google).
2266
+ Concluído depois, na Frente F (28/09/2026, logo acima): modo simples e fontes embutidas, sem o Google. O modo
2267
+ simples saiu mais tarde (a faixa mostra tudo).
2191
2268
 
2192
2269
  ## Frente D (preguiçoso): trocar o tema muda o arranjo dos slides de conteúdo — 28/09/2026
2193
2270
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sagadeck",
3
- "version": "1.11.2",
3
+ "version": "1.11.4",
4
4
  "description": "Gerador de apresentações: YAML -> HTML animado + PowerPoint editável, com temas e figuras geradas na hora.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/build.js CHANGED
@@ -19,6 +19,7 @@ import { esc, notesHTML, plain, md, INLINE_MATH } from "./markup.js";
19
19
  import { normalizeSpec } from "./fiscal/normalize.js";
20
20
  import { readRecordings } from "./api-client.js";
21
21
  import { explorationStates } from './exploration.js';
22
+ import { writeDeckHTML } from "./export/recent-html.js";
22
23
  import { calcModel } from './lessons.js';
23
24
  import { loadMapConfig, mapaFile, tilesForDeck } from "./map-config.js";
24
25
 
@@ -257,7 +258,7 @@ export function buildFile(file, outFile) {
257
258
  const spec = loadSpec(file);
258
259
  const r = buildHTML(spec);
259
260
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
260
- fs.writeFileSync(outFile, r.html);
261
+ writeDeckHTML(outFile, r.html); // a conferência e a exportação abrem em seguida sem reler (export/recent-html.js)
261
262
  return { ...r, spec, outFile };
262
263
  }
263
264
 
@@ -4,6 +4,8 @@ import http from "node:http";
4
4
  import path from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { chromium } from "playwright-core";
7
+ import { recentHTML } from "./recent-html.js";
8
+ export { writeDeckHTML } from "./recent-html.js";
7
9
 
8
10
  const CANDIDATES = [
9
11
  process.env.SAGADECK_BROWSER,
@@ -24,10 +26,12 @@ export function findBrowser() {
24
26
 
25
27
  // Deck com mapa abre por um endereço local (http://127.0.0.1), não como arquivo: o servidor de tiles do OpenStreetMap
26
28
  // pede que o navegador diga de onde vem o pedido, e página aberta do disco não diz. Só lê a pasta do deck.
27
- async function serveDir(dir) {
29
+ // main: { "deck.html": texto } serve o deck da memória (sem o Node reler o arquivo; veja writeDeckHTML)
30
+ async function serveDir(dir, main = {}) {
28
31
  const server = http.createServer((req, res) => {
29
32
  const rel = decodeURIComponent(new URL(req.url, "http://x").pathname).replace(/^\/+/, "");
30
33
  const file = path.resolve(dir, rel);
34
+ if (req.method === "GET" && Object.hasOwn(main, rel)) { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); return res.end(main[rel]); }
31
35
  if (req.method !== "GET" || !file.startsWith(path.resolve(dir)) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); return res.end(); }
32
36
  res.writeHead(200, { "Content-Type": file.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream" });
33
37
  fs.createReadStream(file).pipe(res);
@@ -41,15 +45,23 @@ export const settleSlide = (page) => page.evaluate(() => window.SagaMap?.settled
41
45
 
42
46
  export async function openDeck(htmlFile, { scale = 1 } = {}) {
43
47
  const browser = await chromium.launch({ executablePath: findBrowser() });
44
- const hasMap = fs.readFileSync(htmlFile, "utf8").includes('class="map-box');
45
- const server = hasMap ? await serveDir(path.dirname(htmlFile)) : null;
46
- if (server) { const close = browser.close.bind(browser); browser.close = async () => { await close(); server.close(); }; }
47
48
  try {
48
49
  const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: scale });
49
50
  const errors = [];
50
51
  page.on("pageerror", (e) => errors.push(e.message));
51
52
  page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
52
- await page.goto((server ? `http://127.0.0.1:${server.address().port}/${encodeURIComponent(path.basename(htmlFile))}` : pathToFileURL(htmlFile).href) + "?export=1");
53
+ const fileURL = pathToFileURL(htmlFile).href + "?export=1";
54
+ // tem mapa? Pelo texto na memória; sem ele, quem lê o arquivo é o Chrome, e o Node só relê se houver mapa
55
+ let html = recentHTML(htmlFile), hasMap;
56
+ if (html != null) hasMap = html.includes('class="map-box');
57
+ else { await page.goto(fileURL); hasMap = await page.evaluate(() => !!document.querySelector(".map-box")); }
58
+ if (hasMap) {
59
+ const name = path.basename(htmlFile);
60
+ const server = await serveDir(path.dirname(htmlFile), { [name]: html ?? fs.readFileSync(htmlFile) });
61
+ const close = browser.close.bind(browser); browser.close = async () => { await close(); server.close(); };
62
+ errors.length = 0;
63
+ await page.goto(`http://127.0.0.1:${server.address().port}/${encodeURIComponent(name)}?export=1`);
64
+ } else if (html != null) await page.goto(fileURL);
53
65
  await page.waitForFunction(() => window.sagadeck && window.sagadeck.cur >= 0 && document.fonts.status === "loaded");
54
66
  // a janela com o tamanho do slide (deck.aspect: 4:3 → 1920 × 1440): foto, PDF e PPTX sem faixa nem corte
55
67
  const size = await page.evaluate(() => window.sagadeck.size || { w: 1920, h: 1080 });
@@ -0,0 +1,24 @@
1
+ // HTML que este processo acabou de gravar (build, exportação, conferência). No Windows, o antivírus segura por 15 a
2
+ // 20 s a primeira leitura pelo Node de um HTML grande recém-gravado (o Chrome abre na hora): a abertura no Chrome
3
+ // (browser.js: openDeck) usa o texto da memória em vez de reler o arquivo. Vale só enquanto o arquivo no disco é o
4
+ // mesmo (tamanho e data); guarda só os últimos (o Studio fica aberto o dia todo). Módulo à parte, sem o Playwright,
5
+ // para o build gravar por aqui sem carregar o navegador.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ const RECENT = new Map(); // caminho -> { html, size, mtimeMs }
10
+
11
+ export function writeDeckHTML(file, html) {
12
+ fs.writeFileSync(file, html);
13
+ const key = path.resolve(file), st = fs.statSync(file);
14
+ RECENT.delete(key);
15
+ RECENT.set(key, { html, size: st.size, mtimeMs: st.mtimeMs });
16
+ while (RECENT.size > 4) RECENT.delete(RECENT.keys().next().value);
17
+ }
18
+
19
+ export function recentHTML(file) {
20
+ const r = RECENT.get(path.resolve(file));
21
+ if (!r) return null;
22
+ const st = fs.statSync(file, { throwIfNoEntry: false });
23
+ return st && st.size === r.size && st.mtimeMs === r.mtimeMs ? r.html : null;
24
+ }
package/src/fs-retry.js CHANGED
@@ -32,12 +32,15 @@ export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
32
32
 
33
33
  // arquivo ou pasta inteira (com imagens): duplicar uma apresentação é o caso — no Windows o antivírus segura o
34
34
  // arquivo recém-criado por um instante e a cópia falhava com EPERM (era o flake do CI no "mover (pasta inteira…)").
35
- export function copyRetry(from, to, { delays = RENAME_DELAYS } = {}) {
35
+ // skip(rel): o que fica de fora (rel = caminho relativo à pasta copiada, com "/")
36
+ export function copyRetry(from, to, { delays = RENAME_DELAYS, skip, rel = "" } = {}) {
36
37
  const st = fs.statSync(from);
37
38
  if (!st.isDirectory()) return retry(delays, () => fs.copyFileSync(from, to));
38
39
  fs.mkdirSync(to, { recursive: true });
39
40
  for (const e of fs.readdirSync(from, { withFileTypes: true })) {
40
- copyRetry(path.join(from, e.name), path.join(to, e.name), { delays });
41
+ const sub = rel ? `${rel}/${e.name}` : e.name;
42
+ if (skip?.(sub)) continue;
43
+ copyRetry(path.join(from, e.name), path.join(to, e.name), { delays, skip, rel: sub });
41
44
  }
42
45
  }
43
46
 
package/src/library.js CHANGED
@@ -135,6 +135,10 @@ export function safeName(s, fallback = "Sem título") {
135
135
  return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(clean) || !clean ? fallback : clean;
136
136
  }
137
137
 
138
+ // o que não vai junto ao enviar uma cópia para outra pessoa (sendDeckTo): conversa, versões, avisos da criação e o
139
+ // que foi apagado no projeto. A conversa antiga (<deck>.conversa.json, ao lado do .yaml) também fica.
140
+ const SEND_SKIP = new Set([".sagadeck/conversa.json", ".sagadeck/versoes", ".sagadeck/avaliacao-geracao.json", ".sagadeck/lixeira"]);
141
+
138
142
  function uniquePath(dir, name, ext = "") {
139
143
  let p = path.join(dir, name + ext);
140
144
  for (let n = 2; fs.existsSync(p); n++) p = path.join(dir, `${name} (${n})${ext}`);
@@ -318,6 +322,31 @@ export function openLibrary(root, { trashDays } = {}) {
318
322
  return idOf(yaml);
319
323
  }
320
324
 
325
+ // Envia uma cópia para OUTRA biblioteca (no servidor, quem administra manda a versão revisada para a biblioteca de
326
+ // alguém): sempre apresentação nova, no tópico de mesmo nome, com nome livre (nunca por cima da dela). Vai a pasta
327
+ // inteira (imagens, contexto/, widgets); fica de fora o que é da conversa de quem fez a cópia: o chat, as versões
328
+ // e o relatório de avisos da criação (a outra pessoa recomeça a conferência do zero).
329
+ function sendDeckTo(id, dest, { suffix = "versão revisada" } = {}) {
330
+ const { file, unit, info } = unitOf(id);
331
+ if (path.resolve(dest.root) === root) throw new Error("a biblioteca de destino é esta mesma: use Duplicar");
332
+ const tail = suffix ? ` (${suffix})` : "";
333
+ const title = tail && !info.title.endsWith(tail) ? info.title + tail : info.title;
334
+ const topicDirOf = dest.resolveId(dest.createTopic(info.topic || "Sem tópico"));
335
+ const ext = unit === file ? path.extname(file) : "";
336
+ const to = uniquePath(topicDirOf, safeName(title), ext);
337
+ copyRetry(unit, to, { skip: (rel) => SEND_SKIP.has(rel) || /^[^/]+\.conversa\.json$/i.test(rel) });
338
+ let yaml = unit === file ? to : path.join(to, path.basename(file));
339
+ if (unit !== file && path.basename(file).replace(/\.ya?ml$/i, "") === path.basename(unit)) {
340
+ const renamed = path.join(to, path.basename(to) + path.extname(file));
341
+ renameRetry(yaml, renamed);
342
+ yaml = renamed;
343
+ }
344
+ const spec = YAML.parse(fs.readFileSync(yaml, "utf8")) || {};
345
+ spec.title = title;
346
+ writeDeckFile(yaml, spec); // só o título muda no arquivo
347
+ return { id: dest.idOf(yaml), title };
348
+ }
349
+
321
350
  // ---- geração em andamento (invisível): a IA trabalha numa pasta oculta do tópico e a apresentação
322
351
  // só aparece na biblioteca quando pronta (publicada com o título final, num renomear atômico). Nada a meio
323
352
  // fazer fica visível — nem pode ser apagado sem querer no meio da geração. Interrompida: vai para a lixeira
@@ -533,7 +562,7 @@ export function openLibrary(root, { trashDays } = {}) {
533
562
  importOffice,
534
563
  root, list, resolveId, idOf,
535
564
  createTopic, updateTopic, deleteTopic,
536
- createDeck, moveDeck, renameDeck, duplicateDeck,
565
+ createDeck, moveDeck, renameDeck, duplicateDeck, sendDeckTo,
537
566
  createStagingDeck, publishStagedDeck, discardStaging, recoverStaging,
538
567
  trashDeck, restoreDeck, purgeDeck, purgeOld, listTrash, emptyTrash,
539
568
  importPackage,
@@ -1,9 +1,10 @@
1
- // Rotas do painel Revisar › Conferência (src/studio/conference.js): a lista, a decisão por item e o Conferir de novo.
1
+ // Rotas do painel Revisar › Conferência (src/studio/conference.js): a lista, a decisão por item, o Corrigir tudo e o
2
+ // Conferir de novo.
2
3
  import path from "node:path";
3
4
  import { storedDocumentMaterials } from "../ai/document-materials.js";
4
5
  import { llmAvailable } from "../ai/llm.js";
5
6
  import * as Project from "./project.js";
6
- import { conferenceItems, decideItem, recheckDeck, loadGenReport } from "./conference.js";
7
+ import { conferenceItems, conferenceBatch, decideItem, decideItems, recheckDeck, loadGenReport } from "./conference.js";
7
8
 
8
9
  const send = (res, data, status = 200) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); };
9
10
 
@@ -26,6 +27,17 @@ export async function conferenceRoutes({ req, res, pathname, W, readJSON, isBund
26
27
  catch (e) { send(res, { error: e.message }, 400); }
27
28
  return true;
28
29
  }
30
+ // Corrigir tudo com IA: monta o pedido único e marca como enviado o que não se confere sozinho; o Studio manda ao chat
31
+ if (pathname === "/api/conferencia/corrigir-tudo" && req.method === "POST") {
32
+ try {
33
+ if (!dir) throw new Error("A apresentação precisa estar salva na biblioteca.");
34
+ const batch = conferenceBatch(conferenceItems(loadGenReport(dir), W.spec, { dir }));
35
+ if (!batch) throw new Error("Nada pendente para corrigir.");
36
+ decideItems(dir, batch.enviados, "enviado");
37
+ send(res, { ok: true, pedido: batch.text, contas: batch.contas, count: batch.count, ...list() });
38
+ } catch (e) { send(res, { error: e.message }, 400); }
39
+ return true;
40
+ }
29
41
  if (pathname === "/api/conferencia/conferir" && req.method === "POST") {
30
42
  if (!dir) { send(res, { error: "A apresentação precisa estar salva na biblioteca." }, 400); return true; }
31
43
  const notes = [];
@@ -99,15 +99,37 @@ export function conferencePrompt(items) {
99
99
  return `PENDÊNCIAS DA CONFERÊNCIA AUTOMÁTICA (painel Revisar › Conferência): o que as checagens acharam e a pessoa ainda não resolveu nem ignorou. Avisos antigos da conversa que não estão nesta lista foram resolvidos ou ignorados pela pessoa: não volte a eles. Mexa nestes só se o pedido tiver a ver.\n${list.slice(0, 40).map((it) => `- ${it.slide ? `slide ${it.slide}` : "deck"} · ${it.label}: ${it.text}`).join("\n")}`;
100
100
  }
101
101
 
102
+ // Corrigir tudo com IA (botão do painel): um pedido só ao chat com todas as pendências que ainda valem, por slide.
103
+ // Na aula de hidráulica a criação deixou 24 avisos: um a um dava trabalho, e num pedido só a IA resolveu tudo.
104
+ // Aviso de slide que mudou fica de fora (talvez já esteja resolvido: a pessoa olha antes). contas: os slides que o
105
+ // Studio confere depois da resposta; enviados: o que sai do painel ao ir para o chat (o resto, a conferência decide).
106
+ export function conferenceBatch(items) {
107
+ const list = (items || []).filter((it) => !it.stale);
108
+ if (!list.length) return null;
109
+ const groups = new Map();
110
+ for (const it of list) { const k = it.slide || 0; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(it); }
111
+ const order = [...groups.keys()].sort((a, b) => (a || 1e9) - (b || 1e9));
112
+ const text = `Corrija ${list.length === 1 ? "esta pendência" : `de uma vez estas ${list.length} pendências`} da conferência automática (painel Revisar › Conferência). Cada item diz o que fazer; resolva ${list.length === 1 ? "na" : "todos na mesma"} resposta e não mexa no resto do deck.\n\n${order.map((k) => `${k ? `Slide ${k}` : "Deck todo"}:\n${groups.get(k).map((it) => `- ${it.fix}`).join("\n")}`).join("\n\n")}`;
113
+ const contas = [...new Set(list.flatMap((it) => it.contas || []))].sort((a, b) => a - b);
114
+ const enviados = list.filter((it) => it.kind !== "conta" && it.kind !== "material").map((it) => it.id);
115
+ return { text, contas, enviados, count: list.length };
116
+ }
117
+
102
118
  // A pessoa decidiu sobre um item: ignorado ("está certo assim") ou enviado (o pedido de correção foi ao chat)
103
119
  export function decideItem(dir, id, status) {
120
+ return decideItems(dir, [id], status)[0];
121
+ }
122
+
123
+ // Vários de uma vez (Corrigir tudo): uma gravação só do relatório
124
+ export function decideItems(dir, ids, status) {
104
125
  if (!dir) throw new Error("A apresentação precisa estar salva na biblioteca.");
105
126
  if (!["ignorado", "enviado"].includes(status)) throw new Error("status: ignorado ou enviado");
106
- if (!/^[a-z]+:[^\s]{1,200}$/.test(String(id || ""))) throw new Error("item da conferência inválido");
127
+ if (!ids.every((id) => /^[a-z]+:[^\s]{1,200}$/.test(String(id || "")))) throw new Error("item da conferência inválido");
107
128
  const report = loadGenReport(dir) || { createdAt: new Date().toISOString(), avisado: true };
108
- report.decisoes = { ...(report.decisoes || {}), [id]: { status, em: new Date().toISOString() } };
109
- writeGenReport(dir, report);
110
- return report.decisoes[id];
129
+ const em = new Date().toISOString();
130
+ report.decisoes = { ...(report.decisoes || {}), ...Object.fromEntries(ids.map((id) => [id, { status, em }])) };
131
+ if (ids.length) writeGenReport(dir, report);
132
+ return ids.map((id) => report.decisoes[id]);
111
133
  }
112
134
 
113
135
  // O chat conferiu as contas de alguns slides (pedido da pessoa, do painel ou edição que mexeu nos números): o resultado
@@ -9,6 +9,7 @@ import os from "node:os";
9
9
  import path from "node:path";
10
10
  import JSZip from "jszip";
11
11
  import { buildHTML } from "../build.js";
12
+ import { writeDeckHTML } from "../export/recent-html.js";
12
13
  import { THEMES } from "../themes.js";
13
14
  import { loadPreferences } from "../preferences.js";
14
15
  import { packDeck, EXTENSION, MIME } from "../package.js";
@@ -46,7 +47,7 @@ export async function generateExport(kind, spec, name, { notes = true, audience,
46
47
  if (["pptx","pdf","tudo"].includes(kind)) spec=expandApiCollections(spec);
47
48
  const r = buildHTML(spec);
48
49
  const htmlFile = path.join(tmp, "deck.html");
49
- fs.writeFileSync(htmlFile, r.html);
50
+ writeDeckHTML(htmlFile, r.html);
50
51
  const errors = [];
51
52
  const make = async (what) => {
52
53
  const progress = event => onProgress({ ...event, format: what });
@@ -60,7 +61,7 @@ export async function generateExport(kind, spec, name, { notes = true, audience,
60
61
  // Preferências › Exportação: tema escuro com par claro (manual-noite → manual) sai claro no PDF, bom para imprimir
61
62
  const light = loadPreferences().exportacao.pdfClaro === true ? lightVariant(spec) : null;
62
63
  let file = htmlFile;
63
- if (light) { file = path.join(tmp, "deck-claro.html"); fs.writeFileSync(file, buildHTML(light).html); }
64
+ if (light) { file = path.join(tmp, "deck-claro.html"); writeDeckHTML(file, buildHTML(light).html); }
64
65
  await pdf(file, out, { onProgress: progress });
65
66
  } else if (what === "estudo" || what === "estudo-html") {
66
67
  const { shots } = await import("../export/shots.js");
@@ -10,15 +10,46 @@ import { createExperienceDeck } from "../experiences.js";
10
10
  import { demoDeck } from "./demo-decks.js";
11
11
  import { DEMO_FILES } from "../api-demo.js";
12
12
  import { developerStarter } from "./developer-starters.js";
13
+ import { openLibrary, safeName } from "../library.js";
13
14
 
14
- export async function libraryRoutes({ req, res, pathname, url, W, opts, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS }) {
15
+ // Bibliotecas das outras pessoas do servidor (<raiz>/usuarios/<usuário>), para quem administra escolher o destino de
16
+ // "Enviar cópia para…". O nome vem do .perfil.json que a biblioteca de cada um grava ao abrir (o id do portal não diz
17
+ // nada); sem ele, o id, quantas apresentações tem e a mais recente ajudam a reconhecer.
18
+ const PROFILE = ".perfil.json";
19
+ function peerLibraries(usersRoot, self) {
20
+ let dirs = [];
21
+ try { dirs = fs.readdirSync(usersRoot, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== self).map((d) => d.name); } catch {}
22
+ return dirs.map((id) => {
23
+ const dir = path.join(usersRoot, id);
24
+ let profile = {};
25
+ try { profile = JSON.parse(fs.readFileSync(path.join(dir, PROFILE), "utf8")); } catch {}
26
+ let decks = [];
27
+ try { decks = openLibrary(dir).list().decks; } catch {}
28
+ const recent = decks.sort((a, b) => b.edited - a.edited)[0];
29
+ return { id, name: profile.name || "", email: profile.email || "", decks: decks.length, recent: recent?.title || "" };
30
+ }).sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id, "pt-BR"));
31
+ }
32
+ function peerLibrary(usersRoot, id, self) {
33
+ const name = String(id || "");
34
+ const dir = path.join(usersRoot, name);
35
+ if (!name || name !== safeName(name, "") || name.startsWith(".") || path.dirname(dir) !== path.resolve(usersRoot) || !fs.existsSync(dir)) throw new Error("usuário não encontrado");
36
+ if (name === self) throw new Error("esta é a sua biblioteca: use Duplicar");
37
+ return openLibrary(dir);
38
+ }
39
+
40
+ export async function libraryRoutes({ req, res, pathname, url, W, opts, admin, usersRoot, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS }) {
15
41
  const L = W.library;
16
42
  const ok = (obj = {}) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...obj })); };
17
43
  const fail = (e, code = 400) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message || String(e) })); };
18
44
  try {
19
45
  if (pathname === "/api/library" && req.method === "GET") {
20
46
  const openId = W.file && W.file.startsWith(L.root + path.sep) ? L.idOf(W.file) : null;
21
- return ok({ ...L.list(), user: W.user, multiuser: !!opts.multiuser, open: openId });
47
+ return ok({ ...L.list(), user: W.user, multiuser: !!opts.multiuser, admin, open: openId });
48
+ }
49
+ // quem administra escolhe para quem enviar a cópia (só no multiusuário)
50
+ if (pathname === "/api/library/users" && req.method === "GET") {
51
+ if (!admin) return fail(new Error("Só quem administra o servidor envia cópias para outras pessoas."), 403);
52
+ return ok({ users: peerLibraries(usersRoot, path.basename(L.root)) });
22
53
  }
23
54
  if (pathname === "/api/library/gallery-cover" && req.method === "GET") {
24
55
  const key = galleryKey(url.searchParams.get("key")), entry = galleryEntry(key);
@@ -71,6 +102,24 @@ export async function libraryRoutes({ req, res, pathname, url, W, opts, readJSON
71
102
  buildHTML(spec); // Valida antes de criar o arquivo na biblioteca.
72
103
  return ok({ id: L.createDeck(b.topic || "", spec) });
73
104
  }
105
+ // o nome do portal (username/e-mail do /whoami), para quem administra reconhecer a biblioteca de cada um
106
+ case "/api/library/me": {
107
+ if (!opts.multiuser) return ok();
108
+ const clip = (v) => String(v || "").trim().slice(0, 120);
109
+ const profile = { name: clip(b.name), email: clip(b.email) };
110
+ const file = path.join(L.root, PROFILE);
111
+ let old = null;
112
+ try { old = fs.readFileSync(file, "utf8"); } catch {}
113
+ if (old !== JSON.stringify(profile)) fs.writeFileSync(file, JSON.stringify(profile));
114
+ return ok();
115
+ }
116
+ // Enviar cópia para…: apresentação nova na biblioteca da outra pessoa, nunca por cima da dela (src/library.js)
117
+ case "/api/library/decks/send": {
118
+ if (!admin) return fail(new Error("Só quem administra o servidor envia cópias para outras pessoas."), 403);
119
+ const sent = L.sendDeckTo(b.id, peerLibrary(usersRoot, b.user, path.basename(L.root)));
120
+ console.log(`[Studio] ${W.user} enviou "${sent.title}" para a biblioteca de ${b.user} (${sent.id})`);
121
+ return ok(sent);
122
+ }
74
123
  case "/api/library/topics": return ok({ id: L.createTopic(b.name, b.color) });
75
124
  case "/api/library/topics/update": return ok({ id: L.updateTopic(b.id, b) });
76
125
  case "/api/library/topics/delete": L.deleteTopic(b.id); return ok();
@@ -1,7 +1,8 @@
1
1
  // Painel Revisar › Conferência: tudo o que as checagens automáticas acharam no deck aberto (contas, números sem base
2
2
  // no material, avisos da revisão visual, figuras do material sem slide, slides que a IA calculou ou incluiu), por
3
- // slide, com Ir ao slide, Corrigir com IA (um pedido pontual ao chat) e Ignorar (gravado no relatório). A lista vem
4
- // do servidor (src/studio/conference.js); o app passa o que o painel usa (create(ctx)).
3
+ // slide, com Ir ao slide, Corrigir com IA (um pedido pontual ao chat), Ignorar (gravado no relatório) e Corrigir tudo
4
+ // com IA (todas as pendências num pedido só). A lista vem do servidor (src/studio/conference.js); o app passa o que o
5
+ // painel usa (create(ctx)).
5
6
  (function () {
6
7
  const KIND_ICON = { conta: "calculator", material: "file-text", numero: "hash", visual: "scan-eye", figura: "image-off", derivado: "calculator", proprio: "sparkles" };
7
8
  function create(ctx) {
@@ -48,13 +49,25 @@
48
49
  if (it.kind !== "conta" && it.kind !== "material") decide(it.id, "enviado");
49
50
  askChat(it.fix, it.contas ? { contas: it.contas } : {});
50
51
  }
52
+ // Corrigir tudo com IA: um pedido só ao chat com as pendências que ainda valem, por slide (o servidor monta o
53
+ // pedido e marca como enviado o que não se confere sozinho; as contas vão com contas: [n] e são conferidas depois)
54
+ async function fixAll(btn, dlg) {
55
+ if (chatBusy()) { showToast("Espere a IA terminar o pedido atual.", 3000); return; }
56
+ btn.disabled = true;
57
+ try {
58
+ const j = await json(await fetch("api/conferencia/corrigir-tudo", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }));
59
+ take(j);
60
+ dlg.close();
61
+ askChat(j.pedido, j.contas?.length ? { contas: j.contas } : {});
62
+ } catch (e) { showToast(e.message); btn.disabled = false; }
63
+ }
51
64
  function open() {
52
65
  let dlg = document.getElementById("conference-dialog");
53
66
  const fill = () => {
54
67
  const groups = new Map();
55
68
  for (const it of items) { const k = it.slide || 0; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(it); }
56
69
  const order = [...groups.keys()].sort((a, b) => (a || 1e9) - (b || 1e9));
57
- dlg.innerHTML = `<header><i class="ic" data-ic="list-checks"></i><b>Conferência</b><span class="rv-count">${items.length}</span><span class="grow"></span>${salvo ? `<button type="button" class="btn btn-sm" data-recheck title="Refaz no deck atual a conferência das contas, a checagem dos números e das figuras do material"><i class="ic" data-ic="${checking ? "loader-circle" : "refresh-cw"}"></i> ${checking ? "Conferindo…" : "Conferir de novo"}</button>` : ""}<button type="button" class="btn btn-sm" data-close title="Fechar"><i class="ic" data-ic="x"></i></button></header>
70
+ dlg.innerHTML = `<header><i class="ic" data-ic="list-checks"></i><b>Conferência</b><span class="rv-count">${items.length}</span><span class="grow"></span>${items.some((it) => !it.stale) ? `<button type="button" class="btn btn-primary btn-sm" data-fix-all${chatBusy() ? " disabled" : ""} title="Manda ao chat, num pedido só, todas as pendências (menos os avisos de slides que já mudaram)"><i class="ic" data-ic="wand-sparkles"></i> Corrigir tudo com IA</button>` : ""}${salvo ? `<button type="button" class="btn btn-sm" data-recheck title="Refaz no deck atual a conferência das contas, a checagem dos números e das figuras do material"><i class="ic" data-ic="${checking ? "loader-circle" : "refresh-cw"}"></i> ${checking ? "Conferindo…" : "Conferir de novo"}</button>` : ""}<button type="button" class="btn btn-sm" data-close title="Fechar"><i class="ic" data-ic="x"></i></button></header>
58
71
  <div class="rv-list conf-list">${items.length ? order.map((k) => `<section class="conf-group" data-slide="${k}"><div class="conf-group-head">${k ? `<b class="rv-num">${k}</b><span class="conf-slide-title"></span><button type="button" class="btn btn-sm" data-go="${k}">Ir ao slide</button>` : "<b>Deck todo</b>"}</div>${groups.get(k).map((it) => `<div class="conf-item conf-${it.kind}" data-id="${it.id}"><span class="conf-kind"><i class="ic" data-ic="${KIND_ICON[it.kind] || "circle-alert"}"></i><span></span></span><span class="conf-text"></span><span class="conf-actions"><button type="button" class="btn btn-primary btn-sm" data-fix><i class="ic" data-ic="wand-sparkles"></i> Corrigir com IA</button><button type="button" class="btn btn-sm" data-ignore title="Está certo assim: sai do painel e dos próximos pedidos à IA"><i class="ic" data-ic="eye-off"></i> Ignorar</button></span></div>`).join("")}</section>`).join("")
59
72
  : `<div class="rv-empty">${salvo ? "Nada pendente: as checagens automáticas não deixaram avisos neste deck." : "A conferência vale para apresentações salvas na biblioteca."}</div>`}</div>
60
73
  ${conferidoEm ? `<div class="conf-foot">Conferido em ${new Date(conferidoEm).toLocaleString("pt-BR")}</div>` : ""}`;
@@ -71,6 +84,7 @@
71
84
  row.querySelector("[data-ignore]").onclick = () => decide(it.id, "ignorado");
72
85
  });
73
86
  dlg.querySelector("[data-recheck]")?.addEventListener("click", (e) => recheck(e.currentTarget));
87
+ dlg.querySelector("[data-fix-all]")?.addEventListener("click", (e) => fixAll(e.currentTarget, dlg));
74
88
  dlg.querySelector("[data-close]").onclick = () => dlg.close();
75
89
  hydrateIcons(dlg);
76
90
  };
@@ -241,6 +241,13 @@ body.lib { display: grid; grid-template-columns: 218px minmax(0, 1fr); grid-temp
241
241
 
242
242
  /* Nova: diálogo largo (IA em uma tela; vitrine "Modelo pronto") */
243
243
  .ldlg.wide { width: min(760px, calc(100vw - 32px)); }
244
+ /* Enviar cópia para… (quem administra): uma linha por pessoa do servidor */
245
+ .ldlg .hint { color: var(--text-2); font-size: 12px; line-height: 1.6; margin: 0 0 12px; }
246
+ .send-users { display: grid; gap: 6px; max-height: 320px; overflow-y: auto; }
247
+ .ldlg .send-user { display: flex; gap: 10px; align-items: flex-start; margin: 0; padding: 10px 12px; border: 1px solid var(--ui-line); border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--text); }
248
+ .ldlg .send-user:has(input:checked) { border-color: var(--brand); }
249
+ .send-user input { margin-top: 3px; accent-color: var(--brand); }
250
+ .send-user small { display: block; color: var(--text-3); font-size: 11px; margin-top: 2px; overflow-wrap: anywhere; }
244
251
  .ldlg select { width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--ui-line-strong); background: var(--chrome); color: var(--text); font: inherit; font-size: 13px; }
245
252
  .ldlg option { background: var(--chrome); color: var(--text); }
246
253
  .dlg-two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 16px; }
@@ -12,7 +12,7 @@
12
12
  let data = { topics: [], decks: [], trash: [], root: "" };
13
13
  let view = new URLSearchParams(location.search).get("topic") || store.get("libView", "recentes");
14
14
  let sortBy = store.get("libSort", "editada");
15
- let dialogSession = 0;
15
+ let dialogSession = 0, profileSaved = false;
16
16
  let dialogKeyboard = null, dialogOrigin = null;
17
17
 
18
18
  function hydrate(root = document) {
@@ -49,6 +49,8 @@
49
49
  const r = await fetch("/whoami");
50
50
  const w = r.ok ? await r.json() : null;
51
51
  if (w && w.logged_in) name = w.username || w.email || name;
52
+ // grava o nome do portal na biblioteca (uma vez por página): quem administra reconhece a pessoa em "Enviar cópia para…"
53
+ if (w && w.logged_in && !profileSaved) { profileSaved = true; api("api/library/me", { name: w.username || "", email: w.email || "" }).catch(() => {}); }
52
54
  } catch {}
53
55
  }
54
56
  if (name) {
@@ -237,6 +239,7 @@
237
239
  <div class="sep"></div>
238
240
  <button class="mi" data-a="rename">Renomear</button>
239
241
  <button class="mi" data-a="dup">${ic("copy")}Duplicar</button>
242
+ ${data.admin ? `<button class="mi" data-a="send">${ic("send")}Enviar cópia para…<small>outra pessoa</small></button>` : ""}
240
243
  ${others.map((t) => `<button class="mi" data-move="${esc(t.id)}"><span class="dot" style="background:${esc(t.color)}"></span>Mover para ${esc(t.name)}</button>`).join("")}
241
244
  <div class="sep"></div>
242
245
  <button class="mi" data-dl="tudo">${ic("download")}Baixar tudo<small>PowerPoint, PDF e roteiro</small></button>
@@ -254,6 +257,7 @@
254
257
  if (a === "open" || a === "present") return openEditor(id, a === "present");
255
258
  if (a === "rename") return nameDialog("Renomear apresentação", d.title, (title) => act(() => api("api/library/decks/rename", { id, title }), "Renomeada"));
256
259
  if (a === "dup") return act(() => api("api/library/decks/duplicate", { id }), "Cópia criada");
260
+ if (a === "send") return sendDialog(d);
257
261
  if (a === "trash") return act(() => api("api/library/decks/trash", { id }), `Na lixeira — dá para restaurar por ${data.trashDays === 1 ? "um dia" : `${data.trashDays || 30} dias`}`);
258
262
  });
259
263
  m.querySelectorAll("[data-move]").forEach((b) => b.onclick = () => { closeMenus(); act(() => api("api/library/decks/move", { id, topic: b.dataset.move }), `Movida para ${topicOf(b.dataset.move).name}`); });
@@ -391,6 +395,31 @@
391
395
  box.querySelector("#dlg-name").onkeydown = (e) => { if (e.key === "Enter") go(); };
392
396
  });
393
397
  }
398
+ // Enviar cópia para… (só quem administra o servidor): a apresentação vai como NOVA para a biblioteca da outra pessoa,
399
+ // com "(versão revisada)" no título; a dela não é tocada. Sem a conversa, as versões e os avisos da criação.
400
+ function sendDialog(d) {
401
+ dialog(`<h3>Enviar cópia para…</h3>
402
+ <p class="hint">"${esc(d.title)}" chega como apresentação nova, com "(versão revisada)" no título. Nada do que a pessoa já tem é substituído. A conversa com a IA, as versões e os avisos da criação não vão junto.</p>
403
+ <div class="send-users" id="send-users"><p class="hint">Carregando…</p></div>
404
+ <div class="row"><button class="lib-btn ghost" data-cancel>Cancelar</button><button class="lib-btn primary" id="dlg-ok" disabled>Enviar</button></div>`, async (box, close, isCurrent) => {
405
+ let chosen = null;
406
+ const list = box.querySelector("#send-users"), okBtn = box.querySelector("#dlg-ok");
407
+ try {
408
+ const { users } = await api("api/library/users");
409
+ if (!isCurrent()) return;
410
+ list.innerHTML = users.length ? users.map((u) => `<label class="send-user"><input type="radio" name="send-user" value="${esc(u.id)}">
411
+ <span><b>${esc(u.name || u.id)}</b>${u.email ? ` <small>${esc(u.email)}</small>` : ""}
412
+ <small>${u.name ? `${esc(u.id)} · ` : ""}${plural(u.decks)}${u.recent ? ` · mais recente: ${esc(u.recent)}` : ""}</small></span></label>`).join("")
413
+ : `<p class="hint">Ninguém mais tem biblioteca neste servidor ainda.</p>`;
414
+ list.querySelectorAll("input").forEach((r) => r.onchange = () => { chosen = users.find((u) => u.id === r.value); okBtn.disabled = false; });
415
+ } catch (e) { list.innerHTML = `<p class="hint">Não deu para listar as pessoas: ${esc(e.message)}</p>`; }
416
+ okBtn.onclick = () => {
417
+ if (!chosen) return;
418
+ close();
419
+ act(() => api("api/library/decks/send", { id: d.id, user: chosen.id }), `Enviada para ${chosen.name || chosen.id} como apresentação nova`);
420
+ };
421
+ });
422
+ }
394
423
  function topicDialog(t) {
395
424
  let color = t?.color || COLORS[data.topics.length % COLORS.length];
396
425
  dialog(`<h3>${t ? "Tópico" : "Novo tópico"}</h3><label>Nome</label><input type="text" id="dlg-name" value="${esc(t?.name || "")}" placeholder="Ex.: Palestras, Trabalho, Aulas">
@@ -190,6 +190,11 @@ export function createStudioServer(deckPath = null, opts = {}) {
190
190
  // servidor multiusuário, só quem está em --agentes / SAGADECK_AGENTES (o comando roda NA MÁQUINA DO SERVIDOR); os
191
191
  // outros usam tudo (gerar slides, conversar, imagens), só sem comandos, e a IA deles nem sabe que existem.
192
192
  const AGENTS = new Set([].concat(opts.agentUsers ?? String(process.env.SAGADECK_AGENTES || "").split(",")).map((u) => String(u).trim()).filter(Boolean));
193
+ // Quem administra o servidor multiusuário (--admins / SAGADECK_ADMINS; sem lista, os de --agentes, que já mandam
194
+ // na máquina do servidor): envia cópia de uma apresentação para a biblioteca de outra pessoa (Enviar cópia para…).
195
+ const adminList = [].concat(opts.adminUsers ?? String(process.env.SAGADECK_ADMINS || "").split(",")).map((u) => String(u).trim()).filter(Boolean);
196
+ const ADMINS = adminList.length ? new Set(adminList) : AGENTS;
197
+ const isAdmin = (W) => !!opts.multiuser && !!W?.user && ADMINS.has(W.user);
193
198
  function commandsAllowed(req, W) {
194
199
  if (!W) return false;
195
200
  return opts.multiuser ? !!W.user && AGENTS.has(W.user) : !apiBlocked(req);
@@ -1235,7 +1240,7 @@ Responda só com JSON: {"colunas": [{"nome": "…", "tipo": "tempo|categoria|num
1235
1240
  if (download && req.method === 'GET') return exports.download(owner, download[1], res);
1236
1241
  return json(405, { error: 'Método ou endereço de exportação não suportado.' });
1237
1242
  }
1238
- if (pathname.startsWith("/api/library")) return libraryRoutes({ req, res, pathname, url, W, opts, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS });
1243
+ if (pathname.startsWith("/api/library")) return libraryRoutes({ req, res, pathname, url, W, opts, admin: isAdmin(W), usersRoot: path.join(libraryRoot, "usuarios"), readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS });
1239
1244
 
1240
1245
  // Baixar o deck aberto: .sagadeck (YAML + imagens, CSS, widgets), PowerPoint, PDF ou roteiro
1241
1246
  const exportKind = (pathname.match(/^\/api\/export\/(sagadeck|pptx|pdf|roteiro|tudo|estudo|estudo-html)$/) || [])[1];