@aksp/opencrew 1.7.1 → 1.9.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +87 -4
  3. package/package.json +1 -1
  4. package/src/commands/update.js +9 -3
  5. package/src/lib/blocos.js +7 -3
  6. package/src/lib/resumo.js +3 -0
  7. package/templates/AGENTS.md +4 -0
  8. package/templates/_opencrew/.opencrew-version +1 -1
  9. package/templates/_opencrew/core/prompts/discovery.prompt.md +1 -1
  10. package/templates/_opencrew/core/prompts/entrega.prompt.md +205 -0
  11. package/templates/_opencrew/core/prompts/export.prompt.md +5 -81
  12. package/templates/_opencrew/core/runner.pipeline.md +32 -23
  13. package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +50 -0
  14. package/templates/_opencrew/core/scripts/entrega/canais.mjs +53 -0
  15. package/templates/_opencrew/core/scripts/entrega/comparar.mjs +104 -0
  16. package/templates/_opencrew/core/scripts/entrega/copia.mjs +138 -0
  17. package/templates/_opencrew/core/scripts/entrega/destino.mjs +98 -0
  18. package/templates/_opencrew/core/scripts/entrega/fora.mjs +50 -0
  19. package/templates/_opencrew/core/scripts/entrega/gravar.mjs +83 -0
  20. package/templates/_opencrew/core/scripts/entrega/guardar.mjs +60 -0
  21. package/templates/_opencrew/core/scripts/entrega/leiame.mjs +125 -0
  22. package/templates/_opencrew/core/scripts/entrega/leitor.mjs +133 -0
  23. package/templates/_opencrew/core/scripts/entrega/lembrar.mjs +65 -0
  24. package/templates/_opencrew/core/scripts/entrega/longas.mjs +109 -0
  25. package/templates/_opencrew/core/scripts/entrega/nomes.mjs +78 -0
  26. package/templates/_opencrew/core/scripts/entrega/passos.mjs +104 -0
  27. package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +97 -0
  28. package/templates/_opencrew/core/scripts/entrega/ressalvas.mjs +78 -0
  29. package/templates/_opencrew/core/scripts/entrega/resumo.mjs +29 -0
  30. package/templates/_opencrew/core/scripts/entrega/retrato.mjs +42 -0
  31. package/templates/_opencrew/core/scripts/entrega/separar.mjs +106 -0
  32. package/templates/_opencrew/core/scripts/entrega/texto.mjs +95 -0
  33. package/templates/_opencrew/core/scripts/entregar.mjs +166 -0
  34. package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +4 -4
  35. package/templates/_opencrew/core/scripts/verificar/entradas.mjs +26 -0
  36. package/templates/_opencrew/core/scripts/verificar/gravacao.mjs +41 -0
  37. package/templates/_opencrew/core/scripts/verificar/pecas.mjs +4 -4
  38. package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +10 -3
  39. package/templates/_opencrew/core/scripts/verificar/secoes.mjs +22 -2
  40. package/templates/_opencrew/core/scripts/verificar.mjs +21 -29
  41. package/templates/gitignore +1 -0
