primocode 8.11.1 → 8.12.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/lib/catalogo.js +3 -1
- package/lib/studio.js +115 -7
- package/lib/tools.js +2 -0
- package/lib/ui.js +1 -0
- package/package.json +2 -2
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/ferramentas/00-protocolo.md +15 -2
- package/studio/ferramentas/01-modelos.md +2 -0
- package/studio/ferramentas/corte.md +18 -2
- package/studio/server.py +314 -23
- package/studio/voz.py +307 -0
- package/studio/web/js/narracao.js +18 -2
- package/studio/web/js/voz-neural.js +88 -178
package/studio/server.py
CHANGED
|
@@ -25,6 +25,37 @@ from urllib.parse import urlparse, unquote, urlencode
|
|
|
25
25
|
import urllib.request
|
|
26
26
|
|
|
27
27
|
RAIZ = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
sys.path.insert(0, RAIZ)
|
|
29
|
+
try:
|
|
30
|
+
import voz as voz_lib # noqa: E402 (precisa da RAIZ no path antes)
|
|
31
|
+
except Exception as _e: # instalação parcial, ou voz.py de uma versão velha
|
|
32
|
+
# Narração é acabamento; o Studio inteiro não pode morrer por causa dela.
|
|
33
|
+
# Sem o módulo, a corrente começa direto no Google e tudo mais funciona.
|
|
34
|
+
sys.stderr.write("[voz] módulo indisponível (%s); narração vai só pelo Google\n" % _e)
|
|
35
|
+
|
|
36
|
+
class _SemVoz:
|
|
37
|
+
@staticmethod
|
|
38
|
+
def pronta(*a, **k):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def sintetizar(*a, **k):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def instalar(*a, **k):
|
|
47
|
+
return {"pronta": False, "erro": "módulo de voz indisponível"}
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def estado():
|
|
51
|
+
return {"pronta": False, "vozes": [], "erro": "módulo de voz indisponível"}
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def apagar():
|
|
55
|
+
return {"ok": True}
|
|
56
|
+
|
|
57
|
+
voz_lib = _SemVoz()
|
|
58
|
+
|
|
28
59
|
WEB = os.path.join(RAIZ, "web")
|
|
29
60
|
ARQUIVOS = os.path.join(RAIZ, "arquivos")
|
|
30
61
|
FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
|
|
@@ -32,13 +63,21 @@ MIDIA = os.path.join(RAIZ, "midia")
|
|
|
32
63
|
PORTA = int(sys.argv[1]) if len(sys.argv) > 1 else 7777
|
|
33
64
|
|
|
34
65
|
def _assinatura():
|
|
35
|
-
"""SHA-1 do
|
|
66
|
+
"""SHA-1 do código do Studio, lido NA SUBIDA. Se o npm trocar o código no
|
|
36
67
|
disco depois, o processo no ar continua respondendo a assinatura antiga —
|
|
37
68
|
e é exatamente essa diferença que o CLI usa para saber que precisa
|
|
38
|
-
reiniciar o Studio em vez de servir a versão velha para sempre.
|
|
69
|
+
reiniciar o Studio em vez de servir a versão velha para sempre.
|
|
70
|
+
|
|
71
|
+
Entra todo módulo do Studio, não só este arquivo: uma correção que mexa
|
|
72
|
+
apenas na voz também precisa derrubar o processo velho."""
|
|
73
|
+
h = hashlib.sha1()
|
|
39
74
|
try:
|
|
40
|
-
|
|
41
|
-
|
|
75
|
+
for nome in ("server.py", "voz.py"):
|
|
76
|
+
caminho = os.path.join(RAIZ, nome)
|
|
77
|
+
if os.path.exists(caminho):
|
|
78
|
+
with open(caminho, "rb") as fh:
|
|
79
|
+
h.update(fh.read())
|
|
80
|
+
return h.hexdigest()[:12]
|
|
42
81
|
except Exception:
|
|
43
82
|
return "?"
|
|
44
83
|
|
|
@@ -149,6 +188,132 @@ def gravar(arq):
|
|
|
149
188
|
return arq
|
|
150
189
|
|
|
151
190
|
|
|
191
|
+
# ------------------------------------------------------------------ versões --
|
|
192
|
+
#
|
|
193
|
+
# Um pedido de ajuste apagou o trabalho de um usuário: ele tinha um vídeo de
|
|
194
|
+
# oito cenas, com narração e trilha, pediu "aplique a identidade visual" e o
|
|
195
|
+
# agente mandou um PUT com doc={"tema":"..."}. O handler trocava o documento
|
|
196
|
+
# INTEIRO pelo que chegou — as oito cenas, a narração e a trilha viraram um
|
|
197
|
+
# documento de uma chave só. Não havia como voltar.
|
|
198
|
+
#
|
|
199
|
+
# Daí saíram duas defesas. `mesclar` faz o pedido parcial ser parcial de
|
|
200
|
+
# verdade, que é o conserto. Este histórico é o que sobra quando o conserto
|
|
201
|
+
# não basta: guarda o estado anterior a cada gravação, para que sobrescrever
|
|
202
|
+
# nunca mais seja definitivo. Nada aqui apaga o arquivo vivo; o pior caso é
|
|
203
|
+
# ocupar disco, e vinte versões de um documento de texto não dão um megabyte.
|
|
204
|
+
|
|
205
|
+
VERSOES = os.path.join(ARQUIVOS, ".versoes")
|
|
206
|
+
MAX_VERSOES = 20
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_seq_versao = 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def guardar_versao(arq):
|
|
213
|
+
"""Copia o estado atual para .versoes/<id>/<carimbo>.json antes de trocar.
|
|
214
|
+
|
|
215
|
+
O carimbo é ordenável como texto, e por isso precisa de milissegundo: o
|
|
216
|
+
editor do navegador salva a cada 600 ms, e duas versões do mesmo segundo
|
|
217
|
+
desempatadas por sorteio fariam "desfaz a última mudança" desfazer a
|
|
218
|
+
penúltima. O contador no fim resolve o empate que sobra."""
|
|
219
|
+
global _seq_versao
|
|
220
|
+
try:
|
|
221
|
+
pasta = os.path.join(VERSOES, arq["id"])
|
|
222
|
+
os.makedirs(pasta, exist_ok=True)
|
|
223
|
+
_seq_versao = (_seq_versao + 1) % 10000
|
|
224
|
+
agora = datetime.now(timezone.utc)
|
|
225
|
+
carimbo = "%s%03d-%04d" % (agora.strftime("%Y%m%dT%H%M%S"),
|
|
226
|
+
agora.microsecond // 1000, _seq_versao)
|
|
227
|
+
with open(os.path.join(pasta, carimbo + ".json"), "w", encoding="utf-8") as fh:
|
|
228
|
+
json.dump(arq, fh, ensure_ascii=False)
|
|
229
|
+
antigas = sorted(n for n in os.listdir(pasta) if n.endswith(".json"))
|
|
230
|
+
for velha in antigas[:-MAX_VERSOES]:
|
|
231
|
+
os.remove(os.path.join(pasta, velha))
|
|
232
|
+
return carimbo
|
|
233
|
+
except Exception:
|
|
234
|
+
# Histórico é rede de proteção, não requisito: se o disco recusar, a
|
|
235
|
+
# gravação principal segue. Falhar aqui seria trocar perder o passado
|
|
236
|
+
# por perder o presente.
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def versoes(arq_id):
|
|
241
|
+
"""As versões guardadas, da mais nova para a mais velha."""
|
|
242
|
+
if not caminho(arq_id):
|
|
243
|
+
return []
|
|
244
|
+
pasta = os.path.join(VERSOES, arq_id)
|
|
245
|
+
if not os.path.isdir(pasta):
|
|
246
|
+
return []
|
|
247
|
+
saida = []
|
|
248
|
+
for nome in sorted(os.listdir(pasta), reverse=True):
|
|
249
|
+
if not nome.endswith(".json"):
|
|
250
|
+
continue
|
|
251
|
+
try:
|
|
252
|
+
with open(os.path.join(pasta, nome), encoding="utf-8") as fh:
|
|
253
|
+
a = json.load(fh)
|
|
254
|
+
except Exception:
|
|
255
|
+
continue
|
|
256
|
+
doc = a.get("doc") or {}
|
|
257
|
+
saida.append({
|
|
258
|
+
"versao": nome[:-5],
|
|
259
|
+
"titulo": a.get("titulo", ""),
|
|
260
|
+
"pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
|
|
261
|
+
"atualizado": a.get("atualizado", ""),
|
|
262
|
+
})
|
|
263
|
+
return saida
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def ler_versao(arq_id, versao):
|
|
267
|
+
if not caminho(arq_id) or not re.fullmatch(r"[0-9A-Za-z\-]{1,40}", versao or ""):
|
|
268
|
+
return None
|
|
269
|
+
c = os.path.join(VERSOES, arq_id, versao + ".json")
|
|
270
|
+
if not os.path.exists(c):
|
|
271
|
+
return None
|
|
272
|
+
with open(c, encoding="utf-8") as fh:
|
|
273
|
+
return json.load(fh)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _tem_narracao(doc):
|
|
277
|
+
if not isinstance(doc, dict):
|
|
278
|
+
return False
|
|
279
|
+
if isinstance(doc.get("narracao"), str) and doc["narracao"].strip():
|
|
280
|
+
return True
|
|
281
|
+
for c in doc.get("cenas") or []:
|
|
282
|
+
if isinstance(c, dict) and isinstance(c.get("narracao"), str) and c["narracao"].strip():
|
|
283
|
+
return True
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def preparar_voz(doc):
|
|
288
|
+
"""Documento com narração chegou: começa a baixar a voz agora.
|
|
289
|
+
|
|
290
|
+
O download é de ~90 MB e leva algum tempo. Fazê-lo quando o usuário
|
|
291
|
+
aperta play seria tarde — ele ouviria a voz robótica do Google. Fazê-lo
|
|
292
|
+
aqui, no instante em que o roteiro é gravado, dá ao download a mesma
|
|
293
|
+
janela que o agente gasta escrevendo o resto: quando alguém toca o vídeo,
|
|
294
|
+
a voz boa já está na máquina. Se não der tempo, a corrente cobre."""
|
|
295
|
+
if _tem_narracao(doc) and not voz_lib.pronta():
|
|
296
|
+
try:
|
|
297
|
+
voz_lib.instalar()
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def mesclar(antigo, novo):
|
|
303
|
+
"""O documento novo por cima do antigo, chave a chave.
|
|
304
|
+
|
|
305
|
+
Quem só quer trocar o tema manda {"tema":"papel"} — e é a coisa natural a
|
|
306
|
+
mandar, porque o pedido era esse. Trocar o documento inteiro por isso
|
|
307
|
+
apagava cenas, narração e trilha. Mesclando, o que não foi citado fica
|
|
308
|
+
onde estava e o que foi citado vale: mandar cenas novas ainda troca as
|
|
309
|
+
cenas, que é o ajuste de verdade."""
|
|
310
|
+
if not isinstance(antigo, dict) or not isinstance(novo, dict):
|
|
311
|
+
return novo
|
|
312
|
+
saida = dict(antigo)
|
|
313
|
+
saida.update(novo)
|
|
314
|
+
return saida
|
|
315
|
+
|
|
316
|
+
|
|
152
317
|
def listar():
|
|
153
318
|
saida = []
|
|
154
319
|
for nome in os.listdir(ARQUIVOS):
|
|
@@ -269,6 +434,7 @@ def criar(dados):
|
|
|
269
434
|
if problema:
|
|
270
435
|
raise ValueError(problema)
|
|
271
436
|
|
|
437
|
+
preparar_voz(doc)
|
|
272
438
|
arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
|
|
273
439
|
return gravar({
|
|
274
440
|
"id": arq_id,
|
|
@@ -311,6 +477,22 @@ def _texto_do_doc(doc):
|
|
|
311
477
|
return " ".join(pedacos).lower()
|
|
312
478
|
|
|
313
479
|
|
|
480
|
+
# Os temas que o render.css de fato define. Fora desta lista não existe cor
|
|
481
|
+
# nenhuma — foi o que aconteceu com "noite", que passou como se fosse tema.
|
|
482
|
+
TEMAS_VALIDOS = {"claro", "meia-noite", "papel", "neon", "brasa", "floresta",
|
|
483
|
+
"retro", "oceano", "doce", "mono"}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _tema_invalido(tema, onde=""):
|
|
487
|
+
if tema is None:
|
|
488
|
+
return None
|
|
489
|
+
if isinstance(tema, str) and tema in TEMAS_VALIDOS:
|
|
490
|
+
return None
|
|
491
|
+
return ('tema %r%s não existe — a peça sairia sem cor nenhuma. Os temas são: %s. '
|
|
492
|
+
'Para escuro use "meia-noite".'
|
|
493
|
+
% (tema, onde, ", ".join(sorted(TEMAS_VALIDOS))))
|
|
494
|
+
|
|
495
|
+
|
|
314
496
|
def validar_doc(modelo, doc):
|
|
315
497
|
"""None quando o doc serve; senão, a frase que ENSINA o formato certo.
|
|
316
498
|
|
|
@@ -332,6 +514,13 @@ def validar_doc(modelo, doc):
|
|
|
332
514
|
TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
|
|
333
515
|
"citacao", "forma", "imagem", "icone", "grafico", "no", "conector"}
|
|
334
516
|
|
|
517
|
+
# Tema inventado tinha o mesmo destino silencioso do tipo inventado: o CSS
|
|
518
|
+
# não casa com [data-tema="noite"], nenhuma cor é definida e a peça sai
|
|
519
|
+
# sem identidade nenhuma. Recusar aqui devolve a lista certa ao agente.
|
|
520
|
+
problema = _tema_invalido(doc.get("tema"))
|
|
521
|
+
if problema:
|
|
522
|
+
return problema
|
|
523
|
+
|
|
335
524
|
if modelo == "cena":
|
|
336
525
|
cenas = doc.get("cenas")
|
|
337
526
|
if not isinstance(cenas, list) or not cenas:
|
|
@@ -345,6 +534,9 @@ def validar_doc(modelo, doc):
|
|
|
345
534
|
if not isinstance(els, list) or not els:
|
|
346
535
|
return ('cena %d sem "elementos". Cada cena precisa de elementos:[...] — '
|
|
347
536
|
'ex.: {"tipo":"texto","texto":"..."}. Leia studio_spec e refaça.' % (i + 1))
|
|
537
|
+
problema = _tema_invalido(c.get("tema"), " (cena %d)" % (i + 1))
|
|
538
|
+
if problema:
|
|
539
|
+
return problema
|
|
348
540
|
dur = c.get("duracao")
|
|
349
541
|
if dur is not None and not isinstance(dur, (int, float)):
|
|
350
542
|
return ('cena %d com "duracao" que não é número (veio %r). Use número de segundos: '
|
|
@@ -600,6 +792,12 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
600
792
|
a = ler(m.group(1))
|
|
601
793
|
return self.js(a) if a else self.js({"erro": "não encontrado"}, 404)
|
|
602
794
|
|
|
795
|
+
m = re.fullmatch(r"/api/arquivos/([^/]+)/versoes", p)
|
|
796
|
+
if m:
|
|
797
|
+
if not ler(m.group(1)):
|
|
798
|
+
return self.js({"erro": "não encontrado"}, 404)
|
|
799
|
+
return self.js({"versoes": versoes(m.group(1))})
|
|
800
|
+
|
|
603
801
|
m = re.fullmatch(r"/api/arquivos/([^/]+)/export", p)
|
|
604
802
|
if m:
|
|
605
803
|
a = ler(m.group(1))
|
|
@@ -610,17 +808,25 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
610
808
|
return super().do_GET()
|
|
611
809
|
|
|
612
810
|
# ── Voz ────────────────────────────────────────────────────────────
|
|
613
|
-
#
|
|
614
|
-
# de CORS: do navegador direto o pedido morre, daqui funciona.
|
|
811
|
+
# A narração inteira é decidida AQUI, no Python, e não no navegador.
|
|
615
812
|
#
|
|
616
|
-
#
|
|
617
|
-
#
|
|
618
|
-
#
|
|
619
|
-
#
|
|
813
|
+
# Antes o navegador tentava a voz neural do Edge por WebSocket e, quando
|
|
814
|
+
# ela não subia, caía no speechSynthesis — o TTS da própria máquina, que
|
|
815
|
+
# soa a computador. A voz do Edge parou de subir (a Microsoft responde
|
|
816
|
+
# 403 na síntese), então na prática TODO vídeo saía com voz de robô. Era
|
|
817
|
+
# o que o usuário estava ouvindo.
|
|
620
818
|
#
|
|
621
|
-
#
|
|
622
|
-
#
|
|
623
|
-
#
|
|
819
|
+
# A corrente agora é esta, e o navegador só consome o resultado:
|
|
820
|
+
# 1. PIPER neural, roda na máquina, offline, ilimitado, com escolha
|
|
821
|
+
# de voz — inclusive graves de verdade (voz.py mede e diz
|
|
822
|
+
# a frequência de cada uma). É o bom.
|
|
823
|
+
# 2. VOICEBOX se o usuário tiver o app: voz clonável.
|
|
824
|
+
# 3. GOOGLE uma voz só, robótica, mas responde na hora sem instalar
|
|
825
|
+
# nada — é o que cobre enquanto o Piper baixa.
|
|
826
|
+
#
|
|
827
|
+
# O endpoint do Google recusa texto comprido, então a frase é cortada em
|
|
828
|
+
# pedaços de até 190 caracteres, sempre numa fronteira de palavra, e os
|
|
829
|
+
# MP3 são emendados. MP3 aceita concatenação de quadros: é feio, mas toca.
|
|
624
830
|
def voz(self, query):
|
|
625
831
|
from urllib.parse import parse_qs
|
|
626
832
|
q = parse_qs(query or "")
|
|
@@ -631,22 +837,45 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
631
837
|
if len(texto) > 4000:
|
|
632
838
|
texto = texto[:4000]
|
|
633
839
|
|
|
634
|
-
|
|
840
|
+
voz_pedida = (q.get("voz", [""])[0] or "").strip()
|
|
635
841
|
instrucao = (q.get("instrucao", [""])[0] or "").strip()
|
|
636
842
|
motor = (q.get("motor", [""])[0] or "").strip()
|
|
843
|
+
grave = (q.get("grave", [""])[0] or "").lower() in ("1", "true", "sim")
|
|
844
|
+
try:
|
|
845
|
+
velocidade = float(q.get("velocidade", ["1"])[0])
|
|
846
|
+
except ValueError:
|
|
847
|
+
velocidade = 1.0
|
|
637
848
|
|
|
638
849
|
tipo = "audio/mpeg"
|
|
639
850
|
audio = b""
|
|
640
|
-
|
|
851
|
+
|
|
852
|
+
# 1) Piper: o motor bom. Se ainda não baixou, começa a baixar agora e
|
|
853
|
+
# segue para o próximo — quem pediu narração hoje ouve o Google, e
|
|
854
|
+
# a partir da próxima já é voz de verdade.
|
|
855
|
+
if motor not in ("google", "voicebox"):
|
|
856
|
+
try:
|
|
857
|
+
if voz_lib.pronta(voz_pedida or None):
|
|
858
|
+
audio = voz_lib.sintetizar(texto, voz_pedida or None,
|
|
859
|
+
grave=grave, velocidade=velocidade) or b""
|
|
860
|
+
if audio:
|
|
861
|
+
tipo = "audio/wav"
|
|
862
|
+
else:
|
|
863
|
+
voz_lib.instalar(voz_pedida or None)
|
|
864
|
+
except Exception as e:
|
|
865
|
+
sys.stderr.write("[voz] Piper falhou (%s); seguindo a corrente\n" % e)
|
|
866
|
+
audio = b""
|
|
867
|
+
|
|
868
|
+
# 2) Voicebox, se o usuário tiver instalado: voz clonável, ilimitada e
|
|
641
869
|
# sem depender de nada na internet.
|
|
642
|
-
if motor != "google" and self._voicebox_vivo():
|
|
870
|
+
if not audio and motor != "google" and self._voicebox_vivo():
|
|
643
871
|
try:
|
|
644
|
-
audio, tipo = self._voz_voicebox(texto,
|
|
872
|
+
audio, tipo = self._voz_voicebox(texto, voz_pedida, instrucao)
|
|
645
873
|
except Exception as e:
|
|
646
874
|
sys.stderr.write("[voz] Voicebox falhou (%s); indo de Google\n" % e)
|
|
647
875
|
audio = b""
|
|
648
|
-
#
|
|
876
|
+
# 3) Google: uma voz só, mas responde sempre.
|
|
649
877
|
if not audio:
|
|
878
|
+
tipo = "audio/mpeg"
|
|
650
879
|
try:
|
|
651
880
|
audio = b"".join(self._trecho_de_voz(t, idioma) for t in _picar(texto, 190))
|
|
652
881
|
except Exception as e: # rede, bloqueio, mudança do serviço
|
|
@@ -675,7 +904,8 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
675
904
|
|
|
676
905
|
def voz_status(self):
|
|
677
906
|
"""O que está disponível para narrar, para a interface não oferecer o
|
|
678
|
-
que não existe
|
|
907
|
+
que não existe — e para ela poder mostrar o download em vez de fingir
|
|
908
|
+
que já está tudo pronto."""
|
|
679
909
|
vivo = self._voicebox_vivo()
|
|
680
910
|
vozes = []
|
|
681
911
|
if vivo:
|
|
@@ -686,6 +916,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
686
916
|
except Exception:
|
|
687
917
|
vozes = []
|
|
688
918
|
return self.js({
|
|
919
|
+
"piper": voz_lib.estado(),
|
|
689
920
|
"voicebox": {"ativo": vivo, "vozes": vozes, "site": "https://voicebox.sh"},
|
|
690
921
|
"google": {"ativo": True},
|
|
691
922
|
})
|
|
@@ -828,6 +1059,13 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
828
1059
|
corpo, status = guardar_midia(self.corpo())
|
|
829
1060
|
return self.js(corpo, status)
|
|
830
1061
|
|
|
1062
|
+
if rota == "/api/voz/instalar":
|
|
1063
|
+
pedido = self.corpo()
|
|
1064
|
+
if pedido.get("apagar"):
|
|
1065
|
+
return self.js(voz_lib.apagar())
|
|
1066
|
+
return self.js(voz_lib.instalar(pedido.get("voz"),
|
|
1067
|
+
esperar=min(60, int(pedido.get("esperar") or 0))))
|
|
1068
|
+
|
|
831
1069
|
if rota == "/api/sair":
|
|
832
1070
|
# Só o CLI chama, e só quando o código no disco mudou (update do
|
|
833
1071
|
# npm). Sem isto o processo antigo ficava no ar servindo o bug que
|
|
@@ -838,6 +1076,32 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
838
1076
|
threading.Thread(target=lambda: (time.sleep(0.3), os._exit(0)), daemon=True).start()
|
|
839
1077
|
return
|
|
840
1078
|
|
|
1079
|
+
m = re.fullmatch(r"/api/arquivos/([^/]+)/restaurar", rota)
|
|
1080
|
+
if m:
|
|
1081
|
+
a = ler(m.group(1))
|
|
1082
|
+
if not a:
|
|
1083
|
+
return self.js({"erro": "não encontrado"}, 404)
|
|
1084
|
+
pedido = self.corpo()
|
|
1085
|
+
disponiveis = versoes(m.group(1))
|
|
1086
|
+
# Sem versão dita, a anterior — que é o que "desfaz" quer dizer
|
|
1087
|
+
# quando alguém acabou de estragar o arquivo.
|
|
1088
|
+
alvo = pedido.get("versao") or (disponiveis[0]["versao"] if disponiveis else None)
|
|
1089
|
+
velha = ler_versao(m.group(1), alvo) if alvo else None
|
|
1090
|
+
if not velha:
|
|
1091
|
+
return self.js({"erro": "versão não encontrada",
|
|
1092
|
+
"versoes": disponiveis}, 404)
|
|
1093
|
+
# Restaurar também guarda versão: desfazer o desfazer tem de valer.
|
|
1094
|
+
guardar_versao(a)
|
|
1095
|
+
a["titulo"] = velha.get("titulo", a.get("titulo"))
|
|
1096
|
+
a["doc"] = velha.get("doc") or a.get("doc")
|
|
1097
|
+
gravar(a)
|
|
1098
|
+
doc = a.get("doc") or {}
|
|
1099
|
+
return self.js({
|
|
1100
|
+
"ok": True, "id": a["id"], "restaurado": alvo,
|
|
1101
|
+
"titulo": a.get("titulo"),
|
|
1102
|
+
"pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
|
|
1103
|
+
})
|
|
1104
|
+
|
|
841
1105
|
if rota == "/api/arquivos":
|
|
842
1106
|
try:
|
|
843
1107
|
a = criar(self.corpo())
|
|
@@ -873,10 +1137,37 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
873
1137
|
return self.js({"erro": "titulo precisa ser texto"}, 400)
|
|
874
1138
|
if "doc" in dados and dados["doc"] is not None and not isinstance(dados["doc"], dict):
|
|
875
1139
|
return self.js({"erro": "doc precisa ser um objeto"}, 400)
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1140
|
+
|
|
1141
|
+
antes = a.get("doc") or {}
|
|
1142
|
+
novo = a.get("doc")
|
|
1143
|
+
if "doc" in dados and dados["doc"] is not None:
|
|
1144
|
+
# Mesclar em vez de trocar. Era aqui que o trabalho sumia: um doc
|
|
1145
|
+
# parcial substituía o inteiro. Quem quiser mesmo jogar fora o que
|
|
1146
|
+
# existe manda "substituir": true e assume.
|
|
1147
|
+
novo = dados["doc"] if dados.get("substituir") else mesclar(antes, dados["doc"])
|
|
1148
|
+
|
|
1149
|
+
# O agente precisa da validação: é a única forma de ele saber que
|
|
1150
|
+
# errou o formato. O editor do navegador, não — ali há uma pessoa
|
|
1151
|
+
# digitando, e uma cena momentaneamente vazia no meio da edição não
|
|
1152
|
+
# pode fazer o salvamento falhar calado.
|
|
1153
|
+
if self.headers.get("X-Primo-Agente"):
|
|
1154
|
+
problema = validar_doc(
|
|
1155
|
+
POR_SLUG.get(a.get("ferramenta"), {}).get("modelo"), novo)
|
|
1156
|
+
if problema:
|
|
1157
|
+
return self.js({"erro": problema}, 400)
|
|
1158
|
+
|
|
1159
|
+
preparar_voz(novo)
|
|
1160
|
+
versao = guardar_versao(a)
|
|
1161
|
+
if "titulo" in dados:
|
|
1162
|
+
a["titulo"] = dados["titulo"]
|
|
1163
|
+
if "doc" in dados:
|
|
1164
|
+
a["doc"] = novo
|
|
1165
|
+
salvo = gravar(a)
|
|
1166
|
+
if versao:
|
|
1167
|
+
# O agente vê isto no resultado da tool: se ele estragou algo, o
|
|
1168
|
+
# caminho de volta está na resposta, não numa doc que ele não leu.
|
|
1169
|
+
salvo = dict(salvo, versao_anterior=versao)
|
|
1170
|
+
return self.js(salvo)
|
|
880
1171
|
|
|
881
1172
|
@seguro
|
|
882
1173
|
def do_DELETE(self):
|