primocode 8.11.1 → 8.11.2
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/lib/catalogo.js +3 -1
- package/lib/studio.js +89 -5
- package/lib/tools.js +2 -0
- package/lib/ui.js +1 -0
- package/package.json +2 -2
- package/studio/ferramentas/00-protocolo.md +15 -2
- package/studio/ferramentas/01-modelos.md +2 -0
- package/studio/server.py +188 -4
package/lib/catalogo.js
CHANGED
|
@@ -83,7 +83,9 @@ const GRUPOS = [
|
|
|
83
83
|
args: 'ferramenta: prisma | tela | prosa | grade | corte | traco. titulo. doc: objeto no formato que studio_spec daquela ferramenta descreve.' },
|
|
84
84
|
{ tool: 'studio_ler', escreve: false, faz: 'lê algo que já foi criado', args: 'id.' },
|
|
85
85
|
{ tool: 'studio_atualizar', escreve: false, faz: 'ajusta o que já existe, sem refazer do zero',
|
|
86
|
-
args: 'id. titulo (opcional). doc (opcional).' },
|
|
86
|
+
args: 'id. titulo (opcional). doc (opcional): só as chaves que MUDAM — o resto do documento fica intacto.' },
|
|
87
|
+
{ tool: 'studio_versoes', escreve: false, faz: 'histórico de um arquivo, e volta a uma versão anterior',
|
|
88
|
+
args: 'id. restaurar (opcional): "anterior" desfaz a última mudança, ou o nome de uma versão da lista.' },
|
|
87
89
|
{ tool: 'studio_listar', escreve: false, faz: 'lista o que já existe no estúdio', args: null },
|
|
88
90
|
{ tool: 'studio_midia', escreve: false, faz: 'manda imagem, música ou vídeo seu para usar nas peças',
|
|
89
91
|
args: 'arquivo (opcional): caminho no projeto — sem ele, lista a galeria. nome (opcional).' },
|
package/lib/studio.js
CHANGED
|
@@ -112,13 +112,36 @@ function pedir(metodo, rota, corpo, timeout = 30000) {
|
|
|
112
112
|
path: rota,
|
|
113
113
|
method: metodo,
|
|
114
114
|
timeout,
|
|
115
|
-
|
|
115
|
+
// Este cabeçalho diz ao Studio que quem fala é a IA, não o editor
|
|
116
|
+
// do navegador. O servidor valida o documento com rigor só neste
|
|
117
|
+
// caso: erro de formato tem de voltar para o agente como lição, e
|
|
118
|
+
// não pode fazer o salvamento de uma pessoa que está digitando
|
|
119
|
+
// falhar no meio da edição.
|
|
120
|
+
headers: Object.assign({ 'X-Primo-Agente': '1' },
|
|
121
|
+
dados ? { 'Content-Type': 'application/json', 'Content-Length': dados.length } : {}),
|
|
116
122
|
}, (res) => {
|
|
117
123
|
let texto = '';
|
|
118
124
|
res.setEncoding('utf8');
|
|
119
125
|
res.on('data', (c) => { texto += c; });
|
|
120
126
|
res.on('end', () => {
|
|
121
|
-
if (res.statusCode >= 400)
|
|
127
|
+
if (res.statusCode >= 400) {
|
|
128
|
+
// A mensagem do servidor é escrita PARA o agente ("tema X
|
|
129
|
+
// não existe, os temas são..."). Envolver em "HTTP 400:
|
|
130
|
+
// {json}" só afasta a lição do olho dele.
|
|
131
|
+
let motivo = texto.slice(0, 400);
|
|
132
|
+
let corpoErro = null;
|
|
133
|
+
try {
|
|
134
|
+
corpoErro = JSON.parse(texto);
|
|
135
|
+
motivo = corpoErro.erro || motivo;
|
|
136
|
+
} catch { /* texto cru serve */ }
|
|
137
|
+
const erro = new Error(motivo);
|
|
138
|
+
erro.corpo = corpoErro;
|
|
139
|
+
// O código vem no objeto, e não mais embutido no texto:
|
|
140
|
+
// quem trata 404 (comIdReal) precisa saber que é 404 sem
|
|
141
|
+
// depender de a mensagem começar com "HTTP 404".
|
|
142
|
+
erro.status = res.statusCode;
|
|
143
|
+
return reject(erro);
|
|
144
|
+
}
|
|
122
145
|
try { resolve(JSON.parse(texto)); }
|
|
123
146
|
catch { resolve({ texto }); }
|
|
124
147
|
});
|
|
@@ -264,7 +287,7 @@ async function comIdReal(id, fn) {
|
|
|
264
287
|
try {
|
|
265
288
|
return await fn();
|
|
266
289
|
} catch (e) {
|
|
267
|
-
if (
|
|
290
|
+
if (!(e && e.status === 404)) throw e;
|
|
268
291
|
let existem = [];
|
|
269
292
|
try { existem = (await pedir('GET', '/api/arquivos')).map((a) => a.id).slice(0, 8); } catch {}
|
|
270
293
|
return {
|
|
@@ -284,13 +307,74 @@ const criar = ({ ferramenta, titulo, doc }) => comStudio(async () => {
|
|
|
284
307
|
return { ok: true, ...r };
|
|
285
308
|
});
|
|
286
309
|
|
|
310
|
+
// O que o agente recebe de volta depois de mexer num arquivo. Devolver o
|
|
311
|
+
// documento inteiro era caro e inútil: um vídeo de oito cenas volta como
|
|
312
|
+
// milhares de tokens que ele acabou de escrever. O que ele precisa saber é
|
|
313
|
+
// que gravou, quantas peças ficaram e como voltar atrás.
|
|
314
|
+
const resumoDoArquivo = (r) => {
|
|
315
|
+
const doc = r.doc || {};
|
|
316
|
+
const pecas = (doc.cenas || doc.blocos || doc.abas || []).length;
|
|
317
|
+
return {
|
|
318
|
+
id: r.id, titulo: r.titulo, ferramenta: r.ferramenta,
|
|
319
|
+
pecas, tema: doc.tema,
|
|
320
|
+
temNarracao: !!(doc.narracao || (doc.cenas || []).some((c) => c && c.narracao)),
|
|
321
|
+
temTrilha: !!doc.trilha,
|
|
322
|
+
// O mesmo link de sempre: ajustar não cria peça nova, e é isso que o
|
|
323
|
+
// agente precisa dizer ao usuário no finish.
|
|
324
|
+
// localhost, e não 127.0.0.1, para bater com o link que studio_criar
|
|
325
|
+
// já deu ao usuário — dois endereços para a mesma peça confundem.
|
|
326
|
+
ver: `http://localhost:${PORTA}/v/${r.id}`,
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
|
|
287
330
|
const atualizar = ({ id, titulo, doc }) => comStudio(() => comIdReal(id, async () => {
|
|
288
331
|
if (!id) return { ok: false, error: 'Falta o id do arquivo.' };
|
|
289
332
|
const corpo = {};
|
|
290
333
|
if (titulo != null) corpo.titulo = titulo;
|
|
291
334
|
if (doc != null) corpo.doc = doc;
|
|
292
335
|
const r = await pedir('PUT', `/api/arquivos/${encodeURIComponent(id)}`, corpo);
|
|
293
|
-
|
|
336
|
+
const saida = { ok: true, ...resumoDoArquivo(r) };
|
|
337
|
+
// A mescla é a razão de isto não ter destruído nada: as chaves que você
|
|
338
|
+
// não mandou continuam lá. Dizer isso evita a rodada seguinte em que o
|
|
339
|
+
// agente "reconstrói" o que nunca se perdeu.
|
|
340
|
+
saida.nota = 'Só as chaves que você mandou mudaram; o resto do documento continua como estava.';
|
|
341
|
+
if (r.versao_anterior) saida.desfazer = `studio_versoes com id "${r.id}" e restaurar "${r.versao_anterior}" volta ao estado anterior.`;
|
|
342
|
+
return saida;
|
|
343
|
+
}));
|
|
344
|
+
|
|
345
|
+
// ── Histórico ────────────────────────────────────────────────────────────
|
|
346
|
+
// Existe porque uma vez não existiu: um ajuste apagou oito cenas de trabalho
|
|
347
|
+
// e não havia de onde tirar de volta.
|
|
348
|
+
const versoesDe = ({ id, restaurar }) => comStudio(() => comIdReal(id, async () => {
|
|
349
|
+
if (!id) return { ok: false, error: 'Falta o id do arquivo.' };
|
|
350
|
+
if (restaurar) {
|
|
351
|
+
const alvo = restaurar === true || restaurar === 'anterior' ? null : String(restaurar);
|
|
352
|
+
try {
|
|
353
|
+
const r = await pedir('POST', `/api/arquivos/${encodeURIComponent(id)}/restaurar`,
|
|
354
|
+
alvo ? { versao: alvo } : {});
|
|
355
|
+
return { ok: true, ...r };
|
|
356
|
+
} catch (e) {
|
|
357
|
+
// 404 aqui pode ser o arquivo OU a versão. Quando o servidor manda
|
|
358
|
+
// a lista junto, o arquivo existe e quem não existe é a versão —
|
|
359
|
+
// deixar comIdReal dizer "o id não existe" mandaria o agente
|
|
360
|
+
// caçar um problema que não é o dele.
|
|
361
|
+
if (e && e.status === 404 && e.corpo && Array.isArray(e.corpo.versoes)) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
error: `A versão "${alvo}" não existe.`,
|
|
365
|
+
versoes: e.corpo.versoes,
|
|
366
|
+
comoCorrigir: 'Use um "versao" desta lista, copiado LITERALMENTE, ou "anterior" para desfazer a última mudança.',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
throw e;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const r = await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}/versoes`);
|
|
373
|
+
return {
|
|
374
|
+
ok: true,
|
|
375
|
+
versoes: r.versoes || [],
|
|
376
|
+
comoVoltar: 'Para voltar, chame de novo com restaurar: "<versao>" — ou restaurar: "anterior" para desfazer a última mudança.',
|
|
377
|
+
};
|
|
294
378
|
}));
|
|
295
379
|
|
|
296
380
|
// ── Mídia: a imagem de fundo e a música do usuário ───────────────────────
|
|
@@ -338,6 +422,6 @@ const midiaEnviar = ({ arquivo, nome }) => comStudio(async () => {
|
|
|
338
422
|
module.exports = {
|
|
339
423
|
BASE, PORTA, CASA,
|
|
340
424
|
garantirNoAr, estaNoAr, prepararCasa, pythonCmd,
|
|
341
|
-
manual, ferramentas, spec, listar, ler, criar, atualizar,
|
|
425
|
+
manual, ferramentas, spec, listar, ler, criar, atualizar, versoesDe,
|
|
342
426
|
midiaListar, midiaEnviar,
|
|
343
427
|
};
|
package/lib/tools.js
CHANGED
|
@@ -535,6 +535,8 @@ async function despachar(name, args, ctx) {
|
|
|
535
535
|
return studio.ler(args.id);
|
|
536
536
|
case 'studio_atualizar':
|
|
537
537
|
return studio.atualizar({ id: args.id, titulo: args.titulo, doc: args.doc });
|
|
538
|
+
case 'studio_versoes':
|
|
539
|
+
return studio.versoesDe({ id: args.id, restaurar: args.restaurar });
|
|
538
540
|
case 'studio_listar':
|
|
539
541
|
return studio.listar();
|
|
540
542
|
case 'studio_midia':
|
package/lib/ui.js
CHANGED
|
@@ -278,6 +278,7 @@ function actionHeader(name, args) {
|
|
|
278
278
|
desktop_double_click:'Desktop·2click', desktop_open_app:'Desktop·app',
|
|
279
279
|
studio_manual:'Estúdio·manual', studio_spec:'Estúdio·spec', studio_criar:'Estúdio·criar',
|
|
280
280
|
studio_ler:'Estúdio·ler', studio_atualizar:'Estúdio·ajustar', studio_listar:'Estúdio·lista',
|
|
281
|
+
studio_versoes:'Estúdio·histórico',
|
|
281
282
|
desktop_windows:'Desktop·janelas', desktop_focus:'Desktop·foco',
|
|
282
283
|
desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
|
|
283
284
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "8.11.
|
|
3
|
+
"version": "8.11.2",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/primocode.js",
|
|
11
|
-
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
|
@@ -106,6 +106,19 @@ A resposta do POST traz:
|
|
|
106
106
|
|
|
107
107
|
## Iterar
|
|
108
108
|
|
|
109
|
-
Para ajustar algo que o usuário pediu depois,
|
|
110
|
-
|
|
109
|
+
Para ajustar algo que o usuário pediu depois, mande no `PUT` **só as chaves que
|
|
110
|
+
mudam**. Elas entram por cima do documento; o que você não citou fica como estava.
|
|
111
|
+
Trocar o tema de um vídeo pronto é `{"doc":{"tema":"papel"}}` — as cenas, a narração
|
|
112
|
+
e a trilha continuam lá. Não recrie do zero: o link já está com o usuário e deve
|
|
111
113
|
continuar valendo.
|
|
114
|
+
|
|
115
|
+
Se quiser mesmo jogar fora o documento inteiro e pôr outro no lugar, mande
|
|
116
|
+
`{"doc": {...}, "substituir": true}`. Sem essa chave, nada é apagado.
|
|
117
|
+
|
|
118
|
+
| Método | Rota | O que faz |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `GET` | `/api/arquivos/<id>/versoes` | as últimas 20 versões, a mais nova primeiro |
|
|
121
|
+
| `POST` | `/api/arquivos/<id>/restaurar` | `{versao?}` — sem `versao`, desfaz a última mudança |
|
|
122
|
+
|
|
123
|
+
Toda gravação guarda a versão anterior antes de trocar. Estragou um arquivo pronto?
|
|
124
|
+
`POST /api/arquivos/<id>/restaurar` traz de volta.
|
|
@@ -136,6 +136,8 @@ Sem declarar nada, os elementos já entram em cascata na ordem em que aparecem.
|
|
|
136
136
|
|
|
137
137
|
`meia-noite` `claro` `papel` `neon` `brasa` `floresta` `retro` `oceano` `doce` `mono`
|
|
138
138
|
|
|
139
|
+
São esses dez, e só. Nome fora da lista é recusado: escuro é `meia-noite`, não
|
|
140
|
+
`noite`. Cor de marca não vira tema — ela entra em `cor` nos elementos.
|
|
139
141
|
Vale no documento ou por cena. Uma cena com tema diferente no meio cria respiro —
|
|
140
142
|
use uma vez, não cinco.
|
|
141
143
|
|
package/studio/server.py
CHANGED
|
@@ -149,6 +149,106 @@ def gravar(arq):
|
|
|
149
149
|
return arq
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
# ------------------------------------------------------------------ versões --
|
|
153
|
+
#
|
|
154
|
+
# Um pedido de ajuste apagou o trabalho de um usuário: ele tinha um vídeo de
|
|
155
|
+
# oito cenas, com narração e trilha, pediu "aplique a identidade visual" e o
|
|
156
|
+
# agente mandou um PUT com doc={"tema":"..."}. O handler trocava o documento
|
|
157
|
+
# INTEIRO pelo que chegou — as oito cenas, a narração e a trilha viraram um
|
|
158
|
+
# documento de uma chave só. Não havia como voltar.
|
|
159
|
+
#
|
|
160
|
+
# Daí saíram duas defesas. `mesclar` faz o pedido parcial ser parcial de
|
|
161
|
+
# verdade, que é o conserto. Este histórico é o que sobra quando o conserto
|
|
162
|
+
# não basta: guarda o estado anterior a cada gravação, para que sobrescrever
|
|
163
|
+
# nunca mais seja definitivo. Nada aqui apaga o arquivo vivo; o pior caso é
|
|
164
|
+
# ocupar disco, e vinte versões de um documento de texto não dão um megabyte.
|
|
165
|
+
|
|
166
|
+
VERSOES = os.path.join(ARQUIVOS, ".versoes")
|
|
167
|
+
MAX_VERSOES = 20
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_seq_versao = 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def guardar_versao(arq):
|
|
174
|
+
"""Copia o estado atual para .versoes/<id>/<carimbo>.json antes de trocar.
|
|
175
|
+
|
|
176
|
+
O carimbo é ordenável como texto, e por isso precisa de milissegundo: o
|
|
177
|
+
editor do navegador salva a cada 600 ms, e duas versões do mesmo segundo
|
|
178
|
+
desempatadas por sorteio fariam "desfaz a última mudança" desfazer a
|
|
179
|
+
penúltima. O contador no fim resolve o empate que sobra."""
|
|
180
|
+
global _seq_versao
|
|
181
|
+
try:
|
|
182
|
+
pasta = os.path.join(VERSOES, arq["id"])
|
|
183
|
+
os.makedirs(pasta, exist_ok=True)
|
|
184
|
+
_seq_versao = (_seq_versao + 1) % 10000
|
|
185
|
+
agora = datetime.now(timezone.utc)
|
|
186
|
+
carimbo = "%s%03d-%04d" % (agora.strftime("%Y%m%dT%H%M%S"),
|
|
187
|
+
agora.microsecond // 1000, _seq_versao)
|
|
188
|
+
with open(os.path.join(pasta, carimbo + ".json"), "w", encoding="utf-8") as fh:
|
|
189
|
+
json.dump(arq, fh, ensure_ascii=False)
|
|
190
|
+
antigas = sorted(n for n in os.listdir(pasta) if n.endswith(".json"))
|
|
191
|
+
for velha in antigas[:-MAX_VERSOES]:
|
|
192
|
+
os.remove(os.path.join(pasta, velha))
|
|
193
|
+
return carimbo
|
|
194
|
+
except Exception:
|
|
195
|
+
# Histórico é rede de proteção, não requisito: se o disco recusar, a
|
|
196
|
+
# gravação principal segue. Falhar aqui seria trocar perder o passado
|
|
197
|
+
# por perder o presente.
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def versoes(arq_id):
|
|
202
|
+
"""As versões guardadas, da mais nova para a mais velha."""
|
|
203
|
+
if not caminho(arq_id):
|
|
204
|
+
return []
|
|
205
|
+
pasta = os.path.join(VERSOES, arq_id)
|
|
206
|
+
if not os.path.isdir(pasta):
|
|
207
|
+
return []
|
|
208
|
+
saida = []
|
|
209
|
+
for nome in sorted(os.listdir(pasta), reverse=True):
|
|
210
|
+
if not nome.endswith(".json"):
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
with open(os.path.join(pasta, nome), encoding="utf-8") as fh:
|
|
214
|
+
a = json.load(fh)
|
|
215
|
+
except Exception:
|
|
216
|
+
continue
|
|
217
|
+
doc = a.get("doc") or {}
|
|
218
|
+
saida.append({
|
|
219
|
+
"versao": nome[:-5],
|
|
220
|
+
"titulo": a.get("titulo", ""),
|
|
221
|
+
"pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
|
|
222
|
+
"atualizado": a.get("atualizado", ""),
|
|
223
|
+
})
|
|
224
|
+
return saida
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def ler_versao(arq_id, versao):
|
|
228
|
+
if not caminho(arq_id) or not re.fullmatch(r"[0-9A-Za-z\-]{1,40}", versao or ""):
|
|
229
|
+
return None
|
|
230
|
+
c = os.path.join(VERSOES, arq_id, versao + ".json")
|
|
231
|
+
if not os.path.exists(c):
|
|
232
|
+
return None
|
|
233
|
+
with open(c, encoding="utf-8") as fh:
|
|
234
|
+
return json.load(fh)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def mesclar(antigo, novo):
|
|
238
|
+
"""O documento novo por cima do antigo, chave a chave.
|
|
239
|
+
|
|
240
|
+
Quem só quer trocar o tema manda {"tema":"papel"} — e é a coisa natural a
|
|
241
|
+
mandar, porque o pedido era esse. Trocar o documento inteiro por isso
|
|
242
|
+
apagava cenas, narração e trilha. Mesclando, o que não foi citado fica
|
|
243
|
+
onde estava e o que foi citado vale: mandar cenas novas ainda troca as
|
|
244
|
+
cenas, que é o ajuste de verdade."""
|
|
245
|
+
if not isinstance(antigo, dict) or not isinstance(novo, dict):
|
|
246
|
+
return novo
|
|
247
|
+
saida = dict(antigo)
|
|
248
|
+
saida.update(novo)
|
|
249
|
+
return saida
|
|
250
|
+
|
|
251
|
+
|
|
152
252
|
def listar():
|
|
153
253
|
saida = []
|
|
154
254
|
for nome in os.listdir(ARQUIVOS):
|
|
@@ -311,6 +411,22 @@ def _texto_do_doc(doc):
|
|
|
311
411
|
return " ".join(pedacos).lower()
|
|
312
412
|
|
|
313
413
|
|
|
414
|
+
# Os temas que o render.css de fato define. Fora desta lista não existe cor
|
|
415
|
+
# nenhuma — foi o que aconteceu com "noite", que passou como se fosse tema.
|
|
416
|
+
TEMAS_VALIDOS = {"claro", "meia-noite", "papel", "neon", "brasa", "floresta",
|
|
417
|
+
"retro", "oceano", "doce", "mono"}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _tema_invalido(tema, onde=""):
|
|
421
|
+
if tema is None:
|
|
422
|
+
return None
|
|
423
|
+
if isinstance(tema, str) and tema in TEMAS_VALIDOS:
|
|
424
|
+
return None
|
|
425
|
+
return ('tema %r%s não existe — a peça sairia sem cor nenhuma. Os temas são: %s. '
|
|
426
|
+
'Para escuro use "meia-noite".'
|
|
427
|
+
% (tema, onde, ", ".join(sorted(TEMAS_VALIDOS))))
|
|
428
|
+
|
|
429
|
+
|
|
314
430
|
def validar_doc(modelo, doc):
|
|
315
431
|
"""None quando o doc serve; senão, a frase que ENSINA o formato certo.
|
|
316
432
|
|
|
@@ -332,6 +448,13 @@ def validar_doc(modelo, doc):
|
|
|
332
448
|
TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
|
|
333
449
|
"citacao", "forma", "imagem", "icone", "grafico", "no", "conector"}
|
|
334
450
|
|
|
451
|
+
# Tema inventado tinha o mesmo destino silencioso do tipo inventado: o CSS
|
|
452
|
+
# não casa com [data-tema="noite"], nenhuma cor é definida e a peça sai
|
|
453
|
+
# sem identidade nenhuma. Recusar aqui devolve a lista certa ao agente.
|
|
454
|
+
problema = _tema_invalido(doc.get("tema"))
|
|
455
|
+
if problema:
|
|
456
|
+
return problema
|
|
457
|
+
|
|
335
458
|
if modelo == "cena":
|
|
336
459
|
cenas = doc.get("cenas")
|
|
337
460
|
if not isinstance(cenas, list) or not cenas:
|
|
@@ -345,6 +468,9 @@ def validar_doc(modelo, doc):
|
|
|
345
468
|
if not isinstance(els, list) or not els:
|
|
346
469
|
return ('cena %d sem "elementos". Cada cena precisa de elementos:[...] — '
|
|
347
470
|
'ex.: {"tipo":"texto","texto":"..."}. Leia studio_spec e refaça.' % (i + 1))
|
|
471
|
+
problema = _tema_invalido(c.get("tema"), " (cena %d)" % (i + 1))
|
|
472
|
+
if problema:
|
|
473
|
+
return problema
|
|
348
474
|
dur = c.get("duracao")
|
|
349
475
|
if dur is not None and not isinstance(dur, (int, float)):
|
|
350
476
|
return ('cena %d com "duracao" que não é número (veio %r). Use número de segundos: '
|
|
@@ -600,6 +726,12 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
600
726
|
a = ler(m.group(1))
|
|
601
727
|
return self.js(a) if a else self.js({"erro": "não encontrado"}, 404)
|
|
602
728
|
|
|
729
|
+
m = re.fullmatch(r"/api/arquivos/([^/]+)/versoes", p)
|
|
730
|
+
if m:
|
|
731
|
+
if not ler(m.group(1)):
|
|
732
|
+
return self.js({"erro": "não encontrado"}, 404)
|
|
733
|
+
return self.js({"versoes": versoes(m.group(1))})
|
|
734
|
+
|
|
603
735
|
m = re.fullmatch(r"/api/arquivos/([^/]+)/export", p)
|
|
604
736
|
if m:
|
|
605
737
|
a = ler(m.group(1))
|
|
@@ -838,6 +970,32 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
838
970
|
threading.Thread(target=lambda: (time.sleep(0.3), os._exit(0)), daemon=True).start()
|
|
839
971
|
return
|
|
840
972
|
|
|
973
|
+
m = re.fullmatch(r"/api/arquivos/([^/]+)/restaurar", rota)
|
|
974
|
+
if m:
|
|
975
|
+
a = ler(m.group(1))
|
|
976
|
+
if not a:
|
|
977
|
+
return self.js({"erro": "não encontrado"}, 404)
|
|
978
|
+
pedido = self.corpo()
|
|
979
|
+
disponiveis = versoes(m.group(1))
|
|
980
|
+
# Sem versão dita, a anterior — que é o que "desfaz" quer dizer
|
|
981
|
+
# quando alguém acabou de estragar o arquivo.
|
|
982
|
+
alvo = pedido.get("versao") or (disponiveis[0]["versao"] if disponiveis else None)
|
|
983
|
+
velha = ler_versao(m.group(1), alvo) if alvo else None
|
|
984
|
+
if not velha:
|
|
985
|
+
return self.js({"erro": "versão não encontrada",
|
|
986
|
+
"versoes": disponiveis}, 404)
|
|
987
|
+
# Restaurar também guarda versão: desfazer o desfazer tem de valer.
|
|
988
|
+
guardar_versao(a)
|
|
989
|
+
a["titulo"] = velha.get("titulo", a.get("titulo"))
|
|
990
|
+
a["doc"] = velha.get("doc") or a.get("doc")
|
|
991
|
+
gravar(a)
|
|
992
|
+
doc = a.get("doc") or {}
|
|
993
|
+
return self.js({
|
|
994
|
+
"ok": True, "id": a["id"], "restaurado": alvo,
|
|
995
|
+
"titulo": a.get("titulo"),
|
|
996
|
+
"pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
|
|
997
|
+
})
|
|
998
|
+
|
|
841
999
|
if rota == "/api/arquivos":
|
|
842
1000
|
try:
|
|
843
1001
|
a = criar(self.corpo())
|
|
@@ -873,10 +1031,36 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
873
1031
|
return self.js({"erro": "titulo precisa ser texto"}, 400)
|
|
874
1032
|
if "doc" in dados and dados["doc"] is not None and not isinstance(dados["doc"], dict):
|
|
875
1033
|
return self.js({"erro": "doc precisa ser um objeto"}, 400)
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1034
|
+
|
|
1035
|
+
antes = a.get("doc") or {}
|
|
1036
|
+
novo = a.get("doc")
|
|
1037
|
+
if "doc" in dados and dados["doc"] is not None:
|
|
1038
|
+
# Mesclar em vez de trocar. Era aqui que o trabalho sumia: um doc
|
|
1039
|
+
# parcial substituía o inteiro. Quem quiser mesmo jogar fora o que
|
|
1040
|
+
# existe manda "substituir": true e assume.
|
|
1041
|
+
novo = dados["doc"] if dados.get("substituir") else mesclar(antes, dados["doc"])
|
|
1042
|
+
|
|
1043
|
+
# O agente precisa da validação: é a única forma de ele saber que
|
|
1044
|
+
# errou o formato. O editor do navegador, não — ali há uma pessoa
|
|
1045
|
+
# digitando, e uma cena momentaneamente vazia no meio da edição não
|
|
1046
|
+
# pode fazer o salvamento falhar calado.
|
|
1047
|
+
if self.headers.get("X-Primo-Agente"):
|
|
1048
|
+
problema = validar_doc(
|
|
1049
|
+
POR_SLUG.get(a.get("ferramenta"), {}).get("modelo"), novo)
|
|
1050
|
+
if problema:
|
|
1051
|
+
return self.js({"erro": problema}, 400)
|
|
1052
|
+
|
|
1053
|
+
versao = guardar_versao(a)
|
|
1054
|
+
if "titulo" in dados:
|
|
1055
|
+
a["titulo"] = dados["titulo"]
|
|
1056
|
+
if "doc" in dados:
|
|
1057
|
+
a["doc"] = novo
|
|
1058
|
+
salvo = gravar(a)
|
|
1059
|
+
if versao:
|
|
1060
|
+
# O agente vê isto no resultado da tool: se ele estragou algo, o
|
|
1061
|
+
# caminho de volta está na resposta, não numa doc que ele não leu.
|
|
1062
|
+
salvo = dict(salvo, versao_anterior=versao)
|
|
1063
|
+
return self.js(salvo)
|
|
880
1064
|
|
|
881
1065
|
@seguro
|
|
882
1066
|
def do_DELETE(self):
|