@aksp/opencrew 1.8.0 → 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 (35) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +45 -12
  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 +1 -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 +90 -16
  11. package/templates/_opencrew/core/prompts/export.prompt.md +5 -81
  12. package/templates/_opencrew/core/runner.pipeline.md +20 -20
  13. package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +13 -9
  14. package/templates/_opencrew/core/scripts/entrega/canais.mjs +5 -3
  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 +4 -3
  19. package/templates/_opencrew/core/scripts/entrega/guardar.mjs +60 -0
  20. package/templates/_opencrew/core/scripts/entrega/leiame.mjs +52 -16
  21. package/templates/_opencrew/core/scripts/entrega/leitor.mjs +16 -3
  22. package/templates/_opencrew/core/scripts/entrega/lembrar.mjs +65 -0
  23. package/templates/_opencrew/core/scripts/entrega/passos.mjs +10 -5
  24. package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +16 -6
  25. package/templates/_opencrew/core/scripts/entrega/ressalvas.mjs +78 -0
  26. package/templates/_opencrew/core/scripts/entrega/resumo.mjs +29 -0
  27. package/templates/_opencrew/core/scripts/entrega/retrato.mjs +42 -0
  28. package/templates/_opencrew/core/scripts/entrega/separar.mjs +6 -3
  29. package/templates/_opencrew/core/scripts/entregar.mjs +64 -39
  30. package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +4 -4
  31. package/templates/_opencrew/core/scripts/verificar/entradas.mjs +26 -0
  32. package/templates/_opencrew/core/scripts/verificar/gravacao.mjs +41 -0
  33. package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +10 -3
  34. package/templates/_opencrew/core/scripts/verificar.mjs +17 -27
  35. package/templates/gitignore +1 -0
@@ -1,17 +1,22 @@
1
- // O LEIA-ME.md da entrega: títulos fixos, na ordem da spec; seção sem conteúdo não aparece.
2
- // Arquivo da entrega é citado com caminho relativo à pasta do LEIA-ME; o de origem, relativo ao
3
- // projeto. Sem hora: as mesmas entradas geram os mesmos bytes. PT-BR fixo.
4
- // Spec: fase-u3a1-pasta-de-entrega.md, §4 e §6 (repositório do OpenCrew).
1
+ // O LEIA-ME.md da entrega e o da cópia: títulos fixos, na ordem da spec; seção sem conteúdo não
2
+ // aparece. Arquivo da entrega é citado com caminho relativo à pasta do LEIA-ME; o de origem,
3
+ // relativo ao projeto. Sem hora: as mesmas entradas geram os mesmos bytes. PT-BR fixo.
4
+ // Specs: fase-u3a1-pasta-de-entrega.md, §4 e §6, e fase-u3a2-entrega-no-projeto.md, §4 e §6
5
+ // (ressalvas, a cópia e "Sobre esta pasta"), no repositório do OpenCrew.
5
6
  import { CANAIS, EDITAVEIS, OUTROS } from './canais.mjs';
6
7
  import { frasesDePendencia } from './pendencias.mjs';
7
8
  import { passosDe } from './passos.mjs';
8
9
 
9
10
  export const TEXTO = {
10
11
  vaiPublicar: 'Esta crew publica este canal sozinha. Antes de postar à mão, confira se já saiu.',
11
- pdf: 'Abra o arquivo que você quer (por exemplo, o artigo do blog) no navegador ou no editor de texto e use Imprimir → Salvar como PDF.',
12
+ pdf: 'Abra o arquivo que você quer no navegador ou no editor de texto e use Imprimir → Salvar como PDF.',
12
13
  semCanal: 'sem canal de publicação; está aqui para você usar como quiser.',
13
14
  sobre: 'Esta pasta é refeita a cada entrega e fica fora do git: o que você editar aqui se perde. Para guardar, copie a pasta para outro lugar do projeto.',
15
+ sobreComCopia: (pasta) => `Esta pasta é refeita a cada entrega e fica fora do git: o que você editar aqui se perde. A cópia para guardar está em \`${pasta}\`.`,
16
+ sobreDaCopia: (run) => `Esta é a cópia da entrega da execução ${run}. Ela não é refeita: pode editar e guardar. Se a entrega mudar, a versão nova vai para outra pasta, ao lado desta.`,
17
+ comRessalva: 'Entregue com ressalva:',
14
18
  pronto: 'Pronto',
19
+ prontoComRessalva: 'Pronto, com ressalva',
15
20
  naoPronto: 'Não está pronto',
16
21
  };
