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