@@ -0,0 +1,109 @@
1
+ // Peças que o leitor do verificador não lê: blog (`seo.txt` e `artigo.md`), e-mail (assunto,
2
+ // prévia e corpo), mensagem de WhatsApp e os tweets de uma thread (`TWEET n/N`).
3
+ // Recebem o texto do arquivo, sem BOM e com LF. Spec: fase-u3a1-pasta-de-entrega.md, §4.
4
+ import { lerFrontmatter, semAcento, semFrontmatter } from '../verificar/leitura.mjs';
5
+ import { lerPecas } from '../verificar/pecas.mjs';
6
+ import { rotuloDe } from '../verificar/secoes.mjs';
7
+ import { fecharMd, paraColar, secoesDeRotulo, semServico } from './texto.mjs';
8
+
9
+ const parte = (nome, ext, texto) => ({ nome, sufixo: '', ext, tipo: nome, texto });
10
+ const TITULO = /^#{1,6}\s+(.*)$/s;
11
+ const palavra = (formas) => new RegExp(`(?<![\\p{L}\\p{N}])(?:${formas})(?![\\p{L}\\p{N}])`, 'u');
12
+
13
+ // ── Blog ─────────────────────────────────────────────────────────────────────────────────────
14
+ const FORA_DO_ARTIGO = new Set(['TITLE', 'TITLE TAG', 'META DESCRIPTION', 'TARGET KEYWORD']);
15
+ const emUmaLinha = (valor) => String(valor).replace(/\s+/g, ' ').trim();
16
+
17
+ /** Palavra-chave: do frontmatter; senão, o que vem depois de `Primary:` em `=== TARGET KEYWORD ===`. */
18
+ function palavraChave(fm, secoes) {
19
+ const doRotulo = secoes.find((s) => s.rotulo === 'TARGET KEYWORD')?.texto.match(/Primary:[ \t]*([^|\n]+)/i)?.[1];
20
+ return [fm.palavra_chave, fm.keyword, doRotulo].find((v) => v != null && typeof v !== 'object' && String(v).trim());
21
+ }
22
+
23
+ /** @returns {object[]} uma unidade com `seo.txt` (só as linhas que existem) e `artigo.md` */
24
+ export function pecasDoBlog(texto, formato) {
25
+ const fm = lerFrontmatter(texto) ?? {};
26
+ const secoes = secoesDeRotulo(semServico(semFrontmatter(texto)));
27
+ const lida = (tipo) => lerPecas(texto, formato).find((p) => p.tipo === tipo)?.texto;
28
+ const campos = [['Título', lida('titulo')], ['Meta description', lida('meta')], ['Palavra-chave', palavraChave(fm, secoes)], ['Slug', typeof fm.slug === 'object' ? null : fm.slug]];
29
+ const seo = campos.filter(([, v]) => v != null && emUmaLinha(v)).map(([campo, v]) => `${campo}: ${emUmaLinha(v)}\n`).join('');
30
+ const artigo = secoes.filter((s) => !FORA_DO_ARTIGO.has(s.rotulo) && s.texto).map((s) => s.texto).join('\n\n');
31
+ return [{ tipo: 'blog', partes: [parte('seo', 'txt', seo), parte('artigo', 'md', fecharMd(artigo))] }];
32
+ }
33
+
34
+ // ── E-mail ───────────────────────────────────────────────────────────────────────────────────
35
+ const DO_ROTULO = { 'SUBJECT LINE': 'assunto', 'PREVIEW TEXT': 'previa' };
36
+ const DO_CABECALHO = [['assunto', palavra('assuntos?|subject')], ['previa', palavra('previas?|preview')], ['corpo', /^(?:corpo|body)(?![\p{L}\p{N}])/u]];
37
+
38
+ /** O que a linha abre: assunto, prévia ou corpo (linha de rótulo ou cabeçalho que nomeia); senão null. */
39
+ function papelDaLinha(linha) {
40
+ const rotulo = rotuloDe(linha);
41
+ if (rotulo) return DO_ROTULO[rotulo] ?? 'corpo';
42
+ const h = linha.match(TITULO);
43
+ return h ? DO_CABECALHO.find(([, rx]) => rx.test(semAcento(h[1]).trim()))?.[0] ?? null : null;
44
+ }
45
+
46
+ /** Tira do fim das linhas o cabeçalho que vem logo antes de um assunto: é o título do bloco. */
47
+ function tituloPendente(linhas) {
48
+ let fim = linhas.length;
49
+ while (fim && !linhas[fim - 1].trim()) fim--;
50
+ const h = fim ? linhas[fim - 1].match(TITULO) : null;
51
+ if (!h) return null;
52
+ linhas.length = fim - 1;
53
+ return h[1].trim();
54
+ }
55
+
56
+ /** Os e-mails do corpo, na ordem: cada assunto abre um. `[{ titulo, assunto, previa, corpo }]` (linhas). */
57
+ function lerEmails(corpo) {
58
+ const novo = () => ({ titulo: null, assunto: [], previa: [], corpo: [] });
59
+ const emails = [novo()];
60
+ let alvo = 'corpo';
61
+ for (const linha of corpo.split('\n')) {
62
+ const papel = papelDaLinha(linha);
63
+ if (papel === 'assunto') {
64
+ const titulo = tituloPendente(emails.at(-1).corpo);
65
+ if (emails.at(-1).assunto.length) emails.push(novo());
66
+ emails.at(-1).titulo = titulo;
67
+ }
68
+ if (papel) alvo = papel;
69
+ else if (TITULO.test(linha) && alvo !== 'corpo') alvo = 'corpo'; // outro cabeçalho: é conteúdo
70
+ if (!papel) emails.at(-1)[alvo].push(linha);
71
+ }
72
+ return emails;
73
+ }
74
+
75
+ /** @returns {object[]} uma unidade por e-mail, com `assunto.txt`, `previa.txt` e `corpo.md` */
76
+ export function pecasDoEmail(texto) {
77
+ const emails = lerEmails(semServico(semFrontmatter(texto)));
78
+ return emails.map((e) => ({
79
+ tipo: 'email',
80
+ titulo: e.titulo,
81
+ partes: [
82
+ parte('assunto', 'txt', paraColar(e.assunto.join('\n'))),
83
+ parte('previa', 'txt', paraColar(e.previa.join('\n'))),
84
+ parte('corpo', 'md', fecharMd(e.corpo.join('\n'))),
85
+ ],
86
+ }));
87
+ }
88
+
89
+ // ── WhatsApp ─────────────────────────────────────────────────────────────────────────────────
90
+ /** @returns {object[]} uma unidade: os textos das seções, na ordem, separados por uma linha em branco */
91
+ export function pecasDoWhatsapp(texto) {
92
+ const textos = secoesDeRotulo(semServico(semFrontmatter(texto))).map((s) => s.texto).filter(Boolean);
93
+ return [{ tipo: 'mensagem', partes: [parte('mensagem', 'txt', paraColar(textos.join('\n\n'), { whatsapp: true }))] }];
94
+ }
95
+
96
+ // ── Thread ───────────────────────────────────────────────────────────────────────────────────
97
+ const TWEET_N = /^[\s*_#>-]{0,8}TWEET\s{1,3}\d{1,3}\s{0,3}\/\s{0,3}(?:\d{1,3}|N)(?![\p{L}\p{N}])/iu;
98
+
99
+ /** Texto de cada bloco iniciado por `TWEET n/N` (sem essa linha), até o próximo bloco, rótulo ou cabeçalho. */
100
+ export function tweetsDaThread(corpo) {
101
+ const blocos = [];
102
+ let atual = null;
103
+ for (const linha of corpo.split('\n')) {
104
+ if (TWEET_N.test(linha)) blocos.push((atual = []));
105
+ else if (rotuloDe(linha) || TITULO.test(linha)) atual = null;
106
+ else atual?.push(linha);
107
+ }
108
+ return blocos.map((b) => b.join('\n').trim()).filter(Boolean);
109
+ }
@@ -0,0 +1,78 @@
1
+ // Nome de cada arquivo da entrega: peças do mesmo tipo no mesmo canal são numeradas; cópia cujo
2
+ // nome já existe na pasta ganha o prefixo `2-`, `3-`. Nenhum arquivo é gravado por cima de outro.
3
+ // Spec: fase-u3a-entrega-por-canal.md, regras 7 e 8, citadas pela fatia 1 (repositório do OpenCrew).
4
+
5
+ export const MSG = {
6
+ mesmoNome: (arquivo, novo) => `${arquivo} tem o mesmo nome de outro e foi guardado como ${novo}.`,
7
+ };
8
+
9
+ const chave = (pasta, nome) => `${pasta}/${nome.toLowerCase()}`;
10
+
11
+ /** Arquivos das unidades: `post.txt` quando há uma só no canal; `post-1.txt`, `post-1-comentario.txt`… com várias. */
12
+ function nomearUnidades(unidades) {
13
+ const total = {};
14
+ for (const u of unidades) total[`${u.pasta}|${u.tipo}`] = (total[`${u.pasta}|${u.tipo}`] ?? 0) + 1;
15
+ const vez = {};
16
+ return unidades.flatMap((u) => {
17
+ const grupo = `${u.pasta}|${u.tipo}`;
18
+ vez[grupo] = (vez[grupo] ?? 0) + 1;
19
+ const numero = total[grupo] > 1 ? `-${vez[grupo]}` : '';
20
+ return u.partes.map((parte) => ({
21
+ pasta: u.pasta, nome: `${parte.nome}${numero}${parte.sufixo}.${parte.ext}`, tipo: parte.tipo, texto: parte.texto,
22
+ origem: u.origem, formato: u.formato, titulo: numero ? u.titulo ?? null : null, numerado: Boolean(numero), ordem: u.ordem, total: u.total,
23
+ }));
24
+ });
25
+ }
26
+
27
+ /** Cópias agrupadas por pasta da entrega e, dentro dela, por pasta de origem, na ordem da lista. */
28
+ function grupos(copias) {
29
+ const porChave = new Map();
30
+ for (const c of copias) {
31
+ const id = `${c.pasta}|${c.pastaDeOrigem}`;
32
+ if (!porChave.has(id)) porChave.set(id, { pasta: c.pasta, copias: [] });
33
+ // O mesmo arquivo listado duas vezes para a mesma pasta é uma cópia só.
34
+ if (!porChave.get(id).copias.some((outra) => outra.nome === c.nome)) porChave.get(id).copias.push(c);
35
+ }
36
+ return [...porChave.values()];
37
+ }
38
+
39
+ /** O menor prefixo, a partir de `n-`, com que nenhum nome do grupo bate com um nome já usado. */
40
+ function prefixoLivre(grupo, usados, n) {
41
+ while (grupo.copias.some((c) => usados.has(chave(grupo.pasta, `${n}-${c.nome}`)))) n += 1;
42
+ return n;
43
+ }
44
+
45
+ /**
46
+ * Cópias com o nome original. Quando um arquivo chegaria a uma pasta onde já há outro com esse
47
+ * nome, todos os que vêm da mesma pasta de origem ganham o prefixo (`2-`; os da pasta seguinte,
48
+ * `3-`). O aviso sai para cada arquivo cujo nome batia.
49
+ */
50
+ function nomearCopias(copias, usados) {
51
+ const arquivos = [];
52
+ const avisos = [];
53
+ const proximo = {};
54
+ for (const grupo of grupos(copias)) {
55
+ const bate = (c) => usados.has(chave(grupo.pasta, c.nome));
56
+ const n = grupo.copias.some(bate) ? prefixoLivre(grupo, usados, proximo[grupo.pasta] ?? 2) : null;
57
+ if (n) proximo[grupo.pasta] = n + 1;
58
+ for (const c of grupo.copias) {
59
+ const nome = n ? `${n}-${c.nome}` : c.nome;
60
+ if (n && bate(c)) avisos.push({ pasta: c.pasta, texto: MSG.mesmoNome(c.origem, nome) });
61
+ arquivos.push({ ...c, nome });
62
+ }
63
+ for (const a of arquivos.slice(-grupo.copias.length)) usados.add(chave(a.pasta, a.nome));
64
+ }
65
+ return { arquivos, avisos };
66
+ }
67
+
68
+ /**
69
+ * @param {{ unidades: object[], copias: object[], avisos: object[] }} produtos o que `separar` devolve
70
+ * @returns {{ arquivos: object[], avisos: object[] }} `arquivos`: `{ pasta, nome, tipo, origem,
71
+ * texto | de, … }`, na ordem da lista (as peças antes das cópias), sem dois com o mesmo caminho
72
+ */
73
+ export function nomear({ unidades, copias, avisos }) {
74
+ const gerados = nomearUnidades(unidades);
75
+ const usados = new Set(gerados.map((a) => chave(a.pasta, a.nome)));
76
+ const copiados = nomearCopias(copias, usados);
77
+ return { arquivos: [...gerados, ...copiados.arquivos], avisos: [...avisos, ...copiados.avisos] };
78
+ }
@@ -0,0 +1,104 @@
1
+ // Os passos de cada canal no LEIA-ME: o quadro "Passos por canal" da spec, guardado como dado.
2
+ // O texto é literal (aprovado com a spec): não reescreva aqui sem mudar a spec.
3
+ // Specs: fase-u3a1-pasta-de-entrega.md, §4, e fase-u3a2-entrega-no-projeto.md, §4 (o primeiro
4
+ // passo do canal com ressalva), no repositório do OpenCrew.
5
+
6
+ /** O primeiro passo do canal que não está pronto (ajuste da execução real). */
7
+ export const ANTES_DE_POSTAR = 'Antes de postar, resolva o que está em Pendências. Corrija no arquivo de origem e peça para montar a entrega de novo: o que você mudar nesta pasta se perde.';
8
+ /** O primeiro passo do canal entregue com ressalva. */
9
+ export const CONFIRA_AS_RESSALVAS = 'Antes de postar, confira as ressalvas em "Antes de usar".';
10
+ const MARKDOWN = 'Este texto está em markdown: se o seu editor não aceitar, ajuste títulos, negrito e links depois de colar.';
11
+
12
+ // Cada passo: [o que o faz aparecer, o texto]. null = sempre que o canal tem arquivo; um tipo de
13
+ // arquivo = só quando a entrega tem arquivo desse tipo no canal; `tipo:um` e `tipo:varios` = só
14
+ // quando a peça é uma só, ou quando é numerada. `{arquivo}`: uma linha por arquivo. `{campos}`:
15
+ // os campos que o `seo.txt` daquela entrega tem.
16
+ const PASSOS = {
17
+ instagram: [
18
+ [null, 'No computador, abra instagram.com e comece uma publicação nova. Pelo celular, mande os arquivos para ele antes.'],
19
+ ['imagem', 'Escolha as imagens na ordem dos nomes dos arquivos.'],
20
+ ['legenda', 'Abra `instagram/legenda.txt`, copie tudo e cole no campo da legenda. As hashtags já estão no fim.'],
21
+ [null, 'Confira a prévia e publique.'],
22
+ ['texto', '`instagram/{arquivo}` é para ler e produzir (gravar ou montar os slides): não é texto para colar.'],
23
+ ],
24
+ linkedin: [
25
+ [null, 'No computador, abra linkedin.com e comece uma publicação.'],
26
+ ['post', 'Abra `linkedin/post.txt`, copie tudo e cole.'],
27
+ ['imagem', 'Anexe as imagens na ordem dos nomes dos arquivos.'],
28
+ [null, 'Confira e publique.'],
29
+ ['comentario', 'Depois de publicar, abra `linkedin/post-comentario.txt`, copie e cole como primeiro comentário.'],
30
+ ['roteiro', 'Para o artigo, escolha escrever um artigo no LinkedIn e cole o texto de `linkedin/{arquivo}`, seção por seção.'],
31
+ ],
32
+ blog: [
33
+ [null, 'Abra o editor do seu blog e crie um post novo.'],
34
+ ['seo', 'Abra `blog/seo.txt` e copie cada linha para o campo de mesmo nome ({campos}).'],
35
+ ['artigo', 'Abra `blog/artigo.md`, copie tudo e cole no corpo do post.'],
36
+ ['artigo', MARKDOWN],
37
+ [null, 'Confira a prévia e publique.'],
38
+ ],
39
+ email: [
40
+ [null, 'Abra a sua ferramenta de e-mail e crie uma mensagem nova.'],
41
+ ['assunto', 'Copie o texto de `email/assunto.txt` para o campo do assunto.'],
42
+ ['previa', 'Copie o texto de `email/previa.txt` para o campo de prévia (a linha que aparece ao lado do assunto).'],
43
+ ['corpo', 'Abra `email/corpo.md`, copie tudo e cole no corpo.'],
44
+ ['corpo', MARKDOWN],
45
+ [null, 'Envie um teste para você antes de enviar para a lista.'],
46
+ ],
47
+ whatsapp: [
48
+ [null, 'No computador, abra o WhatsApp Web ou o aplicativo. Pelo celular, mande o arquivo para ele antes.'],
49
+ ['mensagem', 'Abra `whatsapp/mensagem.txt`, copie tudo e cole na conversa ou na lista de transmissão.'],
50
+ [null, 'Se o texto tiver `{{…}}`, troque pelo dado real, ou confira se a sua ferramenta de envio faz a troca.'],
51
+ [null, 'Envie primeiro para você, para ver como ficou.'],
52
+ ],
53
+ twitter: [
54
+ [null, 'No computador, abra x.com e comece uma publicação.'],
55
+ ['tweet:um', 'Abra `twitter/tweet.txt`, copie tudo e cole.'],
56
+ ['tweet:varios', 'Para a sequência, cole `twitter/tweet-1.txt`, acrescente outra publicação e cole o arquivo seguinte, na ordem dos números.'],
57
+ ['imagem', 'Anexe as imagens.'],
58
+ [null, 'Confira e publique.'],
59
+ ],
60
+ youtube: [['roteiro', '`youtube/{arquivo}` é o roteiro para gravar: não é texto para colar.']],
61
+ };
62
+ // "texto": o roteiro e o arquivo que foi inteiro (carrossel em texto, peça não encontrada).
63
+ const TIPOS = { texto: ['roteiro', 'inteiro'] };
64
+
65
+ /** Com várias peças, o passo cita os arquivos numerados no lugar do nome sem número. */
66
+ function citar(texto, pasta, achados) {
67
+ if (!achados[0].numerado) return texto;
68
+ const lista = achados.map((a) => `\`${pasta}/${a.nome}\``).join(', ');
69
+ return texto.replace(new RegExp(`\`${pasta}/[^\`]+\``), lista);
70
+ }
71
+
72
+ /** Os campos que os arquivos têm (`Título: …` → "título"), cada um uma vez, na ordem das linhas. */
73
+ function camposDe(achados) {
74
+ const linhas = achados.flatMap((a) => (a.texto ?? '').split('\n')).filter((l) => l.includes(':'));
75
+ return [...new Set(linhas.map((l) => l.slice(0, l.indexOf(':')).toLowerCase()))].join(', ');
76
+ }
77
+
78
+ /** Os passos do quadro, só os que citam arquivo que existe nesse canal, na ordem do quadro. */
79
+ function doQuadro(pasta, arquivos) {
80
+ if (!arquivos.length) return [];
81
+ return (PASSOS[pasta] ?? []).flatMap(([condicao, texto]) => {
82
+ if (!condicao) return [texto];
83
+ const [tipo, quantos] = condicao.split(':');
84
+ const achados = arquivos.filter((a) => (TIPOS[tipo] ?? [tipo]).includes(a.tipo));
85
+ if (!achados.length) return [];
86
+ if (quantos) return Boolean(achados[0].numerado) === (quantos === 'varios') ? [texto] : [];
87
+ if (texto.includes('{arquivo}')) return achados.map((a) => texto.replace('{arquivo}', a.nome));
88
+ return [citar(texto, pasta, achados).replace('{campos}', camposDe(achados))];
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Os passos de um canal. No canal que não está pronto, o primeiro manda resolver as pendências; no
94
+ * que foi entregue com ressalva, manda conferir as ressalvas.
95
+ * @param {string} pasta a pasta do canal
96
+ * @param {object[]} arquivos os arquivos da entrega nessa pasta: `{ nome, tipo, numerado, texto }`
97
+ * @param {boolean} [comPendencia] o canal tem pendência · @param {boolean} [comRessalva] tem ressalva
98
+ * @returns {string[]}
99
+ */
100
+ export function passosDe(pasta, arquivos, comPendencia = false, comRessalva = false) {
101
+ const passos = doQuadro(pasta, arquivos);
102
+ const antes = comPendencia ? ANTES_DE_POSTAR : comRessalva ? CONFIRA_AS_RESSALVAS : null;
103
+ return antes && passos.length ? [antes, ...passos] : passos;
104
+ }
@@ -0,0 +1,97 @@
1
+ // O que a verificação diz da entrega: as pendências de cada canal (bloqueio, [PREENCHER], item
2
+ // que não existe), o que não foi conferido e o alerta de tamanho do texto entregue.
3
+ // Specs: fase-u3a1-pasta-de-entrega.md, regras 17, 19, 20 e 34, e fase-u3a2-entrega-no-projeto.md,
4
+ // regra 18: a chave de cada pendência, que a faz virar ressalva (repositório do OpenCrew).
5
+ import path from 'node:path';
6
+ import { lerLimites } from '../verificar/leitura.mjs';
7
+ import { contar, FALTA_INFO, NAO_MEDIDO, NAO_VERIFICADO } from '../verificar/regras.mjs';
8
+ import { OUTROS, nomeDaPasta } from './canais.mjs';
9
+
10
+ export const MSG = {
11
+ naoEncontrei: (arquivo) => `Não encontrei ${arquivo}.`,
12
+ naoPronto: (canal, n) => `${canal} não está pronto: ${n} ${n === 1 ? 'pendência' : 'pendências'}.`,
13
+ alerta: (arquivo, n, limite) => `ALERTA: \`${arquivo}\` tem ${n} caracteres; o limite é ${limite}. Encurte antes de publicar.`,
14
+ };
15
+ /** A lista fixa do que o script nunca confere. */
16
+ export const NUNCA_CONFERIDO = ['Links e fatos citados no texto.', 'Texto dentro das imagens.', 'Aparência final em cada rede.'];
17
+
18
+ const ehNaoMedido = (i) => i.item === NAO_MEDIDO || i.item === NAO_VERIFICADO;
19
+ const origem = (arquivo) => `origem: \`${arquivo}\``;
20
+
21
+ /** Uma linha de pendência ou de alerta, com o trecho (ou a medida) e o arquivo de origem. */
22
+ function linhaDoItem(i, arquivo) {
23
+ if (i.item === FALTA_INFO) return `Falta preencher "${i.detalhe}" — ${origem(arquivo)}`;
24
+ if (ehNaoMedido(i)) return `${i.item} — ${i.detalhe} — ${origem(arquivo)}`;
25
+ if (i.medido != null) return `${i.item}: ${i.medido} (${i.minimo ? 'mínimo' : 'limite'} ${i.limite}) — ${origem(arquivo)}`;
26
+ return `${i.item}: "${i.detalhe}" — ${origem(arquivo)}`;
27
+ }
28
+
29
+ /** O resultado do verificador para um item da lista (só os arquivos de texto têm). */
30
+ const resultadoDe = (raiz, r, item) => r.arquivos.find((a) => path.resolve(raiz, a.arquivo) === item.abs && (a.formato ?? null) === (item.formato ?? null));
31
+
32
+ /** A pendência de um bloqueio: a linha, a chave que a identifica e, em [PREENCHER], o trecho pedido. */
33
+ function doBloqueio(i, arquivo) {
34
+ const trecho = i.medido != null ? `${i.medido}/${i.limite}` : String(i.detalhe ?? '');
35
+ return { linha: linhaDoItem(i, arquivo), chave: { arquivo, item: i.item, trecho }, preencher: i.item === FALTA_INFO ? i.detalhe : null };
36
+ }
37
+
38
+ /**
39
+ * Pendências por pasta da entrega (canal ou `outros`), na ordem da lista.
40
+ * @returns {Map<string, object[]>} só as pastas que têm pendência: `{ linha, chave, preencher }` —
41
+ * `chave`: `{ arquivo, item, trecho }` (em bloqueio de medida, o trecho é `medido/limite`); null
42
+ * no arquivo que não existe, que nunca vira ressalva
43
+ */
44
+ export function pendenciasPorPasta(raiz, itens, r) {
45
+ const mapa = new Map();
46
+ const somar = (item, p) => mapa.set(item.canal ?? OUTROS, [...(mapa.get(item.canal ?? OUTROS) ?? []), p]);
47
+ for (const item of itens) {
48
+ if (item.tipo !== 'arquivo') somar(item, { linha: MSG.naoEncontrei(item.rel), chave: null, preencher: null });
49
+ for (const i of resultadoDe(raiz, r, item)?.itens ?? []) if (i.nivel === 'bloqueio') somar(item, doBloqueio(i, item.rel));
50
+ }
51
+ return mapa;
52
+ }
53
+
54
+ /** As frases "{canal} não está pronto: {n} pendências.", na ordem das pastas dadas. */
55
+ export const frasesDePendencia = (pastas, pendencias) => pastas.filter((p) => pendencias.has(p)).map((p) => MSG.naoPronto(nomeDaPasta(p), pendencias.get(p).length));
56
+
57
+ /** O que não foi conferido: alertas, "não medido", "não verificado" e a lista fixa. Não muda o final. */
58
+ export function naoConferido(raiz, itens, r) {
59
+ const linhas = [];
60
+ for (const item of itens.filter((i) => i.tipo === 'arquivo')) {
61
+ for (const i of resultadoDe(raiz, r, item)?.itens ?? []) {
62
+ if (i.nivel === 'alerta' || ehNaoMedido(i)) linhas.push(linhaDoItem(i, item.rel));
63
+ }
64
+ }
65
+ if (r.naoTexto.length) linhas.push(`${NAO_VERIFICADO} — não é texto: ${r.naoTexto.join(', ')}`);
66
+ return [...linhas, ...NUNCA_CONFERIDO];
67
+ }
68
+
69
+ const LIMITE = { legenda: 'caption_max_chars', post: 'post_max_chars', tweet: 'tweet_max_chars' };
70
+ const NOME_NO_RELATORIO = { legenda: 'Legenda Instagram', post: 'Post LinkedIn', tweet: 'Tweet' };
71
+ const NUMERADA = { legenda: 'legenda', post: 'post', tweet: 'Tweet' };
72
+
73
+ /** O verificador já bloqueou, no arquivo de origem, o tamanho desta mesma peça? */
74
+ function jaApontada(itensDaOrigem, a) {
75
+ const doTipo = itensDaOrigem.filter((i) => i.nivel === 'bloqueio' && i.item.startsWith(NOME_NO_RELATORIO[a.tipo]) && i.item.endsWith('caracteres'));
76
+ if (a.total < 2) return doTipo.length > 0;
77
+ const numero = new RegExp(`${NUMERADA[a.tipo]} ${a.ordem}(?!\\d)`);
78
+ return doTipo.some((i) => numero.test(i.item));
79
+ }
80
+
81
+ /**
82
+ * Regra 34: cada `legenda*.txt`, `post*.txt` (não o comentário) e `tweet*.txt` gerado é contado
83
+ * como o verificador conta e comparado com o limite do formato do item. Passou: um ALERTA — mas
84
+ * só quando o verificador não apontou a mesma peça. Não é pendência.
85
+ * @returns {Promise<{ pasta: string, texto: string }[]>}
86
+ */
87
+ export async function alertasDeTamanho(raiz, itens, arquivos, r) {
88
+ const alertas = [];
89
+ for (const a of arquivos.filter((x) => LIMITE[x.tipo] && x.texto)) {
90
+ const limite = (await lerLimites(raiz, a.formato)).limites?.[LIMITE[a.tipo]];
91
+ const medido = contar(a.texto);
92
+ const item = itens.find((i) => i.rel === a.origem && i.formato === a.formato);
93
+ const daOrigem = (item && resultadoDe(raiz, r, item)?.itens) ?? [];
94
+ if (typeof limite === 'number' && medido > limite && !jaApontada(daOrigem, a)) alertas.push({ pasta: a.pasta, texto: MSG.alerta(`${a.pasta}/${a.nome}`, medido, limite) });
95
+ }
96
+ return alertas;
97
+ }
@@ -0,0 +1,78 @@
1
+ // Ressalvas: as pendências que o usuário aceitou ("entregar assim mesmo"). Ficam em
2
+ // `crews/<crew>/output/<run>/ressalvas.json` e valem para as entregas seguintes da execução,
3
+ // enquanto a pendência existir sem interrupção (mesmo arquivo de origem, item e trecho): a cada
4
+ // entrega o arquivo é regravado só com as que ainda valem, e a pendência que some e volta é nova.
5
+ // Spec: fase-u3a2-entrega-no-projeto.md, §4 e regras 18 e 19 (repositório do OpenCrew).
6
+ import { promises as fs } from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export const ARQUIVO = 'ressalvas.json';
10
+ export const MSG = {
11
+ ilegivel: (arquivo) => `Não consegui ler ${arquivo}. Segui sem ele.`,
12
+ nova: (item) => `Há pendência nova, que você ainda não aceitou: ${item}.`,
13
+ };
14
+
15
+ const CAMPOS = ['arquivo', 'item', 'trecho'];
16
+ const ehRessalva = (r) => r != null && typeof r === 'object' && CAMPOS.every((c) => typeof r[c] === 'string');
17
+ const mesma = (a, b) => CAMPOS.every((c) => a[c] === b[c]);
18
+
19
+ /**
20
+ * As ressalvas já aceitas nesta execução. Arquivo que não existe: nenhuma. Arquivo que não é o
21
+ * JSON esperado vale como vazio, com `ilegivel: true` (o resumo avisa); ele não é apagado.
22
+ * @returns {Promise<{ aceitas: object[], ilegivel: boolean }>} `aceitas`: `{ arquivo, item, trecho }`
23
+ */
24
+ export async function lerRessalvas(execucao) {
25
+ let texto;
26
+ try {
27
+ texto = await fs.readFile(path.join(execucao, ARQUIVO), 'utf8');
28
+ } catch (erro) {
29
+ return { aceitas: [], ilegivel: erro.code !== 'ENOENT' };
30
+ }
31
+ try {
32
+ const lista = JSON.parse(texto)?.ressalvas;
33
+ if (Array.isArray(lista) && lista.every(ehRessalva)) return { aceitas: lista, ilegivel: false };
34
+ } catch { /* não é JSON: cai no retorno abaixo */ }
35
+ return { aceitas: [], ilegivel: true };
36
+ }
37
+
38
+ /**
39
+ * Separa as pendências em aceitas (ressalvas) e não aceitas. Pendência sem chave (arquivo que não
40
+ * existe) nunca é aceita.
41
+ * @param {Map<string, object[]>} todas por pasta: `{ linha, chave, preencher }`
42
+ * @param {object[]} aceitas o que `lerRessalvas` leu · @param {boolean} aceitarTudo `--aceitar-pendencias`
43
+ * @returns {{ pendencias: Map<string, string[]>, ressalvas: Map<string, object[]>, novas: string[] }}
44
+ * `novas`: o aviso de cada pendência que apareceu depois de um aceite
45
+ */
46
+ export function separarPendencias(todas, aceitas, aceitarTudo) {
47
+ const [pendencias, ressalvas, novas] = [new Map(), new Map(), new Set()];
48
+ const somar = (mapa, pasta, valor) => mapa.set(pasta, [...(mapa.get(pasta) ?? []), valor]);
49
+ for (const [pasta, lista] of todas) {
50
+ for (const p of lista) {
51
+ const aceita = Boolean(p.chave) && (aceitarTudo || aceitas.some((a) => mesma(a, p.chave)));
52
+ if (aceita) somar(ressalvas, pasta, p);
53
+ else somar(pendencias, pasta, p.linha);
54
+ if (!aceita && p.chave && aceitas.length) novas.add(MSG.nova(p.chave.item));
55
+ }
56
+ }
57
+ return { pendencias, ressalvas, novas: [...novas] };
58
+ }
59
+
60
+ /**
61
+ * Regrava `ressalvas.json` com as ressalvas deste momento: a que não corresponde mais a uma
62
+ * pendência sai. Sem nenhuma, o arquivo que existe fica com a lista vazia e o que não existe não é
63
+ * criado; com o mesmo conteúdo, nada é gravado.
64
+ * @returns {Promise<string|null>} null quando está gravado; senão, o arquivo que não foi gravado
65
+ */
66
+ export async function gravarRessalvas(execucao, ressalvas) {
67
+ const lista = [...ressalvas.values()].flat().map((r) => r.chave);
68
+ const arquivo = path.join(execucao, ARQUIVO);
69
+ const texto = `${JSON.stringify({ ressalvas: lista }, null, 2)}\n`;
70
+ try {
71
+ const atual = await fs.readFile(arquivo, 'utf8').catch(() => null);
72
+ if (atual == null && !lista.length) return null;
73
+ if (atual !== texto) await fs.writeFile(arquivo, texto, 'utf8');
74
+ return null;
75
+ } catch {
76
+ return arquivo;
77
+ }
78
+ }
@@ -0,0 +1,29 @@
1
+ // O resumo que o `entregar.mjs` mostra na tela: a pasta, a situação de cada canal, o que falta, os
2
+ // avisos, a cópia e o LEIA-ME. O runner mostra essas linhas ao usuário como vieram.
3
+ // Specs: fase-u3a1-pasta-de-entrega.md, §4, e fase-u3a2-entrega-no-projeto.md, §4 e §6 (a linha
4
+ // `Cópia:` e a situação "Pronto, com ressalva"), no repositório do OpenCrew.
5
+ import { CANAIS, OUTROS } from './canais.mjs';
6
+ import { situacaoDe } from './leiame.mjs';
7
+ import { frasesDePendencia } from './pendencias.mjs';
8
+
9
+ /**
10
+ * @param {string} execucao a pasta da execução, relativa ao projeto
11
+ * @param {object} dados os dados da entrega (os do LEIA-ME)
12
+ * @param {string[]} notas o que sai em "Avisos:" antes dos avisos dos arquivos
13
+ * @param {string[]} daCopia as linhas da cópia (ou da gravação que falhou), antes da linha do LEIA-ME
14
+ * @returns {string[]} as linhas do resumo, sem a linha `ENTREGA:`
15
+ */
16
+ export function resumo(execucao, dados, notas, daCopia) {
17
+ const { crew, run, pastas, pendencias, avisos, alertas } = dados;
18
+ const comPendencia = [...pastas, OUTROS].filter((p) => pendencias.has(p));
19
+ const avisar = [...alertas.map((a) => a.texto), ...notas, ...avisos.map((a) => a.tela ?? a.texto)];
20
+ return [
21
+ `Entrega da execução ${run} da crew ${crew}`,
22
+ `Pasta: ${execucao}/entrega`,
23
+ ...pastas.map((p) => `- ${CANAIS[p]}: ${situacaoDe(dados, p)}`),
24
+ ...frasesDePendencia(comPendencia, pendencias).flatMap((frase, i) => [frase, ...pendencias.get(comPendencia[i]).map((l) => `- ${l}`)]),
25
+ ...(avisar.length ? ['Avisos:', ...avisar.map((l) => `- ${l}`)] : []),
26
+ ...daCopia,
27
+ `LEIA-ME: ${execucao}/entrega/LEIA-ME.md`,
28
+ ];
29
+ }
@@ -0,0 +1,42 @@
1
+ // O retrato da cópia: o que o script copiou para cada pasta de cópia (caminho → hash), guardado em
2
+ // `crews/<crew>/output/<run>/copia.json`. É com ele que a entrega seguinte é comparada: assim, o
3
+ // que o usuário editar na cópia nunca vira "a entrega mudou". Arquivo de serviço do script.
4
+ // Spec: fase-u3a2-entrega-no-projeto.md, regra 14 (repositório do OpenCrew).
5
+ import { promises as fs } from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export const ARQUIVO = 'copia.json';
9
+
10
+ const ehObjeto = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
11
+ const ehRetrato = (v) => ehObjeto(v) && Object.values(v).every((hash) => typeof hash === 'string');
12
+
13
+ /**
14
+ * Os retratos desta execução, por pasta de cópia (relativa ao projeto, com `/`). Arquivo que não
15
+ * existe (cópia feita por versão anterior) ou que não é o JSON esperado: nenhum retrato — a
16
+ * comparação volta a ser com os arquivos da pasta.
17
+ * @returns {Promise<Record<string, Record<string, string>>>}
18
+ */
19
+ export async function lerRetratos(execucao) {
20
+ try {
21
+ const copias = JSON.parse(await fs.readFile(path.join(execucao, ARQUIVO), 'utf8'))?.copias;
22
+ return ehObjeto(copias) && Object.values(copias).every(ehRetrato) ? copias : {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Regrava `copia.json` com os retratos dados; com o mesmo conteúdo, nada é gravado.
30
+ * @returns {Promise<string|null>} null quando está gravado; senão, o arquivo que não foi gravado
31
+ */
32
+ export async function gravarRetratos(execucao, copias) {
33
+ const arquivo = path.join(execucao, ARQUIVO);
34
+ const texto = `${JSON.stringify({ copias }, null, 2)}\n`;
35
+ try {
36
+ const atual = await fs.readFile(arquivo, 'utf8').catch(() => null);
37
+ if (atual !== texto) await fs.writeFile(arquivo, texto, 'utf8');
38
+ return null;
39
+ } catch {
40
+ return arquivo;
41
+ }
42
+ }
@@ -0,0 +1,106 @@
1
+ // Separa os itens da lista em produtos da entrega: peças que viram arquivo próprio (unidades),
2
+ // cópias com o nome original e avisos. Não escreve nada: quem grava é `gravar.mjs`.
3
+ // Spec: fase-u3a1-pasta-de-entrega.md, §4 e regras 4, 8, 9 e 11 (repositório do OpenCrew). O
4
+ // `doCanal` da cópia é da fase-u3a2-entrega-no-projeto.md, regra 14: o editável segue o canal dele.
5
+ import path from 'node:path';
6
+ import { lerItem } from '../verificar/arquivos.mjs';
7
+ import { semFrontmatter } from '../verificar/leitura.mjs';
8
+ import { EDITAVEIS, OUTROS } from './canais.mjs';
9
+ import { avisoDeFora, blocosFora } from './fora.mjs';
10
+ import { PRINCIPAL, hashtagsSoltas, pecasDoLeitor } from './leitor.mjs';
11
+ import { pecasDoBlog, pecasDoEmail, pecasDoWhatsapp, tweetsDaThread } from './longas.mjs';
12
+ import { emLf, fecharMd, paraColar, semServico } from './texto.mjs';
13
+
14
+ export const MSG = {
15
+ // Só no resumo da tela: no LEIA-ME, a linha do arquivo em "Outros arquivos" já diz.
16
+ semCanal: (arquivo) => `${arquivo} não tem canal conhecido. Está em \`outros/\`.`,
17
+ semPeca: (peca, arquivo) => `Não encontrei ${peca} em ${arquivo}. Confira antes de colar.`,
18
+ legendaInteira: (arquivo) => `Não encontrei a legenda marcada em ${arquivo}: usei o texto inteiro. Confira antes de colar.`,
19
+ outroCanal: (arquivo, cabecalho) => `${arquivo} tem uma seção de outro canal (${cabecalho}) que não foi separada. Ela continua no arquivo de origem.`,
20
+ };
21
+
22
+ const IMAGEM = new Set(['.png', '.jpg', '.jpeg']);
23
+ const HTML = new Set(['.html', '.htm']);
24
+ const DITA = { legenda: 'a legenda', post: 'o post', tweet: 'o tweet', blog: 'o artigo', email: 'o e-mail', mensagem: 'a mensagem' };
25
+ const LONGAS = {
26
+ 'blog-post': ['blog', pecasDoBlog], 'blog-seo': ['blog', pecasDoBlog],
27
+ 'email-newsletter': ['email', pecasDoEmail], 'email-sales': ['email', pecasDoEmail],
28
+ 'whatsapp-broadcast': ['mensagem', pecasDoWhatsapp],
29
+ };
30
+
31
+ const extensao = (item) => path.extname(item.abs).toLowerCase();
32
+ const semExtensao = (item) => item.abs.slice(0, item.abs.length - path.extname(item.abs).length).toLowerCase();
33
+ const ehImagem = (item) => IMAGEM.has(extensao(item));
34
+
35
+ /** HTML editável: tem na lista uma imagem de mesmo nome, na mesma pasta. */
36
+ const ehEditavel = (item, itens) => HTML.has(extensao(item)) && itens.some((i) => i.tipo === 'arquivo' && ehImagem(i) && semExtensao(i) === semExtensao(item));
37
+
38
+ /** Cópia com o nome original: `de` (os mesmos bytes do arquivo) ou `texto` (o corpo tratado). */
39
+ function copia(item, pasta, tipo, texto = null) {
40
+ const base = { pasta, nome: path.basename(item.abs), tipo, origem: item.rel, pastaDeOrigem: path.dirname(item.abs), doCanal: item.canal ?? OUTROS };
41
+ return texto ? { ...base, texto } : { ...base, de: item.abs };
42
+ }
43
+
44
+ /** Tweets de uma thread escrita em blocos `TWEET n/N`; as hashtags soltas vão no fim do primeiro. */
45
+ function daThread(corpo) {
46
+ const tweets = tweetsDaThread(corpo);
47
+ const soltas = tweets.length ? hashtagsSoltas(corpo) : [];
48
+ const unidades = tweets.map((t, i) => {
49
+ const texto = paraColar(i === 0 && soltas.length ? `${t}\n\n${soltas.join(' ')}` : t);
50
+ return { tipo: 'tweet', ordem: i + 1, total: tweets.length, partes: [{ nome: 'tweet', sufixo: '', ext: 'txt', tipo: 'tweet', texto }] };
51
+ });
52
+ return { unidades, temSlides: false, outros: [] };
53
+ }
54
+
55
+ /** As unidades de um arquivo de texto, pelo formato dele; `tipo`: a peça que não pode faltar; `fora`: os blocos que não entram. */
56
+ function lerUnidades(texto, formato) {
57
+ if (LONGAS[formato]) return { tipo: LONGAS[formato][0], unidades: LONGAS[formato][1](texto, formato), temSlides: false, outros: [], fora: blocosFora(texto) };
58
+ const corpo = semServico(semFrontmatter(texto));
59
+ const thread = formato === 'twitter-thread' ? daThread(corpo) : null;
60
+ if (thread?.unidades.length) return { tipo: PRINCIPAL[formato], ...thread, fora: blocosFora(texto) };
61
+ return { tipo: PRINCIPAL[formato], ...pecasDoLeitor(corpo, formato), fora: blocosFora(texto, formato) };
62
+ }
63
+
64
+ /** Arquivo `.md` ou `.txt` de um formato com peças: as unidades, ou o arquivo inteiro, com aviso. */
65
+ function separarPecas(item, texto, itens, p) {
66
+ const { tipo, unidades, temSlides, outros, fora } = lerUnidades(texto, item.formato);
67
+ const comArquivo = unidades.map((u) => ({ ...u, partes: u.partes.filter((parte) => parte.texto) })).filter((u) => u.partes.length);
68
+ for (const u of comArquivo) p.unidades.push({ ...u, pasta: item.canal, origem: item.rel, formato: item.formato });
69
+ if (comArquivo.some((u) => u.inteiro)) p.avisos.push({ pasta: item.canal, texto: MSG.legendaInteira(item.rel) });
70
+ for (const cabecalho of outros) p.avisos.push({ pasta: item.canal, texto: MSG.outroCanal(item.rel, cabecalho) });
71
+ const semImagem = !itens.some((i) => i.tipo === 'arquivo' && ehImagem(i) && i.formato === item.formato);
72
+ if (!comArquivo.length) p.avisos.push({ pasta: item.canal, texto: MSG.semPeca(DITA[tipo], item.rel) });
73
+ // O arquivo que vai inteiro leva todos os blocos: só há o que avisar quando ele não vai.
74
+ if (!comArquivo.length || (temSlides && semImagem)) p.copias.push(copia(item, item.canal, 'inteiro'));
75
+ else p.avisos.push(...avisoDeFora(item, fora));
76
+ }
77
+
78
+ /** Um item que existe → os produtos dele. */
79
+ async function separarItem(item, itens, p) {
80
+ if (ehEditavel(item, itens)) return p.copias.push(copia(item, EDITAVEIS, 'editavel'));
81
+ if (!item.canal) {
82
+ p.avisos.push({ pasta: OUTROS, tela: MSG.semCanal(item.rel) });
83
+ return p.copias.push(copia(item, OUTROS, 'copia'));
84
+ }
85
+ const lido = ['.md', '.txt'].includes(extensao(item)) ? await lerItem(item.abs) : null;
86
+ if (lido?.tipo !== 'texto') return p.copias.push(copia(item, item.canal, ehImagem(item) ? 'imagem' : 'copia'));
87
+ const texto = emLf(lido.texto);
88
+ if (LONGAS[item.formato] || PRINCIPAL[item.formato]) return separarPecas(item, texto, itens, p);
89
+ // Roteiro ou artigo: o corpo, sem frontmatter e sem blocos de serviço; as linhas de rótulo ficam.
90
+ p.avisos.push(...avisoDeFora(item, blocosFora(texto)));
91
+ return p.copias.push(copia(item, item.canal, 'roteiro', fecharMd(semServico(semFrontmatter(texto)))));
92
+ }
93
+
94
+ /**
95
+ * @param {object[]} itens os itens da lista, já sem os arquivos de serviço:
96
+ * `{ abs, rel, formato, canal, tipo }` (`tipo`: arquivo, pasta ou ausente)
97
+ * @returns {Promise<{ unidades: object[], copias: object[], avisos: object[] }>}
98
+ * · `unidades`: `{ pasta, tipo, titulo, origem, formato, ordem, total, partes }`
99
+ * · `copias`: `{ pasta, nome, tipo, origem, pastaDeOrigem, doCanal, de | texto }`
100
+ * · `avisos`: `{ pasta, texto, tela }` (`tela`: o texto do resumo da tela; sem `texto`, só sai lá)
101
+ */
102
+ export async function separar(itens) {
103
+ const produtos = { unidades: [], copias: [], avisos: [] };
104
+ for (const item of itens.filter((i) => i.tipo === 'arquivo')) await separarItem(item, itens, produtos);
105
+ return produtos;
106
+ }