17
22
 
@@ -19,6 +24,12 @@ const lista = (linhas) => linhas.map((l) => `- ${l}`).join('\n');
19
24
  const bloco = (titulo, linhas) => (linhas.length ? [`${titulo}\n${lista(linhas)}`] : []);
20
25
  const secao = (titulo, partes) => (partes.length ? [`## ${titulo}\n\n${partes.join('\n\n')}`] : []);
21
26
 
27
+ /** A situação de um canal: uma só. Pendência não aceita vale sobre a ressalva. */
28
+ export function situacaoDe(d, pasta) {
29
+ if (d.pendencias.has(pasta)) return TEXTO.naoPronto;
30
+ return d.ressalvas.has(pasta) ? TEXTO.prontoComRessalva : TEXTO.pronto;
31
+ }
32
+
22
33
  /** Uma linha por arquivo entregue: o caminho na entrega, o título do bloco (se houver) e a origem. */
23
34
  function linhaDoArquivo(a) {
24
35
  const titulo = a.titulo ? ` — ${a.titulo}` : '';
@@ -28,22 +39,35 @@ function linhaDoArquivo(a) {
28
39
  /** Os avisos de uma pasta que o LEIA-ME mostra (o que só vai para a tela não tem `texto`). */
29
40
  const avisosDe = (d, pasta) => d.avisos.filter((a) => a.pasta === pasta && a.texto).map((a) => a.texto);
30
41
 
31
- /** `## Antes de usar`: os alertas de tamanho e, por canal que não está pronto, o que falta. */
42
+ /** A linha de uma ressalva; a de [PREENCHER] cita os arquivos entregues daquela origem que têm o trecho. */
43
+ function linhaDaRessalva(d, r) {
44
+ const temOTrecho = (a) => a.origem === r.chave.arquivo && a.texto?.includes('[PREENCHER') && a.texto.includes(r.preencher);
45
+ const onde = r.preencher ? d.arquivos.filter(temOTrecho).map((a) => `\`${a.pasta}/${a.nome}\``) : [];
46
+ return `${r.linha}${onde.length ? ` — está em ${onde.join(', ')}` : ''}`;
47
+ }
48
+
49
+ /** `## Antes de usar`: as ressalvas, os alertas de tamanho e, por canal que não está pronto, o que falta. */
32
50
  function antesDeUsar(d) {
51
+ const ressalvas = [...d.ressalvas.values()].flat().map((r) => linhaDaRessalva(d, r));
33
52
  const pastas = [...d.pastas, OUTROS].filter((p) => d.pendencias.has(p));
34
53
  const frases = frasesDePendencia(pastas, d.pendencias);
35
54
  const faltas = pastas.map((p, i) => `- ${frases[i]}\n${d.pendencias.get(p).map((l) => ` - ${l}`).join('\n')}`);
36
- return secao('Antes de usar', [[...d.alertas.map((a) => `- ${a}`), ...faltas].join('\n')].filter(Boolean));
55
+ // Na cópia, o alerta de um arquivo que não foi copiado não aparece: ele cita um arquivo que não está lá.
56
+ const alertas = d.alertas.filter((a) => !d.ehCopia || !d.pendencias.has(a.pasta)).map((a) => `- ${a.texto}`);
57
+ return secao('Antes de usar', [...bloco(TEXTO.comRessalva, ressalvas), [...alertas, ...faltas].join('\n')].filter(Boolean));
37
58
  }
38
59
 
39
60
  /** `## {Canal}`: o aviso de publicação, a situação, os arquivos, as pendências, os avisos e os passos. */
40
61
  function secaoDoCanal(d, pasta) {
41
- const arquivos = d.arquivos.filter((a) => a.pasta === pasta);
42
62
  const pendencias = d.pendencias.get(pasta) ?? [];
43
- const passos = passosDe(pasta, arquivos, pendencias.length > 0).map((p, i) => `${i + 1}. ${p}`);
63
+ const situacao = `Situação: ${situacaoDe(d, pasta)}`;
64
+ // Na cópia, o canal que não foi copiado traz só a situação e o que falta.
65
+ if (d.ehCopia && pendencias.length) return secao(CANAIS[pasta], [situacao, ...bloco('Pendências:', pendencias)]);
66
+ const arquivos = d.arquivos.filter((a) => a.pasta === pasta);
67
+ const passos = passosDe(pasta, arquivos, pendencias.length > 0, d.ressalvas.has(pasta)).map((p, i) => `${i + 1}. ${p}`);
44
68
  return secao(CANAIS[pasta], [
45
69
  ...(d.vaiPublicar.includes(pasta) ? [TEXTO.vaiPublicar] : []),
46
- `Situação: ${pendencias.length ? TEXTO.naoPronto : TEXTO.pronto}`,
70
+ situacao,
47
71
  ...bloco('Arquivos:', arquivos.map(linhaDoArquivo)),
48
72
  ...bloco('Pendências:', pendencias),
49
73
  ...bloco('Atenção:', avisosDe(d, pasta)),
@@ -59,31 +83,43 @@ function secaoDaPasta(d, pasta, titulo) {
59
83
  return secao(titulo, [
60
84
  ...(arquivos.length ? [lista(arquivos)] : []),
61
85
  ...bloco('Pendências:', pendencias),
62
- ...bloco('Atenção:', avisosDe(d, pasta)),
86
+ ...(d.ehCopia && pendencias.length ? [] : bloco('Atenção:', avisosDe(d, pasta))),
63
87
  ]);
64
88
  }
65
89
 
90
+ /** `## Sobre esta pasta`: o que é a pasta da entrega (com a cópia, onde ela está) ou o que é a cópia. */
91
+ function sobre(d) {
92
+ if (d.ehCopia) return TEXTO.sobreDaCopia(d.run);
93
+ return d.copiaEm ? TEXTO.sobreComCopia(d.copiaEm) : TEXTO.sobre;
94
+ }
95
+
66
96
  /**
67
97
  * @param {object} d
68
98
  * @param {string} d.crew nome da crew · @param {string} d.run id da execução
69
99
  * @param {string[]} d.pastas os canais presentes, na ordem das seções
70
- * @param {object[]} d.arquivos `{ pasta, nome, tipo, origem, titulo, numerado }`
100
+ * @param {object[]} d.arquivos `{ pasta, nome, tipo, origem, titulo, numerado }` — no LEIA-ME da
101
+ * cópia, só os que foram copiados
71
102
  * @param {object[]} d.avisos `{ pasta, texto, tela }` (`tela`: como o aviso sai no resumo da tela)
72
- * @param {Map<string, string[]>} d.pendencias por pasta · @param {string[]} d.alertas regra 34
103
+ * @param {Map<string, string[]>} d.pendencias por pasta, as não aceitas
104
+ * @param {Map<string, object[]>} d.ressalvas por pasta, as aceitas: `{ linha, chave, preencher }`
105
+ * @param {object[]} d.alertas regra 34: `{ pasta, texto }`
73
106
  * @param {string[]} d.naoConferido · @param {string[]} d.vaiPublicar canais que a crew publica
107
+ * @param {string|null} [d.copiaEm] a pasta da cópia, relativa ao projeto, quando ela foi feita
108
+ * @param {boolean} [d.ehCopia] é o LEIA-ME da cópia: todo arquivo citado existe nela
109
+ * @param {number} [d.reentrega] na cópia, o número da pasta: de 2 em diante, o título diz `(reentrega N)`
74
110
  * @returns {string} o LEIA-ME, em UTF-8 sem BOM e com LF
75
111
  */
76
112
  export function montarLeiame(d) {
77
113
  const temMd = d.arquivos.some((a) => a.nome.toLowerCase().endsWith('.md'));
78
114
  const partes = [
79
- `# Entrega — ${d.crew} — ${d.run}`,
115
+ `# Entrega — ${d.crew} — ${d.run}${d.reentrega > 1 ? ` (reentrega ${d.reentrega})` : ''}`,
80
116
  ...antesDeUsar(d),
81
117
  ...d.pastas.flatMap((pasta) => secaoDoCanal(d, pasta)),
82
118
  ...secaoDaPasta(d, OUTROS, 'Outros arquivos'),
83
119
  ...secaoDaPasta(d, EDITAVEIS, 'Editáveis'),
84
120
  ...secao('O que não foi conferido', [lista(d.naoConferido)]),
85
121
  ...(temMd ? secao('Para ter um PDF', [TEXTO.pdf]) : []),
86
- ...secao('Sobre esta pasta', [TEXTO.sobre]),
122
+ ...secao('Sobre esta pasta', [sobre(d)]),
87
123
  ];
88
124
  return `${partes.join('\n\n')}\n`;
89
- }
125
+ }
@@ -1,8 +1,9 @@
1
1
  // Peças que vêm do leitor do verificador (`lerPecas`): legenda, post e tweet, cada um com as
2
2
  // suas hashtags no fim — e o primeiro comentário do LinkedIn, que sai do texto do post.
3
- // Spec: fase-u3a1-pasta-de-entrega.md, §4 e regra 4 (repositório do OpenCrew).
3
+ // Spec: fase-u3a1-pasta-de-entrega.md, §4 e regra 4 (repositório do OpenCrew). A legenda sem
4
+ // marcador (o arquivo que é só o texto) é da fase-u3a2-entrega-no-projeto.md, §4.
4
5
  import { semAcento } from '../verificar/leitura.mjs';
5
- import { lerPecas, listarHashtags } from '../verificar/pecas.mjs';
6
+ import { contarSlides, lerPecas, listarHashtags } from '../verificar/pecas.mjs';
6
7
  import { lerTrechos, marcar, rotuloDe } from '../verificar/secoes.mjs';
7
8
  import { paraColar } from './texto.mjs';
8
9
 
@@ -93,6 +94,17 @@ function distribuir(marcas, comentarios) {
93
94
  return marcas.map((numeros) => (numeros.length ? numeros : sobras.splice(0, 1)).map((n) => comentarios[n]).join('\n\n'));
94
95
  }
95
96
 
97
+ /**
98
+ * Arquivo de `instagram-feed` que é só o texto (sem rótulo, sem cabeçalho e sem linha "Slide N"): o
99
+ * corpo inteiro é a legenda, como o leitor do verificador já faz com o post e com o tweet. A
100
+ * unidade sai com `inteiro: true`, para o aviso.
101
+ */
102
+ function legendaInteira(corpo) {
103
+ const soTexto = corpo.trim() && !contarSlides(corpo) && !marcar(corpo).some((m) => m.rotulo || m.nivel);
104
+ if (!soTexto) return [];
105
+ return [{ tipo: 'legenda', titulo: null, ordem: 1, total: 1, inteiro: true, partes: [{ nome: 'legenda', sufixo: '', ext: 'txt', tipo: 'legenda', texto: paraColar(corpo) }] }];
106
+ }
107
+
96
108
  /**
97
109
  * Lê as peças do formato num corpo já sem frontmatter e sem blocos de serviço.
98
110
  * @returns {{ unidades: object[], temSlides: boolean, outros: string[] }} `unidades`: uma por
@@ -116,5 +128,6 @@ export function pecasDoLeitor(corpoOriginal, formato) {
116
128
  return { tipo, titulo: titulos[i], ordem: i + 1, total: principais.length, partes };
117
129
  });
118
130
  const outros = pecas.filter((p) => p.formato !== tabela && p.tipo !== 'hashtags' && p.cabecalho).map((p) => p.cabecalho);
119
- return { unidades, temSlides: pecas.some((p) => p.formato === tabela && p.tipo === 'slides'), outros: [...new Set(outros)] };
131
+ const achadas = unidades.length || tipo !== 'legenda' ? unidades : legendaInteira(corpo);
132
+ return { unidades: achadas, temSlides: pecas.some((p) => p.formato === tabela && p.tipo === 'slides'), outros: [...new Set(outros)] };
120
133
  }
@@ -0,0 +1,65 @@
1
+ // `--lembrar-destino`: grava `entrega.destino` no `crew.yaml`, sem mexer em nenhuma outra linha, e
2
+ // deixa ao lado a cópia do arquivo anterior (`crew.yaml.bak`; se já existe, `.bak-<data-hora>`).
3
+ // É o único arquivo fora da execução e do destino que a entrega escreve.
4
+ // Spec: fase-u3a2-entrega-no-projeto.md, regra 16 (repositório do OpenCrew).
5
+ import { constants, existsSync, promises as fs } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { blocoDaEntrega, destinoDoTexto, validarDestino } from './destino.mjs';
8
+
9
+ const FIM = /\r?\n$/;
10
+ const fimDe = (linha) => linha.match(FIM)?.[0] ?? '';
11
+ const recuoDe = (linha) => linha.match(/^[ \t]*/)[0];
12
+
13
+ /** O que já está gravado é o mesmo destino? (`Não` e `nao` são o mesmo "não"; `a\b` e `a/b`, a mesma pasta) */
14
+ function jaGravado(raiz, texto, destino) {
15
+ const escrito = destinoDoTexto(texto);
16
+ if (escrito == null) return false;
17
+ const atual = validarDestino(raiz, escrito);
18
+ return atual.tipo === destino.tipo && atual.rel === destino.rel;
19
+ }
20
+
21
+ /** Troca a linha de `destino:` (e a lista que vinha embaixo dela, se havia) pela linha nova. */
22
+ function trocar(linhas, bloco, linhaNova, eol) {
23
+ const i = bloco.destino;
24
+ let fim = i + 1;
25
+ while (fim < bloco.fim && linhas[fim].trim() && recuoDe(linhas[fim]).length > bloco.recuo.length) fim += 1;
26
+ linhas.splice(i, fim - i, `${linhaNova}${fimDe(linhas[i]) || eol}`);
27
+ }
28
+
29
+ /** O texto do `crew.yaml` com `entrega.destino: valor`; as outras linhas ficam como estavam, byte a byte. */
30
+ export function comDestino(texto, valor) {
31
+ const eol = texto.includes('\r\n') ? '\r\n' : '\n';
32
+ const linhas = texto.split(/(?<=\n)/);
33
+ const bloco = blocoDaEntrega(linhas);
34
+ const escrito = valor === 'nao' ? 'nao' : JSON.stringify(valor);
35
+ if (bloco && bloco.destino >= 0) trocar(linhas, bloco, `${bloco.recuo}destino: ${escrito}`, eol);
36
+ else if (bloco) {
37
+ if (!fimDe(linhas[bloco.inicio])) linhas[bloco.inicio] += eol;
38
+ linhas.splice(bloco.inicio + 1, 0, `${bloco.recuo}destino: ${escrito}${eol}`);
39
+ } else {
40
+ if (linhas.length && !fimDe(linhas.at(-1))) linhas.push(eol);
41
+ linhas.push(`entrega:${eol}`, ` destino: ${escrito}${eol}`);
42
+ }
43
+ return linhas.join('');
44
+ }
45
+
46
+ const nomeDaCopia = (arquivo) => (existsSync(`${arquivo}.bak`) ? `${arquivo}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}` : `${arquivo}.bak`);
47
+
48
+ /**
49
+ * Grava o destino no `crew.yaml`. Valor igual ao gravado: nada é regravado (nem cópia nova).
50
+ * @param {string} raiz pasta do projeto · @param {string} crew pasta da crew, absoluta
51
+ * @param {object} destino o que `validarDestino` devolveu: `nao` ou `pasta`
52
+ * @returns {Promise<string|null>} null quando está gravado; senão, o arquivo que não foi gravado
53
+ */
54
+ export async function lembrarDestino(raiz, crew, destino) {
55
+ const arquivo = path.join(crew, 'crew.yaml');
56
+ try {
57
+ const antes = await fs.readFile(arquivo, 'utf8');
58
+ if (jaGravado(raiz, antes, destino)) return null;
59
+ await fs.copyFile(arquivo, nomeDaCopia(arquivo), constants.COPYFILE_EXCL);
60
+ await fs.writeFile(arquivo, comDestino(antes, destino.tipo === 'nao' ? 'nao' : destino.rel), 'utf8');
61
+ return null;
62
+ } catch {
63
+ return arquivo;
64
+ }
65
+ }
@@ -1,9 +1,12 @@
1
1
  // Os passos de cada canal no LEIA-ME: o quadro "Passos por canal" da spec, guardado como dado.
2
2
  // O texto é literal (aprovado com a spec): não reescreva aqui sem mudar a spec.
3
- // Spec: fase-u3a1-pasta-de-entrega.md, §4 (repositório do OpenCrew).
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.
4
5
 
5
6
  /** O primeiro passo do canal que não está pronto (ajuste da execução real). */
6
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".';
7
10
  const MARKDOWN = 'Este texto está em markdown: se o seu editor não aceitar, ajuste títulos, negrito e links depois de colar.';
8
11
 
9
12
  // Cada passo: [o que o faz aparecer, o texto]. null = sempre que o canal tem arquivo; um tipo de
@@ -87,13 +90,15 @@ function doQuadro(pasta, arquivos) {
87
90
  }
88
91
 
89
92
  /**
90
- * Os passos de um canal. No canal que não está pronto, o primeiro manda resolver as pendências.
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.
91
95
  * @param {string} pasta a pasta do canal
92
96
  * @param {object[]} arquivos os arquivos da entrega nessa pasta: `{ nome, tipo, numerado, texto }`
93
- * @param {boolean} [comPendencia] o canal tem pendência
97
+ * @param {boolean} [comPendencia] o canal tem pendência · @param {boolean} [comRessalva] tem ressalva
94
98
  * @returns {string[]}
95
99
  */
96
- export function passosDe(pasta, arquivos, comPendencia = false) {
100
+ export function passosDe(pasta, arquivos, comPendencia = false, comRessalva = false) {
97
101
  const passos = doQuadro(pasta, arquivos);
98
- return comPendencia && passos.length ? [ANTES_DE_POSTAR, ...passos] : passos;
102
+ const antes = comPendencia ? ANTES_DE_POSTAR : comRessalva ? CONFIRA_AS_RESSALVAS : null;
103
+ return antes && passos.length ? [antes, ...passos] : passos;
99
104
  }
@@ -1,6 +1,7 @@
1
1
  // O que a verificação diz da entrega: as pendências de cada canal (bloqueio, [PREENCHER], item
2
2
  // que não existe), o que não foi conferido e o alerta de tamanho do texto entregue.
3
- // Spec: fase-u3a1-pasta-de-entrega.md, regras 17, 19, 20 e 34 (repositório do OpenCrew).
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).
4
5
  import path from 'node:path';
5
6
  import { lerLimites } from '../verificar/leitura.mjs';
6
7
  import { contar, FALTA_INFO, NAO_MEDIDO, NAO_VERIFICADO } from '../verificar/regras.mjs';
@@ -28,16 +29,24 @@ function linhaDoItem(i, arquivo) {
28
29
  /** O resultado do verificador para um item da lista (só os arquivos de texto têm). */
29
30
  const resultadoDe = (raiz, r, item) => r.arquivos.find((a) => path.resolve(raiz, a.arquivo) === item.abs && (a.formato ?? null) === (item.formato ?? null));
30
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
+
31
38
  /**
32
39
  * Pendências por pasta da entrega (canal ou `outros`), na ordem da lista.
33
- * @returns {Map<string, string[]>} só as pastas que têm pendência
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
34
43
  */
35
44
  export function pendenciasPorPasta(raiz, itens, r) {
36
45
  const mapa = new Map();
37
- const somar = (item, linha) => mapa.set(item.canal ?? OUTROS, [...(mapa.get(item.canal ?? OUTROS) ?? []), linha]);
46
+ const somar = (item, p) => mapa.set(item.canal ?? OUTROS, [...(mapa.get(item.canal ?? OUTROS) ?? []), p]);
38
47
  for (const item of itens) {
39
- if (item.tipo !== 'arquivo') somar(item, MSG.naoEncontrei(item.rel));
40
- for (const i of resultadoDe(raiz, r, item)?.itens ?? []) if (i.nivel === 'bloqueio') somar(item, linhaDoItem(i, item.rel));
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));
41
50
  }
42
51
  return mapa;
43
52
  }
@@ -73,6 +82,7 @@ function jaApontada(itensDaOrigem, a) {
73
82
  * Regra 34: cada `legenda*.txt`, `post*.txt` (não o comentário) e `tweet*.txt` gerado é contado
74
83
  * como o verificador conta e comparado com o limite do formato do item. Passou: um ALERTA — mas
75
84
  * só quando o verificador não apontou a mesma peça. Não é pendência.
85
+ * @returns {Promise<{ pasta: string, texto: string }[]>}
76
86
  */
77
87
  export async function alertasDeTamanho(raiz, itens, arquivos, r) {
78
88
  const alertas = [];
@@ -81,7 +91,7 @@ export async function alertasDeTamanho(raiz, itens, arquivos, r) {
81
91
  const medido = contar(a.texto);
82
92
  const item = itens.find((i) => i.rel === a.origem && i.formato === a.formato);
83
93
  const daOrigem = (item && resultadoDe(raiz, r, item)?.itens) ?? [];
84
- if (typeof limite === 'number' && medido > limite && !jaApontada(daOrigem, a)) alertas.push(MSG.alerta(`${a.pasta}/${a.nome}`, medido, limite));
94
+ if (typeof limite === 'number' && medido > limite && !jaApontada(daOrigem, a)) alertas.push({ pasta: a.pasta, texto: MSG.alerta(`${a.pasta}/${a.nome}`, medido, limite) });
85
95
  }
86
96
  return alertas;
87
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
+ }
@@ -1,6 +1,7 @@
1
1
  // Separa os itens da lista em produtos da entrega: peças que viram arquivo próprio (unidades),
2
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).
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.
4
5
  import path from 'node:path';
5
6
  import { lerItem } from '../verificar/arquivos.mjs';
6
7
  import { semFrontmatter } from '../verificar/leitura.mjs';
@@ -14,6 +15,7 @@ export const MSG = {
14
15
  // Só no resumo da tela: no LEIA-ME, a linha do arquivo em "Outros arquivos" já diz.
15
16
  semCanal: (arquivo) => `${arquivo} não tem canal conhecido. Está em \`outros/\`.`,
16
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.`,
17
19
  outroCanal: (arquivo, cabecalho) => `${arquivo} tem uma seção de outro canal (${cabecalho}) que não foi separada. Ela continua no arquivo de origem.`,
18
20
  };
19
21
 
@@ -35,7 +37,7 @@ const ehEditavel = (item, itens) => HTML.has(extensao(item)) && itens.some((i) =
35
37
 
36
38
  /** Cópia com o nome original: `de` (os mesmos bytes do arquivo) ou `texto` (o corpo tratado). */
37
39
  function copia(item, pasta, tipo, texto = null) {
38
- const base = { pasta, nome: path.basename(item.abs), tipo, origem: item.rel, pastaDeOrigem: path.dirname(item.abs) };
40
+ const base = { pasta, nome: path.basename(item.abs), tipo, origem: item.rel, pastaDeOrigem: path.dirname(item.abs), doCanal: item.canal ?? OUTROS };
39
41
  return texto ? { ...base, texto } : { ...base, de: item.abs };
40
42
  }
41
43
 
@@ -64,6 +66,7 @@ function separarPecas(item, texto, itens, p) {
64
66
  const { tipo, unidades, temSlides, outros, fora } = lerUnidades(texto, item.formato);
65
67
  const comArquivo = unidades.map((u) => ({ ...u, partes: u.partes.filter((parte) => parte.texto) })).filter((u) => u.partes.length);
66
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) });
67
70
  for (const cabecalho of outros) p.avisos.push({ pasta: item.canal, texto: MSG.outroCanal(item.rel, cabecalho) });
68
71
  const semImagem = !itens.some((i) => i.tipo === 'arquivo' && ehImagem(i) && i.formato === item.formato);
69
72
  if (!comArquivo.length) p.avisos.push({ pasta: item.canal, texto: MSG.semPeca(DITA[tipo], item.rel) });
@@ -93,7 +96,7 @@ async function separarItem(item, itens, p) {
93
96
  * `{ abs, rel, formato, canal, tipo }` (`tipo`: arquivo, pasta ou ausente)
94
97
  * @returns {Promise<{ unidades: object[], copias: object[], avisos: object[] }>}
95
98
  * · `unidades`: `{ pasta, tipo, titulo, origem, formato, ordem, total, partes }`
96
- * · `copias`: `{ pasta, nome, tipo, origem, pastaDeOrigem, de | texto }`
99
+ * · `copias`: `{ pasta, nome, tipo, origem, pastaDeOrigem, doCanal, de | texto }`
97
100
  * · `avisos`: `{ pasta, texto, tela }` (`tela`: o texto do resumo da tela; sem `texto`, só sai lá)
98
101
  */
99
102
  export async function separar(itens) {