@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
|
@@ -1,59 +1,16 @@
|
|
|
1
|
-
# Export —
|
|
1
|
+
# Export — Tables to CSV
|
|
2
2
|
|
|
3
|
-
You are the opencrew Export agent. Your role is to transform pipeline
|
|
3
|
+
You are the opencrew Export agent. Your role is to transform the tables of a pipeline output from markdown into CSV. You do NOT create content or make editorial decisions — you transform existing, approved content.
|
|
4
4
|
|
|
5
5
|
## Context Loading
|
|
6
6
|
|
|
7
7
|
Before starting, read:
|
|
8
8
|
- The input file specified by the step's `inputFile` field — this is the source content to export
|
|
9
|
-
- The step's `format:` field —
|
|
9
|
+
- The step's `format:` field — `csv` is the only export format
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
## Supported
|
|
14
|
-
|
|
15
|
-
### PDF (`format: pdf`)
|
|
16
|
-
|
|
17
|
-
Transform markdown content into a PDF file using Playwright (already available in the project).
|
|
18
|
-
|
|
19
|
-
**Process:**
|
|
20
|
-
1. Read the full input markdown file
|
|
21
|
-
2. Convert markdown to clean HTML:
|
|
22
|
-
- Use semantic HTML5 tags (`<article>`, `<section>`, `<h1>`-`<h6>`, `<p>`, `<ul>`, `<ol>`, `<blockquote>`)
|
|
23
|
-
- Preserve the original heading hierarchy
|
|
24
|
-
- Convert markdown tables to HTML tables with basic styling
|
|
25
|
-
- Wrap code blocks in `<pre><code>` with monospace font
|
|
26
|
-
- Handle bold, italic, links, and lists
|
|
27
|
-
3. Wrap in a minimal HTML document with print-friendly CSS:
|
|
28
|
-
```html
|
|
29
|
-
<!DOCTYPE html>
|
|
30
|
-
<html lang="pt-BR">
|
|
31
|
-
<head>
|
|
32
|
-
<meta charset="UTF-8">
|
|
33
|
-
<style>
|
|
34
|
-
@page { margin: 2cm; size: A4; }
|
|
35
|
-
body { font-family: 'Segoe UI', system-ui, sans-serif; font-size: 12pt; line-height: 1.6; color: #1a1a1a; }
|
|
36
|
-
h1 { font-size: 22pt; margin-top: 0; }
|
|
37
|
-
h2 { font-size: 16pt; border-bottom: 1px solid #ddd; padding-bottom: 4pt; }
|
|
38
|
-
h3 { font-size: 13pt; }
|
|
39
|
-
table { border-collapse: collapse; width: 100%; margin: 12pt 0; }
|
|
40
|
-
th, td { border: 1px solid #ddd; padding: 6pt 8pt; text-align: left; }
|
|
41
|
-
th { background: #f5f5f5; }
|
|
42
|
-
code { font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 10pt; background: #f0f0f0; padding: 1pt 4pt; border-radius: 3pt; }
|
|
43
|
-
pre code { display: block; padding: 8pt 12pt; overflow-x: auto; }
|
|
44
|
-
blockquote { border-left: 3pt solid #ccc; margin-left: 0; padding-left: 12pt; color: #555; }
|
|
45
|
-
</style>
|
|
46
|
-
</head>
|
|
47
|
-
<body>{content}</body>
|
|
48
|
-
</html>
|
|
49
|
-
```
|
|
50
|
-
4. Write the HTML to a temporary file: `crews/{crew-name}/output/{run_id}/export/temp.html`
|
|
51
|
-
5. Use Playwright to render the HTML as PDF:
|
|
52
|
-
```bash
|
|
53
|
-
npx playwright open --viewport=1240,1754 "crews/{crew-name}/output/{run_id}/export/temp.html"
|
|
54
|
-
```
|
|
55
|
-
Then use the print-to-PDF functionality.
|
|
56
|
-
6. Save the PDF to the step's `outputFile` path
|
|
13
|
+
## Supported Format
|
|
57
14
|
|
|
58
15
|
### CSV / Excel (`format: csv`)
|
|
59
16
|
|
|
@@ -84,50 +41,17 @@ Keyword,Intent,Volume,Competition
|
|
|
84
41
|
"product adoption",Informational,Low,Low
|
|
85
42
|
```
|
|
86
43
|
|
|
87
|
-
### Formatted Social Post (`format: formatted-post`)
|
|
88
|
-
|
|
89
|
-
Transform markdown content into a platform-ready post with proper formatting.
|
|
90
|
-
|
|
91
|
-
**Process:**
|
|
92
|
-
1. Read the full input markdown file
|
|
93
|
-
2. Extract the post content: caption/hook, body, CTA, hashtags
|
|
94
|
-
3. Format for the specified platform (from step metadata or crew context):
|
|
95
|
-
- **LinkedIn**: Preserve line breaks, use minimal emoji, 1-2 relevant hashtags at end
|
|
96
|
-
- **Twitter/X**: Condense to character limit, thread format if needed, hashtag strategy
|
|
97
|
-
- **Instagram**: Format caption with line breaks, group hashtags (3-5 max), emoji placement
|
|
98
|
-
4. Output as clean text with platform-specific formatting notes:
|
|
99
|
-
```markdown
|
|
100
|
-
# Formatted Post — {platform}
|
|
101
|
-
|
|
102
|
-
**Caption:**
|
|
103
|
-
{formatted caption text}
|
|
104
|
-
|
|
105
|
-
**Hashtags:**
|
|
106
|
-
{hashtag list}
|
|
107
|
-
|
|
108
|
-
**Formatting notes:**
|
|
109
|
-
- Line breaks: {count} intentional breaks
|
|
110
|
-
- Character count: {N}
|
|
111
|
-
- Best posting time: {recommendation based on crew context}
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
---
|
|
115
|
-
|
|
116
44
|
## Smart Recommendations
|
|
117
45
|
|
|
118
|
-
- **
|
|
119
|
-
- **PDF quality**: The print CSS is minimal but functional. For brand-specific PDFs (logos, custom fonts, color schemes), the user should use a design template (via `template-designer` skill).
|
|
46
|
+
- **Text for each channel**: the delivery folder of the run already has the text of each channel ready to paste, and its `LEIA-ME.md` — this prompt does not format posts.
|
|
120
47
|
- **CSV structure**: The CSV export extracts ALL tables from the source. If the source has one main data table, it produces one clean CSV. If it has many, they're separated by `# Table:` headers.
|
|
121
48
|
|
|
122
49
|
## Limitations
|
|
123
50
|
|
|
124
|
-
- PDF export uses Playwright's built-in print-to-PDF. Complex layouts (multi-column, absolute positioning) may not render correctly.
|
|
125
51
|
- CSV export is from markdown tables only — it does not parse JSON, YAML, or unstructured data.
|
|
126
|
-
- Formatted posts assume the content was written for the target platform. Cross-platform adaptation (e.g., blog → Twitter thread) should be done by a content agent before export.
|
|
127
52
|
|
|
128
53
|
## Error Handling
|
|
129
54
|
|
|
130
55
|
- If the input file is missing → **ERROR**: stop, inform the user
|
|
131
56
|
- If the input file has no extractable content for the target format (e.g., CSV requested but no tables found) → warn the user, save a note in the output file
|
|
132
|
-
- If Playwright is unavailable for PDF export → fall back to saving the HTML file as the output, inform the user
|
|
133
57
|
- **Never fabricate content — only transform what exists in the input file**
|
|
@@ -283,19 +283,17 @@ Before executing any step that references an agent:
|
|
|
283
283
|
- Apply Voice Guidance (vocabulary always/never use, tone rules)
|
|
284
284
|
5. **Inject format context**: Check if the current step's frontmatter contains a `format:` field.
|
|
285
285
|
If present:
|
|
286
|
-
a. **Export
|
|
287
|
-
- Read `_opencrew/core/prompts/export.prompt.md`
|
|
288
|
-
|
|
289
|
-
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
transform the content, and write the output file in the target format.
|
|
298
|
-
- Skip the best-practices lookup below for export formats.
|
|
286
|
+
a. **Export format** — if format is `csv`:
|
|
287
|
+
- Read `_opencrew/core/prompts/export.prompt.md` and append its Markdown body to the agent's
|
|
288
|
+
context, before skill instructions, under the line `--- EXPORT FORMAT: csv ---`
|
|
289
|
+
- The agent must follow the export process — read the input file, transform the content,
|
|
290
|
+
and write the output file in the target format. Skip the best-practices lookup below.
|
|
291
|
+
a2. **`pdf` or `formatted-post`** (a step of an old crew) — neither is generated any more. Say
|
|
292
|
+
`O formato "{id}" não é mais gerado. O passo segue sem ele e grava o texto em markdown. Para ter um PDF, use Imprimir → Salvar como PDF.`
|
|
293
|
+
(`{id}` = the format) and run it as a common step, with no format injection. For `pdf`, the
|
|
294
|
+
agent writes markdown and the `outputFile` is used with the extension `.md`: that is the
|
|
295
|
+
path that goes to `caminho.mjs` (`saida`, `conferir`) and that the next steps read (their
|
|
296
|
+
`inputFile`, too) — no `.pdf` is created.
|
|
299
297
|
b. **Content formats** — otherwise, read `_opencrew/best-practices.local/{format}.md` (the user's
|
|
300
298
|
own version, never touched by `update`) if it exists, else `_opencrew/core/best-practices/{format}.md`
|
|
301
299
|
(e.g., `_opencrew/core/best-practices/instagram-feed.md`)
|
|
@@ -500,6 +498,7 @@ root the one-line command of each moment and read the last line (`CAMINHO:OK {pa
|
|
|
500
498
|
is empty → this check never fires (legacy behavior).
|
|
501
499
|
|
|
502
500
|
0b. **Escritório** — if it is on, run `passo`, or `checkpoint` when the step is a checkpoint (see "Escritório" above).
|
|
501
|
+
0c. **Entrega** — before the first step that publishes or sends, once the final approval was given, run the delivery (see "Entrega" below).
|
|
503
502
|
|
|
504
503
|
1. **Pre-Step Input Validation** — MANDATORY. If the step's frontmatter declares an `inputFile`, the input comes from the `entrada` action, never from a path you build: validate that the input exists before executing the step. Run the `entrada` command (Output Path Transformation) with the `inputFile` as declared:
|
|
505
504
|
- `CAMINHO:OK {path}` → that path is the step's input (the newest version that has the file): read the input from it and execute the step.
|
|
@@ -668,10 +667,10 @@ When a step has `on_reject: {step-id}` (a review step):
|
|
|
668
667
|
the `format:` of the step that generated that file; a step with no `format:`, with an export
|
|
669
668
|
format (`pdf`, `csv`, `formatted-post`) or with one outside `[a-z0-9-]+` goes without `=formato`:
|
|
670
669
|
```bash
|
|
671
|
-
node _opencrew/core/scripts/verificar.mjs --crew "crews/{name}" --arquivo "{path1}={format1},{path2},…"
|
|
670
|
+
node _opencrew/core/scripts/verificar.mjs --crew "crews/{name}" --arquivo "{path1}={format1},{path2},…" --relatorio "crews/{name}/output/{run_id}/verificacao-ciclo-{N}.md"
|
|
672
671
|
```
|
|
673
|
-
|
|
674
|
-
into the reviewer's context as `--- VERIFICAÇÃO AUTOMÁTICA ---`. The reviewer must copy the
|
|
672
|
+
The script writes its report to that file (`{N}` = the cycle; do not save it yourself). Inject
|
|
673
|
+
the output into the reviewer's context as `--- VERIFICAÇÃO AUTOMÁTICA ---`. The reviewer must copy the
|
|
675
674
|
measured values from it (see best-practices `review.md`). If the checker did not run (no Node,
|
|
676
675
|
an error, or no `VERIFICACAO:` status line), tell the user, continue with the normal review and
|
|
677
676
|
repeat it at the final approval: "⚠️ A verificação automática não rodou: {motivo}".
|
|
@@ -694,11 +693,12 @@ When a step has `on_reject: {step-id}` (a review step):
|
|
|
694
693
|
{any other status} A revisão não aprovou o texto depois de {N} ciclos. Motivo: {parecer resumido}
|
|
695
694
|
|
|
696
695
|
1. Corrigir eu mesmo (eu edito o texto e você verifica de novo)
|
|
697
|
-
2. Aceitar assim mesmo
|
|
696
|
+
2. Aceitar assim mesmo (fica registrado na entrega)
|
|
698
697
|
3. Abortar
|
|
699
698
|
```
|
|
700
699
|
5. **Final approval checkpoint** (the checkpoint after the review): show the summary of the last
|
|
701
|
-
report — `Verificação automática: {N} bloqueios, {M} alertas, {Z} não medidos
|
|
700
|
+
report — `Verificação automática: {N} bloqueios, {M} alertas, {Z} não medidos`, with
|
|
701
|
+
`{P} a preencher` right after the blocks when the report counts any — plus the list
|
|
702
702
|
of alerts and the {Z} items not measured or not verified (the `Não medido` and `Não verificado`
|
|
703
703
|
lines under each file, not the "não é texto" line of **Notas**), one per line as
|
|
704
704
|
`{arquivo} — {motivo}`, then the lines under `**Notas:**` in that report, as they are written,
|
|
@@ -706,7 +706,8 @@ When a step has `on_reject: {step-id}` (a review step):
|
|
|
706
706
|
every file left unchecked by the safe-name rule. List the same way every file the path script
|
|
707
707
|
did not check (see Output Path Transformation). If the approved
|
|
708
708
|
text still contains `[PREENCHER: …]`, ask the user for each missing piece of real information
|
|
709
|
-
and write it into the text before approving.
|
|
709
|
+
and write it into the text before approving. If the user does not have it, do not insist and
|
|
710
|
+
never invent: keep the `[PREENCHER]`, say `Sem problema: deixo [PREENCHER: {o que falta}] no texto. Na entrega você escolhe entre preencher depois e entregar assim mesmo, com ressalva.` and go on.
|
|
710
711
|
|
|
711
712
|
### Step Execution Order (Summary)
|
|
712
713
|
|
|
@@ -715,6 +716,7 @@ For reference, the complete execution order for each pipeline step is:
|
|
|
715
716
|
```
|
|
716
717
|
0. Agent deselection check (skip step if its agent was deselected)
|
|
717
718
|
0b. Escritório command (passo or checkpoint) — only if it is on
|
|
719
|
+
0c. Entrega (delivery script) — only before the first step that publishes or sends
|
|
718
720
|
1. Pre-Step Input Validation (script gate: `entrada`)
|
|
719
721
|
2. Read step file
|
|
720
722
|
3. Check execution mode and execute (subagent / inline / checkpoint)
|
|
@@ -724,10 +726,18 @@ For reference, the complete execution order for each pipeline step is:
|
|
|
724
726
|
|
|
725
727
|
Steps 1 and 4 are binary script gates. If either fails, the pipeline does NOT advance — the user is consulted.
|
|
726
728
|
|
|
729
|
+
### Entrega
|
|
730
|
+
|
|
731
|
+
One script turns the approved files into `crews/{name}/output/{run_id}/entrega/` (a folder per channel, text ready to paste, a `LEIA-ME.md`) and copies what is ready to the folder of the project the user chose. Read `_opencrew/core/prompts/entrega.prompt.md` and follow it: how to build `{lista}`, when to add `--vai-publicar`, what to do with `ENTREGA:OK`, `ENTREGA:COM_RESSALVA` and `ENTREGA:INCOMPLETA`, the question about the folder of the project that keeps a copy (asked once per crew) and what to do with a script that did not run.
|
|
732
|
+
|
|
733
|
+
- **Command** — from the project root, by the safe-name rule (nome seguro), everything between double quotes: `node _opencrew/core/scripts/entregar.mjs --crew "crews/{name}" --run "{run_id}" --arquivo "{lista}"`
|
|
734
|
+
- **When** — once, after the final approval, immediately before the first step that publishes or sends (`side_effects: irreversible`, in the step or in the agent's skill); with no such step, after the last step. Always before the end-of-run command of the Escritório. If the irreversible step comes before the final approval (a crew built by an old version), the delivery runs at the end.
|
|
735
|
+
- **Crew with no final approval checkpoint** — same moments, and show `Esta crew não tem aprovação final: confira os arquivos antes de usar.` **Never** for a run that was rejected, aborted before the final approval or left with no approved file.
|
|
736
|
+
- The output of the script is the final summary of the run: show it as it came; if the run stops later, at an irreversible step, show it before stopping. After "Edit this content" changes an approved file, run it again.
|
|
737
|
+
|
|
727
738
|
### After Pipeline Completion
|
|
728
739
|
|
|
729
|
-
1.
|
|
730
|
-
(The run folder was created during initialization — no separate date subfolder needed)
|
|
740
|
+
1. **Entrega** — if the delivery has not run in this run, run it now (see "Entrega" above).
|
|
731
741
|
1b. **Escritório** — if it is on, run `concluir` (see "Escritório" above).
|
|
732
742
|
|
|
733
743
|
2. **Update crew memory** — write to BOTH files:
|
|
@@ -828,8 +838,7 @@ Steps 1 and 4 are binary script gates. If either fails, the pipeline does NOT ad
|
|
|
828
838
|
```
|
|
829
839
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
830
840
|
✅ Pipeline complete!
|
|
831
|
-
📁
|
|
832
|
-
📄 Output saved to: {output path}
|
|
841
|
+
📁 Delivery: crews/{name}/output/{run_id}/entrega/ — start with LEIA-ME.md
|
|
833
842
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
834
843
|
|
|
835
844
|
What would you like to do?
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Linha de comando do `entregar.mjs`: as opções, a lista `caminho=formato` e a linha de uso.
|
|
2
|
+
// Specs: fase-u3a1-pasta-de-entrega.md, §3 e §6, e fase-u3a2-entrega-no-projeto.md, §3
|
|
3
|
+
// (repositório do OpenCrew).
|
|
4
|
+
import { lerItemDaLista } from '../verificar/argumentos.mjs';
|
|
5
|
+
|
|
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]';
|
|
7
|
+
|
|
8
|
+
const OPCAO = /^--(crew|run|arquivo|vai-publicar|destino|lembrar-destino)(?:=(.*))?$/s;
|
|
9
|
+
const CHAVE = { 'lembrar-destino': 'lembrarDestino' };
|
|
10
|
+
|
|
11
|
+
/** Texto que veio da linha de comando e volta numa mensagem: uma linha só, até 200 caracteres. */
|
|
12
|
+
export const limpar = (valor) => String(valor).replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
13
|
+
|
|
14
|
+
function guardar(args, nome, valor) {
|
|
15
|
+
if (nome === 'arquivo') args.arquivos.push(valor);
|
|
16
|
+
else if (nome === 'vai-publicar') args.vaiPublicar.push(valor.trim().toLowerCase());
|
|
17
|
+
else args[CHAVE[nome] ?? nome] = valor;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
export function lerArgs(argv) {
|
|
28
|
+
const args = { arquivos: [], vaiPublicar: [], aceitar: false, ajuda: false, desconhecida: null };
|
|
29
|
+
let naLista = false;
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const [, nome, colado] = argv[i].match(OPCAO) ?? [];
|
|
32
|
+
const solto = !nome && !argv[i].startsWith('--');
|
|
33
|
+
if (nome) guardar(args, nome, colado ?? (i + 1 < argv.length && !argv[i + 1].startsWith('--') ? argv[++i] : ''));
|
|
34
|
+
else if (argv[i] === '--ajuda') args.ajuda = true;
|
|
35
|
+
else if (argv[i] === '--aceitar-pendencias') args.aceitar = true;
|
|
36
|
+
else if (solto && naLista) args.arquivos.push(argv[i]);
|
|
37
|
+
else args.desconhecida ??= argv[i];
|
|
38
|
+
naLista = nome ? nome === 'arquivo' : solto && naLista;
|
|
39
|
+
}
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A lista de `--arquivo` → `[{ arquivo, formato }]` (`formato` null quando não veio `=formato`). */
|
|
44
|
+
export function lerLista(arquivos) {
|
|
45
|
+
const textos = arquivos.join(',').split(',').map((s) => s.trim()).filter(Boolean);
|
|
46
|
+
return textos.map(lerItemDaLista).map((i) => (typeof i === 'string' ? { arquivo: i, formato: null } : i));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** O `--run` é um segmento só: sem `/`, `\` nem `..`. */
|
|
50
|
+
export const runValido = (run) => !/[\\/]/.test(run) && run !== '..' && run !== '.';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Canais da entrega: a pasta de cada formato (o `platform:` do best-practice), os nomes que o
|
|
2
|
+
// LEIA-ME mostra e o que nunca entra numa entrega.
|
|
3
|
+
// Spec: fase-u3a1-pasta-de-entrega.md, regras 1 e 3 (repositório do OpenCrew).
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { lerFrontmatter, lerTexto } from '../verificar/leitura.mjs';
|
|
7
|
+
|
|
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' };
|
|
10
|
+
export const OUTROS = 'outros';
|
|
11
|
+
export const EDITAVEIS = 'editaveis';
|
|
12
|
+
export const ehCanal = (pasta) => Object.hasOwn(CANAIS, pasta);
|
|
13
|
+
/** Como a pasta é chamada numa mensagem: o nome do canal, ou `outros/`. */
|
|
14
|
+
export const nomeDaPasta = (pasta) => CANAIS[pasta] ?? `${pasta}/`;
|
|
15
|
+
|
|
16
|
+
async function plataformaEm(raiz, pasta, formato) {
|
|
17
|
+
const arquivo = path.join(raiz, '_opencrew', ...pasta, `${formato}.md`);
|
|
18
|
+
if (!existsSync(arquivo)) return null;
|
|
19
|
+
const valor = lerFrontmatter(await lerTexto(arquivo))?.platform;
|
|
20
|
+
return typeof valor === 'string' && valor.trim() ? valor.trim().toLowerCase() : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Canal de um formato: o `platform:` de `_opencrew/best-practices.local/<formato>.md`, quando o
|
|
25
|
+
* arquivo o declara; senão, o do core. Sem formato, sem best-practice, sem `platform:` ou com
|
|
26
|
+
* plataforma que não é uma das sete pastas: null (o arquivo vai para `outros/`).
|
|
27
|
+
*/
|
|
28
|
+
export async function canalDoFormato(raiz, formato) {
|
|
29
|
+
if (!formato || !/^[a-z0-9-]+$/.test(formato)) return null;
|
|
30
|
+
const local = await plataformaEm(raiz, ['best-practices.local'], formato);
|
|
31
|
+
const plataforma = local ?? (await plataformaEm(raiz, ['core', 'best-practices'], formato));
|
|
32
|
+
return plataforma && ehCanal(plataforma) ? plataforma : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PASTAS_DE_SERVICO = new Set(['entrega', 'entrega.tmp', 'export']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Arquivo de serviço nunca entra, mesmo listado: `verificacao-*.md`, `ressalvas.json` (as
|
|
39
|
+
* pendências aceitas, fase-u3a2-entrega-no-projeto.md, regra 18), `publicado.json`, o que está
|
|
40
|
+
* em `entrega/`, `entrega.tmp/` ou `export/` de uma execução e o `caption.txt` direto na pasta
|
|
41
|
+
* da execução (é ali que o publicador o grava; dentro de uma pasta `vN` ele é saída de passo).
|
|
42
|
+
* E o `copia.json` direto na pasta da execução: o retrato do que foi copiado (mesma spec, regra 14).
|
|
43
|
+
* @param {string} rel caminho relativo ao projeto, com `/`
|
|
44
|
+
*/
|
|
45
|
+
export function ehDeServico(rel) {
|
|
46
|
+
const partes = rel.toLowerCase().split('/');
|
|
47
|
+
const nome = partes.at(-1);
|
|
48
|
+
if (/^verificacao-.*\.md$/.test(nome) || nome === 'publicado.json' || nome === 'ressalvas.json') return true;
|
|
49
|
+
const naSaida = partes[0] === 'crews' && partes[2] === 'output';
|
|
50
|
+
if (!naSaida) return false;
|
|
51
|
+
if (partes.slice(4, -1).some((p) => PASTAS_DE_SERVICO.has(p))) return true;
|
|
52
|
+
return (nome === 'caption.txt' || nome === 'copia.json') && partes.length === 5;
|
|
53
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Comparação entre o que a entrega copiaria agora e a cópia que já está no destino: é ela que
|
|
2
|
+
// decide se nada muda, se só há arquivos a acrescentar ou se a entrega vai para outra pasta.
|
|
3
|
+
// Com o retrato do que foi copiado (`copia.json`, ver `retrato.mjs`), a comparação é com ele: o que o
|
|
4
|
+
// usuário fez na cópia depois não conta. Sem retrato, é com os arquivos da pasta.
|
|
5
|
+
// Este módulo só lê. Spec: fase-u3a2-entrega-no-projeto.md, regra 14 (repositório do OpenCrew).
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
// Em texto, CRLF e LF são o mesmo conteúdo (o editor ou a sincronização trocam); o resto, byte a byte.
|
|
11
|
+
const DE_TEXTO = new Set(['.txt', '.md', '.html', '.htm', '.csv', '.json']);
|
|
12
|
+
export const emLf = (texto) => texto.replace(/\r\n/g, '\n');
|
|
13
|
+
|
|
14
|
+
/** Os bytes que um arquivo da entrega tem: o texto gerado, ou os do arquivo de origem. */
|
|
15
|
+
export const bytesDe = async (a) => (a.texto != null ? Buffer.from(a.texto, 'utf8') : fs.readFile(a.de));
|
|
16
|
+
|
|
17
|
+
const ehTexto = (nome) => DE_TEXTO.has(path.extname(nome).toLowerCase());
|
|
18
|
+
const mesmoConteudo = (nome, a, b) => a.equals(b) || (ehTexto(nome) && emLf(a.toString('utf8')) === emLf(b.toString('utf8')));
|
|
19
|
+
|
|
20
|
+
/** Os arquivos sob uma pasta, com o prefixo dado e `/`; pasta que não existe: nenhum. */
|
|
21
|
+
async function listar(pasta, prefixo) {
|
|
22
|
+
const entradas = await fs.readdir(pasta, { withFileTypes: true }).catch(() => []);
|
|
23
|
+
const achados = [];
|
|
24
|
+
for (const e of entradas) {
|
|
25
|
+
if (e.isDirectory()) achados.push(...(await listar(path.join(pasta, e.name), `${prefixo}${e.name}/`)));
|
|
26
|
+
else achados.push(`${prefixo}${e.name}`);
|
|
27
|
+
}
|
|
28
|
+
return achados;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const REENTREGA = '-reentrega-';
|
|
32
|
+
|
|
33
|
+
/** 1 para `<run>`, N para `<run>-reentrega-N` (N ≥ 2), 0 para qualquer outro nome. */
|
|
34
|
+
function numeroDe(nome, run) {
|
|
35
|
+
if (nome === run) return 1;
|
|
36
|
+
const n = nome.startsWith(`${run}${REENTREGA}`) ? nome.slice(run.length + REENTREGA.length) : '';
|
|
37
|
+
return /^[1-9]\d*$/.test(n) && Number(n) >= 2 ? Number(n) : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* As pastas desta execução no destino, da mais antiga para a mais nova: `<run>` é a 1 e
|
|
42
|
+
* `<run>-reentrega-N` é a N. Só pastas: arquivo com um desses nomes não conta.
|
|
43
|
+
* @returns {Promise<{ n: number, nome: string }[]>}
|
|
44
|
+
*/
|
|
45
|
+
export async function pastasDaExecucao(destino, run) {
|
|
46
|
+
const entradas = await fs.readdir(destino, { withFileTypes: true }).catch(() => []);
|
|
47
|
+
const pastas = entradas.filter((e) => e.isDirectory()).map((e) => ({ n: numeroDe(e.name, run), nome: e.name }));
|
|
48
|
+
return pastas.filter((p) => p.n).sort((a, b) => a.n - b.n);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hashDe = (nome, bytes) => createHash('sha1').update(ehTexto(nome) ? emLf(bytes.toString('utf8')) : bytes).digest('hex');
|
|
52
|
+
|
|
53
|
+
/** O retrato de uma lista de arquivos da entrega: `{ 'pasta/nome': hash }` (em texto, sem contar CRLF/LF). */
|
|
54
|
+
export async function retratoDe(arquivos) {
|
|
55
|
+
const retrato = {};
|
|
56
|
+
for (const a of arquivos) retrato[`${a.pasta}/${a.nome}`] = hashDe(a.nome, await bytesDe(a));
|
|
57
|
+
return retrato;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A mesma pergunta de `oQueFalta`, respondida pelo retrato do que foi copiado para a pasta. O que
|
|
62
|
+
* o usuário editou, apagou ou acrescentou na cópia não é diferença. Arquivo novo cujo lugar já
|
|
63
|
+
* está ocupado só conta como copiado quando o conteúdo é o mesmo; senão, é diferença.
|
|
64
|
+
*/
|
|
65
|
+
async function contraORetrato(pasta, esperados, canais, ignorar, retrato) {
|
|
66
|
+
const saiu = (rel) => canais.has(rel.split('/')[0]) && !ignorar.has(rel) && !esperados.has(rel);
|
|
67
|
+
if (Object.keys(retrato).some(saiu)) return null;
|
|
68
|
+
const faltam = [];
|
|
69
|
+
for (const [rel, a] of esperados) {
|
|
70
|
+
const bytes = await bytesDe(a);
|
|
71
|
+
if (Object.hasOwn(retrato, rel)) {
|
|
72
|
+
if (retrato[rel] !== hashDe(rel, bytes)) return null;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const noDisco = await fs.readFile(path.join(pasta, rel)).catch(() => null);
|
|
76
|
+
if (noDisco == null) faltam.push(a);
|
|
77
|
+
else if (!mesmoConteudo(rel, noDisco, bytes)) return null;
|
|
78
|
+
}
|
|
79
|
+
return faltam;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compara os arquivos que seriam copiados agora com uma pasta de cópia. Só as pastas de canal que
|
|
84
|
+
* seriam copiadas agora são olhadas: a raiz da cópia e as outras pastas ficam de fora.
|
|
85
|
+
* @param {string} pasta a cópia, absoluta · @param {object[]} arquivos `{ pasta, nome, texto | de }`
|
|
86
|
+
* @param {Set<string>} ignorar `pasta/nome` do que a entrega tem mas não copia agora
|
|
87
|
+
* @param {object|null} [retrato] o retrato do que foi copiado para esta pasta; null = comparar com os arquivos
|
|
88
|
+
* @returns {Promise<null | object[]>} null = diferente (arquivo que mudou, saiu ou está a mais);
|
|
89
|
+
* senão, os arquivos que faltam na cópia (nenhum = igual)
|
|
90
|
+
*/
|
|
91
|
+
export async function oQueFalta(pasta, arquivos, ignorar, retrato = null) {
|
|
92
|
+
const esperados = new Map(arquivos.map((a) => [`${a.pasta}/${a.nome}`, a]));
|
|
93
|
+
if (retrato) return contraORetrato(pasta, esperados, new Set(arquivos.map((a) => a.pasta)), ignorar, retrato);
|
|
94
|
+
const faltam = new Map(esperados);
|
|
95
|
+
for (const canal of new Set(arquivos.map((a) => a.pasta))) {
|
|
96
|
+
for (const rel of await listar(path.join(pasta, canal), `${canal}/`)) {
|
|
97
|
+
if (ignorar.has(rel)) continue;
|
|
98
|
+
const a = esperados.get(rel);
|
|
99
|
+
if (!a || !mesmoConteudo(rel, await fs.readFile(path.join(pasta, rel)), await bytesDe(a))) return null;
|
|
100
|
+
faltam.delete(rel);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return [...faltam.values()];
|
|
104
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// A cópia da entrega para a pasta do projeto que o usuário escolheu: uma pasta por execução
|
|
2
|
+
// (`<destino>/<run_id>/`). Nada é sobrescrito, menos o `LEIA-ME.md` da cópia, que é do script.
|
|
3
|
+
// Pasta nova é montada em `<pasta>.tmp/`, ao lado, e renomeada no fim: nada fica pela metade.
|
|
4
|
+
// O script só apaga o temporário que ele mesmo criou. O que o usuário edita na cópia fica: a
|
|
5
|
+
// entrega seguinte é comparada com o retrato do que foi copiado (`retrato.mjs`), não com a pasta.
|
|
6
|
+
// Spec: fase-u3a2-entrega-no-projeto.md, regras 14 e 15 (repositório do OpenCrew).
|
|
7
|
+
import { constants, existsSync, promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { emLf, oQueFalta, pastasDaExecucao } from './comparar.mjs';
|
|
10
|
+
|
|
11
|
+
export const AVISO = (pasta) => `Há uma entrega mais nova desta execução em \`${pasta}\`.`;
|
|
12
|
+
const AVISO_ANTERIOR = /^Há uma entrega mais nova desta execução em `[^`\n]*`\.\r?\n\r?\n/;
|
|
13
|
+
const LEIAME = 'LEIA-ME.md';
|
|
14
|
+
const apagar = (pasta) => fs.rm(pasta, { recursive: true, force: true, maxRetries: 3 });
|
|
15
|
+
const ehPasta = async (p) => fs.stat(p).then((s) => s.isDirectory(), () => false);
|
|
16
|
+
|
|
17
|
+
/** Grava um arquivo que ainda não existe ali: se existir, falha (nunca por cima). */
|
|
18
|
+
async function gravarNovo(alvo, a) {
|
|
19
|
+
await fs.mkdir(path.dirname(alvo), { recursive: true });
|
|
20
|
+
if (a.texto != null) await fs.writeFile(alvo, a.texto, { encoding: 'utf8', flag: 'wx' });
|
|
21
|
+
else await fs.copyFile(a.de, alvo, constants.COPYFILE_EXCL);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Monta a pasta nova no temporário e a põe no lugar; em falha, o temporário some. */
|
|
25
|
+
async function montar(pasta, arquivos, leiame, em) {
|
|
26
|
+
const tmp = `${pasta}.tmp`;
|
|
27
|
+
if (await ehPasta(tmp)) await apagar(tmp); // sobra de uma chamada interrompida
|
|
28
|
+
await fs.mkdir(tmp);
|
|
29
|
+
try {
|
|
30
|
+
for (const a of arquivos) {
|
|
31
|
+
em.alvo = path.join(pasta, a.pasta, a.nome);
|
|
32
|
+
await gravarNovo(path.join(tmp, a.pasta, a.nome), a);
|
|
33
|
+
}
|
|
34
|
+
em.alvo = path.join(pasta, LEIAME);
|
|
35
|
+
await fs.writeFile(path.join(tmp, LEIAME), leiame, 'utf8');
|
|
36
|
+
em.alvo = pasta;
|
|
37
|
+
await fs.rename(tmp, pasta);
|
|
38
|
+
} catch (erro) {
|
|
39
|
+
await apagar(tmp).catch(() => {});
|
|
40
|
+
throw erro;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A pasta mais alta do caminho que ainda não existe (é a que o script cria); null quando todas existem. */
|
|
45
|
+
function primeiraQueFalta(pasta) {
|
|
46
|
+
let falta = null;
|
|
47
|
+
for (let p = pasta; !existsSync(p); p = path.dirname(p)) falta = p;
|
|
48
|
+
return falta;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Desfaz as pastas vazias que esta chamada criou, de `pasta` até `criada`. */
|
|
52
|
+
async function desfazer(criada, pasta) {
|
|
53
|
+
for (let p = pasta; ; p = path.dirname(p)) {
|
|
54
|
+
await fs.rmdir(p);
|
|
55
|
+
if (p === criada) return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Cria `pasta` com a entrega inteira. @returns {Promise<boolean>} o destino precisou ser criado? */
|
|
60
|
+
async function criar(pasta, arquivos, leiame, em) {
|
|
61
|
+
em.alvo = pasta;
|
|
62
|
+
if (existsSync(pasta)) throw new Error('já existe um arquivo com o nome da pasta');
|
|
63
|
+
const destino = path.dirname(pasta);
|
|
64
|
+
const criada = primeiraQueFalta(destino);
|
|
65
|
+
try {
|
|
66
|
+
await fs.mkdir(destino, { recursive: true });
|
|
67
|
+
await montar(pasta, arquivos, leiame, em);
|
|
68
|
+
} catch (erro) {
|
|
69
|
+
if (criada) await desfazer(criada, destino).catch(() => {});
|
|
70
|
+
throw erro;
|
|
71
|
+
}
|
|
72
|
+
return Boolean(criada);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** O LEIA-ME da cópia é regravado quando muda (CRLF no lugar de LF não é mudança), sempre por último. */
|
|
76
|
+
async function regravarLeiame(pasta, leiame, em) {
|
|
77
|
+
em.alvo = path.join(pasta, LEIAME);
|
|
78
|
+
const atual = await fs.readFile(em.alvo, 'utf8').catch(() => null);
|
|
79
|
+
if (atual == null || emLf(atual) !== leiame) await fs.writeFile(em.alvo, leiame, 'utf8');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** As pastas anteriores ganham, na primeira linha do LEIA-ME, o aviso da pasta nova (um só). */
|
|
83
|
+
async function avisarAnteriores(destino, anteriores, pastaNova, em) {
|
|
84
|
+
for (const { nome } of anteriores) {
|
|
85
|
+
em.alvo = path.join(destino, nome, LEIAME);
|
|
86
|
+
const atual = await fs.readFile(em.alvo, 'utf8').catch(() => null);
|
|
87
|
+
if (atual != null) await fs.writeFile(em.alvo, `${AVISO(pastaNova)}\n\n${atual.replace(AVISO_ANTERIOR, '')}`, 'utf8');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** O LEIA-ME da pasta número `n` (1 = `<run>`; N = `<run>-reentrega-N`): um texto fixo, ou quem o monta. */
|
|
92
|
+
const leiameDe = (leiame, n) => (typeof leiame === 'function' ? leiame(n) : leiame);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Completa a pasta mais nova com o que falta, ou cria a pasta de reentrega quando algo mudou. A
|
|
96
|
+
* comparação é com o retrato do que foi copiado para ela; sem retrato, com os arquivos dela.
|
|
97
|
+
*/
|
|
98
|
+
async function atualizar({ destino, run, arquivos, ignorar, leiame, retratos }, existentes, em) {
|
|
99
|
+
const ultima = existentes.at(-1);
|
|
100
|
+
const pasta = path.join(destino.abs, ultima.nome);
|
|
101
|
+
const faltam = await oQueFalta(pasta, arquivos, ignorar, retratos?.[`${destino.rel}/${ultima.nome}`] ?? null);
|
|
102
|
+
if (!faltam) {
|
|
103
|
+
const nome = `${run}-reentrega-${ultima.n + 1}`;
|
|
104
|
+
await criar(path.join(destino.abs, nome), arquivos, leiameDe(leiame, ultima.n + 1), em);
|
|
105
|
+
await avisarAnteriores(destino.abs, existentes, `${destino.rel}/${nome}`, em);
|
|
106
|
+
return { tipo: 'reentrega', pasta: `${destino.rel}/${nome}` };
|
|
107
|
+
}
|
|
108
|
+
for (const a of faltam) {
|
|
109
|
+
em.alvo = path.join(pasta, a.pasta, a.nome);
|
|
110
|
+
await gravarNovo(em.alvo, a);
|
|
111
|
+
}
|
|
112
|
+
await regravarLeiame(pasta, leiameDe(leiame, ultima.n), em);
|
|
113
|
+
return { tipo: faltam.length ? 'completada' : 'igual', pasta: `${destino.rel}/${ultima.nome}`, novos: faltam.length };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Copia para o destino o que está pronto.
|
|
118
|
+
* @param {object} o
|
|
119
|
+
* @param {{ rel: string, abs: string }} o.destino a pasta escolhida, já validada · @param {string} o.run
|
|
120
|
+
* @param {object[]} o.arquivos o que é copiado agora: `{ pasta, nome, texto | de }`
|
|
121
|
+
* @param {Set<string>} o.ignorar `pasta/nome` do que a entrega tem e não é copiado agora
|
|
122
|
+
* @param {string|function(number): string} o.leiame o LEIA-ME da cópia, ou quem o monta para a pasta número N
|
|
123
|
+
* @param {object} [o.retratos] por pasta de cópia (relativa ao projeto), o retrato do que foi copiado
|
|
124
|
+
* @returns {Promise<object>} `{ tipo, pasta, novos, criouDestino }` — `tipo`: nova, igual,
|
|
125
|
+
* completada ou reentrega; `pasta`: relativa ao projeto · ou `{ tipo: 'falha', arquivo }`, com o
|
|
126
|
+
* caminho absoluto que não pôde ser gravado
|
|
127
|
+
*/
|
|
128
|
+
export async function copiar(o) {
|
|
129
|
+
const em = { alvo: o.destino.abs };
|
|
130
|
+
try {
|
|
131
|
+
const existentes = await pastasDaExecucao(o.destino.abs, o.run);
|
|
132
|
+
if (existentes.length) return await atualizar(o, existentes, em);
|
|
133
|
+
const criouDestino = await criar(path.join(o.destino.abs, o.run), o.arquivos, leiameDe(o.leiame, 1), em);
|
|
134
|
+
return { tipo: 'nova', pasta: `${o.destino.rel}/${o.run}`, criouDestino };
|
|
135
|
+
} catch {
|
|
136
|
+
return { tipo: 'falha', arquivo: em.alvo };
|
|
137
|
+
}
|
|
138
|
+
}
|