primocode 7.18.0 → 8.0.1

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.
@@ -0,0 +1,862 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PrimoCode Studio — suíte de criação operada por IA.
4
+
5
+ python3 server.py # http://localhost:7777
6
+ python3 server.py 8080
7
+
8
+ Só biblioteca padrão do Python. Nada para instalar.
9
+ """
10
+
11
+ import base64
12
+ import functools
13
+ import json
14
+ import mimetypes
15
+ import os
16
+ import re
17
+ import sys
18
+ import unicodedata
19
+ import uuid
20
+ from datetime import datetime, timezone
21
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
22
+ from urllib.parse import urlparse, unquote
23
+
24
+ RAIZ = os.path.dirname(os.path.abspath(__file__))
25
+ WEB = os.path.join(RAIZ, "web")
26
+ ARQUIVOS = os.path.join(RAIZ, "arquivos")
27
+ FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
28
+ MIDIA = os.path.join(RAIZ, "midia")
29
+ PORTA = int(sys.argv[1]) if len(sys.argv) > 1 else 7777
30
+
31
+ # 60 MB por arquivo: cobre faixa longa em mp3 e foto de câmera sem susto
32
+ LIMITE_MIDIA = 60 * 1024 * 1024
33
+
34
+ os.makedirs(ARQUIVOS, exist_ok=True)
35
+ os.makedirs(MIDIA, exist_ok=True)
36
+
37
+
38
+ # ------------------------------------------------------------- ferramentas --
39
+
40
+ FERRAMENTAS_INFO = [
41
+ {
42
+ "slug": "prisma",
43
+ "nome": "Prisma",
44
+ "papel": "Apresentações",
45
+ "equivale": "PowerPoint, Keynote, Canva Apresentações",
46
+ "modelo": "cena",
47
+ "resumo": "Decks com layouts automáticos, temas, animação de entrada, notas do apresentador e export em PDF.",
48
+ "cor": "#FF5A36",
49
+ },
50
+ {
51
+ "slug": "tela",
52
+ "nome": "Tela",
53
+ "papel": "Design gráfico",
54
+ "equivale": "Photoshop, Figma, Canva",
55
+ "modelo": "cena",
56
+ "resumo": "Artes em canvas livre com camadas, formas, texto, imagens, gradientes e sombras. Post, capa, banner, thumbnail.",
57
+ "cor": "#8B5CF6",
58
+ },
59
+ {
60
+ "slug": "prosa",
61
+ "nome": "Prosa",
62
+ "papel": "Documentos",
63
+ "equivale": "Word, Google Docs, Notion",
64
+ "modelo": "documento",
65
+ "resumo": "Textos longos com hierarquia, citações, listas, tabelas, capa e sumário. Export em HTML e Markdown.",
66
+ "cor": "#2E9E6B",
67
+ },
68
+ {
69
+ "slug": "grade",
70
+ "nome": "Grade",
71
+ "papel": "Planilhas",
72
+ "equivale": "Excel, Google Sheets",
73
+ "modelo": "planilha",
74
+ "resumo": "Tabelas com fórmulas, formatação condicional, totais e gráficos. Export em CSV.",
75
+ "cor": "#1F8FE0",
76
+ },
77
+ {
78
+ "slug": "corte",
79
+ "nome": "Corte",
80
+ "papel": "Vídeo",
81
+ "equivale": "CapCut, Premiere, After Effects",
82
+ "modelo": "cena",
83
+ "resumo": "Montagem por cenas com duração, transições e animação. Reproduz no navegador e grava .webm.",
84
+ "cor": "#E0397A",
85
+ },
86
+ {
87
+ "slug": "traco",
88
+ "nome": "Traço",
89
+ "papel": "Diagramas",
90
+ "equivale": "Miro, Whimsical, Lucidchart",
91
+ "modelo": "cena",
92
+ "resumo": "Fluxos, mapas mentais, arquiteturas e organogramas com nós e conectores que se ligam sozinhos.",
93
+ "cor": "#D9A21B",
94
+ },
95
+ ]
96
+
97
+ POR_SLUG = {f["slug"]: f for f in FERRAMENTAS_INFO}
98
+
99
+
100
+ # -------------------------------------------------------------- utilidades --
101
+
102
+ def _normaliza(texto, tamanho):
103
+ """NFKD → ASCII → não-alfanumérico vira hífen → minúsculo → corta.
104
+ Compartilhada por slugify() e nome_seguro(), que faziam a mesma conta
105
+ com um corte de tamanho diferente cada uma."""
106
+ texto = unicodedata.normalize("NFKD", texto or "").encode("ascii", "ignore").decode()
107
+ texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
108
+ return (texto or "arquivo")[:tamanho]
109
+
110
+
111
+ def slugify(texto):
112
+ return _normaliza(texto, 40)
113
+
114
+
115
+ def caminho(arq_id):
116
+ if not re.fullmatch(r"[a-z0-9\-]{1,72}", arq_id or ""):
117
+ return None
118
+ return os.path.join(ARQUIVOS, arq_id + ".json")
119
+
120
+
121
+ def ler(arq_id):
122
+ c = caminho(arq_id)
123
+ if not c or not os.path.exists(c):
124
+ return None
125
+ with open(c, encoding="utf-8") as fh:
126
+ return json.load(fh)
127
+
128
+
129
+ def gravar(arq):
130
+ arq["atualizado"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
131
+ with open(caminho(arq["id"]), "w", encoding="utf-8") as fh:
132
+ json.dump(arq, fh, ensure_ascii=False, indent=2)
133
+ return arq
134
+
135
+
136
+ def listar():
137
+ saida = []
138
+ for nome in os.listdir(ARQUIVOS):
139
+ if not nome.endswith(".json"):
140
+ continue
141
+ try:
142
+ with open(os.path.join(ARQUIVOS, nome), encoding="utf-8") as fh:
143
+ a = json.load(fh)
144
+ doc = a.get("doc") or {}
145
+ saida.append({
146
+ "id": a["id"],
147
+ "titulo": a.get("titulo", "Sem título"),
148
+ "ferramenta": a.get("ferramenta", "prisma"),
149
+ "pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
150
+ "atualizado": a.get("atualizado", ""),
151
+ })
152
+ except Exception:
153
+ pass
154
+ return sorted(saida, key=lambda a: a["atualizado"], reverse=True)
155
+
156
+
157
+ # ------------------------------------------------------------------ mídia --
158
+
159
+ TIPOS_OK = {
160
+ ".png": "imagem", ".jpg": "imagem", ".jpeg": "imagem", ".gif": "imagem",
161
+ ".webp": "imagem", ".svg": "imagem", ".avif": "imagem",
162
+ ".mp3": "audio", ".m4a": "audio", ".wav": "audio", ".ogg": "audio",
163
+ ".aac": "audio", ".flac": "audio", ".opus": "audio", ".weba": "audio",
164
+ ".mp4": "video", ".webm": "video", ".mov": "video",
165
+ }
166
+
167
+ # .weba é WebM só com áudio — é o que sai da gravação de voz pelo microfone
168
+ mimetypes.add_type("audio/webm", ".weba")
169
+ mimetypes.add_type("audio/mp4", ".m4a")
170
+
171
+
172
+ def nome_seguro(nome):
173
+ base, ext = os.path.splitext(nome or "arquivo")
174
+ return _normaliza(base, 48), ext.lower()
175
+
176
+
177
+ def listar_midia():
178
+ fora = []
179
+ for nome in sorted(os.listdir(MIDIA)):
180
+ ext = os.path.splitext(nome)[1].lower()
181
+ if ext not in TIPOS_OK:
182
+ continue
183
+ caminho_arq = os.path.join(MIDIA, nome)
184
+ fora.append({
185
+ "nome": nome,
186
+ "url": "/midia/" + nome,
187
+ "tipo": TIPOS_OK[ext],
188
+ "bytes": os.path.getsize(caminho_arq),
189
+ })
190
+ return sorted(fora, key=lambda m: m["nome"])
191
+
192
+
193
+ def guardar_midia(dados):
194
+ """Recebe {nome, dados:"data:...;base64,..."} e grava em midia/."""
195
+ bruto = dados.get("dados") or ""
196
+ m = re.match(r"^data:([^;]*);base64,(.*)$", bruto, re.S)
197
+ if not m:
198
+ return {"erro": "envie 'dados' como data URI base64"}, 400
199
+
200
+ base, ext = nome_seguro(dados.get("nome"))
201
+ if ext not in TIPOS_OK:
202
+ adivinhado = mimetypes.guess_extension(m.group(1) or "") or ""
203
+ ext = adivinhado.lower()
204
+ if ext not in TIPOS_OK:
205
+ return {"erro": "formato não aceito", "aceitos": sorted(TIPOS_OK)}, 400
206
+
207
+ try:
208
+ conteudo = base64.b64decode(m.group(2), validate=True)
209
+ except Exception:
210
+ return {"erro": "base64 inválido"}, 400
211
+
212
+ if len(conteudo) > LIMITE_MIDIA:
213
+ return {"erro": "arquivo acima de %d MB" % (LIMITE_MIDIA // 1048576)}, 413
214
+
215
+ nome = base + ext
216
+ if os.path.exists(os.path.join(MIDIA, nome)):
217
+ nome = base + "-" + uuid.uuid4().hex[:4] + ext
218
+ with open(os.path.join(MIDIA, nome), "wb") as fh:
219
+ fh.write(conteudo)
220
+
221
+ return {"nome": nome, "url": "/midia/" + nome,
222
+ "tipo": TIPOS_OK[ext], "bytes": len(conteudo)}, 201
223
+
224
+
225
+ def criar(dados):
226
+ # O cliente é um agente de IA falando JSON cru — um tipo errado aqui
227
+ # (titulo como lista, por exemplo) não pode virar AttributeError sem
228
+ # resposta HTTP nenhuma; melhor um 400 com o motivo.
229
+ ferramenta_bruta = dados.get("ferramenta")
230
+ if ferramenta_bruta is not None and not isinstance(ferramenta_bruta, str):
231
+ raise ValueError("ferramenta precisa ser texto")
232
+ ferramenta = (ferramenta_bruta or "prisma").lower()
233
+ if ferramenta not in POR_SLUG:
234
+ return None
235
+
236
+ titulo_bruto = dados.get("titulo")
237
+ if titulo_bruto is not None and not isinstance(titulo_bruto, str):
238
+ raise ValueError("titulo precisa ser texto")
239
+ titulo = (titulo_bruto or "Sem título").strip()
240
+
241
+ doc = dados.get("doc")
242
+ if doc is not None and not isinstance(doc, dict):
243
+ raise ValueError("doc precisa ser um objeto")
244
+
245
+ arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
246
+ return gravar({
247
+ "id": arq_id,
248
+ "ferramenta": ferramenta,
249
+ "titulo": titulo,
250
+ "doc": doc or exemplo(ferramenta),
251
+ })
252
+
253
+
254
+ def exemplo(ferramenta):
255
+ """Documento inicial de cada ferramenta — o mínimo que já fica bonito."""
256
+ if ferramenta == "prosa":
257
+ return {
258
+ "tema": "papel",
259
+ "blocos": [
260
+ {"tipo": "titulo", "texto": "Título do documento"},
261
+ {"tipo": "paragrafo", "texto": "Comece a escrever por aqui."},
262
+ ],
263
+ }
264
+ if ferramenta == "grade":
265
+ return {
266
+ "tema": "claro",
267
+ "abas": [{
268
+ "nome": "Planilha 1",
269
+ "colunas": ["Item", "Valor"],
270
+ "linhas": [["Exemplo", 100]],
271
+ }],
272
+ }
273
+ if ferramenta == "tela":
274
+ return {
275
+ "tema": "meia-noite", "largura": 1080, "altura": 1080,
276
+ "cenas": [{"layout": "livre", "fundo": "malha", "elementos": [
277
+ {"tipo": "texto", "x": 100, "y": 440, "l": 880, "a": 200,
278
+ "texto": "Sua arte", "tamanho": 120, "peso": 700, "alinhar": "centro"},
279
+ ]}],
280
+ }
281
+ if ferramenta == "corte":
282
+ return {
283
+ "tema": "meia-noite", "largura": 1920, "altura": 1080,
284
+ "cenas": [
285
+ {"layout": "capa", "duracao": 3, "fundo": "malha",
286
+ "elementos": [{"tipo": "texto", "papel": "titulo", "texto": "Primeira cena"}]},
287
+ {"layout": "capa", "duracao": 3, "fundo": "brilho",
288
+ "elementos": [{"tipo": "texto", "papel": "titulo", "texto": "Segunda cena"}]},
289
+ ],
290
+ }
291
+ if ferramenta == "traco":
292
+ return {
293
+ "tema": "meia-noite", "largura": 1920, "altura": 1080,
294
+ "cenas": [{"layout": "livre", "elementos": [
295
+ {"id": "a", "tipo": "no", "x": 300, "y": 460, "l": 340, "a": 160, "texto": "Início"},
296
+ {"id": "b", "tipo": "no", "x": 1100, "y": 460, "l": 340, "a": 160, "texto": "Fim"},
297
+ {"tipo": "conector", "de": "a", "para": "b"},
298
+ ]}],
299
+ }
300
+ return {
301
+ "tema": "meia-noite", "largura": 1920, "altura": 1080,
302
+ "cenas": [
303
+ {"layout": "capa", "fundo": "malha", "elementos": [
304
+ {"tipo": "texto", "papel": "chapeu", "texto": "PrimoCode Studio"},
305
+ {"tipo": "texto", "papel": "titulo", "texto": "Título da apresentação"},
306
+ {"tipo": "texto", "papel": "apoio", "texto": "Uma frase que explica do que se trata."},
307
+ ]},
308
+ {"layout": "titulo", "elementos": [
309
+ {"tipo": "texto", "papel": "titulo", "texto": "Uma ideia por cena"},
310
+ {"tipo": "lista", "itens": ["Primeiro ponto", "Segundo ponto", "Terceiro ponto"]},
311
+ ]},
312
+ ],
313
+ }
314
+
315
+
316
+ # ------------------------------------------------------------------ manual --
317
+
318
+ def manual():
319
+ """Junta ferramentas/*.md — a pasta é a fonte da verdade para a IA."""
320
+ ordem = ["00-protocolo", "01-modelos"] + [f["slug"] for f in FERRAMENTAS_INFO]
321
+ partes = ["PRIMOCODE STUDIO — MANUAL PARA AGENTES",
322
+ "Servidor: http://localhost:%d" % PORTA, ""]
323
+ for nome in ordem:
324
+ p = os.path.join(FERRAMENTAS, nome + ".md")
325
+ if os.path.exists(p):
326
+ with open(p, encoding="utf-8") as fh:
327
+ partes.append(fh.read().rstrip())
328
+ partes.append("\n" + "=" * 70 + "\n")
329
+ return "\n".join(partes)
330
+
331
+
332
+ # ----------------------------------------------------------------- handler --
333
+
334
+ def seguro(metodo):
335
+ """Qualquer exceção não prevista na rota vira 500 em JSON, não conexão
336
+ fechada sem resposta nenhuma — era o comportamento padrão do
337
+ BaseHTTPRequestHandler para exceção não tratada num do_*, e o cliente (o
338
+ agente de IA) não tem como interpretar uma conexão que só morre."""
339
+ @functools.wraps(metodo)
340
+ def envolto(self, *a, **kw):
341
+ try:
342
+ return metodo(self, *a, **kw)
343
+ except Exception as e:
344
+ try:
345
+ self.js({"erro": "erro interno: %s" % e}, 500)
346
+ except Exception:
347
+ pass
348
+ return envolto
349
+
350
+
351
+ class Handler(SimpleHTTPRequestHandler):
352
+ server_version = "PrimoCode/1.0"
353
+
354
+ def __init__(self, *a, **kw):
355
+ super().__init__(*a, directory=WEB, **kw)
356
+
357
+ def log_message(self, fmt, *args):
358
+ if "/api/" in (self.path or ""):
359
+ sys.stderr.write(" %s %s\n" % (self.command, self.path))
360
+
361
+ def end_headers(self):
362
+ self.send_header("Cache-Control", "no-store, max-age=0")
363
+ super().end_headers()
364
+
365
+ # -------------------------------------------------------------- saídas --
366
+
367
+ def js(self, obj, status=200):
368
+ corpo = json.dumps(obj, ensure_ascii=False).encode("utf-8")
369
+ self.send_response(status)
370
+ self.send_header("Content-Type", "application/json; charset=utf-8")
371
+ self.send_header("Content-Length", str(len(corpo)))
372
+ self.end_headers()
373
+ self.wfile.write(corpo)
374
+
375
+ def txt(self, texto, ctype="text/plain; charset=utf-8", status=200, baixar=None):
376
+ corpo = texto.encode("utf-8")
377
+ self.send_response(status)
378
+ self.send_header("Content-Type", ctype)
379
+ self.send_header("Content-Length", str(len(corpo)))
380
+ if baixar:
381
+ self.send_header("Content-Disposition", 'attachment; filename="%s"' % baixar)
382
+ self.end_headers()
383
+ self.wfile.write(corpo)
384
+
385
+ def pagina(self, nome):
386
+ with open(os.path.join(WEB, nome), encoding="utf-8") as fh:
387
+ self.txt(fh.read(), "text/html; charset=utf-8")
388
+
389
+ def corpo(self):
390
+ n = int(self.headers.get("Content-Length") or 0)
391
+ if not n:
392
+ return {}
393
+ try:
394
+ return json.loads(self.rfile.read(n).decode("utf-8"))
395
+ except Exception:
396
+ return {}
397
+
398
+ # -------------------------------------------------------------- rotas --
399
+
400
+ @seguro
401
+ def do_GET(self):
402
+ p = urlparse(self.path).path
403
+
404
+ if p == "/":
405
+ return self.pagina("home.html")
406
+ if re.fullmatch(r"/t/[^/]+/[^/]+", p):
407
+ return self.pagina("tool.html")
408
+ if re.fullmatch(r"/v/[^/]+", p):
409
+ return self.pagina("view.html")
410
+
411
+ if p == "/api/manual":
412
+ return self.txt(manual())
413
+ if p == "/api/ferramentas":
414
+ return self.js(FERRAMENTAS_INFO)
415
+
416
+ m = re.fullmatch(r"/api/ferramentas/([a-z]+)", p)
417
+ if m:
418
+ info = POR_SLUG.get(m.group(1))
419
+ if not info:
420
+ return self.js({"erro": "ferramenta desconhecida"}, 404)
421
+ doc = os.path.join(FERRAMENTAS, m.group(1) + ".md")
422
+ texto = ""
423
+ if os.path.exists(doc):
424
+ with open(doc, encoding="utf-8") as fh:
425
+ texto = fh.read()
426
+ return self.txt(texto or json.dumps(info, ensure_ascii=False, indent=2))
427
+
428
+ if p == "/api/midia":
429
+ return self.js(listar_midia())
430
+ if p.startswith("/midia/"):
431
+ return self.servir_midia(unquote(p[len("/midia/"):]))
432
+
433
+ if p == "/api/arquivos":
434
+ return self.js(listar())
435
+
436
+ m = re.fullmatch(r"/api/arquivos/([^/]+)", p)
437
+ if m:
438
+ a = ler(m.group(1))
439
+ return self.js(a) if a else self.js({"erro": "não encontrado"}, 404)
440
+
441
+ m = re.fullmatch(r"/api/arquivos/([^/]+)/export", p)
442
+ if m:
443
+ a = ler(m.group(1))
444
+ if not a:
445
+ return self.js({"erro": "não encontrado"}, 404)
446
+ return self.exportar(a)
447
+
448
+ return super().do_GET()
449
+
450
+ def servir_midia(self, nome):
451
+ """Serve com suporte a Range — sem isso o áudio não busca no <audio>."""
452
+ if "/" in nome or ".." in nome:
453
+ return self.js({"erro": "caminho inválido"}, 400)
454
+ caminho_arq = os.path.join(MIDIA, nome)
455
+ if not os.path.isfile(caminho_arq):
456
+ return self.js({"erro": "não encontrado"}, 404)
457
+
458
+ tamanho = os.path.getsize(caminho_arq)
459
+ ctype = mimetypes.guess_type(caminho_arq)[0] or "application/octet-stream"
460
+ faixa = self.headers.get("Range")
461
+ inicio, fim = 0, tamanho - 1
462
+ status = 200
463
+
464
+ if faixa:
465
+ m = re.match(r"bytes=(\d*)-(\d*)", faixa)
466
+ if m:
467
+ if not m.group(1) and m.group(2):
468
+ # "bytes=-500" é sufixo HTTP válido: os ÚLTIMOS 500 bytes,
469
+ # não os primeiros 500 — sem este caso à parte, group(2)
470
+ # virava fim absoluto e o pedido servia o pedaço errado.
471
+ inicio = max(0, tamanho - int(m.group(2)))
472
+ fim = tamanho - 1
473
+ else:
474
+ if m.group(1):
475
+ inicio = int(m.group(1))
476
+ if m.group(2):
477
+ fim = min(int(m.group(2)), tamanho - 1)
478
+ if inicio > fim or inicio >= tamanho:
479
+ self.send_response(416)
480
+ self.send_header("Content-Range", "bytes */%d" % tamanho)
481
+ self.end_headers()
482
+ return
483
+ status = 206
484
+
485
+ self.send_response(status)
486
+ self.send_header("Content-Type", ctype)
487
+ self.send_header("Accept-Ranges", "bytes")
488
+ self.send_header("Content-Length", str(fim - inicio + 1))
489
+ if status == 206:
490
+ self.send_header("Content-Range", "bytes %d-%d/%d" % (inicio, fim, tamanho))
491
+ self.end_headers()
492
+
493
+ with open(caminho_arq, "rb") as fh:
494
+ fh.seek(inicio)
495
+ restante = fim - inicio + 1
496
+ while restante > 0:
497
+ pedaco = fh.read(min(262144, restante))
498
+ if not pedaco:
499
+ break
500
+ self.wfile.write(pedaco)
501
+ restante -= len(pedaco)
502
+
503
+ @seguro
504
+ def do_POST(self):
505
+ rota = urlparse(self.path).path
506
+ if rota == "/api/midia":
507
+ corpo, status = guardar_midia(self.corpo())
508
+ return self.js(corpo, status)
509
+
510
+ if rota == "/api/arquivos":
511
+ try:
512
+ a = criar(self.corpo())
513
+ except ValueError as e:
514
+ return self.js({"erro": str(e)}, 400)
515
+ if not a:
516
+ return self.js({"erro": "ferramenta inválida",
517
+ "validas": list(POR_SLUG)}, 400)
518
+ base = "http://localhost:%d" % PORTA
519
+ return self.js({
520
+ "id": a["id"],
521
+ "ferramenta": a["ferramenta"],
522
+ "titulo": a["titulo"],
523
+ "editar": "%s/t/%s/%s" % (base, a["ferramenta"], a["id"]),
524
+ "ver": "%s/v/%s" % (base, a["id"]),
525
+ }, 201)
526
+ return self.js({"erro": "não encontrado"}, 404)
527
+
528
+ @seguro
529
+ def do_PUT(self):
530
+ m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
531
+ if not m:
532
+ return self.js({"erro": "não encontrado"}, 404)
533
+ a = ler(m.group(1))
534
+ if not a:
535
+ return self.js({"erro": "não encontrado"}, 404)
536
+ dados = self.corpo()
537
+ # Mesma validação de criar(): sem isso, um PUT com titulo/doc do tipo
538
+ # errado grava sujeira que só quebra depois, no consumo (export etc).
539
+ if "titulo" in dados and dados["titulo"] is not None and not isinstance(dados["titulo"], str):
540
+ return self.js({"erro": "titulo precisa ser texto"}, 400)
541
+ if "doc" in dados and dados["doc"] is not None and not isinstance(dados["doc"], dict):
542
+ return self.js({"erro": "doc precisa ser um objeto"}, 400)
543
+ for chave in ("titulo", "doc"):
544
+ if chave in dados:
545
+ a[chave] = dados[chave]
546
+ return self.js(gravar(a))
547
+
548
+ @seguro
549
+ def do_DELETE(self):
550
+ rota = urlparse(self.path).path
551
+ m = re.fullmatch(r"/api/midia/([^/]+)", rota)
552
+ if m:
553
+ nome = unquote(m.group(1))
554
+ alvo = os.path.join(MIDIA, nome)
555
+ if "/" not in nome and ".." not in nome and os.path.isfile(alvo):
556
+ os.remove(alvo)
557
+ return self.js({"ok": True})
558
+ return self.js({"erro": "não encontrado"}, 404)
559
+
560
+ m = re.fullmatch(r"/api/arquivos/([^/]+)", rota)
561
+ c = caminho(m.group(1)) if m else None
562
+ if c and os.path.exists(c):
563
+ os.remove(c)
564
+ return self.js({"ok": True})
565
+ return self.js({"erro": "não encontrado"}, 404)
566
+
567
+ # ------------------------------------------------------------- export --
568
+
569
+ def exportar(self, a):
570
+ ferramenta = a.get("ferramenta")
571
+ doc = a.get("doc") or {}
572
+
573
+ if ferramenta == "grade":
574
+ linhas = []
575
+ for aba in doc.get("abas", []):
576
+ linhas.append(",".join(csv_celula(c) for c in aba.get("colunas", [])))
577
+ for lin in calcular_aba(aba):
578
+ linhas.append(",".join(csv_celula(c) for c in lin))
579
+ linhas.append("")
580
+ return self.txt("\n".join(linhas), "text/csv; charset=utf-8",
581
+ baixar=a["id"] + ".csv")
582
+
583
+ if ferramenta == "prosa":
584
+ return self.txt(prosa_markdown(doc), "text/markdown; charset=utf-8",
585
+ baixar=a["id"] + ".md")
586
+
587
+ return self.txt(html_autonomo(a), "text/html; charset=utf-8",
588
+ baixar=a["id"] + ".html")
589
+
590
+
591
+ def csv_celula(valor):
592
+ if isinstance(valor, float):
593
+ valor = round(valor, 2)
594
+ if valor == int(valor):
595
+ valor = int(valor)
596
+ s = "" if valor is None else str(valor)
597
+ return '"%s"' % s.replace('"', '""') if re.search(r'[",\n\r]', s) else s
598
+
599
+
600
+ # ------------------------------------------------- fórmulas (espelha o JS) --
601
+
602
+ def _mediana(v):
603
+ if not v:
604
+ return 0
605
+ o = sorted(v)
606
+ m = len(o) // 2
607
+ return o[m] if len(o) % 2 else (o[m - 1] + o[m]) / 2
608
+
609
+
610
+ _FUNCOES = {
611
+ "SOMA": sum,
612
+ "MEDIA": lambda v: sum(v) / len(v) if v else 0,
613
+ "MIN": lambda v: min(v) if v else 0,
614
+ "MAX": lambda v: max(v) if v else 0,
615
+ "CONT": len,
616
+ "ARRED": lambda v: round(v[0], int(v[1])) if len(v) > 1 else round(v[0] if v else 0),
617
+ "ABS": lambda v: abs(v[0] if v else 0),
618
+ "RAIZ": lambda v: abs(v[0] if v else 0) ** 0.5,
619
+ "POT": lambda v: (v[0] if v else 0) ** (v[1] if len(v) > 1 else 2),
620
+ "TETO": lambda v: -(-(v[0] if v else 0) // 1),
621
+ "PISO": lambda v: (v[0] if v else 0) // 1,
622
+ "MEDIANA": _mediana,
623
+ "MAIOR": lambda v: sorted(v[:-1], reverse=True)[max(0, int(v[-1]) - 1)] if len(v) > 1 else 0,
624
+ "MENOR": lambda v: sorted(v[:-1])[max(0, int(v[-1]) - 1)] if len(v) > 1 else 0,
625
+ "SE": lambda v: (v[1] if len(v) > 1 else 1) if v and v[0] else (v[2] if len(v) > 2 else 0),
626
+ }
627
+
628
+
629
+ def _criterio(texto):
630
+ m = re.match(r'^(<=|>=|<>|!=|<|>|=)?\s*(-?[\d.,]+)$', str(texto).strip().strip('"\''))
631
+ if not m:
632
+ return lambda x: False
633
+ op, alvo = m.group(1) or "=", float(m.group(2).replace(",", "."))
634
+ return {
635
+ ">": lambda x: x > alvo, "<": lambda x: x < alvo,
636
+ ">=": lambda x: x >= alvo, "<=": lambda x: x <= alvo,
637
+ "<>": lambda x: x != alvo, "!=": lambda x: x != alvo,
638
+ }.get(op, lambda x: x == alvo)
639
+
640
+
641
+ def _col(letras):
642
+ n = 0
643
+ for c in letras:
644
+ n = n * 26 + (ord(c) - 64)
645
+ return n - 1
646
+
647
+
648
+ def _num(v):
649
+ if isinstance(v, (int, float)):
650
+ return v
651
+ if v in (None, ""):
652
+ return 0
653
+ try:
654
+ return float(re.sub(r"[R$\s%.]", "", str(v)).replace(",", "."))
655
+ except ValueError:
656
+ return 0
657
+
658
+
659
+ def calcular_aba(aba):
660
+ """Resolve as fórmulas da aba e devolve a matriz de valores."""
661
+ linhas = [list(l) for l in aba.get("linhas", [])]
662
+
663
+ def celula(c, l, prof):
664
+ # l/c negativos (referência tipo "A0", que vira índice -1) não podem
665
+ # passar: índice negativo do Python "funciona" contando do fim da
666
+ # lista, e a última linha da planilha aparecia como resposta de uma
667
+ # referência inválida em vez de virar 0.
668
+ if prof > 24 or l < 0 or c < 0 or l >= len(linhas) or c >= len(linhas[l]):
669
+ return 0
670
+ bruto = linhas[l][c]
671
+ if isinstance(bruto, str) and bruto.startswith("="):
672
+ return avaliar(bruto[1:], prof + 1)
673
+ return bruto
674
+
675
+ def faixa(ref, prof):
676
+ m = re.fullmatch(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", ref)
677
+ if not m:
678
+ return []
679
+ c1, l1, c2, l2 = _col(m.group(1)), int(m.group(2)) - 1, _col(m.group(3)), int(m.group(4)) - 1
680
+ return [_num(celula(c, l, prof))
681
+ for l in range(min(l1, l2), max(l1, l2) + 1)
682
+ for c in range(min(c1, c2), max(c1, c2) + 1)]
683
+
684
+ def ref_simples(ref, prof):
685
+ m = re.fullmatch(r"([A-Z]+)(\d+)", ref)
686
+ return celula(_col(m.group(1)), int(m.group(2)) - 1, prof) if m else 0
687
+
688
+ def avaliar(expr, prof):
689
+ def fn(m):
690
+ nome = m.group(1)
691
+ partes = [p.strip() for p in m.group(2).split(";")]
692
+
693
+ if nome in ("SOMASE", "CONTSE"):
694
+ base = faixa(partes[0], prof) if re.fullmatch(r"[A-Z]+\d+:[A-Z]+\d+", partes[0]) else []
695
+ passou = [x for x in base if _criterio(partes[1] if len(partes) > 1 else "")(x)]
696
+ return repr(sum(passou) if nome == "SOMASE" else len(passou))
697
+
698
+ f = _FUNCOES.get(nome)
699
+ if not f:
700
+ return "0"
701
+ vals = []
702
+ for parte in partes:
703
+ if re.fullmatch(r"[A-Z]+\d+:[A-Z]+\d+", parte):
704
+ vals += faixa(parte, prof)
705
+ elif parte:
706
+ # qualquer outro argumento é uma expressão: resolve antes
707
+ vals.append(_num(avaliar(parte, prof)))
708
+ try:
709
+ # MAIOR/MENOR estouram IndexError com k maior que o intervalo;
710
+ # POT estoura OverflowError com base/expoente grandes demais.
711
+ # Uma fórmula ruim vira "#ERRO" NESSA célula, não derruba a
712
+ # exportação inteira (mesma regra do eval() logo abaixo).
713
+ return repr(f(vals))
714
+ except Exception:
715
+ return "#ERRO"
716
+
717
+ # repete até esgotar: resolve de dentro para fora em fórmula aninhada
718
+ e, voltas = expr, 0
719
+ while re.search(r"[A-Z]+\(", e) and voltas < 12:
720
+ e = re.sub(r"([A-Z]+)\(([^()]*)\)", fn, e)
721
+ voltas += 1
722
+ e = re.sub(r"\b[A-Z]+\d+\b", lambda m: repr(_num(ref_simples(m.group(0), prof))), e)
723
+ # comparações são permitidas para o SE funcionar
724
+ if not re.fullmatch(r"[-+*/().\d\s,<>=!]*", e):
725
+ return "#ERRO"
726
+ try:
727
+ pronto = e.replace(",", ".").replace("<>", "!=")
728
+ pronto = re.sub(r"([^<>!=])=(?!=)", r"\1==", pronto) or "0"
729
+ r = eval(pronto, {"__builtins__": {}}, {})
730
+ if isinstance(r, bool):
731
+ return 1 if r else 0
732
+ return round(r, 6) if isinstance(r, float) else r
733
+ except Exception:
734
+ return "#ERRO"
735
+
736
+ return [[celula(c, l, 0) for c in range(len(linhas[l]))] for l in range(len(linhas))]
737
+
738
+
739
+ def prosa_markdown(doc):
740
+ fora = []
741
+ capa = doc.get("capa")
742
+ if capa:
743
+ if capa.get("chapeu"):
744
+ fora += ["*%s*" % capa["chapeu"], ""]
745
+ if capa.get("titulo"):
746
+ fora += ["# " + capa["titulo"], ""]
747
+ if capa.get("apoio"):
748
+ fora += [capa["apoio"], ""]
749
+ assinatura = " · ".join(x for x in (capa.get("autor"), capa.get("data")) if x)
750
+ if assinatura:
751
+ fora += ["_%s_" % assinatura, ""]
752
+ fora += ["---", ""]
753
+
754
+ for b in doc.get("blocos", []):
755
+ t, texto = b.get("tipo"), b.get("texto", "")
756
+ if t == "titulo":
757
+ fora.append("# " + texto)
758
+ elif t == "secao":
759
+ fora.append("## " + texto)
760
+ elif t == "subsecao":
761
+ fora.append("### " + texto)
762
+ elif t == "chapeu":
763
+ fora.append("*" + texto + "*")
764
+ elif t == "apoio":
765
+ fora.append("_" + texto + "_")
766
+ elif t == "destaque":
767
+ fora.append("> **%s** " % b.get("titulo", "Nota"))
768
+ fora.append("> " + texto)
769
+ elif t == "sumario":
770
+ fora.append("## " + (texto or "Sumário"))
771
+ fora += ["%d. %s" % (i + 1, s.get("texto", ""))
772
+ for i, s in enumerate([x for x in doc.get("blocos", []) if x.get("tipo") == "secao"])]
773
+ elif t == "citacao":
774
+ fora.append("> " + texto)
775
+ if b.get("autor"):
776
+ fora += [">", "> — " + b["autor"]]
777
+ elif t == "lista":
778
+ fora += ["- " + i for i in b.get("itens", [])]
779
+ elif t == "numerada":
780
+ fora += ["%d. %s" % (i + 1, v) for i, v in enumerate(b.get("itens", []))]
781
+ elif t == "codigo":
782
+ fora.append("```\n" + texto + "\n```")
783
+ elif t == "divisor":
784
+ fora.append("---")
785
+ elif t == "tabela":
786
+ linhas = b.get("linhas", [])
787
+ if linhas:
788
+ fora.append("| " + " | ".join(map(str, linhas[0])) + " |")
789
+ fora.append("|" + "---|" * len(linhas[0]))
790
+ for l in linhas[1:]:
791
+ fora.append("| " + " | ".join(map(str, l)) + " |")
792
+ else:
793
+ fora.append(texto)
794
+ fora.append("")
795
+ return "\n".join(fora)
796
+
797
+
798
+ def embutir_midia(texto, limite=24 * 1024 * 1024):
799
+ """Troca /midia/x por data URI para o export abrir sem servidor nenhum."""
800
+ def troca(m):
801
+ nome = unquote(m.group(1))
802
+ alvo = os.path.join(MIDIA, nome)
803
+ if "/" in nome or ".." in nome or not os.path.isfile(alvo):
804
+ return m.group(0)
805
+ if os.path.getsize(alvo) > limite:
806
+ return m.group(0) # grande demais: mantém o link
807
+ tipo = mimetypes.guess_type(alvo)[0] or "application/octet-stream"
808
+ with open(alvo, "rb") as fh:
809
+ return "data:%s;base64,%s" % (tipo, base64.b64encode(fh.read()).decode())
810
+
811
+ return re.sub(r"/midia/([A-Za-z0-9._%\-]+)", troca, texto)
812
+
813
+
814
+ def html_autonomo(a):
815
+ """Arquivo único: CSS, renderizadores, áudio, mídia e documento embutidos."""
816
+ def leia(*partes):
817
+ with open(os.path.join(WEB, *partes), encoding="utf-8") as fh:
818
+ return fh.read()
819
+
820
+ js = "\n".join(leia("js", n) for n in
821
+ ("icones.js", "audio.js", "render.js", "render-prosa.js", "render-grade.js"))
822
+
823
+ dados = json.dumps(a, ensure_ascii=False).replace("</script", "<\\/script")
824
+
825
+ return """<!doctype html>
826
+ <html lang="pt-BR"><head><meta charset="utf-8">
827
+ <meta name="viewport" content="width=device-width,initial-scale=1">
828
+ <title>%(titulo)s</title>
829
+ <style>%(css)s</style></head>
830
+ <body class="modo-ver">
831
+ <div id="palco" class="palco"></div>
832
+ <script id="dados" type="application/json">%(dados)s</script>
833
+ <script>%(js)s</script>
834
+ <script>%(player)s</script>
835
+ </body></html>
836
+ """ % {
837
+ "titulo": (a.get("titulo") or "PrimoCode").replace("<", "&lt;"),
838
+ "css": leia("core.css") + leia("render.css"),
839
+ "dados": embutir_midia(dados),
840
+ "js": js,
841
+ "player": leia("js", "player.js"),
842
+ }
843
+
844
+
845
+ # -------------------------------------------------------------------- main --
846
+
847
+ if __name__ == "__main__":
848
+ url = "http://localhost:%d" % PORTA
849
+ print("""
850
+ ██ PrimoCode Studio
851
+ ────────────────────────────────────────────────────────
852
+ Estúdio %s
853
+ Manual da IA %s/api/manual
854
+ Ferramentas %s
855
+ ────────────────────────────────────────────────────────
856
+ Ctrl+C para parar
857
+ """ % (url, url, ", ".join(f["nome"] for f in FERRAMENTAS_INFO)))
858
+
859
+ try:
860
+ ThreadingHTTPServer(("127.0.0.1", PORTA), Handler).serve_forever()
861
+ except KeyboardInterrupt:
862
+ print("\n Encerrado.\n")