sagadeck 1.11.4 → 1.11.5

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/bin/sagadeck.js CHANGED
@@ -414,6 +414,8 @@ async function main() {
414
414
  console.error(`A porta ${port} está ocupada por outro programa. Use --port=OUTRA.`);
415
415
  process.exit(1);
416
416
  });
417
+ // SIGTERM do systemd ao reiniciar: sai em segundos, sem esperar o SIGKILL (src/studio/shutdown.js)
418
+ (await import("../src/studio/shutdown.js")).installShutdown(server);
417
419
  server.listen(port, host, async () => {
418
420
  const { studioBanner } = await import("../src/studio/banner.js");
419
421
  for (const line of studioBanner({ version: VERSION, url: `http://${shownHost}:${port}/`, library, ai: (await import("../src/ai/llm.js")).llmConfig(), multiuser, usersDir: path.join(library, "usuarios"), isLibrary: !deckFile })) console.log(line);
package/docs/ANDAMENTO.md CHANGED
@@ -8,6 +8,24 @@ começar. Trabalho em curso vai para o GitHub (commit + push) a cada etapa, nunc
8
8
 
9
9
  ---
10
10
 
11
+ ## 1.11.5: parada do servidor, biblioteca no OneDrive — 23/10/2026 (branch `claude/parada-e-fotos`)
12
+
13
+ - **Parada**: o Playwright, ao abrir o Chrome, instala um tratador de SIGTERM que só fecha o navegador; com ele, o
14
+ Node não sai mais sozinho. No Oracle, cada reinício ficava 90 s fora do ar até o SIGKILL (o log de toda semana:
15
+ "Failed with result 'timeout'"). `src/studio/shutdown.js`: SIGTERM/SIGHUP param de aceitar conexões, dão 8 s para
16
+ as que estão em uso, cortam o resto, fecham o Chrome das miniaturas e saem. Teste: `test/studio-shutdown.test.js`
17
+ (o prazo; e o Studio de verdade, depois de abrir o Chrome, sai em segundos com SIGTERM; só no Linux, porque no
18
+ Windows o SIGTERM mata na hora). Conferido no Oracle: sem a correção, não saiu em 15 s; com ela, saiu em 2 s.
19
+ - **Biblioteca no OneDrive**: a listagem lia e interpretava o YAML de todas as apresentações a cada abertura (com
20
+ "arquivos só na nuvem", isso baixa cada uma). Agora guarda título, slides, tema e avisos pela data, tamanho e inode
21
+ do `.yaml` e do relatório de avisos, em memória e em `lista.json` no cache. Teste: `library.test.js` (nenhum
22
+ `.yaml` relido sem mudança, nem depois de reiniciar; só o que mudou; apagado some; cache estragado não quebra).
23
+ - **Cache fora da nuvem**: `libraryCacheDir` põe capas, vitrine e lista em `%LOCALAPPDATA%\sagadeck\cache\<biblioteca>`
24
+ quando a biblioteca está dentro do OneDrive (variáveis `OneDrive`, `OneDriveCommercial`, `OneDriveConsumer`); o
25
+ `.cache` antigo de dentro dela sai (só `capas`, `vitrine` e `lista.json`). Fora do OneDrive e no servidor, nada muda.
26
+ - **Fotos da transformação, medido e deixado como está**: cada foto custa ~0,4 s e o lote tem no máximo 5 slides;
27
+ abrir o deck uma vez por lote pouparia ~10 s numa transformação de 40 slides, que leva minutos de IA.
28
+
11
29
  ## 1.11.4: CI mais rápida, implantação por roteiro, diário em dia — 23/10/2026 (branch `claude/melhorias-infra`)
12
30
 
13
31
  - **Antivírus do Windows segurava a abertura do deck** (exportação, fotos, PDF, PPTX e conferência): o Node lia o
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sagadeck",
3
- "version": "1.11.4",
3
+ "version": "1.11.5",
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/library.js CHANGED
@@ -10,9 +10,10 @@
10
10
  // rascunho.yaml .yaml solto na raiz = apresentação sem tópico
11
11
  // Na raiz, pasta é SEMPRE tópico. "Nova sem tópico" vai para "Sem tópico"; importar sem tópico, para "Importados".
12
12
  // .lixeira/ excluídas (1 dia), com .origem.json
13
- // .cache/ capas geradas
13
+ // .cache/ capas geradas e lista (no OneDrive, fica fora: libraryCacheDir)
14
14
  //
15
15
  // O id de uma apresentação é o caminho do .yaml dela, relativo à biblioteca, com "/" (ex.: "Palestras/X/X.yaml").
16
+ import crypto from "node:crypto";
16
17
  import fs from "node:fs";
17
18
  import { renameRetry, copyRetry, removeRetry } from "./fs-retry.js";
18
19
  import os from "node:os";
@@ -69,6 +70,20 @@ export function defaultLibraryRoot(env = process.env, { platform = process.platf
69
70
  return path.resolve(target);
70
71
  }
71
72
 
73
+ // Cache do que se refaz sozinho (capas, vitrine, lista da biblioteca). Com a biblioteca no OneDrive, fica na pasta
74
+ // local do Windows (%LOCALAPPDATA%\sagadeck\cache\<biblioteca>), fora da nuvem: não ocupa a cota, não sincroniza a
75
+ // cada capa nova e uma máquina não pega o cache da outra. Fora do OneDrive (e no servidor): <biblioteca>/.cache.
76
+ export function libraryCacheDir(root, { env = process.env, platform = process.platform } = {}) {
77
+ root = path.resolve(root);
78
+ const low = root.toLowerCase();
79
+ const clouds = platform === "win32" ? [env.OneDrive, env.OneDriveCommercial, env.OneDriveConsumer].filter(Boolean).map((p) => path.resolve(p).toLowerCase()) : [];
80
+ if (env.LOCALAPPDATA && clouds.some((c) => low === c || low.startsWith(c + path.sep))) {
81
+ const tag = `${safeName(path.basename(root), "biblioteca")}-${crypto.createHash("sha1").update(low).digest("hex").slice(0, 8)}`;
82
+ return path.join(env.LOCALAPPDATA, "sagadeck", "cache", tag);
83
+ }
84
+ return path.join(root, ".cache");
85
+ }
86
+
72
87
  const isLink = (p) => { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } };
73
88
  const hasContent = (p) => { try { return fs.readdirSync(p).length > 0; } catch { return false; } };
74
89
 
@@ -150,6 +165,13 @@ export function openLibrary(root, { trashDays } = {}) {
150
165
  const keepDays = () => trashDays ?? loadPreferences().biblioteca?.lixeiraDias ?? TRASH_DAYS;
151
166
  root = path.resolve(root);
152
167
  fs.mkdirSync(root, { recursive: true });
168
+ const cacheDir = libraryCacheDir(root);
169
+ // o cache saiu da biblioteca no OneDrive (libraryCacheDir): o que ficou lá dentro é só do sagadeck e se refaz
170
+ if (cacheDir !== path.join(root, ".cache")) {
171
+ const old = path.join(root, ".cache");
172
+ for (const n of ["capas", "vitrine", "lista.json"]) try { fs.rmSync(path.join(old, n), { recursive: true, force: true }); } catch { /* travado pela sincronização: fica para a próxima */ }
173
+ try { fs.rmdirSync(old); } catch { /* não existe ou tem outra coisa */ }
174
+ }
153
175
 
154
176
  // caminho absoluto de um id, sem sair da biblioteca
155
177
  function resolveId(id) {
@@ -172,17 +194,48 @@ export function openLibrary(root, { trashDays } = {}) {
172
194
  return path.join(dir, same || files.sort()[0]);
173
195
  }
174
196
 
175
- function deckInfo(file, topic) {
176
- const st = fs.statSync(file);
197
+ // O que a listagem mostra de cada apresentação (título, slides, tema, avisos), guardado pela data, tamanho e inode
198
+ // do .yaml e do relatório de avisos: só relê o que mudou. Ler a data e o tamanho não abre o arquivo; ler o conteúdo,
199
+ // sim. Com a biblioteca no OneDrive e "arquivos só na nuvem", cada listagem baixava todas as apresentações, e
200
+ // interpretar o YAML de todas a cada abertura da biblioteca crescia com ela. Fica também em .cache/lista.json, para
201
+ // valer depois de reiniciar o Studio.
202
+ const listCacheFile = path.join(cacheDir, "lista.json");
203
+ let listCache = null, listCacheDirty = false;
204
+ const stamp = (f) => { const s = fs.statSync(f, { throwIfNoEntry: false }); return s ? `${s.mtimeMs}:${s.size}:${s.ino}` : "-"; };
205
+ function cachedInfo(file, own) {
206
+ if (!listCache) { try { listCache = JSON.parse(fs.readFileSync(listCacheFile, "utf8")) || {}; } catch { listCache = {}; } }
207
+ const id = idOf(file), key = `${stamp(file)}|${own ? stamp(path.join(path.dirname(file), ".sagadeck", "avaliacao-geracao.json")) : ""}`;
208
+ const hit = listCache[id];
209
+ if (hit?.key === key) return hit.info;
177
210
  let spec = {};
178
211
  try { spec = YAML.parse(fs.readFileSync(file, "utf8")) || {}; } catch {}
179
- const own = idOf(file).split("/").length === 3;
180
212
  const pending = own ? pendingWarnings(path.dirname(file), spec) : 0;
213
+ const info = { title: String(spec.title || path.basename(file).replace(/\.ya?ml$/i, "")), slides: Array.isArray(spec.slides) ? spec.slides.length : 0, theme: spec.theme || "", ...(pending ? { pendencias: pending } : {}) };
214
+ listCache[id] = { key, info };
215
+ listCacheDirty = true;
216
+ return info;
217
+ }
218
+ function saveListCache(ids) {
219
+ if (!listCache) return;
220
+ for (const id of Object.keys(listCache)) if (!ids.has(id)) { delete listCache[id]; listCacheDirty = true; } // apagada ou movida
221
+ if (!listCacheDirty) return;
222
+ try {
223
+ fs.mkdirSync(path.dirname(listCacheFile), { recursive: true });
224
+ const tmp = `${listCacheFile}.tmp-${process.pid}`;
225
+ fs.writeFileSync(tmp, JSON.stringify(listCache));
226
+ renameRetry(tmp, listCacheFile);
227
+ listCacheDirty = false;
228
+ } catch { /* sem cache em disco: só fica a memória */ }
229
+ }
230
+
231
+ function deckInfo(file, topic) {
232
+ const st = fs.statSync(file);
233
+ const own = idOf(file).split("/").length === 3;
234
+ const { pendencias, ...info } = cachedInfo(file, own);
181
235
  return {
182
- id: idOf(file), topic: topic || "", title: String(spec.title || path.basename(file).replace(/\.ya?ml$/i, "")),
183
- slides: Array.isArray(spec.slides) ? spec.slides.length : 0, theme: spec.theme || "", edited: st.mtimeMs,
236
+ id: idOf(file), topic: topic || "", ...info, edited: st.mtimeMs,
184
237
  folder: own, // true = pasta própria (com imagens etc.); false = .yaml solto
185
- ...(pending ? { pendencias: pending } : {}), // só quando há aviso
238
+ ...(pendencias ? { pendencias } : {}), // só quando há aviso
186
239
  };
187
240
  }
188
241
 
@@ -212,6 +265,7 @@ export function openLibrary(root, { trashDays } = {}) {
212
265
  topics.push({ id: e.name, name: e.name, color: meta.color || COLORS[topics.length % COLORS.length], count: mine.length, created: meta.created || 0 });
213
266
  }
214
267
  topics.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"));
268
+ saveListCache(new Set(decks.map((d) => d.id)));
215
269
  return { root, topics, decks, trash: listTrash(), trashDays: keepDays() };
216
270
  }
217
271
 
@@ -560,7 +614,7 @@ export function openLibrary(root, { trashDays } = {}) {
560
614
  return {
561
615
  listStyles, saveStyle, loadStyle, applyStyleTo, defaultStyle, setDefaultStyle, withDefaultStyle,
562
616
  importOffice,
563
- root, list, resolveId, idOf,
617
+ root, cacheDir, list, resolveId, idOf,
564
618
  createTopic, updateTopic, deleteTopic,
565
619
  createDeck, moveDeck, renameDeck, duplicateDeck, sendDeckTo,
566
620
  createStagingDeck, publishStagedDeck, discardStaging, recoverStaging,
@@ -54,7 +54,7 @@ export async function libraryRoutes({ req, res, pathname, url, W, opts, admin, u
54
54
  if (pathname === "/api/library/gallery-cover" && req.method === "GET") {
55
55
  const key = galleryKey(url.searchParams.get("key")), entry = galleryEntry(key);
56
56
  const spec = entry.dir ? { ...entry.spec, _dir: entry.dir } : entry.spec;
57
- const cacheDir = path.join(L.root, ".cache", "vitrine");
57
+ const cacheDir = path.join(L.cacheDir, "vitrine");
58
58
  const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${key}|${JSON.stringify(entry.spec)}`).digest("hex")}.jpg`);
59
59
  if (!fs.existsSync(cached)) {
60
60
  const [shot] = await slideSnapshots(spec, 0, { mode: "final", width: 480 });
@@ -68,7 +68,7 @@ export async function libraryRoutes({ req, res, pathname, url, W, opts, admin, u
68
68
  if (pathname === "/api/library/cover" && req.method === "GET") {
69
69
  const file = L.resolveId(url.searchParams.get("id"));
70
70
  const st = fs.statSync(file);
71
- const cacheDir = path.join(L.root, ".cache", "capas");
71
+ const cacheDir = path.join(L.cacheDir, "capas");
72
72
  const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${L.idOf(file)}|${st.mtimeMs}`).digest("hex")}.jpg`);
73
73
  if (!fs.existsSync(cached)) {
74
74
  const spec = loadSpec(file);
@@ -0,0 +1,31 @@
1
+ // Parada do Studio quando o sistema pede (SIGTERM do systemd ao reiniciar, SIGHUP ao fechar o terminal). Sem isto,
2
+ // o processo não saía: o Playwright, ao abrir o Chrome (miniaturas, fotos, exportação), instala um tratador de SIGTERM
3
+ // que só fecha o navegador, e com um tratador instalado o Node deixa de sair sozinho. No servidor, cada reinício
4
+ // ficava 90 s fora do ar até o systemd matar à força (SIGKILL), no meio do que estivesse rodando.
5
+ // Aqui: para de aceitar conexões, dá `grace` ms para as que estão em uso terminarem (uma resposta da IA ao vivo, uma
6
+ // gravação), corta o que sobrar, fecha o Chrome das miniaturas e sai.
7
+ export const SINAIS_DE_PARADA = ["SIGTERM", "SIGHUP"];
8
+
9
+ export function installShutdown(server, { grace = 8000, log = (m) => console.log(m), exit = (code) => process.exit(code), closeBrowsers } = {}) {
10
+ let stopping = null;
11
+ const stop = (signal) => {
12
+ if (stopping) return stopping;
13
+ stopping = (async () => {
14
+ log(` ${signal}: parando o Studio (até ${Math.round(grace / 1000)} s para o que está em andamento)`);
15
+ const closed = new Promise((resolve) => server.close(() => resolve()));
16
+ // a conexão que termina a resposta fica aberta à espera de outra (keep-alive) e seguraria o close até o prazo
17
+ server.closeIdleConnections?.();
18
+ const idle = setInterval(() => server.closeIdleConnections?.(), 100);
19
+ let timer;
20
+ const late = new Promise((resolve) => { timer = setTimeout(() => { server.closeAllConnections?.(); resolve(); }, grace); });
21
+ await Promise.race([closed, late]);
22
+ clearTimeout(timer); clearInterval(idle);
23
+ try { await (closeBrowsers ?? (async () => (await import("./snapshot.js")).closeSnapshots()))(); } catch { /* o Chrome já fechou */ }
24
+ exit(0);
25
+ })();
26
+ return stopping;
27
+ };
28
+ const handlers = SINAIS_DE_PARADA.map((s) => [s, () => { stop(s); }]);
29
+ for (const [s, h] of handlers) process.on(s, h);
30
+ return { stop, uninstall: () => { for (const [s, h] of handlers) process.off(s, h); } };
31
+ }