@aksp/opencrew 1.7.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +52 -2
- package/package.json +1 -1
- package/templates/AGENTS.md +3 -0
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/prompts/entrega.prompt.md +131 -0
- package/templates/_opencrew/core/runner.pipeline.md +13 -4
- package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +46 -0
- package/templates/_opencrew/core/scripts/entrega/canais.mjs +51 -0
- package/templates/_opencrew/core/scripts/entrega/fora.mjs +49 -0
- package/templates/_opencrew/core/scripts/entrega/gravar.mjs +83 -0
- package/templates/_opencrew/core/scripts/entrega/leiame.mjs +89 -0
- package/templates/_opencrew/core/scripts/entrega/leitor.mjs +120 -0
- package/templates/_opencrew/core/scripts/entrega/longas.mjs +109 -0
- package/templates/_opencrew/core/scripts/entrega/nomes.mjs +78 -0
- package/templates/_opencrew/core/scripts/entrega/passos.mjs +99 -0
- package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +87 -0
- package/templates/_opencrew/core/scripts/entrega/separar.mjs +103 -0
- package/templates/_opencrew/core/scripts/entrega/texto.mjs +95 -0
- package/templates/_opencrew/core/scripts/entregar.mjs +141 -0
- package/templates/_opencrew/core/scripts/verificar/pecas.mjs +4 -4
- package/templates/_opencrew/core/scripts/verificar/secoes.mjs +22 -2
- package/templates/_opencrew/core/scripts/verificar.mjs +4 -2
|
@@ -0,0 +1,89 @@
|
|
|
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).
|
|
5
|
+
import { CANAIS, EDITAVEIS, OUTROS } from './canais.mjs';
|
|
6
|
+
import { frasesDePendencia } from './pendencias.mjs';
|
|
7
|
+
import { passosDe } from './passos.mjs';
|
|
8
|
+
|
|
9
|
+
export const TEXTO = {
|
|
10
|
+
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
|
+
semCanal: 'sem canal de publicação; está aqui para você usar como quiser.',
|
|
13
|
+
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.',
|
|
14
|
+
pronto: 'Pronto',
|
|
15
|
+
naoPronto: 'Não está pronto',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const lista = (linhas) => linhas.map((l) => `- ${l}`).join('\n');
|
|
19
|
+
const bloco = (titulo, linhas) => (linhas.length ? [`${titulo}\n${lista(linhas)}`] : []);
|
|
20
|
+
const secao = (titulo, partes) => (partes.length ? [`## ${titulo}\n\n${partes.join('\n\n')}`] : []);
|
|
21
|
+
|
|
22
|
+
/** Uma linha por arquivo entregue: o caminho na entrega, o título do bloco (se houver) e a origem. */
|
|
23
|
+
function linhaDoArquivo(a) {
|
|
24
|
+
const titulo = a.titulo ? ` — ${a.titulo}` : '';
|
|
25
|
+
return `\`${a.pasta}/${a.nome}\`${titulo} — origem: \`${a.origem}\``;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Os avisos de uma pasta que o LEIA-ME mostra (o que só vai para a tela não tem `texto`). */
|
|
29
|
+
const avisosDe = (d, pasta) => d.avisos.filter((a) => a.pasta === pasta && a.texto).map((a) => a.texto);
|
|
30
|
+
|
|
31
|
+
/** `## Antes de usar`: os alertas de tamanho e, por canal que não está pronto, o que falta. */
|
|
32
|
+
function antesDeUsar(d) {
|
|
33
|
+
const pastas = [...d.pastas, OUTROS].filter((p) => d.pendencias.has(p));
|
|
34
|
+
const frases = frasesDePendencia(pastas, d.pendencias);
|
|
35
|
+
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));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `## {Canal}`: o aviso de publicação, a situação, os arquivos, as pendências, os avisos e os passos. */
|
|
40
|
+
function secaoDoCanal(d, pasta) {
|
|
41
|
+
const arquivos = d.arquivos.filter((a) => a.pasta === pasta);
|
|
42
|
+
const pendencias = d.pendencias.get(pasta) ?? [];
|
|
43
|
+
const passos = passosDe(pasta, arquivos, pendencias.length > 0).map((p, i) => `${i + 1}. ${p}`);
|
|
44
|
+
return secao(CANAIS[pasta], [
|
|
45
|
+
...(d.vaiPublicar.includes(pasta) ? [TEXTO.vaiPublicar] : []),
|
|
46
|
+
`Situação: ${pendencias.length ? TEXTO.naoPronto : TEXTO.pronto}`,
|
|
47
|
+
...bloco('Arquivos:', arquivos.map(linhaDoArquivo)),
|
|
48
|
+
...bloco('Pendências:', pendencias),
|
|
49
|
+
...bloco('Atenção:', avisosDe(d, pasta)),
|
|
50
|
+
...(passos.length ? [`Passos:\n${passos.join('\n')}`] : []),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `## Outros arquivos` e `## Editáveis`: cada arquivo com a origem; em `outros`, a linha diz por que está ali. */
|
|
55
|
+
function secaoDaPasta(d, pasta, titulo) {
|
|
56
|
+
const fim = pasta === OUTROS ? ` — ${TEXTO.semCanal}` : '';
|
|
57
|
+
const arquivos = d.arquivos.filter((a) => a.pasta === pasta).map((a) => `${linhaDoArquivo(a)}${fim}`);
|
|
58
|
+
const pendencias = d.pendencias.get(pasta) ?? [];
|
|
59
|
+
return secao(titulo, [
|
|
60
|
+
...(arquivos.length ? [lista(arquivos)] : []),
|
|
61
|
+
...bloco('Pendências:', pendencias),
|
|
62
|
+
...bloco('Atenção:', avisosDe(d, pasta)),
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {object} d
|
|
68
|
+
* @param {string} d.crew nome da crew · @param {string} d.run id da execução
|
|
69
|
+
* @param {string[]} d.pastas os canais presentes, na ordem das seções
|
|
70
|
+
* @param {object[]} d.arquivos `{ pasta, nome, tipo, origem, titulo, numerado }`
|
|
71
|
+
* @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
|
|
73
|
+
* @param {string[]} d.naoConferido · @param {string[]} d.vaiPublicar canais que a crew publica
|
|
74
|
+
* @returns {string} o LEIA-ME, em UTF-8 sem BOM e com LF
|
|
75
|
+
*/
|
|
76
|
+
export function montarLeiame(d) {
|
|
77
|
+
const temMd = d.arquivos.some((a) => a.nome.toLowerCase().endsWith('.md'));
|
|
78
|
+
const partes = [
|
|
79
|
+
`# Entrega — ${d.crew} — ${d.run}`,
|
|
80
|
+
...antesDeUsar(d),
|
|
81
|
+
...d.pastas.flatMap((pasta) => secaoDoCanal(d, pasta)),
|
|
82
|
+
...secaoDaPasta(d, OUTROS, 'Outros arquivos'),
|
|
83
|
+
...secaoDaPasta(d, EDITAVEIS, 'Editáveis'),
|
|
84
|
+
...secao('O que não foi conferido', [lista(d.naoConferido)]),
|
|
85
|
+
...(temMd ? secao('Para ter um PDF', [TEXTO.pdf]) : []),
|
|
86
|
+
...secao('Sobre esta pasta', [TEXTO.sobre]),
|
|
87
|
+
];
|
|
88
|
+
return `${partes.join('\n\n')}\n`;
|
|
89
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Peças que vêm do leitor do verificador (`lerPecas`): legenda, post e tweet, cada um com as
|
|
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).
|
|
4
|
+
import { semAcento } from '../verificar/leitura.mjs';
|
|
5
|
+
import { lerPecas, listarHashtags } from '../verificar/pecas.mjs';
|
|
6
|
+
import { lerTrechos, marcar, rotuloDe } from '../verificar/secoes.mjs';
|
|
7
|
+
import { paraColar } from './texto.mjs';
|
|
8
|
+
|
|
9
|
+
/** Peça principal de cada formato: sem ela, o arquivo vai inteiro. */
|
|
10
|
+
export const PRINCIPAL = { 'instagram-feed': 'legenda', 'linkedin-post': 'post', 'twitter-post': 'tweet', 'twitter-thread': 'tweet' };
|
|
11
|
+
const TITULO = /^(#{1,6})\s+(.*)$/s;
|
|
12
|
+
const COMENTARIO = /(?<![\p{L}\p{N}])(?:comentarios?|comments?)(?![\p{L}\p{N}])/u;
|
|
13
|
+
const marca = (n) => `⟦comentario:${n}⟧`;
|
|
14
|
+
const MARCA = /^⟦comentario:(\d+)⟧$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tira do corpo as seções de comentário (cabeçalho com "comentário" ou "comment", até o próximo
|
|
18
|
+
* cabeçalho do mesmo nível ou acima, ou até um rótulo). No lugar de cada uma fica uma linha de
|
|
19
|
+
* marca: ela cai dentro do texto do post que vem antes.
|
|
20
|
+
* @returns {{ corpo: string, comentarios: string[] }}
|
|
21
|
+
*/
|
|
22
|
+
function tirarComentarios(corpo) {
|
|
23
|
+
const linhas = [];
|
|
24
|
+
const comentarios = [];
|
|
25
|
+
let nivel = 0; // nível do cabeçalho de comentário aberto; 0 = nenhum
|
|
26
|
+
for (const linha of corpo.split('\n')) {
|
|
27
|
+
const h = linha.match(TITULO);
|
|
28
|
+
if (nivel && (rotuloDe(linha) || (h && h[1].length <= nivel))) nivel = 0;
|
|
29
|
+
if (!nivel && h && COMENTARIO.test(semAcento(h[2]))) {
|
|
30
|
+
nivel = h[1].length;
|
|
31
|
+
linhas.push(marca(comentarios.push([]) - 1));
|
|
32
|
+
} else if (nivel) comentarios.at(-1).push(linha);
|
|
33
|
+
else linhas.push(linha);
|
|
34
|
+
}
|
|
35
|
+
return { corpo: linhas.join('\n'), comentarios: comentarios.map((c) => c.join('\n')) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Texto da peça sem as linhas de marca, e os números dos comentários que estavam nele. */
|
|
39
|
+
function separarMarcas(texto) {
|
|
40
|
+
const numeros = [];
|
|
41
|
+
const linhas = texto.split('\n').filter((linha) => {
|
|
42
|
+
const m = linha.trim().match(MARCA);
|
|
43
|
+
if (m) numeros.push(Number(m[1]));
|
|
44
|
+
return !m;
|
|
45
|
+
});
|
|
46
|
+
return { texto: linhas.join('\n'), numeros };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Cabeçalhos do corpo, na ordem: `[{ titulo, pai }]` (`pai`: o cabeçalho logo acima, ou null). */
|
|
50
|
+
function cabecalhos(corpo) {
|
|
51
|
+
const lista = [];
|
|
52
|
+
let pilha = [];
|
|
53
|
+
for (const linha of corpo.split('\n')) {
|
|
54
|
+
const h = linha.match(TITULO);
|
|
55
|
+
if (!h) continue;
|
|
56
|
+
pilha = pilha.filter((c) => c.nivel < h[1].length);
|
|
57
|
+
lista.push({ titulo: h[2].trim(), pai: pilha.at(-1)?.titulo ?? null });
|
|
58
|
+
pilha.push({ nivel: h[1].length, titulo: h[2].trim() });
|
|
59
|
+
}
|
|
60
|
+
return lista;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Título do bloco de cada peça de cabeçalho: o cabeçalho logo acima do dela, quando houver. */
|
|
64
|
+
function titulosDeBloco(corpo, pecas) {
|
|
65
|
+
const lista = cabecalhos(corpo);
|
|
66
|
+
let cursor = 0;
|
|
67
|
+
return pecas.map((p) => {
|
|
68
|
+
const i = p.origem === 'cabecalho' ? lista.findIndex((c, n) => n >= cursor && c.titulo === p.cabecalho) : -1;
|
|
69
|
+
if (i < 0) return null;
|
|
70
|
+
cursor = p.porParagrafo ? i : i + 1;
|
|
71
|
+
return lista[i].pai;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Hashtags da seção própria (rótulo ou cabeçalho) da legenda ou do post, sem as que já estão no texto. */
|
|
76
|
+
function hashtagsDaPeca(pecas, p) {
|
|
77
|
+
const h = pecas.find((x) => x.tipo === 'hashtags' && x.de === p.tipo && x.ordem === p.ordem && x.formato === p.formato);
|
|
78
|
+
return h ? listarHashtags(h.texto).slice(listarHashtags(p.texto).length) : [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Hashtags das seções `=== HASHTAGS ===` e de cabeçalho "Hashtags" de um arquivo de tweets. */
|
|
82
|
+
export function hashtagsSoltas(corpo, tabela = 'twitter-post') {
|
|
83
|
+
const trechos = lerTrechos(marcar(corpo), tabela).filter((t) => t.rotulo === 'HASHTAGS' || t.tipos?.[0] === 'hashtags');
|
|
84
|
+
return trechos.flatMap((t) => listarHashtags(t.linhas.join('\n')));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const comHashtags = (texto, hashtags) => paraColar(hashtags.length ? `${texto}\n\n${hashtags.join(' ')}` : texto);
|
|
88
|
+
|
|
89
|
+
/** Comentário de cada post: o que caiu no texto dele; os que sobraram vão, na ordem, para os posts sem comentário. */
|
|
90
|
+
function distribuir(marcas, comentarios) {
|
|
91
|
+
const usados = new Set(marcas.flat());
|
|
92
|
+
const sobras = comentarios.map((_, n) => n).filter((n) => !usados.has(n));
|
|
93
|
+
return marcas.map((numeros) => (numeros.length ? numeros : sobras.splice(0, 1)).map((n) => comentarios[n]).join('\n\n'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Lê as peças do formato num corpo já sem frontmatter e sem blocos de serviço.
|
|
98
|
+
* @returns {{ unidades: object[], temSlides: boolean, outros: string[] }} `unidades`: uma por
|
|
99
|
+
* legenda, post ou tweet, `{ tipo, titulo, ordem, total, partes: [{ nome, sufixo, ext, tipo, texto }] }`
|
|
100
|
+
* · `outros`: cabeçalhos das seções de outro canal, que não são separadas
|
|
101
|
+
*/
|
|
102
|
+
export function pecasDoLeitor(corpoOriginal, formato) {
|
|
103
|
+
const tabela = formato === 'twitter-thread' ? 'twitter-post' : formato;
|
|
104
|
+
const tipo = PRINCIPAL[formato];
|
|
105
|
+
const { corpo, comentarios } = tipo === 'post' ? tirarComentarios(corpoOriginal) : { corpo: corpoOriginal, comentarios: [] };
|
|
106
|
+
const pecas = lerPecas(corpo, tabela);
|
|
107
|
+
const principais = pecas.filter((p) => p.formato === tabela && p.tipo === tipo);
|
|
108
|
+
const limpas = principais.map((p) => separarMarcas(p.texto));
|
|
109
|
+
const doPost = distribuir(limpas.map((l) => l.numeros), comentarios);
|
|
110
|
+
const titulos = titulosDeBloco(corpo, principais);
|
|
111
|
+
const soltas = tipo === 'tweet' ? hashtagsSoltas(corpo) : [];
|
|
112
|
+
const unidades = principais.map((p, i) => {
|
|
113
|
+
const hashtags = tipo === 'tweet' ? (i === 0 ? soltas : []) : hashtagsDaPeca(pecas, p);
|
|
114
|
+
const partes = [{ nome: tipo, sufixo: '', ext: 'txt', tipo, texto: comHashtags(limpas[i].texto, hashtags) }];
|
|
115
|
+
if (doPost[i]?.trim()) partes.push({ nome: tipo, sufixo: '-comentario', ext: 'txt', tipo: 'comentario', texto: paraColar(doPost[i]) });
|
|
116
|
+
return { tipo, titulo: titulos[i], ordem: i + 1, total: principais.length, partes };
|
|
117
|
+
});
|
|
118
|
+
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)] };
|
|
120
|
+
}
|
|
@@ -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,99 @@
|
|
|
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
|
+
// Spec: fase-u3a1-pasta-de-entrega.md, §4 (repositório do OpenCrew).
|
|
4
|
+
|
|
5
|
+
/** O primeiro passo do canal que não está pronto (ajuste da execução real). */
|
|
6
|
+
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.';
|
|
7
|
+
const MARKDOWN = 'Este texto está em markdown: se o seu editor não aceitar, ajuste títulos, negrito e links depois de colar.';
|
|
8
|
+
|
|
9
|
+
// Cada passo: [o que o faz aparecer, o texto]. null = sempre que o canal tem arquivo; um tipo de
|
|
10
|
+
// arquivo = só quando a entrega tem arquivo desse tipo no canal; `tipo:um` e `tipo:varios` = só
|
|
11
|
+
// quando a peça é uma só, ou quando é numerada. `{arquivo}`: uma linha por arquivo. `{campos}`:
|
|
12
|
+
// os campos que o `seo.txt` daquela entrega tem.
|
|
13
|
+
const PASSOS = {
|
|
14
|
+
instagram: [
|
|
15
|
+
[null, 'No computador, abra instagram.com e comece uma publicação nova. Pelo celular, mande os arquivos para ele antes.'],
|
|
16
|
+
['imagem', 'Escolha as imagens na ordem dos nomes dos arquivos.'],
|
|
17
|
+
['legenda', 'Abra `instagram/legenda.txt`, copie tudo e cole no campo da legenda. As hashtags já estão no fim.'],
|
|
18
|
+
[null, 'Confira a prévia e publique.'],
|
|
19
|
+
['texto', '`instagram/{arquivo}` é para ler e produzir (gravar ou montar os slides): não é texto para colar.'],
|
|
20
|
+
],
|
|
21
|
+
linkedin: [
|
|
22
|
+
[null, 'No computador, abra linkedin.com e comece uma publicação.'],
|
|
23
|
+
['post', 'Abra `linkedin/post.txt`, copie tudo e cole.'],
|
|
24
|
+
['imagem', 'Anexe as imagens na ordem dos nomes dos arquivos.'],
|
|
25
|
+
[null, 'Confira e publique.'],
|
|
26
|
+
['comentario', 'Depois de publicar, abra `linkedin/post-comentario.txt`, copie e cole como primeiro comentário.'],
|
|
27
|
+
['roteiro', 'Para o artigo, escolha escrever um artigo no LinkedIn e cole o texto de `linkedin/{arquivo}`, seção por seção.'],
|
|
28
|
+
],
|
|
29
|
+
blog: [
|
|
30
|
+
[null, 'Abra o editor do seu blog e crie um post novo.'],
|
|
31
|
+
['seo', 'Abra `blog/seo.txt` e copie cada linha para o campo de mesmo nome ({campos}).'],
|
|
32
|
+
['artigo', 'Abra `blog/artigo.md`, copie tudo e cole no corpo do post.'],
|
|
33
|
+
['artigo', MARKDOWN],
|
|
34
|
+
[null, 'Confira a prévia e publique.'],
|
|
35
|
+
],
|
|
36
|
+
email: [
|
|
37
|
+
[null, 'Abra a sua ferramenta de e-mail e crie uma mensagem nova.'],
|
|
38
|
+
['assunto', 'Copie o texto de `email/assunto.txt` para o campo do assunto.'],
|
|
39
|
+
['previa', 'Copie o texto de `email/previa.txt` para o campo de prévia (a linha que aparece ao lado do assunto).'],
|
|
40
|
+
['corpo', 'Abra `email/corpo.md`, copie tudo e cole no corpo.'],
|
|
41
|
+
['corpo', MARKDOWN],
|
|
42
|
+
[null, 'Envie um teste para você antes de enviar para a lista.'],
|
|
43
|
+
],
|
|
44
|
+
whatsapp: [
|
|
45
|
+
[null, 'No computador, abra o WhatsApp Web ou o aplicativo. Pelo celular, mande o arquivo para ele antes.'],
|
|
46
|
+
['mensagem', 'Abra `whatsapp/mensagem.txt`, copie tudo e cole na conversa ou na lista de transmissão.'],
|
|
47
|
+
[null, 'Se o texto tiver `{{…}}`, troque pelo dado real, ou confira se a sua ferramenta de envio faz a troca.'],
|
|
48
|
+
[null, 'Envie primeiro para você, para ver como ficou.'],
|
|
49
|
+
],
|
|
50
|
+
twitter: [
|
|
51
|
+
[null, 'No computador, abra x.com e comece uma publicação.'],
|
|
52
|
+
['tweet:um', 'Abra `twitter/tweet.txt`, copie tudo e cole.'],
|
|
53
|
+
['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.'],
|
|
54
|
+
['imagem', 'Anexe as imagens.'],
|
|
55
|
+
[null, 'Confira e publique.'],
|
|
56
|
+
],
|
|
57
|
+
youtube: [['roteiro', '`youtube/{arquivo}` é o roteiro para gravar: não é texto para colar.']],
|
|
58
|
+
};
|
|
59
|
+
// "texto": o roteiro e o arquivo que foi inteiro (carrossel em texto, peça não encontrada).
|
|
60
|
+
const TIPOS = { texto: ['roteiro', 'inteiro'] };
|
|
61
|
+
|
|
62
|
+
/** Com várias peças, o passo cita os arquivos numerados no lugar do nome sem número. */
|
|
63
|
+
function citar(texto, pasta, achados) {
|
|
64
|
+
if (!achados[0].numerado) return texto;
|
|
65
|
+
const lista = achados.map((a) => `\`${pasta}/${a.nome}\``).join(', ');
|
|
66
|
+
return texto.replace(new RegExp(`\`${pasta}/[^\`]+\``), lista);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Os campos que os arquivos têm (`Título: …` → "título"), cada um uma vez, na ordem das linhas. */
|
|
70
|
+
function camposDe(achados) {
|
|
71
|
+
const linhas = achados.flatMap((a) => (a.texto ?? '').split('\n')).filter((l) => l.includes(':'));
|
|
72
|
+
return [...new Set(linhas.map((l) => l.slice(0, l.indexOf(':')).toLowerCase()))].join(', ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Os passos do quadro, só os que citam arquivo que existe nesse canal, na ordem do quadro. */
|
|
76
|
+
function doQuadro(pasta, arquivos) {
|
|
77
|
+
if (!arquivos.length) return [];
|
|
78
|
+
return (PASSOS[pasta] ?? []).flatMap(([condicao, texto]) => {
|
|
79
|
+
if (!condicao) return [texto];
|
|
80
|
+
const [tipo, quantos] = condicao.split(':');
|
|
81
|
+
const achados = arquivos.filter((a) => (TIPOS[tipo] ?? [tipo]).includes(a.tipo));
|
|
82
|
+
if (!achados.length) return [];
|
|
83
|
+
if (quantos) return Boolean(achados[0].numerado) === (quantos === 'varios') ? [texto] : [];
|
|
84
|
+
if (texto.includes('{arquivo}')) return achados.map((a) => texto.replace('{arquivo}', a.nome));
|
|
85
|
+
return [citar(texto, pasta, achados).replace('{campos}', camposDe(achados))];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Os passos de um canal. No canal que não está pronto, o primeiro manda resolver as pendências.
|
|
91
|
+
* @param {string} pasta a pasta do canal
|
|
92
|
+
* @param {object[]} arquivos os arquivos da entrega nessa pasta: `{ nome, tipo, numerado, texto }`
|
|
93
|
+
* @param {boolean} [comPendencia] o canal tem pendência
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function passosDe(pasta, arquivos, comPendencia = false) {
|
|
97
|
+
const passos = doQuadro(pasta, arquivos);
|
|
98
|
+
return comPendencia && passos.length ? [ANTES_DE_POSTAR, ...passos] : passos;
|
|
99
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
// Spec: fase-u3a1-pasta-de-entrega.md, regras 17, 19, 20 e 34 (repositório do OpenCrew).
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { lerLimites } from '../verificar/leitura.mjs';
|
|
6
|
+
import { contar, FALTA_INFO, NAO_MEDIDO, NAO_VERIFICADO } from '../verificar/regras.mjs';
|
|
7
|
+
import { OUTROS, nomeDaPasta } from './canais.mjs';
|
|
8
|
+
|
|
9
|
+
export const MSG = {
|
|
10
|
+
naoEncontrei: (arquivo) => `Não encontrei ${arquivo}.`,
|
|
11
|
+
naoPronto: (canal, n) => `${canal} não está pronto: ${n} ${n === 1 ? 'pendência' : 'pendências'}.`,
|
|
12
|
+
alerta: (arquivo, n, limite) => `ALERTA: \`${arquivo}\` tem ${n} caracteres; o limite é ${limite}. Encurte antes de publicar.`,
|
|
13
|
+
};
|
|
14
|
+
/** A lista fixa do que o script nunca confere. */
|
|
15
|
+
export const NUNCA_CONFERIDO = ['Links e fatos citados no texto.', 'Texto dentro das imagens.', 'Aparência final em cada rede.'];
|
|
16
|
+
|
|
17
|
+
const ehNaoMedido = (i) => i.item === NAO_MEDIDO || i.item === NAO_VERIFICADO;
|
|
18
|
+
const origem = (arquivo) => `origem: \`${arquivo}\``;
|
|
19
|
+
|
|
20
|
+
/** Uma linha de pendência ou de alerta, com o trecho (ou a medida) e o arquivo de origem. */
|
|
21
|
+
function linhaDoItem(i, arquivo) {
|
|
22
|
+
if (i.item === FALTA_INFO) return `Falta preencher "${i.detalhe}" — ${origem(arquivo)}`;
|
|
23
|
+
if (ehNaoMedido(i)) return `${i.item} — ${i.detalhe} — ${origem(arquivo)}`;
|
|
24
|
+
if (i.medido != null) return `${i.item}: ${i.medido} (${i.minimo ? 'mínimo' : 'limite'} ${i.limite}) — ${origem(arquivo)}`;
|
|
25
|
+
return `${i.item}: "${i.detalhe}" — ${origem(arquivo)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** O resultado do verificador para um item da lista (só os arquivos de texto têm). */
|
|
29
|
+
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
|
+
* 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
|
|
34
|
+
*/
|
|
35
|
+
export function pendenciasPorPasta(raiz, itens, r) {
|
|
36
|
+
const mapa = new Map();
|
|
37
|
+
const somar = (item, linha) => mapa.set(item.canal ?? OUTROS, [...(mapa.get(item.canal ?? OUTROS) ?? []), linha]);
|
|
38
|
+
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));
|
|
41
|
+
}
|
|
42
|
+
return mapa;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** As frases "{canal} não está pronto: {n} pendências.", na ordem das pastas dadas. */
|
|
46
|
+
export const frasesDePendencia = (pastas, pendencias) => pastas.filter((p) => pendencias.has(p)).map((p) => MSG.naoPronto(nomeDaPasta(p), pendencias.get(p).length));
|
|
47
|
+
|
|
48
|
+
/** O que não foi conferido: alertas, "não medido", "não verificado" e a lista fixa. Não muda o final. */
|
|
49
|
+
export function naoConferido(raiz, itens, r) {
|
|
50
|
+
const linhas = [];
|
|
51
|
+
for (const item of itens.filter((i) => i.tipo === 'arquivo')) {
|
|
52
|
+
for (const i of resultadoDe(raiz, r, item)?.itens ?? []) {
|
|
53
|
+
if (i.nivel === 'alerta' || ehNaoMedido(i)) linhas.push(linhaDoItem(i, item.rel));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (r.naoTexto.length) linhas.push(`${NAO_VERIFICADO} — não é texto: ${r.naoTexto.join(', ')}`);
|
|
57
|
+
return [...linhas, ...NUNCA_CONFERIDO];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const LIMITE = { legenda: 'caption_max_chars', post: 'post_max_chars', tweet: 'tweet_max_chars' };
|
|
61
|
+
const NOME_NO_RELATORIO = { legenda: 'Legenda Instagram', post: 'Post LinkedIn', tweet: 'Tweet' };
|
|
62
|
+
const NUMERADA = { legenda: 'legenda', post: 'post', tweet: 'Tweet' };
|
|
63
|
+
|
|
64
|
+
/** O verificador já bloqueou, no arquivo de origem, o tamanho desta mesma peça? */
|
|
65
|
+
function jaApontada(itensDaOrigem, a) {
|
|
66
|
+
const doTipo = itensDaOrigem.filter((i) => i.nivel === 'bloqueio' && i.item.startsWith(NOME_NO_RELATORIO[a.tipo]) && i.item.endsWith('caracteres'));
|
|
67
|
+
if (a.total < 2) return doTipo.length > 0;
|
|
68
|
+
const numero = new RegExp(`${NUMERADA[a.tipo]} ${a.ordem}(?!\\d)`);
|
|
69
|
+
return doTipo.some((i) => numero.test(i.item));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Regra 34: cada `legenda*.txt`, `post*.txt` (não o comentário) e `tweet*.txt` gerado é contado
|
|
74
|
+
* como o verificador conta e comparado com o limite do formato do item. Passou: um ALERTA — mas
|
|
75
|
+
* só quando o verificador não apontou a mesma peça. Não é pendência.
|
|
76
|
+
*/
|
|
77
|
+
export async function alertasDeTamanho(raiz, itens, arquivos, r) {
|
|
78
|
+
const alertas = [];
|
|
79
|
+
for (const a of arquivos.filter((x) => LIMITE[x.tipo] && x.texto)) {
|
|
80
|
+
const limite = (await lerLimites(raiz, a.formato)).limites?.[LIMITE[a.tipo]];
|
|
81
|
+
const medido = contar(a.texto);
|
|
82
|
+
const item = itens.find((i) => i.rel === a.origem && i.formato === a.formato);
|
|
83
|
+
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));
|
|
85
|
+
}
|
|
86
|
+
return alertas;
|
|
87
|
+
}
|