@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.
- package/CHANGELOG.md +129 -0
- package/README.md +87 -4
- package/package.json +1 -1
- package/src/commands/update.js +9 -3
- package/src/lib/blocos.js +7 -3
- package/src/lib/resumo.js +3 -0
- package/templates/AGENTS.md +4 -0
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/prompts/discovery.prompt.md +1 -1
- package/templates/_opencrew/core/prompts/entrega.prompt.md +205 -0
- package/templates/_opencrew/core/prompts/export.prompt.md +5 -81
- package/templates/_opencrew/core/runner.pipeline.md +32 -23
- package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +50 -0
- package/templates/_opencrew/core/scripts/entrega/canais.mjs +53 -0
- package/templates/_opencrew/core/scripts/entrega/comparar.mjs +104 -0
- package/templates/_opencrew/core/scripts/entrega/copia.mjs +138 -0
- package/templates/_opencrew/core/scripts/entrega/destino.mjs +98 -0
- package/templates/_opencrew/core/scripts/entrega/fora.mjs +50 -0
- package/templates/_opencrew/core/scripts/entrega/gravar.mjs +83 -0
- package/templates/_opencrew/core/scripts/entrega/guardar.mjs +60 -0
- package/templates/_opencrew/core/scripts/entrega/leiame.mjs +125 -0
- package/templates/_opencrew/core/scripts/entrega/leitor.mjs +133 -0
- package/templates/_opencrew/core/scripts/entrega/lembrar.mjs +65 -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 +104 -0
- package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +97 -0
- package/templates/_opencrew/core/scripts/entrega/ressalvas.mjs +78 -0
- package/templates/_opencrew/core/scripts/entrega/resumo.mjs +29 -0
- package/templates/_opencrew/core/scripts/entrega/retrato.mjs +42 -0
- package/templates/_opencrew/core/scripts/entrega/separar.mjs +106 -0
- package/templates/_opencrew/core/scripts/entrega/texto.mjs +95 -0
- package/templates/_opencrew/core/scripts/entregar.mjs +166 -0
- package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +4 -4
- package/templates/_opencrew/core/scripts/verificar/entradas.mjs +26 -0
- package/templates/_opencrew/core/scripts/verificar/gravacao.mjs +41 -0
- package/templates/_opencrew/core/scripts/verificar/pecas.mjs +4 -4
- package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +10 -3
- package/templates/_opencrew/core/scripts/verificar/secoes.mjs +22 -2
- package/templates/_opencrew/core/scripts/verificar.mjs +21 -29
- package/templates/gitignore +1 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// O destino da cópia: a pasta do projeto que o usuário escolheu para guardar a entrega. Vem de
|
|
2
|
+
// `--lembrar-destino`, de `--destino` ou de `entrega.destino` no `crew.yaml`, nessa ordem, e uma
|
|
3
|
+
// função só valida os três. Este módulo só lê.
|
|
4
|
+
// Spec: fase-u3a2-entrega-no-projeto.md, §3 e regra 13 (repositório do OpenCrew).
|
|
5
|
+
import { existsSync, promises as fs, statSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { lugarReal } from '../comum.mjs';
|
|
8
|
+
import { limpar } from './argumentos.mjs';
|
|
9
|
+
|
|
10
|
+
export const MSG = {
|
|
11
|
+
recusado: (valor) => `Não copiei: o destino precisa ser uma pasta dentro do projeto, fora de \`_opencrew/\`, \`crews/\`, \`skills/\`, \`.git/\` e \`node_modules/\`. Recebi: ${limpar(valor)}.`,
|
|
12
|
+
semDestino: 'Cópia: nenhuma pasta escolhida para esta crew.',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const PROIBIDAS = new Set(['_opencrew', 'crews', 'skills', '.git', 'node_modules']);
|
|
16
|
+
const NAO = /^(?:nao|não|no)$/i;
|
|
17
|
+
// Absoluto: começa por `/`, `\` ou letra de unidade. Lista ou mapa do YAML não é caminho.
|
|
18
|
+
const NAO_E_CAMINHO = /^(?:[\\/]|[A-Za-z]:|[[{]|-(?:\s|$))/;
|
|
19
|
+
|
|
20
|
+
/** Relativo à raiz, com `/`; null quando sai do projeto, é a raiz ou cai numa pasta reservada. */
|
|
21
|
+
function dentro(raiz, alvo) {
|
|
22
|
+
const rel = path.relative(raiz, alvo);
|
|
23
|
+
if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
|
|
24
|
+
const partes = rel.split(path.sep);
|
|
25
|
+
return PROIBIDAS.has(partes[0].toLowerCase()) ? null : partes.join('/');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** O trecho do caminho que já existe é uma pasta? Um arquivo no caminho impede a cópia. */
|
|
29
|
+
function semArquivoNoCaminho(abs) {
|
|
30
|
+
let existente = abs;
|
|
31
|
+
while (!existsSync(existente)) existente = path.dirname(existente);
|
|
32
|
+
return statSync(existente).isDirectory();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Valida um destino (regra 13), venha de onde vier.
|
|
37
|
+
* @returns {{ tipo: 'nao' } | { tipo: 'recusado', valor: string } | { tipo: 'pasta', rel: string, abs: string }}
|
|
38
|
+
* `nao`: o usuário não quer cópia · `rel`: relativo ao projeto, com `/`
|
|
39
|
+
*/
|
|
40
|
+
export function validarDestino(raiz, valor) {
|
|
41
|
+
const texto = String(valor ?? '').trim();
|
|
42
|
+
if (NAO.test(texto)) return { tipo: 'nao' };
|
|
43
|
+
const recusado = { tipo: 'recusado', valor: texto };
|
|
44
|
+
if (!texto || NAO_E_CAMINHO.test(texto)) return recusado;
|
|
45
|
+
// \ vale como separador em qualquer sistema: o usuário digita Pasta\Sub também fora do Windows.
|
|
46
|
+
const abs = path.resolve(raiz, texto.replace(/\\/g, '/'));
|
|
47
|
+
const rel = dentro(raiz, abs);
|
|
48
|
+
// Pelo lugar real também: um atalho dentro do projeto pode levar para fora, ou para `crews/`.
|
|
49
|
+
if (!rel || !dentro(lugarReal(raiz), lugarReal(abs)) || !semArquivoNoCaminho(abs)) return recusado;
|
|
50
|
+
return { tipo: 'pasta', rel, abs };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const semFim = (linha) => linha.replace(/\r?\n$/, '');
|
|
54
|
+
const recuoDe = (linha) => linha.match(/^[ \t]*/)[0];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* O bloco `entrega:` de um `crew.yaml` já separado em linhas: onde começa e acaba, o recuo dos
|
|
58
|
+
* filhos e a linha de `destino:` (um nível abaixo; -1 quando não há). Sem o bloco: null.
|
|
59
|
+
*/
|
|
60
|
+
export function blocoDaEntrega(linhas) {
|
|
61
|
+
const limpas = linhas.map(semFim);
|
|
62
|
+
const inicio = limpas.findIndex((l) => /^entrega:\s*(?:#.*)?$/.test(l));
|
|
63
|
+
if (inicio < 0) return null;
|
|
64
|
+
let fim = inicio + 1;
|
|
65
|
+
while (fim < limpas.length && (!limpas[fim].trim() || /^[ \t]/.test(limpas[fim]))) fim += 1;
|
|
66
|
+
const filhos = limpas.slice(inicio + 1, fim).filter((l) => l.trim());
|
|
67
|
+
const recuo = filhos.length ? recuoDe(filhos[0]) : ' ';
|
|
68
|
+
const destino = limpas.findIndex((l, i) => i > inicio && i < fim && recuoDe(l) === recuo && /^destino:/.test(l.trim()));
|
|
69
|
+
return { inicio, fim, recuo, destino };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** O valor de uma linha `chave: valor`: sem as aspas e sem o comentário do fim da linha. */
|
|
73
|
+
function valorDaLinha(linha) {
|
|
74
|
+
const resto = linha.slice(linha.indexOf(':') + 1).trim();
|
|
75
|
+
const comAspas = resto.match(/^(["'])(.*?)\1\s*(?:#.*)?$/);
|
|
76
|
+
return comAspas ? comAspas[2] : resto.replace(/(?:^|\s+)#.*$/, '').trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `entrega.destino` como está escrito no texto de um `crew.yaml`; null quando não há. Uma lista volta como o primeiro item dela (`- a`), que nunca é um destino válido. */
|
|
80
|
+
export function destinoDoTexto(texto) {
|
|
81
|
+
const linhas = texto.replace(/^\uFEFF/, '').split(/\r?\n/);
|
|
82
|
+
const bloco = blocoDaEntrega(linhas);
|
|
83
|
+
if (!bloco || bloco.destino < 0) return null;
|
|
84
|
+
const valor = valorDaLinha(linhas[bloco.destino]);
|
|
85
|
+
if (valor) return valor;
|
|
86
|
+
const seguinte = (linhas[bloco.destino + 1] ?? '').trim();
|
|
87
|
+
return seguinte.startsWith('-') ? seguinte : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* O destino desta chamada: `--lembrar-destino` vence `--destino`, que vence o `crew.yaml`.
|
|
92
|
+
* @returns {Promise<object>} o que `validarDestino` devolve, ou `{ tipo: 'nenhum' }` (ninguém escolheu)
|
|
93
|
+
*/
|
|
94
|
+
export async function escolherDestino(raiz, crew, args) {
|
|
95
|
+
const doYaml = async () => destinoDoTexto(await fs.readFile(path.join(crew, 'crew.yaml'), 'utf8'));
|
|
96
|
+
const valor = args.lembrarDestino ?? args.destino ?? (await doYaml());
|
|
97
|
+
return valor == null ? { tipo: 'nenhum' } : validarDestino(raiz, valor);
|
|
98
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Blocos de rótulo do arquivo de origem que não chegam à entrega: os de serviço (notas, checklist,
|
|
2
|
+
// FORMAT) e, nos formatos lidos pelo leitor de peças, os que não são a peça do formato (os SLIDES
|
|
3
|
+
// de um carrossel, por exemplo). A entrega não muda: o LEIA-ME só passa a dizer o que ficou fora.
|
|
4
|
+
// Specs: fase-u3a1-pasta-de-entrega.md, §4 e §6, ajuste da execução real, e
|
|
5
|
+
// fase-u3a2-entrega-no-projeto.md, §6: o aviso do LEIA-ME cita o arquivo de origem (repositório do OpenCrew).
|
|
6
|
+
import { semFrontmatter } from '../verificar/leitura.mjs';
|
|
7
|
+
import { ROTULOS } from '../verificar/pecas.mjs';
|
|
8
|
+
import { lerTrechos, marcar } from '../verificar/secoes.mjs';
|
|
9
|
+
import { PRINCIPAL } from './leitor.mjs';
|
|
10
|
+
import { ehServico, secoesDeRotulo, semComentarios } from './texto.mjs';
|
|
11
|
+
|
|
12
|
+
export const MSG = {
|
|
13
|
+
fora: (blocos, arquivo) => `Ficou fora do texto para colar: ${blocos.join(', ')}. Veja no arquivo de origem, \`${arquivo}\`.`,
|
|
14
|
+
foraNaTela: (arquivo, blocos) => `${arquivo}: ficou fora do texto para colar: ${blocos.join(', ')}. Veja no arquivo de origem.`,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const tabelaDe = (formato) => (formato === 'twitter-thread' ? 'twitter-post' : formato);
|
|
18
|
+
|
|
19
|
+
/** O bloco deste rótulo chega à entrega? `formato`: o do leitor de peças, ou null (só o de serviço sai). */
|
|
20
|
+
function entra(rotulo, formato) {
|
|
21
|
+
if (ehServico(rotulo)) return false;
|
|
22
|
+
if (!formato) return true;
|
|
23
|
+
const papel = ROTULOS[tabelaDe(formato)]?.[rotulo];
|
|
24
|
+
return papel === PRINCIPAL[formato] || papel === 'parte' || rotulo === 'HASHTAGS';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** As seções de rótulo como o leitor de peças as vê: cada uma vai até o próximo rótulo ou cabeçalho de peça. */
|
|
28
|
+
function secoesDoLeitor(corpo, formato) {
|
|
29
|
+
const trechos = lerTrechos(marcar(corpo), tabelaDe(formato)).filter((t) => t.rotulo);
|
|
30
|
+
return trechos.map((t) => ({ rotulo: t.rotulo, texto: t.linhas.join('\n').trim() }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rótulos dos blocos com texto que ficam fora da entrega, cada um uma vez, na ordem do arquivo.
|
|
35
|
+
* @param {string} texto o arquivo de origem, sem BOM e com LF
|
|
36
|
+
* @param {string|null} [formato] formato lido pelo leitor de peças (`instagram-feed`,
|
|
37
|
+
* `linkedin-post`, `twitter-post`, `twitter-thread`); null nos outros, em que só o bloco de
|
|
38
|
+
* serviço fica fora
|
|
39
|
+
* @returns {string[]}
|
|
40
|
+
*/
|
|
41
|
+
export function blocosFora(texto, formato = null) {
|
|
42
|
+
const corpo = semComentarios(semFrontmatter(texto));
|
|
43
|
+
const secoes = formato ? secoesDoLeitor(corpo, formato) : secoesDeRotulo(corpo);
|
|
44
|
+
return [...new Set(secoes.filter((s) => s.rotulo && s.texto && !entra(s.rotulo, formato)).map((s) => s.rotulo))];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** O aviso de um arquivo: `{ pasta, texto, tela }`, ou nenhum quando nada ficou fora. */
|
|
48
|
+
export function avisoDeFora(item, blocos) {
|
|
49
|
+
return blocos.length ? [{ pasta: item.canal, texto: MSG.fora(blocos, item.rel), tela: MSG.foraNaTela(item.rel, blocos) }] : [];
|
|
50
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Gravação da entrega: montada em `entrega.tmp/`, ao lado, e trocada no fim. Nada pela metade:
|
|
2
|
+
// em qualquer falha a entrega anterior fica como estava e não sobra pasta temporária. O script só
|
|
3
|
+
// apaga a própria `entrega/` e os temporários que ele mesmo criou.
|
|
4
|
+
// Spec: fase-u3a1-pasta-de-entrega.md, regras 12 e 15 (repositório do OpenCrew).
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const apagar = (pasta) => fs.rm(pasta, { recursive: true, force: true, maxRetries: 3 });
|
|
9
|
+
|
|
10
|
+
/** 'pasta', 'arquivo' ou null (não existe). */
|
|
11
|
+
async function tipoDe(caminho) {
|
|
12
|
+
try {
|
|
13
|
+
return (await fs.stat(caminho)).isDirectory() ? 'pasta' : 'arquivo';
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Temporário que sobrou de uma chamada interrompida: a pasta é do script; arquivo com o mesmo nome não é. */
|
|
20
|
+
async function limparSobras({ entrega, tmp, antiga }) {
|
|
21
|
+
if ((await tipoDe(antiga)) === 'pasta') {
|
|
22
|
+
if (await tipoDe(entrega)) await apagar(antiga);
|
|
23
|
+
else await fs.rename(antiga, entrega); // a troca anterior parou no meio: a entrega volta
|
|
24
|
+
}
|
|
25
|
+
if ((await tipoDe(tmp)) === 'pasta') await apagar(tmp);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Grava um arquivo da entrega na pasta temporária: o texto gerado, ou os mesmos bytes da origem. */
|
|
29
|
+
async function gravarArquivo(destino, a) {
|
|
30
|
+
await fs.mkdir(path.dirname(destino), { recursive: true });
|
|
31
|
+
if (a.texto != null) await fs.writeFile(destino, a.texto, 'utf8');
|
|
32
|
+
else await fs.copyFile(a.de, destino);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Põe a pasta nova no lugar da anterior; se a nova não entra, a anterior volta. */
|
|
36
|
+
async function trocar({ entrega, tmp, antiga }, passo) {
|
|
37
|
+
const anterior = await tipoDe(entrega);
|
|
38
|
+
passo(entrega);
|
|
39
|
+
if (anterior === 'arquivo') throw new Error('entrega não é uma pasta');
|
|
40
|
+
if (anterior) await fs.rename(entrega, antiga);
|
|
41
|
+
try {
|
|
42
|
+
await fs.rename(tmp, entrega);
|
|
43
|
+
} catch (erro) {
|
|
44
|
+
if (anterior) await fs.rename(antiga, entrega);
|
|
45
|
+
throw erro;
|
|
46
|
+
}
|
|
47
|
+
passo(antiga);
|
|
48
|
+
if (anterior) await apagar(antiga);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Refaz `entrega/` do zero e grava, ao lado, o relatório da verificação.
|
|
53
|
+
* @param {string} execucao pasta da execução (`crews/<crew>/output/<run>/`), absoluta
|
|
54
|
+
* @param {object[]} arquivos `{ pasta, nome, texto | de }`
|
|
55
|
+
* @param {{ leiame: string, relatorio: string }} textos
|
|
56
|
+
* @returns {Promise<string|null>} null quando gravou tudo; senão, o caminho que não foi gravado
|
|
57
|
+
*/
|
|
58
|
+
export async function gravarEntrega(execucao, arquivos, { leiame, relatorio }) {
|
|
59
|
+
const pastas = { entrega: path.join(execucao, 'entrega'), tmp: path.join(execucao, 'entrega.tmp'), antiga: path.join(execucao, 'entrega.antiga.tmp') };
|
|
60
|
+
let alvo = pastas.tmp;
|
|
61
|
+
let criei = false;
|
|
62
|
+
const passo = (caminho) => { alvo = caminho; };
|
|
63
|
+
try {
|
|
64
|
+
await limparSobras(pastas);
|
|
65
|
+
await fs.mkdir(pastas.tmp);
|
|
66
|
+
criei = true;
|
|
67
|
+
for (const a of arquivos) {
|
|
68
|
+
passo(path.join(pastas.tmp, a.pasta, a.nome));
|
|
69
|
+
await gravarArquivo(alvo, a);
|
|
70
|
+
}
|
|
71
|
+
passo(path.join(pastas.tmp, 'LEIA-ME.md'));
|
|
72
|
+
await fs.writeFile(alvo, leiame, 'utf8');
|
|
73
|
+
await trocar(pastas, passo);
|
|
74
|
+
criei = false;
|
|
75
|
+
passo(path.join(execucao, 'verificacao-entrega.md'));
|
|
76
|
+
await fs.writeFile(alvo, relatorio, 'utf8');
|
|
77
|
+
return null;
|
|
78
|
+
} catch {
|
|
79
|
+
if (criei) await apagar(pastas.tmp).catch(() => {});
|
|
80
|
+
// O arquivo que falhou dentro da pasta temporária é citado pelo lugar em que ficaria.
|
|
81
|
+
return alvo.startsWith(pastas.tmp + path.sep) ? path.join(pastas.entrega, alvo.slice(pastas.tmp.length + 1)) : alvo;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Guardar a entrega no projeto: decide o que está pronto para ser copiado, chama a cópia e diz,
|
|
2
|
+
// em uma ou duas linhas do resumo, o que aconteceu. Canal com pendência não aceita fica de fora,
|
|
3
|
+
// e o HTML editável dele também. Depois da cópia, guarda o retrato do que foi copiado (`copia.json`).
|
|
4
|
+
// Spec: fase-u3a2-entrega-no-projeto.md, regras 13 a 15 e §6 (repositório do OpenCrew).
|
|
5
|
+
import { relativoAoProjeto } from '../comum.mjs';
|
|
6
|
+
import { EDITAVEIS } from './canais.mjs';
|
|
7
|
+
import { retratoDe } from './comparar.mjs';
|
|
8
|
+
import { copiar } from './copia.mjs';
|
|
9
|
+
import { MSG as DESTINO } from './destino.mjs';
|
|
10
|
+
import { montarLeiame } from './leiame.mjs';
|
|
11
|
+
import { gravarRetratos, lerRetratos } from './retrato.mjs';
|
|
12
|
+
|
|
13
|
+
export const MSG = {
|
|
14
|
+
falhaDeEscrita: (arquivo) => `Não consegui gravar ${arquivo}. Feche o arquivo, ou espere a sincronização da pasta, e rode de novo.`,
|
|
15
|
+
criei: (destino) => `Criei a pasta ${destino}.`,
|
|
16
|
+
nova: (pasta) => `Cópia: ${pasta}`,
|
|
17
|
+
igual: (pasta) => `Cópia: ${pasta} — já está atualizada.`,
|
|
18
|
+
completada: (pasta, n) => `Cópia: ${pasta} — completei com ${n} ${n === 1 ? 'arquivo novo' : 'arquivos novos'}.`,
|
|
19
|
+
reentrega: (pasta) => `Cópia: ${pasta} — guardei aqui porque a entrega mudou. Os arquivos da anterior ficaram como estavam; só o LEIA-ME dela ganhou um aviso.`,
|
|
20
|
+
nada: 'Cópia: nada foi copiado, porque nenhum canal está pronto.',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A pasta cuja situação decide se o arquivo é copiado: a do canal (o editável segue o canal dele). */
|
|
24
|
+
const canalDe = (a) => (a.pasta === EDITAVEIS ? a.doCanal : a.pasta);
|
|
25
|
+
|
|
26
|
+
/** Pasta criada nesta chamada: o retrato dela começa do zero. */
|
|
27
|
+
const ehNova = (r) => r.tipo === 'nova' || r.tipo === 'reentrega';
|
|
28
|
+
|
|
29
|
+
/** As linhas do resumo para o que a cópia devolveu. */
|
|
30
|
+
function linhasDe(r, destino) {
|
|
31
|
+
if (r.tipo === 'nova') return [...(r.criouDestino ? [MSG.criei(destino.rel)] : []), MSG.nova(r.pasta)];
|
|
32
|
+
return [MSG[r.tipo](r.pasta, r.novos)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Copia para o destino o que está pronto e descreve o resultado.
|
|
37
|
+
* @param {string} raiz pasta do projeto · @param {object} destino o que `escolherDestino` devolveu
|
|
38
|
+
* @param {object} dados os dados da entrega (os do LEIA-ME)
|
|
39
|
+
* @param {string} execucao a pasta da execução, absoluta: é nela que fica o retrato da cópia
|
|
40
|
+
* @returns {Promise<{ linhas: string[], pasta: string|null, falhou: boolean }>} `linhas`: as do
|
|
41
|
+
* resumo · `pasta`: a pasta da cópia, relativa ao projeto, quando há uma · `falhou`: destino
|
|
42
|
+
* recusado ou gravação que falhou (o final é ENTREGA:INCOMPLETA)
|
|
43
|
+
*/
|
|
44
|
+
export async function guardar(raiz, destino, dados, execucao) {
|
|
45
|
+
if (destino.tipo === 'nao') return { linhas: [], pasta: null, falhou: false };
|
|
46
|
+
if (destino.tipo === 'nenhum') return { linhas: [DESTINO.semDestino], pasta: null, falhou: false };
|
|
47
|
+
if (destino.tipo === 'recusado') return { linhas: [DESTINO.recusado(destino.valor)], pasta: null, falhou: true };
|
|
48
|
+
const pronto = (a) => !dados.pendencias.has(canalDe(a));
|
|
49
|
+
const arquivos = dados.arquivos.filter(pronto);
|
|
50
|
+
if (!arquivos.length) return { linhas: [MSG.nada], pasta: null, falhou: false };
|
|
51
|
+
const ignorar = new Set(dados.arquivos.filter((a) => !pronto(a)).map((a) => `${a.pasta}/${a.nome}`));
|
|
52
|
+
const leiame = (n) => montarLeiame({ ...dados, arquivos, ehCopia: true, reentrega: n });
|
|
53
|
+
const retratos = await lerRetratos(execucao);
|
|
54
|
+
const r = await copiar({ destino, run: dados.run, arquivos, ignorar, leiame, retratos });
|
|
55
|
+
if (r.tipo === 'falha') return { linhas: [MSG.falhaDeEscrita(relativoAoProjeto(raiz, r.arquivo))], pasta: null, falhou: true };
|
|
56
|
+
// O retrato da pasta: o que já estava nele e o que esta entrega tem (copiado agora ou igual ao que foi).
|
|
57
|
+
const semRetrato = await gravarRetratos(execucao, { ...retratos, [r.pasta]: { ...(ehNova(r) ? {} : retratos[r.pasta]), ...(await retratoDe(arquivos)) } });
|
|
58
|
+
const falha = semRetrato ? [MSG.falhaDeEscrita(relativoAoProjeto(raiz, semRetrato))] : [];
|
|
59
|
+
return { linhas: [...linhasDe(r, destino), ...falha], pasta: r.pasta, falhou: falha.length > 0 };
|
|
60
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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.
|
|
6
|
+
import { CANAIS, EDITAVEIS, OUTROS } from './canais.mjs';
|
|
7
|
+
import { frasesDePendencia } from './pendencias.mjs';
|
|
8
|
+
import { passosDe } from './passos.mjs';
|
|
9
|
+
|
|
10
|
+
export const TEXTO = {
|
|
11
|
+
vaiPublicar: 'Esta crew publica este canal sozinha. Antes de postar à mão, confira se já saiu.',
|
|
12
|
+
pdf: 'Abra o arquivo que você quer no navegador ou no editor de texto e use Imprimir → Salvar como PDF.',
|
|
13
|
+
semCanal: 'sem canal de publicação; está aqui para você usar como quiser.',
|
|
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:',
|
|
18
|
+
pronto: 'Pronto',
|
|
19
|
+
prontoComRessalva: 'Pronto, com ressalva',
|
|
20
|
+
naoPronto: 'Não está pronto',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const lista = (linhas) => linhas.map((l) => `- ${l}`).join('\n');
|
|
24
|
+
const bloco = (titulo, linhas) => (linhas.length ? [`${titulo}\n${lista(linhas)}`] : []);
|
|
25
|
+
const secao = (titulo, partes) => (partes.length ? [`## ${titulo}\n\n${partes.join('\n\n')}`] : []);
|
|
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
|
+
|
|
33
|
+
/** Uma linha por arquivo entregue: o caminho na entrega, o título do bloco (se houver) e a origem. */
|
|
34
|
+
function linhaDoArquivo(a) {
|
|
35
|
+
const titulo = a.titulo ? ` — ${a.titulo}` : '';
|
|
36
|
+
return `\`${a.pasta}/${a.nome}\`${titulo} — origem: \`${a.origem}\``;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Os avisos de uma pasta que o LEIA-ME mostra (o que só vai para a tela não tem `texto`). */
|
|
40
|
+
const avisosDe = (d, pasta) => d.avisos.filter((a) => a.pasta === pasta && a.texto).map((a) => a.texto);
|
|
41
|
+
|
|
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. */
|
|
50
|
+
function antesDeUsar(d) {
|
|
51
|
+
const ressalvas = [...d.ressalvas.values()].flat().map((r) => linhaDaRessalva(d, r));
|
|
52
|
+
const pastas = [...d.pastas, OUTROS].filter((p) => d.pendencias.has(p));
|
|
53
|
+
const frases = frasesDePendencia(pastas, d.pendencias);
|
|
54
|
+
const faltas = pastas.map((p, i) => `- ${frases[i]}\n${d.pendencias.get(p).map((l) => ` - ${l}`).join('\n')}`);
|
|
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));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `## {Canal}`: o aviso de publicação, a situação, os arquivos, as pendências, os avisos e os passos. */
|
|
61
|
+
function secaoDoCanal(d, pasta) {
|
|
62
|
+
const pendencias = d.pendencias.get(pasta) ?? [];
|
|
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}`);
|
|
68
|
+
return secao(CANAIS[pasta], [
|
|
69
|
+
...(d.vaiPublicar.includes(pasta) ? [TEXTO.vaiPublicar] : []),
|
|
70
|
+
situacao,
|
|
71
|
+
...bloco('Arquivos:', arquivos.map(linhaDoArquivo)),
|
|
72
|
+
...bloco('Pendências:', pendencias),
|
|
73
|
+
...bloco('Atenção:', avisosDe(d, pasta)),
|
|
74
|
+
...(passos.length ? [`Passos:\n${passos.join('\n')}`] : []),
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `## Outros arquivos` e `## Editáveis`: cada arquivo com a origem; em `outros`, a linha diz por que está ali. */
|
|
79
|
+
function secaoDaPasta(d, pasta, titulo) {
|
|
80
|
+
const fim = pasta === OUTROS ? ` — ${TEXTO.semCanal}` : '';
|
|
81
|
+
const arquivos = d.arquivos.filter((a) => a.pasta === pasta).map((a) => `${linhaDoArquivo(a)}${fim}`);
|
|
82
|
+
const pendencias = d.pendencias.get(pasta) ?? [];
|
|
83
|
+
return secao(titulo, [
|
|
84
|
+
...(arquivos.length ? [lista(arquivos)] : []),
|
|
85
|
+
...bloco('Pendências:', pendencias),
|
|
86
|
+
...(d.ehCopia && pendencias.length ? [] : bloco('Atenção:', avisosDe(d, pasta))),
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
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
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {object} d
|
|
98
|
+
* @param {string} d.crew nome da crew · @param {string} d.run id da execução
|
|
99
|
+
* @param {string[]} d.pastas os canais presentes, na ordem das seções
|
|
100
|
+
* @param {object[]} d.arquivos `{ pasta, nome, tipo, origem, titulo, numerado }` — no LEIA-ME da
|
|
101
|
+
* cópia, só os que foram copiados
|
|
102
|
+
* @param {object[]} d.avisos `{ pasta, texto, tela }` (`tela`: como o aviso sai no resumo da tela)
|
|
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 }`
|
|
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)`
|
|
110
|
+
* @returns {string} o LEIA-ME, em UTF-8 sem BOM e com LF
|
|
111
|
+
*/
|
|
112
|
+
export function montarLeiame(d) {
|
|
113
|
+
const temMd = d.arquivos.some((a) => a.nome.toLowerCase().endsWith('.md'));
|
|
114
|
+
const partes = [
|
|
115
|
+
`# Entrega — ${d.crew} — ${d.run}${d.reentrega > 1 ? ` (reentrega ${d.reentrega})` : ''}`,
|
|
116
|
+
...antesDeUsar(d),
|
|
117
|
+
...d.pastas.flatMap((pasta) => secaoDoCanal(d, pasta)),
|
|
118
|
+
...secaoDaPasta(d, OUTROS, 'Outros arquivos'),
|
|
119
|
+
...secaoDaPasta(d, EDITAVEIS, 'Editáveis'),
|
|
120
|
+
...secao('O que não foi conferido', [lista(d.naoConferido)]),
|
|
121
|
+
...(temMd ? secao('Para ter um PDF', [TEXTO.pdf]) : []),
|
|
122
|
+
...secao('Sobre esta pasta', [sobre(d)]),
|
|
123
|
+
];
|
|
124
|
+
return `${partes.join('\n\n')}\n`;
|
|
125
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
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). A legenda sem
|
|
4
|
+
// marcador (o arquivo que é só o texto) é da fase-u3a2-entrega-no-projeto.md, §4.
|
|
5
|
+
import { semAcento } from '../verificar/leitura.mjs';
|
|
6
|
+
import { contarSlides, lerPecas, listarHashtags } from '../verificar/pecas.mjs';
|
|
7
|
+
import { lerTrechos, marcar, rotuloDe } from '../verificar/secoes.mjs';
|
|
8
|
+
import { paraColar } from './texto.mjs';
|
|
9
|
+
|
|
10
|
+
/** Peça principal de cada formato: sem ela, o arquivo vai inteiro. */
|
|
11
|
+
export const PRINCIPAL = { 'instagram-feed': 'legenda', 'linkedin-post': 'post', 'twitter-post': 'tweet', 'twitter-thread': 'tweet' };
|
|
12
|
+
const TITULO = /^(#{1,6})\s+(.*)$/s;
|
|
13
|
+
const COMENTARIO = /(?<![\p{L}\p{N}])(?:comentarios?|comments?)(?![\p{L}\p{N}])/u;
|
|
14
|
+
const marca = (n) => `⟦comentario:${n}⟧`;
|
|
15
|
+
const MARCA = /^⟦comentario:(\d+)⟧$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tira do corpo as seções de comentário (cabeçalho com "comentário" ou "comment", até o próximo
|
|
19
|
+
* cabeçalho do mesmo nível ou acima, ou até um rótulo). No lugar de cada uma fica uma linha de
|
|
20
|
+
* marca: ela cai dentro do texto do post que vem antes.
|
|
21
|
+
* @returns {{ corpo: string, comentarios: string[] }}
|
|
22
|
+
*/
|
|
23
|
+
function tirarComentarios(corpo) {
|
|
24
|
+
const linhas = [];
|
|
25
|
+
const comentarios = [];
|
|
26
|
+
let nivel = 0; // nível do cabeçalho de comentário aberto; 0 = nenhum
|
|
27
|
+
for (const linha of corpo.split('\n')) {
|
|
28
|
+
const h = linha.match(TITULO);
|
|
29
|
+
if (nivel && (rotuloDe(linha) || (h && h[1].length <= nivel))) nivel = 0;
|
|
30
|
+
if (!nivel && h && COMENTARIO.test(semAcento(h[2]))) {
|
|
31
|
+
nivel = h[1].length;
|
|
32
|
+
linhas.push(marca(comentarios.push([]) - 1));
|
|
33
|
+
} else if (nivel) comentarios.at(-1).push(linha);
|
|
34
|
+
else linhas.push(linha);
|
|
35
|
+
}
|
|
36
|
+
return { corpo: linhas.join('\n'), comentarios: comentarios.map((c) => c.join('\n')) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Texto da peça sem as linhas de marca, e os números dos comentários que estavam nele. */
|
|
40
|
+
function separarMarcas(texto) {
|
|
41
|
+
const numeros = [];
|
|
42
|
+
const linhas = texto.split('\n').filter((linha) => {
|
|
43
|
+
const m = linha.trim().match(MARCA);
|
|
44
|
+
if (m) numeros.push(Number(m[1]));
|
|
45
|
+
return !m;
|
|
46
|
+
});
|
|
47
|
+
return { texto: linhas.join('\n'), numeros };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Cabeçalhos do corpo, na ordem: `[{ titulo, pai }]` (`pai`: o cabeçalho logo acima, ou null). */
|
|
51
|
+
function cabecalhos(corpo) {
|
|
52
|
+
const lista = [];
|
|
53
|
+
let pilha = [];
|
|
54
|
+
for (const linha of corpo.split('\n')) {
|
|
55
|
+
const h = linha.match(TITULO);
|
|
56
|
+
if (!h) continue;
|
|
57
|
+
pilha = pilha.filter((c) => c.nivel < h[1].length);
|
|
58
|
+
lista.push({ titulo: h[2].trim(), pai: pilha.at(-1)?.titulo ?? null });
|
|
59
|
+
pilha.push({ nivel: h[1].length, titulo: h[2].trim() });
|
|
60
|
+
}
|
|
61
|
+
return lista;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Título do bloco de cada peça de cabeçalho: o cabeçalho logo acima do dela, quando houver. */
|
|
65
|
+
function titulosDeBloco(corpo, pecas) {
|
|
66
|
+
const lista = cabecalhos(corpo);
|
|
67
|
+
let cursor = 0;
|
|
68
|
+
return pecas.map((p) => {
|
|
69
|
+
const i = p.origem === 'cabecalho' ? lista.findIndex((c, n) => n >= cursor && c.titulo === p.cabecalho) : -1;
|
|
70
|
+
if (i < 0) return null;
|
|
71
|
+
cursor = p.porParagrafo ? i : i + 1;
|
|
72
|
+
return lista[i].pai;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Hashtags da seção própria (rótulo ou cabeçalho) da legenda ou do post, sem as que já estão no texto. */
|
|
77
|
+
function hashtagsDaPeca(pecas, p) {
|
|
78
|
+
const h = pecas.find((x) => x.tipo === 'hashtags' && x.de === p.tipo && x.ordem === p.ordem && x.formato === p.formato);
|
|
79
|
+
return h ? listarHashtags(h.texto).slice(listarHashtags(p.texto).length) : [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Hashtags das seções `=== HASHTAGS ===` e de cabeçalho "Hashtags" de um arquivo de tweets. */
|
|
83
|
+
export function hashtagsSoltas(corpo, tabela = 'twitter-post') {
|
|
84
|
+
const trechos = lerTrechos(marcar(corpo), tabela).filter((t) => t.rotulo === 'HASHTAGS' || t.tipos?.[0] === 'hashtags');
|
|
85
|
+
return trechos.flatMap((t) => listarHashtags(t.linhas.join('\n')));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const comHashtags = (texto, hashtags) => paraColar(hashtags.length ? `${texto}\n\n${hashtags.join(' ')}` : texto);
|
|
89
|
+
|
|
90
|
+
/** Comentário de cada post: o que caiu no texto dele; os que sobraram vão, na ordem, para os posts sem comentário. */
|
|
91
|
+
function distribuir(marcas, comentarios) {
|
|
92
|
+
const usados = new Set(marcas.flat());
|
|
93
|
+
const sobras = comentarios.map((_, n) => n).filter((n) => !usados.has(n));
|
|
94
|
+
return marcas.map((numeros) => (numeros.length ? numeros : sobras.splice(0, 1)).map((n) => comentarios[n]).join('\n\n'));
|
|
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
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Lê as peças do formato num corpo já sem frontmatter e sem blocos de serviço.
|
|
110
|
+
* @returns {{ unidades: object[], temSlides: boolean, outros: string[] }} `unidades`: uma por
|
|
111
|
+
* legenda, post ou tweet, `{ tipo, titulo, ordem, total, partes: [{ nome, sufixo, ext, tipo, texto }] }`
|
|
112
|
+
* · `outros`: cabeçalhos das seções de outro canal, que não são separadas
|
|
113
|
+
*/
|
|
114
|
+
export function pecasDoLeitor(corpoOriginal, formato) {
|
|
115
|
+
const tabela = formato === 'twitter-thread' ? 'twitter-post' : formato;
|
|
116
|
+
const tipo = PRINCIPAL[formato];
|
|
117
|
+
const { corpo, comentarios } = tipo === 'post' ? tirarComentarios(corpoOriginal) : { corpo: corpoOriginal, comentarios: [] };
|
|
118
|
+
const pecas = lerPecas(corpo, tabela);
|
|
119
|
+
const principais = pecas.filter((p) => p.formato === tabela && p.tipo === tipo);
|
|
120
|
+
const limpas = principais.map((p) => separarMarcas(p.texto));
|
|
121
|
+
const doPost = distribuir(limpas.map((l) => l.numeros), comentarios);
|
|
122
|
+
const titulos = titulosDeBloco(corpo, principais);
|
|
123
|
+
const soltas = tipo === 'tweet' ? hashtagsSoltas(corpo) : [];
|
|
124
|
+
const unidades = principais.map((p, i) => {
|
|
125
|
+
const hashtags = tipo === 'tweet' ? (i === 0 ? soltas : []) : hashtagsDaPeca(pecas, p);
|
|
126
|
+
const partes = [{ nome: tipo, sufixo: '', ext: 'txt', tipo, texto: comHashtags(limpas[i].texto, hashtags) }];
|
|
127
|
+
if (doPost[i]?.trim()) partes.push({ nome: tipo, sufixo: '-comentario', ext: 'txt', tipo: 'comentario', texto: paraColar(doPost[i]) });
|
|
128
|
+
return { tipo, titulo: titulos[i], ordem: i + 1, total: principais.length, partes };
|
|
129
|
+
});
|
|
130
|
+
const outros = pecas.filter((p) => p.formato !== tabela && p.tipo !== 'hashtags' && p.cabecalho).map((p) => p.cabecalho);
|
|
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)] };
|
|
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
|
+
}
|