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.
- package/bin/primocode.js +0 -0
- package/lib/act.js +13 -3
- package/lib/catalogo.js +12 -0
- package/lib/desktop.js +3 -1
- package/lib/referencia.js +8 -4
- package/lib/studio.js +198 -0
- package/lib/tools.js +24 -22
- package/lib/ui.js +3 -1
- package/package.json +2 -1
- package/studio/PRIMOCODE.md +58 -0
- package/studio/README.md +154 -0
- package/studio/ferramentas/00-protocolo.md +111 -0
- package/studio/ferramentas/01-modelos.md +202 -0
- package/studio/ferramentas/README.md +32 -0
- package/studio/ferramentas/corte.md +193 -0
- package/studio/ferramentas/grade.md +92 -0
- package/studio/ferramentas/prisma.md +99 -0
- package/studio/ferramentas/prosa.md +86 -0
- package/studio/ferramentas/tela.md +72 -0
- package/studio/ferramentas/traco.md +79 -0
- package/studio/server.py +862 -0
- package/studio/web/core.css +723 -0
- package/studio/web/home.html +149 -0
- package/studio/web/js/audio.js +262 -0
- package/studio/web/js/editor.js +1270 -0
- package/studio/web/js/exportar.js +135 -0
- package/studio/web/js/ferramentas.js +105 -0
- package/studio/web/js/icones.js +91 -0
- package/studio/web/js/midia.js +157 -0
- package/studio/web/js/narracao.js +254 -0
- package/studio/web/js/player.js +504 -0
- package/studio/web/js/render-grade.js +243 -0
- package/studio/web/js/render-prosa.js +104 -0
- package/studio/web/js/render.js +622 -0
- package/studio/web/render.css +565 -0
- package/studio/web/tool.html +70 -0
- package/studio/web/view.html +20 -0
package/studio/server.py
ADDED
|
@@ -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("<", "<"),
|
|
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")
|