@aksp/opencrew 1.8.0 → 1.10.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 +127 -0
- package/README.md +133 -16
- 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 +11 -2
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/best-practices/_catalog.yaml +5 -0
- package/templates/_opencrew/core/best-practices/documento-oficial.md +144 -0
- package/templates/_opencrew/core/modelos/documento-oficial.md +42 -0
- package/templates/_opencrew/core/prompts/design.prompt.md +1 -0
- package/templates/_opencrew/core/prompts/discovery.prompt.md +1 -1
- package/templates/_opencrew/core/prompts/documento.prompt.md +134 -0
- package/templates/_opencrew/core/prompts/entrega.prompt.md +93 -18
- package/templates/_opencrew/core/prompts/export.prompt.md +5 -81
- package/templates/_opencrew/core/runner.pipeline.md +20 -20
- package/templates/_opencrew/core/scripts/documento/argumentos.mjs +50 -0
- package/templates/_opencrew/core/scripts/documento/corpo.mjs +51 -0
- package/templates/_opencrew/core/scripts/documento/estilos.mjs +42 -0
- package/templates/_opencrew/core/scripts/documento/gravar.mjs +44 -0
- package/templates/_opencrew/core/scripts/documento/linha.mjs +58 -0
- package/templates/_opencrew/core/scripts/documento/marcacoes.mjs +55 -0
- package/templates/_opencrew/core/scripts/documento/markdown.mjs +92 -0
- package/templates/_opencrew/core/scripts/documento/pacote.mjs +82 -0
- package/templates/_opencrew/core/scripts/documento/perfil.mjs +70 -0
- package/templates/_opencrew/core/scripts/documento/png.mjs +20 -0
- package/templates/_opencrew/core/scripts/documento/projeto.mjs +60 -0
- package/templates/_opencrew/core/scripts/documento/tabelas.mjs +57 -0
- package/templates/_opencrew/core/scripts/documento/timbre.mjs +64 -0
- package/templates/_opencrew/core/scripts/documento/xml.mjs +95 -0
- package/templates/_opencrew/core/scripts/documento/zip.mjs +86 -0
- package/templates/_opencrew/core/scripts/documento.mjs +150 -0
- package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +13 -9
- package/templates/_opencrew/core/scripts/entrega/canais.mjs +12 -6
- 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/documentos.mjs +104 -0
- package/templates/_opencrew/core/scripts/entrega/fora.mjs +4 -3
- package/templates/_opencrew/core/scripts/entrega/gravar.mjs +3 -3
- package/templates/_opencrew/core/scripts/entrega/guardar.mjs +60 -0
- package/templates/_opencrew/core/scripts/entrega/leiame.mjs +54 -17
- package/templates/_opencrew/core/scripts/entrega/leitor.mjs +16 -3
- package/templates/_opencrew/core/scripts/entrega/lembrar.mjs +65 -0
- package/templates/_opencrew/core/scripts/entrega/passos.mjs +21 -6
- package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +16 -6
- 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 +9 -3
- package/templates/_opencrew/core/scripts/entregar.mjs +76 -44
- 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/relatorio.mjs +10 -3
- package/templates/_opencrew/core/scripts/verificar.mjs +17 -27
- package/templates/gitignore +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Leitura mínima de um PNG: a assinatura de 8 bytes e a largura e a altura do bloco IHDR, para a
|
|
2
|
+
// proporção do logotipo. A imagem não é aberta nem regravada: vai para o documento como veio.
|
|
3
|
+
// Spec: fase-u3b-documento-word.md, §3 (repositório do OpenCrew).
|
|
4
|
+
|
|
5
|
+
const ASSINATURA = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
6
|
+
/** Tamanho máximo do logotipo: 2 MB. */
|
|
7
|
+
export const MAXIMO = 2 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Uint8Array} bytes
|
|
11
|
+
* @returns {{ largura: number, altura: number }|null} null quando não é um PNG
|
|
12
|
+
*/
|
|
13
|
+
export function lerPng(bytes) {
|
|
14
|
+
const b = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
15
|
+
if (b.length < 24 || ASSINATURA.some((valor, i) => b[i] !== valor)) return null;
|
|
16
|
+
if (b.toString('latin1', 12, 16) !== 'IHDR') return null;
|
|
17
|
+
const largura = b.readUInt32BE(16);
|
|
18
|
+
const altura = b.readUInt32BE(20);
|
|
19
|
+
return largura > 0 && altura > 0 ? { largura, altura } : null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// O perfil de documento oficial no disco do projeto: onde mora, como nasce do modelo (e nunca é
|
|
2
|
+
// sobrescrito) e a leitura do logotipo que ele cita. Perfil inválido não gera documento: o erro
|
|
3
|
+
// diz a linha.
|
|
4
|
+
// Spec: fase-u3b-documento-word.md, regras 10 e 11 (repositório do OpenCrew).
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { dentroDoProjeto } from '../comum.mjs';
|
|
9
|
+
import { lerPerfil } from './perfil.mjs';
|
|
10
|
+
import { MAXIMO, lerPng } from './png.mjs';
|
|
11
|
+
|
|
12
|
+
/** Onde o perfil do projeto mora (o `update` não toca esta pasta). */
|
|
13
|
+
export const PERFIL = '_opencrew/_memory/documento-oficial.md';
|
|
14
|
+
/** O modelo do pacote, ao lado dos scripts: `_opencrew/core/modelos/documento-oficial.md`. */
|
|
15
|
+
const MODELO = fileURLToPath(new URL('../../modelos/documento-oficial.md', import.meta.url));
|
|
16
|
+
|
|
17
|
+
export const MSG = {
|
|
18
|
+
semLogotipo: (n, arquivo) => `Perfil, linha ${n}: não encontrei o logotipo ${arquivo}.`,
|
|
19
|
+
logotipo: (n, arquivo) => `Perfil, linha ${n}: o logotipo precisa ser um arquivo PNG de até 2 MB, dentro do projeto. Recebi: ${arquivo}.`,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Os bytes do logotipo citado no perfil: `{ bytes }`, ou `{ erro }` com a mensagem da linha. */
|
|
23
|
+
async function lerLogotipo(raiz, arquivo, linha) {
|
|
24
|
+
if (!dentroDoProjeto(raiz, arquivo)) return { erro: MSG.logotipo(linha, arquivo) };
|
|
25
|
+
const info = await fs.stat(path.resolve(raiz, arquivo)).catch(() => null);
|
|
26
|
+
if (!info) return { erro: MSG.semLogotipo(linha, arquivo) };
|
|
27
|
+
if (!info.isFile() || info.size > MAXIMO) return { erro: MSG.logotipo(linha, arquivo) };
|
|
28
|
+
const bytes = await fs.readFile(path.resolve(raiz, arquivo));
|
|
29
|
+
return lerPng(bytes) ? { bytes } : { erro: MSG.logotipo(linha, arquivo) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lê o perfil de um arquivo do projeto, com o logotipo que ele cita.
|
|
34
|
+
* @param {string} raiz a pasta do projeto
|
|
35
|
+
* @param {string} arquivo o perfil, relativo à raiz ou absoluto (já conferido: existe e fica no projeto)
|
|
36
|
+
* @returns {Promise<{ perfil?: object, logotipo?: Buffer, erro?: string }>}
|
|
37
|
+
*/
|
|
38
|
+
export async function lerPerfilDoProjeto(raiz, arquivo) {
|
|
39
|
+
const { perfil, linhas, erro } = lerPerfil(await fs.readFile(path.resolve(raiz, arquivo), 'utf8'));
|
|
40
|
+
if (erro) return { erro };
|
|
41
|
+
if (!perfil.logotipo) return { perfil };
|
|
42
|
+
const logotipo = await lerLogotipo(raiz, perfil.logotipo, linhas.logotipo);
|
|
43
|
+
return logotipo.erro ? { erro: logotipo.erro } : { perfil, logotipo: logotipo.bytes };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Cria o perfil do projeto a partir do modelo, só se ele não existir.
|
|
48
|
+
* @returns {Promise<boolean>} true quando criou; false quando já existia (e não foi tocado)
|
|
49
|
+
*/
|
|
50
|
+
export async function criarPerfil(raiz) {
|
|
51
|
+
const destino = path.join(raiz, PERFIL);
|
|
52
|
+
await fs.mkdir(path.dirname(destino), { recursive: true });
|
|
53
|
+
try {
|
|
54
|
+
await fs.writeFile(destino, await fs.readFile(MODELO), { flag: 'wx' });
|
|
55
|
+
return true;
|
|
56
|
+
} catch (erro) {
|
|
57
|
+
if (erro.code === 'EEXIST') return false;
|
|
58
|
+
throw erro;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// As tabelas do corpo: a tabela de dados (bordas finas, primeira linha em negrito sobre cinza,
|
|
2
|
+
// colunas iguais) e o bloco de assinaturas (sem bordas, duas por linha, a ímpar no centro).
|
|
3
|
+
// Nenhuma linha parte entre páginas. Toda célula termina em parágrafo.
|
|
4
|
+
// Spec: fase-u3b-documento-word.md, §4 e regra 3 (repositório do OpenCrew).
|
|
5
|
+
import { RECUO, borda, paragrafo, twips } from './xml.mjs';
|
|
6
|
+
|
|
7
|
+
const LADOS = ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'];
|
|
8
|
+
const BORDAS = `<w:tblBorders>${LADOS.map((lado) => borda(lado, { cor: 'CCCCCC' })).join('')}</w:tblBorders>`;
|
|
9
|
+
const MARGENS = '<w:tblCellMar><w:top w:w="40" w:type="dxa"/><w:left w:w="100" w:type="dxa"/><w:bottom w:w="40" w:type="dxa"/><w:right w:w="100" w:type="dxa"/></w:tblCellMar>';
|
|
10
|
+
export const SEM_MARGENS = '<w:tblCellMar><w:top w:w="0" w:type="dxa"/><w:left w:w="0" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tblCellMar>';
|
|
11
|
+
const FUNDO = '<w:shd w:val="clear" w:color="auto" w:fill="F0F0F0"/>';
|
|
12
|
+
const NA_CELULA = { espaco: { after: 0 }, jc: 'left' };
|
|
13
|
+
/** Espaço em branco para a assinatura à mão, acima da linha: 36 pt. */
|
|
14
|
+
const ANTES_DA_LINHA = 720;
|
|
15
|
+
const RECUO_DA_SOZINHA = twips(4);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A moldura de uma tabela: propriedades, grade e linhas, nessa ordem.
|
|
19
|
+
* @param {number[]} colunas largura de cada coluna, em twips
|
|
20
|
+
* @param {{ bordas?: string, margens?: string }} partes o que vai em `tblPr`, além da largura e do leiaute fixo
|
|
21
|
+
* @param {string[]} linhas o XML das células de cada linha
|
|
22
|
+
*/
|
|
23
|
+
export function tabela(colunas, { bordas = '', margens = '' }, linhas) {
|
|
24
|
+
const largura = colunas.reduce((a, b) => a + b, 0);
|
|
25
|
+
const grade = colunas.map((w) => `<w:gridCol w:w="${w}"/>`).join('');
|
|
26
|
+
const corpo = linhas.map((l) => `<w:tr><w:trPr><w:cantSplit/></w:trPr>${l}</w:tr>`).join('');
|
|
27
|
+
return `<w:tbl><w:tblPr><w:tblW w:w="${largura}" w:type="dxa"/>${bordas}<w:tblLayout w:type="fixed"/>${margens}</w:tblPr><w:tblGrid>${grade}</w:tblGrid>${corpo}</w:tbl>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Uma célula: largura, o que mais vier em `tcPr` (na ordem do formato) e os parágrafos. */
|
|
31
|
+
export const celula = (largura, propriedades, paragrafos) => `<w:tc><w:tcPr><w:tcW w:w="${largura}" w:type="dxa"/>${propriedades}</w:tcPr>${paragrafos}</w:tc>`;
|
|
32
|
+
|
|
33
|
+
/** Tabela de dados, na largura da área de texto, com colunas iguais. */
|
|
34
|
+
export function tabelaDeDados(bloco, larguraDoTexto) {
|
|
35
|
+
const coluna = Math.floor(larguraDoTexto / bloco.linhas[0].length);
|
|
36
|
+
const linhas = bloco.linhas.map((linha, n) => linha.map((pedacos) => celula(coluna, n === 0 ? FUNDO : '', paragrafo(NA_CELULA, pedacos, { b: n === 0 }))).join(''));
|
|
37
|
+
return tabela(bloco.linhas[0].map(() => coluna), { bordas: BORDAS, margens: MARGENS }, linhas);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Uma assinatura: a linha (borda de cima), o nome em negrito e caixa alta por formatação, e o cargo. */
|
|
41
|
+
function assinatura({ nome, cargo }, recuo) {
|
|
42
|
+
const daLinha = { bordas: borda('top', { sz: 6, cor: '000000', espaco: 1 }), espaco: { before: ANTES_DA_LINHA, after: 0 }, recuo: { left: recuo, right: recuo }, jc: 'center' };
|
|
43
|
+
const doNome = paragrafo(daLinha, [{ texto: nome }], { b: true, caps: true });
|
|
44
|
+
return cargo ? `${doNome}${paragrafo({ espaco: { after: 0 }, jc: 'center' }, [{ texto: cargo }])}` : doNome;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Bloco de assinaturas: duas por linha, na ordem escrita; a que sobra ocupa a linha inteira. */
|
|
48
|
+
export function tabelaDeAssinaturas(bloco, larguraDoTexto) {
|
|
49
|
+
const coluna = Math.floor(larguraDoTexto / 2);
|
|
50
|
+
const linhas = [];
|
|
51
|
+
for (let i = 0; i < bloco.pessoas.length; i += 2) {
|
|
52
|
+
const par = bloco.pessoas.slice(i, i + 2);
|
|
53
|
+
if (par.length === 2) linhas.push(par.map((p) => celula(coluna, '', assinatura(p, RECUO))).join(''));
|
|
54
|
+
else linhas.push(celula(coluna * 2, '<w:gridSpan w:val="2"/>', assinatura(par[0], RECUO_DA_SOZINHA)));
|
|
55
|
+
}
|
|
56
|
+
return tabela([coluna, coluna], { margens: SEM_MARGENS }, linhas);
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// O papel timbrado: cabeçalho (`word/header1.xml`: logotipo e até três linhas, com uma linha
|
|
2
|
+
// preta embaixo) e rodapé (`word/footer1.xml`: o texto do perfil e "Página X de Y").
|
|
3
|
+
// Spec: fase-u3b-documento-word.md, §4 e regra 2 (repositório do OpenCrew).
|
|
4
|
+
import { SEM_MARGENS, celula, tabela } from './tabelas.mjs';
|
|
5
|
+
import { DECLARACAO, NS_R, NS_W, borda, pPr, paragrafo, rPr, trecho, twips } from './xml.mjs';
|
|
6
|
+
|
|
7
|
+
const NS_WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing';
|
|
8
|
+
const NS_A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
|
|
9
|
+
const NS_PIC = 'http://schemas.openxmlformats.org/drawingml/2006/picture';
|
|
10
|
+
/** Unidades do desenho (EMU) por centímetro. */
|
|
11
|
+
const EMU_POR_CM = 360000;
|
|
12
|
+
/** Folga entre o logotipo e as linhas do cabeçalho: 0,4 cm. */
|
|
13
|
+
const FOLGA = twips(0.4);
|
|
14
|
+
const SEM_ESPACO = { espaco: { after: 0 }, jc: 'left' };
|
|
15
|
+
const LINHAS = [['cabecalho_1', { b: true, sz: 12 }], ['cabecalho_2', { sz: 10 }], ['cabecalho_3', { cor: '666666', sz: 10 }]];
|
|
16
|
+
const DO_RODAPE = { sz: 8.5 };
|
|
17
|
+
|
|
18
|
+
/** O cabeçalho existe quando há logotipo ou alguma das três linhas. */
|
|
19
|
+
export const temCabecalho = (perfil, logotipo) => Boolean(logotipo) || LINHAS.some(([chave]) => perfil[chave]);
|
|
20
|
+
/** O rodapé existe quando há texto de rodapé ou número de página. */
|
|
21
|
+
export const temRodape = (perfil) => Boolean(perfil.rodape) || perfil.numero_pagina;
|
|
22
|
+
|
|
23
|
+
/** A imagem, na largura do perfil e na altura proporcional. `rId`: a relação com `media/logo.png`. */
|
|
24
|
+
function desenho(perfil, logotipo, rId) {
|
|
25
|
+
const cx = Math.round(perfil.logotipo_largura_cm * EMU_POR_CM);
|
|
26
|
+
const cy = Math.round((cx * logotipo.altura) / logotipo.largura);
|
|
27
|
+
const figura = `<pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="logo.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${rId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>`;
|
|
28
|
+
const moldura = `<wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="1" name="Logotipo"/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>`;
|
|
29
|
+
return `<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">${moldura}<a:graphic><a:graphicData uri="${NS_PIC}">${figura}</a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} perfil o perfil completo
|
|
34
|
+
* @param {number} largura a largura da área de texto, em twips
|
|
35
|
+
* @param {{ largura: number, altura: number }|null} logotipo as medidas do PNG, em pontos da imagem
|
|
36
|
+
* @param {string} rId a relação da imagem, em `word/_rels/header1.xml.rels`
|
|
37
|
+
*/
|
|
38
|
+
export function montarCabecalho(perfil, largura, logotipo, rId) {
|
|
39
|
+
const linhas = LINHAS.filter(([chave]) => perfil[chave]).map(([chave, letra]) => paragrafo(SEM_ESPACO, [{ texto: perfil[chave] }], letra));
|
|
40
|
+
const texto = linhas.join('') || paragrafo(SEM_ESPACO);
|
|
41
|
+
const daImagem = logotipo ? twips(perfil.logotipo_largura_cm) + FOLGA : 0;
|
|
42
|
+
const centro = '<w:vAlign w:val="center"/>';
|
|
43
|
+
const imagem = logotipo ? celula(daImagem, centro, `<w:p>${pPr(SEM_ESPACO)}${desenho(perfil, logotipo, rId)}</w:p>`) : '';
|
|
44
|
+
const colunas = logotipo ? [daImagem, largura - daImagem] : [largura];
|
|
45
|
+
const bordas = `<w:tblBorders>${borda('bottom', { sz: 8, cor: '000000' })}</w:tblBorders>`;
|
|
46
|
+
const quadro = tabela(colunas, { bordas, margens: SEM_MARGENS }, [`${imagem}${celula(colunas.at(-1), centro, texto)}`]);
|
|
47
|
+
const espacos = `xmlns:w="${NS_W}" xmlns:r="${NS_R}" xmlns:wp="${NS_WP}" xmlns:a="${NS_A}" xmlns:pic="${NS_PIC}"`;
|
|
48
|
+
return `${DECLARACAO}<w:hdr ${espacos}>${quadro}${paragrafo(SEM_ESPACO)}</w:hdr>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Um campo do Word (`PAGE`, `NUMPAGES`), com "1" como valor até o Word calcular. */
|
|
52
|
+
function campo(instrucao) {
|
|
53
|
+
const marca = (tipo) => `<w:r>${rPr(DO_RODAPE)}<w:fldChar w:fldCharType="${tipo}"/></w:r>`;
|
|
54
|
+
const comando = `<w:r>${rPr(DO_RODAPE)}<w:instrText xml:space="preserve"> ${instrucao} </w:instrText></w:r>`;
|
|
55
|
+
return `${marca('begin')}${comando}${marca('separate')}${trecho('1', DO_RODAPE)}${marca('end')}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {object} perfil o perfil completo · @param {number} largura a da área de texto, em twips */
|
|
59
|
+
export function montarRodape(perfil, largura) {
|
|
60
|
+
const props = { bordas: borda('top', { cor: 'AAAAAA', espaco: 4 }), tabs: `<w:tab w:val="right" w:pos="${largura}"/>`, espaco: { after: 0 }, jc: 'left' };
|
|
61
|
+
const texto = trecho(perfil.rodape, { ...DO_RODAPE, i: true, cor: '666666' });
|
|
62
|
+
const pagina = perfil.numero_pagina ? `${trecho('\tPágina ', DO_RODAPE)}${campo('PAGE')}${trecho(' de ', DO_RODAPE)}${campo('NUMPAGES')}` : '';
|
|
63
|
+
return `${DECLARACAO}<w:ftr xmlns:w="${NS_W}"><w:p>${pPr(props)}${texto}${pagina}</w:p></w:ftr>`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Tijolos do XML do Word: texto seguro (escape e caracteres proibidos), medidas, e as propriedades
|
|
2
|
+
// de parágrafo e de letra com os filhos sempre na ordem que o formato exige.
|
|
3
|
+
// Spec: fase-u3b-documento-word.md, regras 3 (g) e 4 (repositório do OpenCrew).
|
|
4
|
+
|
|
5
|
+
export const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
6
|
+
export const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
7
|
+
export const DECLARACAO = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
|
|
8
|
+
/** Um nível de recuo: 0,75 cm. */
|
|
9
|
+
export const RECUO = 425;
|
|
10
|
+
|
|
11
|
+
/** Centímetros → twips (1/20 de ponto). */
|
|
12
|
+
export const twips = (cm) => Math.round(cm * 566.93);
|
|
13
|
+
/** Pontos → meios-pontos (a unidade do tamanho de letra). */
|
|
14
|
+
export const meios = (pt) => Math.round(pt * 2);
|
|
15
|
+
|
|
16
|
+
const ehAlta = (c) => c >= 0xd800 && c <= 0xdbff;
|
|
17
|
+
const ehBaixa = (c) => c >= 0xdc00 && c <= 0xdfff;
|
|
18
|
+
/** Caractere que o XML 1.0 não aceita (as metades de par substituto são vistas à parte). */
|
|
19
|
+
const proibido = (c) => (c < 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) || c === 0xfffe || c === 0xffff || ehAlta(c) || ehBaixa(c);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tira o que não pode estar num XML 1.0: controles (menos tabulação e quebras de linha), U+FFFE,
|
|
23
|
+
* U+FFFF e metade solta de par substituto.
|
|
24
|
+
* @returns {{ texto: string, removidos: number }}
|
|
25
|
+
*/
|
|
26
|
+
export function limpar(texto) {
|
|
27
|
+
let saida = '';
|
|
28
|
+
let removidos = 0;
|
|
29
|
+
for (let i = 0; i < texto.length; i++) {
|
|
30
|
+
const c = texto.charCodeAt(i);
|
|
31
|
+
if (ehAlta(c) && ehBaixa(texto.charCodeAt(i + 1))) saida += texto[i] + texto[++i];
|
|
32
|
+
else if (proibido(c)) removidos++;
|
|
33
|
+
else saida += texto[i];
|
|
34
|
+
}
|
|
35
|
+
return { texto: saida, removidos };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Texto para dentro do XML, em conteúdo ou em atributo. */
|
|
39
|
+
export const esc = (texto) => limpar(String(texto)).texto.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
40
|
+
|
|
41
|
+
const attrs = (o) => Object.entries(o).map(([nome, valor]) => ` w:${nome}="${valor}"`).join('');
|
|
42
|
+
|
|
43
|
+
/** Uma borda simples: `borda('top', { sz: 4, cor: 'CCCCCC' })`. `sz` em oitavos de ponto. */
|
|
44
|
+
export const borda = (lado, { sz = 4, cor, espaco = 0 }) => `<w:${lado} w:val="single" w:sz="${sz}" w:space="${espaco}" w:color="${cor}"/>`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Propriedades do parágrafo, na ordem do formato: estilo, keepNext, quebra de página antes, lista,
|
|
48
|
+
* bordas, tabulações, espaço, recuo, alinhamento e nível de tópico.
|
|
49
|
+
* `espaco`: `{ before, after, line, lineRule }` · `recuo`: `{ left, right, hanging }` (twips).
|
|
50
|
+
*/
|
|
51
|
+
export function pPr(o = {}) {
|
|
52
|
+
const partes = [
|
|
53
|
+
o.estilo && `<w:pStyle w:val="${o.estilo}"/>`,
|
|
54
|
+
o.keepNext && '<w:keepNext/>',
|
|
55
|
+
o.quebra && '<w:pageBreakBefore/>',
|
|
56
|
+
o.lista != null && `<w:numPr><w:ilvl w:val="${o.lista}"/><w:numId w:val="1"/></w:numPr>`,
|
|
57
|
+
o.bordas && `<w:pBdr>${o.bordas}</w:pBdr>`,
|
|
58
|
+
o.tabs && `<w:tabs>${o.tabs}</w:tabs>`,
|
|
59
|
+
o.espaco && `<w:spacing${attrs(o.espaco)}/>`,
|
|
60
|
+
o.recuo && `<w:ind${attrs(o.recuo)}/>`,
|
|
61
|
+
o.jc && `<w:jc w:val="${o.jc}"/>`,
|
|
62
|
+
o.topico != null && `<w:outlineLvl w:val="${o.topico}"/>`,
|
|
63
|
+
].filter(Boolean);
|
|
64
|
+
return partes.length ? `<w:pPr>${partes.join('')}</w:pPr>` : '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Propriedades da letra, na ordem do formato. `sz` em pontos; `fonte` já validada. */
|
|
68
|
+
export function rPr(o = {}) {
|
|
69
|
+
const partes = [
|
|
70
|
+
o.fonte && `<w:rFonts w:ascii="${esc(o.fonte)}" w:hAnsi="${esc(o.fonte)}" w:cs="${esc(o.fonte)}"/>`,
|
|
71
|
+
o.b && '<w:b/>',
|
|
72
|
+
o.i && '<w:i/>',
|
|
73
|
+
o.caps && '<w:caps/>',
|
|
74
|
+
o.cor && `<w:color w:val="${o.cor}"/>`,
|
|
75
|
+
o.sz && `<w:sz w:val="${meios(o.sz)}"/><w:szCs w:val="${meios(o.sz)}"/>`,
|
|
76
|
+
o.idioma && `<w:lang w:val="${o.idioma}"/>`,
|
|
77
|
+
].filter(Boolean);
|
|
78
|
+
return partes.length ? `<w:rPr>${partes.join('')}</w:rPr>` : '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** O texto de um trecho: `w:t` (sempre com `xml:space="preserve"`) e `w:tab` em cada tabulação. */
|
|
82
|
+
const conteudo = (texto) => texto.split('\t').map((parte) => (parte ? `<w:t xml:space="preserve">${esc(parte)}</w:t>` : '')).join('<w:tab/>');
|
|
83
|
+
|
|
84
|
+
/** Um trecho de texto com a sua letra. Texto vazio não gera trecho. */
|
|
85
|
+
export const trecho = (texto, letra = {}) => (texto ? `<w:r>${rPr(letra)}${conteudo(texto)}</w:r>` : '');
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Um parágrafo a partir dos pedaços lidos da linha (`[{ texto, b, i }]`).
|
|
89
|
+
* @param {object} props o que `pPr` recebe
|
|
90
|
+
* @param {object} [letra] letra comum a todos os pedaços (soma com o negrito e o itálico de cada um)
|
|
91
|
+
*/
|
|
92
|
+
export function paragrafo(props, pedacos = [], letra = {}) {
|
|
93
|
+
const trechos = pedacos.map((p) => trecho(p.texto, { ...letra, b: letra.b || p.b, i: letra.i || p.i })).join('');
|
|
94
|
+
return `<w:p>${pPr(props)}${trechos}</w:p>`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Contêiner zip do documento Word, escrito à mão: toda entrada sem compressão (método 0), com
|
|
2
|
+
// CRC-32 próprio e data fixa (1980-01-01 00:00). O mesmo conteúdo dá os mesmos bytes, em qualquer
|
|
3
|
+
// máquina e em qualquer versão do Node. Não importa `node:zlib`.
|
|
4
|
+
// Spec: fase-u3b-documento-word.md, regra 1 (repositório do OpenCrew).
|
|
5
|
+
|
|
6
|
+
const TABELA = Array.from({ length: 256 }, (_, n) => {
|
|
7
|
+
let c = n;
|
|
8
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
|
|
9
|
+
return c >>> 0;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/** CRC-32 (o do zip e do PNG) de um bloco de bytes. */
|
|
13
|
+
export function crc32(bytes) {
|
|
14
|
+
let c = 0xffffffff;
|
|
15
|
+
for (let i = 0; i < bytes.length; i++) c = TABELA[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
16
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const VERSAO = 20;
|
|
20
|
+
const DATA = 0x0021; // 1980-01-01
|
|
21
|
+
const HORA = 0;
|
|
22
|
+
/** Bit 11 (nome em UTF-8): só quando o nome sai do ASCII. */
|
|
23
|
+
const marcas = (nome) => (nome.length === Buffer.byteLength(nome, 'utf8') ? 0 : 0x0800);
|
|
24
|
+
|
|
25
|
+
function cabecalhoLocal(e) {
|
|
26
|
+
const b = Buffer.alloc(30);
|
|
27
|
+
b.writeUInt32LE(0x04034b50, 0);
|
|
28
|
+
b.writeUInt16LE(VERSAO, 4);
|
|
29
|
+
b.writeUInt16LE(marcas(e.nome), 6);
|
|
30
|
+
b.writeUInt16LE(0, 8); // método 0: sem compressão
|
|
31
|
+
b.writeUInt16LE(HORA, 10);
|
|
32
|
+
b.writeUInt16LE(DATA, 12);
|
|
33
|
+
b.writeUInt32LE(e.crc, 14);
|
|
34
|
+
b.writeUInt32LE(e.bytes.length, 18);
|
|
35
|
+
b.writeUInt32LE(e.bytes.length, 22);
|
|
36
|
+
b.writeUInt16LE(e.nomeEmBytes.length, 26);
|
|
37
|
+
return Buffer.concat([b, e.nomeEmBytes]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function registroCentral(e) {
|
|
41
|
+
const b = Buffer.alloc(46);
|
|
42
|
+
b.writeUInt32LE(0x02014b50, 0);
|
|
43
|
+
b.writeUInt16LE(VERSAO, 4);
|
|
44
|
+
b.writeUInt16LE(VERSAO, 6);
|
|
45
|
+
b.writeUInt16LE(marcas(e.nome), 8);
|
|
46
|
+
b.writeUInt16LE(0, 10);
|
|
47
|
+
b.writeUInt16LE(HORA, 12);
|
|
48
|
+
b.writeUInt16LE(DATA, 14);
|
|
49
|
+
b.writeUInt32LE(e.crc, 16);
|
|
50
|
+
b.writeUInt32LE(e.bytes.length, 20);
|
|
51
|
+
b.writeUInt32LE(e.bytes.length, 24);
|
|
52
|
+
b.writeUInt16LE(e.nomeEmBytes.length, 28);
|
|
53
|
+
b.writeUInt32LE(e.inicio, 42);
|
|
54
|
+
return Buffer.concat([b, e.nomeEmBytes]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function registroFinal(total, tamanho, inicio) {
|
|
58
|
+
const b = Buffer.alloc(22);
|
|
59
|
+
b.writeUInt32LE(0x06054b50, 0);
|
|
60
|
+
b.writeUInt16LE(total, 8);
|
|
61
|
+
b.writeUInt16LE(total, 10);
|
|
62
|
+
b.writeUInt32LE(tamanho, 12);
|
|
63
|
+
b.writeUInt32LE(inicio, 16);
|
|
64
|
+
return b;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Monta o zip, na ordem recebida.
|
|
69
|
+
* @param {{ nome: string, bytes: Buffer|string }[]} partes nomes com `/`; texto vai em UTF-8
|
|
70
|
+
* @returns {Buffer}
|
|
71
|
+
*/
|
|
72
|
+
export function zipar(partes) {
|
|
73
|
+
const locais = [];
|
|
74
|
+
const centrais = [];
|
|
75
|
+
let inicio = 0;
|
|
76
|
+
for (const parte of partes) {
|
|
77
|
+
const bytes = Buffer.isBuffer(parte.bytes) ? parte.bytes : Buffer.from(parte.bytes, 'utf8');
|
|
78
|
+
const e = { nome: parte.nome, nomeEmBytes: Buffer.from(parte.nome, 'utf8'), bytes, crc: crc32(bytes), inicio };
|
|
79
|
+
const local = cabecalhoLocal(e);
|
|
80
|
+
locais.push(local, bytes);
|
|
81
|
+
centrais.push(registroCentral(e));
|
|
82
|
+
inicio += local.length + bytes.length;
|
|
83
|
+
}
|
|
84
|
+
const diretorio = Buffer.concat(centrais);
|
|
85
|
+
return Buffer.concat([...locais, diretorio, registroFinal(partes.length, diretorio.length, inicio)]);
|
|
86
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Documento Word: converte um texto em markdown (.md ou .txt) num `.docx`, com as mesmas palavras
|
|
3
|
+
// e os mesmos números, na mesma ordem. Com o perfil de documento oficial do projeto, sai em papel
|
|
4
|
+
// timbrado: cabeçalho com logotipo, rodapé com "Página X de Y", margens e letra do perfil.
|
|
5
|
+
// Uso (na pasta do projeto): node _opencrew/core/scripts/documento.mjs "<arquivo.md>"
|
|
6
|
+
// [--saida <arquivo.docx|pasta>] [--perfil <arquivo>] [--sem-perfil] [--substituir] [--ajuda]
|
|
7
|
+
// node _opencrew/core/scripts/documento.mjs --criar-perfil
|
|
8
|
+
// --saida: onde gravar (padrão: ao lado do texto, com o mesmo nome e `.docx`); a pasta é criada.
|
|
9
|
+
// --perfil: outro arquivo de perfil (padrão: `_opencrew/_memory/documento-oficial.md`, se existir).
|
|
10
|
+
// --sem-perfil: ignora o perfil do projeto (sem timbre, com os padrões).
|
|
11
|
+
// --substituir: autoriza trocar um `.docx` diferente que já existe no destino.
|
|
12
|
+
// --criar-perfil: cria o perfil do projeto a partir do modelo, só se ele não existir.
|
|
13
|
+
// Grava só o `.docx` pedido (por um temporário ao lado, renomeado no fim) e, com --criar-perfil, o
|
|
14
|
+
// perfil que faltava. Nunca apaga nada. Um `.docx` igual ao que seria gravado não é tocado.
|
|
15
|
+
// Última linha da saída: DOCUMENTO:OK (o Word está no destino) · PERFIL:CRIADO ou PERFIL:JA-EXISTE
|
|
16
|
+
// (com --criar-perfil).
|
|
17
|
+
// Código de saída: 0 quando a linha final sai, e em --ajuda · 1 = erro: uma mensagem em PT-BR, sem
|
|
18
|
+
// linha DOCUMENTO: e nada gravado.
|
|
19
|
+
// Para outros scripts: `gerarDocx({ texto, perfil, logotipo })` → `{ bytes, avisos }`, sem tocar o
|
|
20
|
+
// disco; `lerPerfilDoProjeto(raiz, arquivo)` → `{ perfil, logotipo }` ou `{ erro }`.
|
|
21
|
+
// Spec: fase-u3b-documento-word.md (repositório do OpenCrew).
|
|
22
|
+
import { existsSync, promises as fs, statSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { MSG as COMUM, dentroDoProjeto, ehPrincipal, relativoAoProjeto } from './comum.mjs';
|
|
25
|
+
import { MSG, USO, lerArgs, limpar } from './documento/argumentos.mjs';
|
|
26
|
+
import { gravarDocx } from './documento/gravar.mjs';
|
|
27
|
+
import { gerarDocx } from './documento/pacote.mjs';
|
|
28
|
+
import { PERFIL, criarPerfil, lerPerfilDoProjeto } from './documento/projeto.mjs';
|
|
29
|
+
|
|
30
|
+
export { gerarDocx } from './documento/pacote.mjs';
|
|
31
|
+
export { lerPerfil } from './documento/perfil.mjs';
|
|
32
|
+
export { PERFIL, lerPerfilDoProjeto } from './documento/projeto.mjs';
|
|
33
|
+
|
|
34
|
+
const TEXTO = /\.(md|txt)$/i;
|
|
35
|
+
const ehPasta = (p) => existsSync(p) && statSync(p).isDirectory();
|
|
36
|
+
const ehArquivo = (p) => existsSync(p) && statSync(p).isFile();
|
|
37
|
+
const erro = (mensagem, comUso = false) => ({ erro: mensagem, comUso });
|
|
38
|
+
|
|
39
|
+
/** O arquivo de texto pedido: um só, dentro do projeto, `.md` ou `.txt`, e que existe. */
|
|
40
|
+
function conferirOrigem(raiz, arquivos) {
|
|
41
|
+
if (arquivos.length === 0) return erro(MSG.semArquivo, true);
|
|
42
|
+
if (arquivos.length > 1) return erro(MSG.maisDeUm(arquivos.length), true);
|
|
43
|
+
if (!ehPasta(path.join(raiz, '_opencrew'))) return erro(COMUM.semRaiz);
|
|
44
|
+
if (!dentroDoProjeto(raiz, arquivos[0])) return erro(COMUM.foraDoProjeto(limpar(arquivos[0])));
|
|
45
|
+
const rel = relativoAoProjeto(raiz, arquivos[0]);
|
|
46
|
+
if (!TEXTO.test(rel)) return erro(MSG.extensao(rel));
|
|
47
|
+
return ehArquivo(path.resolve(raiz, arquivos[0])) ? { rel, abs: path.resolve(raiz, arquivos[0]) } : erro(MSG.naoEncontrei(rel));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Onde o Word vai: ao lado do texto, ou no arquivo `.docx` ou na pasta de `--saida`. */
|
|
51
|
+
function conferirDestino(raiz, saida, origem) {
|
|
52
|
+
const nome = `${path.basename(origem.abs).replace(TEXTO, '')}.docx`;
|
|
53
|
+
if (saida === undefined) return { destino: path.join(path.dirname(origem.abs), nome) };
|
|
54
|
+
const recusada = erro(MSG.saida(limpar(saida)));
|
|
55
|
+
if (!saida.trim() || !dentroDoProjeto(raiz, saida)) return recusada;
|
|
56
|
+
const alvo = path.resolve(raiz, saida);
|
|
57
|
+
if (/\.docx$/i.test(alvo)) return ehPasta(alvo) ? recusada : { destino: alvo };
|
|
58
|
+
if (ehPasta(alvo)) return { destino: path.join(alvo, nome) };
|
|
59
|
+
return existsSync(alvo) || path.extname(alvo) ? recusada : { destino: path.join(alvo, nome) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** O arquivo de perfil que vale nesta chamada (`arquivo` fica sem valor quando não há perfil). */
|
|
63
|
+
function conferirPerfil(raiz, args) {
|
|
64
|
+
if (args.semPerfil) return {};
|
|
65
|
+
if (args.perfil === undefined) return ehArquivo(path.join(raiz, PERFIL)) ? { arquivo: PERFIL } : {};
|
|
66
|
+
if (!dentroDoProjeto(raiz, args.perfil)) return erro(COMUM.foraDoProjeto(limpar(args.perfil)));
|
|
67
|
+
return ehArquivo(path.resolve(raiz, args.perfil)) ? { arquivo: args.perfil } : erro(MSG.semPerfil(limpar(args.perfil)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Tudo o que a chamada pede, conferido antes de ler ou gravar qualquer coisa. */
|
|
71
|
+
function conferir(raiz, args) {
|
|
72
|
+
const origem = conferirOrigem(raiz, args.arquivos);
|
|
73
|
+
if (origem.erro) return origem;
|
|
74
|
+
const destino = conferirDestino(raiz, args.saida, origem);
|
|
75
|
+
const perfil = conferirPerfil(raiz, args);
|
|
76
|
+
return destino.erro ? destino : perfil.erro ? perfil : { origem, destino: destino.destino, perfil: perfil.arquivo };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** O texto do arquivo, em UTF-8; `null` quando os bytes não são UTF-8. */
|
|
80
|
+
async function lerTexto(arquivo) {
|
|
81
|
+
try {
|
|
82
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(await fs.readFile(arquivo));
|
|
83
|
+
} catch (falha) {
|
|
84
|
+
if (falha instanceof TypeError) return null;
|
|
85
|
+
throw falha;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Monta o documento na memória: `{ bytes, avisos }`, ou `{ erro }` com a mensagem. */
|
|
90
|
+
async function montar(raiz, pedido) {
|
|
91
|
+
const texto = await lerTexto(pedido.origem.abs);
|
|
92
|
+
if (texto === null) return erro(MSG.naoUtf8(pedido.origem.rel));
|
|
93
|
+
const lido = pedido.perfil ? await lerPerfilDoProjeto(raiz, pedido.perfil) : {};
|
|
94
|
+
if (lido.erro) return erro(lido.erro);
|
|
95
|
+
const documento = gerarDocx({ texto, perfil: lido.perfil, logotipo: lido.logotipo });
|
|
96
|
+
return documento.vazio ? erro(MSG.semTexto(pedido.origem.rel)) : documento;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function relatorio(raiz, pedido, estado, avisos) {
|
|
100
|
+
const destino = relativoAoProjeto(raiz, pedido.destino);
|
|
101
|
+
return [
|
|
102
|
+
estado === 'igual' ? MSG.igual(destino) : MSG.gerado(destino),
|
|
103
|
+
pedido.perfil ? MSG.perfil(relativoAoProjeto(raiz, pedido.perfil)) : MSG.nenhumPerfil,
|
|
104
|
+
...(avisos.length ? [MSG.avisos, ...avisos.map((a) => `- ${a}`)] : []),
|
|
105
|
+
...MSG.dicas,
|
|
106
|
+
'DOCUMENTO:OK',
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Converte e grava; devolve as linhas da saída e o código. */
|
|
111
|
+
async function converter(raiz, args, disco) {
|
|
112
|
+
const pedido = conferir(raiz, args);
|
|
113
|
+
if (pedido.erro) return { linhas: [pedido.erro, ...(pedido.comUso ? [USO] : [])], code: 1 };
|
|
114
|
+
const documento = await montar(raiz, pedido);
|
|
115
|
+
if (documento.erro) return { linhas: [documento.erro], code: 1 };
|
|
116
|
+
const estado = await gravarDocx(pedido.destino, documento.bytes, { substituir: args.substituir, disco });
|
|
117
|
+
const destino = relativoAoProjeto(raiz, pedido.destino);
|
|
118
|
+
if (estado === 'diferente') return { linhas: [MSG.jaExiste(destino)], code: 1 };
|
|
119
|
+
if (estado === 'falha') return { linhas: [MSG.falha(destino)], code: 1 };
|
|
120
|
+
return { linhas: relatorio(raiz, pedido, estado, documento.avisos), code: 0 };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function criar(raiz) {
|
|
124
|
+
if (!ehPasta(path.join(raiz, '_opencrew'))) return { linhas: [COMUM.semRaiz], code: 1 };
|
|
125
|
+
const criado = await criarPerfil(raiz);
|
|
126
|
+
return { linhas: criado ? [MSG.perfilCriado(PERFIL), 'PERFIL:CRIADO'] : [MSG.perfilJaExiste(PERFIL), 'PERFIL:JA-EXISTE'], code: 0 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {string[]} argv
|
|
131
|
+
* @param {object} [deps] `cwd` (a pasta do projeto), `escrever` e `disco` (as funções de
|
|
132
|
+
* `fs.promises` usadas na gravação do `.docx`)
|
|
133
|
+
* @returns {Promise<number>} 0 = a linha final saiu (ou `--ajuda`) · 1 = erro (uma mensagem em
|
|
134
|
+
* PT-BR, sem linha `DOCUMENTO:`, nada gravado)
|
|
135
|
+
*/
|
|
136
|
+
export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`), disco = fs } = {}) {
|
|
137
|
+
const args = lerArgs(argv);
|
|
138
|
+
const sair = ({ linhas, code }) => linhas.forEach((l) => escrever(l)) ?? code;
|
|
139
|
+
if (args.ajuda) return sair({ linhas: [USO], code: 0 });
|
|
140
|
+
if (args.desconhecida) return sair({ linhas: [MSG.desconhecida(limpar(args.desconhecida)), USO], code: 1 });
|
|
141
|
+
try {
|
|
142
|
+
return sair(args.criarPerfil ? await criar(cwd) : await converter(cwd, args, disco));
|
|
143
|
+
} catch (falha) {
|
|
144
|
+
return sair({ linhas: [`Não consegui gerar o documento: ${limpar(falha?.message ?? falha)}`], code: 1 });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ehPrincipal(import.meta.url)) {
|
|
149
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
150
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Linha de comando do `entregar.mjs`: as opções, a lista `caminho=formato` e a linha de uso.
|
|
2
|
-
//
|
|
2
|
+
// Specs: fase-u3a1-pasta-de-entrega.md, §3 e §6, e fase-u3a2-entrega-no-projeto.md, §3
|
|
3
|
+
// (repositório do OpenCrew).
|
|
3
4
|
import { lerItemDaLista } from '../verificar/argumentos.mjs';
|
|
4
5
|
|
|
5
|
-
export const USO = 'Uso: node _opencrew/core/scripts/entregar.mjs --crew "crews/<crew>" --run "<id>" --arquivo "<caminho=formato>[,<caminho=formato>…]" [--vai-publicar <canal>] [--ajuda]';
|
|
6
|
+
export const USO = 'Uso: node _opencrew/core/scripts/entregar.mjs --crew "crews/<crew>" --run "<id>" --arquivo "<caminho=formato>[,<caminho=formato>…]" [--destino "<pasta>"] [--lembrar-destino "<pasta>"|nao] [--aceitar-pendencias] [--vai-publicar <canal>] [--ajuda]';
|
|
6
7
|
|
|
7
|
-
const OPCAO = /^--(crew|run|arquivo|vai-publicar)(?:=(.*))?$/s;
|
|
8
|
+
const OPCAO = /^--(crew|run|arquivo|vai-publicar|destino|lembrar-destino)(?:=(.*))?$/s;
|
|
9
|
+
const CHAVE = { 'lembrar-destino': 'lembrarDestino' };
|
|
8
10
|
|
|
9
11
|
/** Texto que veio da linha de comando e volta numa mensagem: uma linha só, até 200 caracteres. */
|
|
10
12
|
export const limpar = (valor) => String(valor).replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
@@ -12,23 +14,25 @@ export const limpar = (valor) => String(valor).replace(/\s+/g, ' ').trim().slice
|
|
|
12
14
|
function guardar(args, nome, valor) {
|
|
13
15
|
if (nome === 'arquivo') args.arquivos.push(valor);
|
|
14
16
|
else if (nome === 'vai-publicar') args.vaiPublicar.push(valor.trim().toLowerCase());
|
|
15
|
-
else args[nome] = valor;
|
|
17
|
+
else args[CHAVE[nome] ?? nome] = valor;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
|
-
* `argv` → `{ crew, run, arquivos, vaiPublicar,
|
|
20
|
-
* `--nome valor` e `--nome=valor`. `--arquivo` e `--vai-publicar`
|
|
21
|
-
* solto logo depois da lista é mais um item dela. `
|
|
22
|
-
* não
|
|
21
|
+
* `argv` → `{ crew, run, arquivos, vaiPublicar, destino, lembrarDestino, aceitar, ajuda,
|
|
22
|
+
* desconhecida }`. Opção vale como `--nome valor` e `--nome=valor`. `--arquivo` e `--vai-publicar`
|
|
23
|
+
* repetidos somam, e o que vem solto logo depois da lista é mais um item dela. `destino` e
|
|
24
|
+
* `lembrarDestino` ficam sem valor (undefined) quando a opção não veio. `desconhecida`: a primeira
|
|
25
|
+
* opção que o script não conhece, ou null.
|
|
23
26
|
*/
|
|
24
27
|
export function lerArgs(argv) {
|
|
25
|
-
const args = { arquivos: [], vaiPublicar: [], ajuda: false, desconhecida: null };
|
|
28
|
+
const args = { arquivos: [], vaiPublicar: [], aceitar: false, ajuda: false, desconhecida: null };
|
|
26
29
|
let naLista = false;
|
|
27
30
|
for (let i = 0; i < argv.length; i++) {
|
|
28
31
|
const [, nome, colado] = argv[i].match(OPCAO) ?? [];
|
|
29
32
|
const solto = !nome && !argv[i].startsWith('--');
|
|
30
33
|
if (nome) guardar(args, nome, colado ?? (i + 1 < argv.length && !argv[i + 1].startsWith('--') ? argv[++i] : ''));
|
|
31
34
|
else if (argv[i] === '--ajuda') args.ajuda = true;
|
|
35
|
+
else if (argv[i] === '--aceitar-pendencias') args.aceitar = true;
|
|
32
36
|
else if (solto && naLista) args.arquivos.push(argv[i]);
|
|
33
37
|
else args.desconhecida ??= argv[i];
|
|
34
38
|
naLista = nome ? nome === 'arquivo' : solto && naLista;
|
|
@@ -6,7 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import { lerFrontmatter, lerTexto } from '../verificar/leitura.mjs';
|
|
7
7
|
|
|
8
8
|
/** Pasta do canal → nome no LEIA-ME, na ordem em que as seções aparecem. */
|
|
9
|
-
export const CANAIS = { instagram: 'Instagram', linkedin: 'LinkedIn', blog: 'Blog', email: 'E-mail', whatsapp: 'WhatsApp', twitter: 'X/Twitter', youtube: 'YouTube' };
|
|
9
|
+
export const CANAIS = { instagram: 'Instagram', linkedin: 'LinkedIn', blog: 'Blog', email: 'E-mail', whatsapp: 'WhatsApp', twitter: 'X/Twitter', youtube: 'YouTube', documentos: 'Documentos' };
|
|
10
|
+
/** Plataforma cuja pasta tem outro nome (fase-u3b-documento-word.md, regra 12). */
|
|
11
|
+
const PASTA_DA_PLATAFORMA = { documento: 'documentos' };
|
|
10
12
|
export const OUTROS = 'outros';
|
|
11
13
|
export const EDITAVEIS = 'editaveis';
|
|
12
14
|
export const ehCanal = (pasta) => Object.hasOwn(CANAIS, pasta);
|
|
@@ -23,29 +25,33 @@ async function plataformaEm(raiz, pasta, formato) {
|
|
|
23
25
|
/**
|
|
24
26
|
* Canal de um formato: o `platform:` de `_opencrew/best-practices.local/<formato>.md`, quando o
|
|
25
27
|
* arquivo o declara; senão, o do core. Sem formato, sem best-practice, sem `platform:` ou com
|
|
26
|
-
* plataforma que não é uma das
|
|
28
|
+
* plataforma que não é uma das oito pastas: null (o arquivo vai para `outros/`). A plataforma
|
|
29
|
+
* `documento` é a pasta `documentos`.
|
|
27
30
|
*/
|
|
28
31
|
export async function canalDoFormato(raiz, formato) {
|
|
29
32
|
if (!formato || !/^[a-z0-9-]+$/.test(formato)) return null;
|
|
30
33
|
const local = await plataformaEm(raiz, ['best-practices.local'], formato);
|
|
31
34
|
const plataforma = local ?? (await plataformaEm(raiz, ['core', 'best-practices'], formato));
|
|
32
|
-
|
|
35
|
+
const pasta = Object.hasOwn(PASTA_DA_PLATAFORMA, plataforma ?? '') ? PASTA_DA_PLATAFORMA[plataforma] : plataforma;
|
|
36
|
+
return pasta && ehCanal(pasta) ? pasta : null;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
const PASTAS_DE_SERVICO = new Set(['entrega', 'entrega.tmp', 'export']);
|
|
36
40
|
|
|
37
41
|
/**
|
|
38
|
-
* Arquivo de serviço nunca entra, mesmo listado: `verificacao-*.md`, `
|
|
42
|
+
* Arquivo de serviço nunca entra, mesmo listado: `verificacao-*.md`, `ressalvas.json` (as
|
|
43
|
+
* pendências aceitas, fase-u3a2-entrega-no-projeto.md, regra 18), `publicado.json`, o que está
|
|
39
44
|
* em `entrega/`, `entrega.tmp/` ou `export/` de uma execução e o `caption.txt` direto na pasta
|
|
40
45
|
* da execução (é ali que o publicador o grava; dentro de uma pasta `vN` ele é saída de passo).
|
|
46
|
+
* E o `copia.json` direto na pasta da execução: o retrato do que foi copiado (mesma spec, regra 14).
|
|
41
47
|
* @param {string} rel caminho relativo ao projeto, com `/`
|
|
42
48
|
*/
|
|
43
49
|
export function ehDeServico(rel) {
|
|
44
50
|
const partes = rel.toLowerCase().split('/');
|
|
45
51
|
const nome = partes.at(-1);
|
|
46
|
-
if (/^verificacao-.*\.md$/.test(nome) || nome === 'publicado.json') return true;
|
|
52
|
+
if (/^verificacao-.*\.md$/.test(nome) || nome === 'publicado.json' || nome === 'ressalvas.json') return true;
|
|
47
53
|
const naSaida = partes[0] === 'crews' && partes[2] === 'output';
|
|
48
54
|
if (!naSaida) return false;
|
|
49
55
|
if (partes.slice(4, -1).some((p) => PASTAS_DE_SERVICO.has(p))) return true;
|
|
50
|
-
return nome === 'caption.txt' && partes.length === 5;
|
|
56
|
+
return (nome === 'caption.txt' || nome === 'copia.json') && partes.length === 5;
|
|
51
57
|
}
|