Dashboard Masticationpedia: differenze tra le versioni
Nessun oggetto della modifica |
Nessun oggetto della modifica Etichetta: Annullato |
||
| Riga 329: | Riga 329: | ||
} | } | ||
</script> | </script> | ||
<!-- ========================================================= | |||
WIDGET "CODECARD" PER DASHBOARD – codice a colori + COPIA | |||
Requisiti: nessuno oltre a Internet per CDN highlight.js | |||
Inserisci questo blocco nella pagina Dashboard (HTML). | |||
========================================================= --> | |||
<!-- 1) Highlight.js (tema chiaro elegante) --> | |||
<link rel="stylesheet" | |||
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" /> | |||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> | |||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/php.min.js"></script> | |||
<!-- 2) Stili della “card” con barra azioni --> | |||
<style> | |||
.codecard { | |||
border: 1px solid #e5e7eb; /* gray-200 */ | |||
border-radius: 12px; | |||
overflow: hidden; | |||
box-shadow: 0 4px 24px rgba(0,0,0,.04); | |||
margin: 18px 0; | |||
background: #fff; | |||
} | |||
.codecard__bar { | |||
display: flex; align-items: center; justify-content: space-between; | |||
padding: 10px 12px; background: #f8fafc; /* slate-50 */ | |||
border-bottom: 1px solid #e5e7eb; | |||
} | |||
.codecard__title { | |||
font: 600 14px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; | |||
color: #0f172a; /* slate-900 */ letter-spacing: .2px; | |||
} | |||
.codecard__actions { display: flex; gap: 8px; } | |||
.codecard__btn { | |||
font: 600 12px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; | |||
padding: 8px 10px; border-radius: 8px; border: 1px solid #e5e7eb; | |||
background: #ffffff; color:#0f172a; cursor: pointer; | |||
transition: background .15s ease, transform .02s ease; | |||
} | |||
.codecard__btn:hover { background: #f1f5f9; } /* slate-100 */ | |||
.codecard__btn:active { transform: scale(0.98); } | |||
.codecard__btn.primary { background:#111827; color:#fff; border-color:#111827; } /* gray-900 */ | |||
.codecard__btn.primary:hover { background:#0b1220; } | |||
.codecard__wrap { padding: 10px 12px 12px; background: #ffffff; } | |||
.codecard pre { margin:0; } | |||
.codecard code { font-size: 13px; } | |||
.codecard code.wrap { white-space: pre-wrap; word-break: break-word; } | |||
.codecard__hint { | |||
font: 12px/1.3 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; | |||
color:#475569; padding: 8px 12px 12px; | |||
} | |||
</style> | |||
<!-- 3) Markup del contenitore dove verrà montata la card --> | |||
<div id="chapter-tool-codecard"></div> | |||
<!-- 4) Script: crea la CodeCard e monta il codice PHP con pulsante Copia --> | |||
<script> | |||
(function(){ | |||
// ==== A) Il tuo file PHP, intero, dentro un template literal ==== | |||
const CODE_PHP = `<?php | |||
/** | |||
* /api/chapter_tool.php | |||
* | |||
* Endpoint JSON: | |||
* { | |||
* "mode": "translate" | "qa" | "summarize", | |||
* "model": "gpt-4.1-mini" | "gpt-4o" | ..., | |||
* "temperature": 0.0-2.0, | |||
* "prompt": "string", | |||
* "files": ["/path/file1.pdf", "/path/file2.docx", ...], | |||
* "url": "https://..." | |||
* } | |||
* | |||
* Output JSON: | |||
* - translate: { ok, mode, text, tokens_used } | |||
* - qa: { ok, mode, answer, tokens_used } | |||
* - summarize: { ok, mode, summary:[...], keywords:[...], tokens_used } | |||
* | |||
* Logging: /var/log/mpai/*.json (fallback /tmp) | |||
*/ | |||
declare(strict_types=1); | |||
mb_internal_encoding('UTF-8'); | |||
header('Content-Type: application/json; charset=utf-8'); | |||
// ---- Config API key ---- | |||
$OPENAI_API_KEY = null; | |||
$keysFile = __DIR__ . '/../secure_config/keys.php'; | |||
if (is_file($keysFile)) { | |||
require_once $keysFile; // Deve definire OPENAI_API_KEY | |||
if (defined('OPENAI_API_KEY')) { | |||
$OPENAI_API_KEY = OPENAI_API_KEY; | |||
} | |||
} | |||
if (!$OPENAI_API_KEY) { | |||
echo json_encode(['ok' => false, 'error' => 'Missing OPENAI_API_KEY']); | |||
exit; | |||
} | |||
// ---- Log dir ---- | |||
$LOG_DIR = '/var/log/mpai'; | |||
if (!is_dir($LOG_DIR) || !is_writable($LOG_DIR)) { | |||
$LOG_DIR = sys_get_temp_dir(); | |||
} | |||
function now_id(): string { | |||
return date('Ymd_His') . '_' . getmypid(); | |||
} | |||
function save_log(string $prefix, $data): void { | |||
global $LOG_DIR; | |||
$fname = $LOG_DIR . '/' . $prefix . '_' . now_id() . '.json'; | |||
@file_put_contents($fname, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); | |||
} | |||
function read_json_input(): array { | |||
$raw = file_get_contents('php://input'); | |||
$data = json_decode($raw, true); | |||
if (!is_array($data)) $data = []; | |||
save_log('chapter_tool_input', ['raw' => $raw, 'parsed' => $data]); | |||
return $data; | |||
} | |||
function curl_get(string $url, int $timeout = 20): array { | |||
$ch = curl_init($url); | |||
curl_setopt_array($ch, [ | |||
CURLOPT_RETURNTRANSFER => true, | |||
CURLOPT_FOLLOWLOCATION => true, | |||
CURLOPT_MAXREDIRS => 5, | |||
CURLOPT_CONNECTTIMEOUT => 10, | |||
CURLOPT_TIMEOUT => $timeout, | |||
CURLOPT_SSL_VERIFYPEER => true, | |||
CURLOPT_SSL_VERIFYHOST => 2, | |||
CURLOPT_USERAGENT => 'Masticationpedia-ChapterTool/1.0 (+https://masticationpedia.org)', | |||
CURLOPT_HTTPHEADER => ['Accept: */*'], | |||
]); | |||
$body = curl_exec($ch); | |||
$err = curl_error($ch); | |||
$info = curl_getinfo($ch); | |||
curl_close($ch); | |||
return ['ok' => ($err === ''), 'error' => $err, 'info' => $info, 'body' => $body]; | |||
} | |||
function normalize_whitespace(string $s): string { | |||
$s = preg_replace("/[ \\t]+/u", " ", $s); | |||
$s = preg_replace("/\\R{3,}/u", "\\n\\n", $s); | |||
return trim($s ?? ''); | |||
} | |||
function html_to_text(string $html): string { | |||
libxml_use_internal_errors(true); | |||
$dom = new DOMDocument(); | |||
$html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); | |||
@$dom->loadHTML($html); | |||
if (!$dom) return strip_tags($html); | |||
$xpath = new DOMXPath($dom); | |||
foreach (['script','style','noscript'] as $tag) { | |||
foreach ($xpath->query("//{$tag}") as $node) { | |||
$node->parentNode->removeChild($node); | |||
} | |||
} | |||
$text = $dom->textContent ?? ''; | |||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |||
return normalize_whitespace($text); | |||
} | |||
function extract_text_from_pdf(string $path): array { | |||
$cmd = 'pdftotext -layout -q ' . escapeshellarg($path) . ' -'; | |||
$out = @shell_exec($cmd . ' 2>&1'); | |||
if ($out === null) return [false, "pdftotext failed or not available"]; | |||
return [true, normalize_whitespace((string)$out)]; | |||
} | |||
function extract_text_with_libreoffice(string $path): array { | |||
$tmpDir = sys_get_temp_dir() . '/ctool_' . now_id(); | |||
@mkdir($tmpDir, 0775, true); | |||
$cmd = 'soffice --headless --convert-to txt --outdir ' . escapeshellarg($tmpDir) . ' ' . escapeshellarg($path); | |||
$out = @shell_exec($cmd . ' 2>&1'); | |||
if ($out === null) return [false, "LibreOffice headless conversion failed or not available"]; | |||
$txtFiles = glob($tmpDir . '/*.txt'); | |||
if (!$txtFiles) return [false, "No TXT produced by LibreOffice"]; | |||
$txt = @file_get_contents($txtFiles[0]); | |||
return [true, normalize_whitespace((string)$txt)]; | |||
} | |||
function extract_text_from_html_file(string $path): array { | |||
$html = @file_get_contents($path); | |||
if ($html === false) return [false, "Cannot read HTML file"]; | |||
return [true, html_to_text($html)]; | |||
} | |||
function extract_text_from_file(string $path): array { | |||
if (!is_file($path) || !is_readable($path)) return [false, "File not readable: $path"]; | |||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); | |||
switch ($ext) { | |||
case 'pdf': return extract_text_from_pdf($path); | |||
case 'doc': | |||
case 'docx': | |||
case 'odt': | |||
case 'rtf': return extract_text_with_libreoffice($path); | |||
case 'html': | |||
case 'htm': return extract_text_from_html_file($path); | |||
default: | |||
$txt = @file_get_contents($path); | |||
if ($txt === false) return [false, "Cannot read file: $path"]; | |||
return [true, normalize_whitespace((string)$txt)]; | |||
} | |||
} | |||
function chunk_text(string $text, int $chunkSize = 12000): array { | |||
$chunks = []; | |||
$len = mb_strlen($text); | |||
for ($i=0; $i<$len; $i+=$chunkSize) $chunks[] = mb_substr($text, $i, $chunkSize); | |||
return $chunks; | |||
} | |||
function tokenize_simple(string $s): array { | |||
$s = mb_strtolower($s); | |||
$s = preg_replace('/[^\\p{L}\\p{N}\\s]/u', ' ', $s); | |||
$parts = preg_split('/\\s+/u', $s, -1, PREG_SPLIT_NO_EMPTY); | |||
return $parts ?: []; | |||
} | |||
function keyword_scores(array $chunks, string $query): array { | |||
$qTokens = array_unique(tokenize_simple($query)); | |||
$scores = []; | |||
foreach ($chunks as $i => $c) { | |||
$toks = tokenize_simple($c); | |||
$count = 0; | |||
if ($toks) { | |||
$set = array_count_values($toks); | |||
foreach ($qTokens as $qt) if (isset($set[$qt])) $count += $set[$qt]; | |||
} | |||
$scores[$i] = $count; | |||
} | |||
arsort($scores); return $scores; | |||
} | |||
function openai_chat(array $messages, string $model, float $temperature, int $maxTokens = 1024): array { | |||
global $OPENAI_API_KEY; | |||
$payload = [ | |||
'model' => $model, | |||
'temperature' => max(0.0, min(2.0, $temperature)), | |||
'messages' => $messages, | |||
'max_tokens' => $maxTokens | |||
]; | |||
save_log('chapter_tool_payload', $payload); | |||
$ch = curl_init('https://api.openai.com/v1/chat/completions'); | |||
curl_setopt_array($ch, [ | |||
CURLOPT_RETURNTRANSFER => true, | |||
CURLOPT_POST => true, | |||
CURLOPT_HTTPHEADER => [ | |||
'Content-Type: application/json', | |||
'Authorization: Bearer ' . $OPENAI_API_KEY | |||
], | |||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), | |||
CURLOPT_CONNECTTIMEOUT => 15, | |||
CURLOPT_TIMEOUT => 60 | |||
]); | |||
$raw = curl_exec($ch); | |||
$err = curl_error($ch); | |||
curl_close($ch); | |||
$resp = $raw ? json_decode($raw, true) : null; | |||
save_log('chapter_tool_response', ['error' => $err, 'raw' => $raw, 'parsed' => $resp]); | |||
if ($err) return ['ok' => false, 'error' => "OpenAI curl error: $err"]; | |||
if (!is_array($resp)) return ['ok' => false, 'error' => 'Invalid OpenAI response']; | |||
return ['ok' => true, 'data' => $resp]; | |||
} | |||
function chat_with_continuations(array $baseMessages, string $model, float $temperature, int $maxTokens = 1024, int $maxRounds = 8): array { | |||
$acc = ''; $totalTokens = 0; $round = 0; $messages = $baseMessages; | |||
while ($round < $maxRounds) { | |||
$round++; | |||
$r = openai_chat($messages, $model, $temperature, $maxTokens); | |||
if (!$r['ok']) return $r; | |||
$resp = $r['data']; | |||
$choice = $resp['choices'][0] ?? null; | |||
if (!$choice) return ['ok' => false, 'error' => 'Malformed OpenAI response (no choices)']; | |||
$content = $choice['message']['content'] ?? ''; | |||
$finish = $choice['finish_reason'] ?? ''; | |||
$usage = $resp['usage'] ?? []; | |||
$totalTokens += (int)($usage['total_tokens'] ?? 0); | |||
$acc .= $content; | |||
if ($finish !== 'length') { | |||
return ['ok' => true, 'text' => $acc, 'tokens_used' => $totalTokens]; | |||
} | |||
$messages[] = ['role' => 'assistant', 'content' => $content]; | |||
$messages[] = ['role' => 'user', 'content' => | |||
"Continue from where you stopped. Do not repeat. Keep the same format. No preambles."]; | |||
} | |||
return ['ok' => true, 'text' => $acc, 'tokens_used' => $totalTokens, 'note' => 'maxRounds reached']; | |||
} | |||
// -------- Parse input -------- | |||
$in = read_json_input(); | |||
$mode = $in['mode'] ?? ''; | |||
$model = $in['model'] ?? 'gpt-4.1-mini'; | |||
$temperature = isset($in['temperature']) ? (float)$in['temperature'] : 0.3; | |||
$prompt = trim((string)($in['prompt'] ?? '')); | |||
$files = is_array($in['files'] ?? null) ? $in['files'] : []; | |||
$url = trim((string)($in['url'] ?? '')); | |||
if (!in_array($mode, ['translate','qa','summarize'], true)) { | |||
echo json_encode(['ok' => false, 'error' => 'Invalid mode']); exit; | |||
} | |||
if (!$prompt && $mode !== 'summarize' && !$url && empty($files)) { | |||
echo json_encode(['ok' => false, 'error' => 'Missing prompt or content sources']); exit; | |||
} | |||
// Acquire sources | |||
$allTextParts = []; | |||
if ($url !== '') { | |||
$got = curl_get($url, 30); | |||
if (!$got['ok']) { echo json_encode(['ok' => false, 'error' => 'URL fetch error: ' . $got['error']]); exit; } | |||
$ct = strtolower((string)($got['info']['content_type'] ?? '')); | |||
$body = (string)$got['body']; | |||
if (strpos($ct, 'text/html') !== false || preg_match('/\\.html?(\\?|$)/i', $url)) { | |||
$allTextParts[] = html_to_text($body); | |||
} else { | |||
$allTextParts[] = normalize_whitespace($body); | |||
} | |||
} | |||
foreach ($files as $f) { | |||
[$ok, $txtOrErr] = extract_text_from_file($f); | |||
if (!$ok) { echo json_encode(['ok' => false, 'error' => $txtOrErr]); exit; } | |||
$allTextParts[] = $txtOrErr; | |||
} | |||
$fullText = normalize_whitespace(implode("\\n\\n", $allTextParts)); | |||
$chunks = $fullText ? chunk_text($fullText, 12000) : []; | |||
try { | |||
if ($mode === 'translate') { | |||
$temperature = 0.2; | |||
if (!$chunks) $chunks = chunk_text($prompt, 12000); | |||
$translated = ''; $tokensSum = 0; | |||
$translateInstruction = $prompt !== '' ? $prompt | |||
: "Translate the following content to the target language (default: English), preserving all markup (HTML/Wikitext) exactly. Do not add preambles, notes, or extra commentary."; | |||
foreach ($chunks as $ck) { | |||
$sys = "You are a precise translation engine. Preserve all markup (HTML/Wikitext) exactly as in input. Do not add preambles."; | |||
$messages = [ | |||
['role'=>'system','content'=>$sys], | |||
['role'=>'user','content'=>$translateInstruction."\\n\\n---\\n".$ck] | |||
]; | |||
$r = chat_with_continuations($messages, $model, $temperature, 1024, 8); | |||
if (!$r['ok']) { echo json_encode(['ok'=>false,'error'=>$r['error']??'translate error']); exit; } | |||
$translated .= $r['text']."\\n"; | |||
$tokensSum += (int)($r['tokens_used'] ?? 0); | |||
} | |||
echo json_encode(['ok'=>true,'mode'=>'translate','text'=>trim($translated),'tokens_used'=>$tokensSum], | |||
JSON_UNESCAPED_UNICODE); exit; | |||
} | |||
if ($mode === 'qa') { | |||
if (!$chunks) { echo json_encode(['ok'=>false,'error'=>'No context available (no url/files text)']); exit; } | |||
$scores = keyword_scores($chunks, $prompt); | |||
$topN = 5; $pickedIdx = array_slice(array_keys($scores), 0, $topN); | |||
$contextPieces = []; | |||
foreach ($pickedIdx as $i) { $contextPieces[] = "### CHUNK #".($i+1)."\\n".$chunks[$i]; } | |||
$context = implode("\\n\\n", $contextPieces); | |||
$sys = "You are a careful scientific assistant. Answer strictly based on the provided CONTEXT. If the answer is not present, say you cannot find it in the context. Be concise and precise."; | |||
$userMsg = "CONTEXT:\\n".$context."\\n\\nQUESTION:\\n".$prompt."\\n\\nRULES: cite relevant chunk sections if possible; do not invent; no preambles."; | |||
$messages = [['role'=>'system','content'=>$sys],['role'=>'user','content'=>$userMsg]]; | |||
$r = chat_with_continuations($messages, $model, $temperature, 1024, 8); | |||
if (!$r['ok']) { echo json_encode(['ok'=>false,'error'=>$r['error']??'qa error']); exit; } | |||
echo json_encode(['ok'=>true,'mode'=>'qa','answer'=>trim($r['text']),'tokens_used'=>(int)($r['tokens_used']??0)], | |||
JSON_UNESCAPED_UNICODE); exit; | |||
} | |||
if ($mode === 'summarize') { | |||
$temperature = max(0.0, min(2.0, (float)$temperature ?: 0.3)); | |||
if (!$chunks) { | |||
if (!$prompt) { echo json_encode(['ok'=>false,'error'=>'Nothing to summarize']); exit; } | |||
$chunks = chunk_text($prompt, 12000); | |||
} | |||
$intermediateSummaries = []; $tokensSum = 0; | |||
foreach ($chunks as $ck) { | |||
$sys = "You are a scientific summarizer."; | |||
$user = "Summarize the following content into 5–7 concise bullets, no preambles. Then extract 10 keywords (single words or short bigrams), comma-separated.\\n\\nCONTENT:\\n".$ck; | |||
$messages = [['role'=>'system','content'=>$sys],['role'=>'user','content'=>$user]]; | |||
$r = chat_with_continuations($messages, $model, $temperature, 900, 6); | |||
if (!$r['ok']) { echo json_encode(['ok'=>false,'error'=>$r['error']??'summarize chunk error']); exit; } | |||
$intermediateSummaries[] = trim($r['text']); $tokensSum += (int)($r['tokens_used'] ?? 0); | |||
} | |||
$mergeInput = implode("\\n\\n---\\n\\n", $intermediateSummaries); | |||
$sys2 = "You are a precise summarizer."; | |||
$user2 = "Merge the following partial summaries into ONE final output:\\n- 5–7 concise bullets.\\n- Then a line with exactly 10 keywords (comma-separated).\\nNo preambles.\\n\\nPARTIALS:\\n".$mergeInput; | |||
$r2 = chat_with_continuations([['role'=>'system','content'=>$sys2],['role'=>'user','content'=>$user2]], $model, $temperature, 700, 4); | |||
if (!$r2['ok']) { echo json_encode(['ok'=>false,'error'=>$r2['error']??'summarize merge error']); exit; } | |||
$tokensSum += (int)($r2['tokens_used'] ?? 0); | |||
$final = trim($r2['text']); $lines = preg_split('/\\R/u', $final); | |||
$bullets = []; $keywords = []; | |||
foreach ($lines as $ln) { | |||
$t = trim($ln); if ($t==='') continue; | |||
if (preg_match('/^[-*•]\\s*(.+)$/u', $t, $m)) { $bullets[] = trim($m[1]); } | |||
else if (!count($keywords) && preg_match('/[,,]/u', $t)) { | |||
$parts = preg_split('/\\s*[,,]\\s*/u', $t); | |||
foreach ($parts as $p) { $p = trim($p); if ($p!=='') $keywords.push($p); } | |||
} | |||
} | |||
if (count($bullets)>7) $bullets = array_slice($bullets,0,7); | |||
if (count($keywords)>10) $keywords = array_slice($keywords,0,10); | |||
echo json_encode(['ok'=>true,'mode'=>'summarize','summary'=>$bullets,'keywords'=>$keywords,'tokens_used'=>$tokensSum], | |||
JSON_UNESCAPED_UNICODE); exit; | |||
} | |||
echo json_encode(['ok'=>false,'error'=>'Unhandled mode']); | |||
} catch (Throwable $e) { | |||
echo json_encode(['ok'=>false,'error'=>'Unhandled exception: '.$e->getMessage()]); | |||
exit; | |||
} | |||
?>`; | |||
// ==== B) Funzione che crea la card ==== | |||
function createCodeCard({ title, filename, language, code }) { | |||
const container = document.createElement('div'); | |||
container.className = 'codecard'; | |||
container.innerHTML = ` | |||
<div class="codecard__bar"> | |||
<div class="codecard__title">${title}</div> | |||
<div class="codecard__actions"> | |||
<button class="codecard__btn" data-action="wrap">Wrap</button> | |||
<button class="codecard__btn" data-action="download">Scarica</button> | |||
<button class="codecard__btn primary" data-action="copy">Copia</button> | |||
</div> | |||
</div> | |||
<div class="codecard__wrap"> | |||
<pre><code class="${language}" id="codecard-code"></code></pre> | |||
</div> | |||
<div class="codecard__hint">Suggerimento: clicca <b>Copia</b> per incollare velocemente il file nella tua cartella <code>/api/</code>.</div> | |||
`; | |||
const codeEl = container.querySelector('#codecard-code'); | |||
codeEl.textContent = code; | |||
// Evidenziazione sintattica | |||
if (window.hljs) { hljs.highlightElement(codeEl); } | |||
// Azioni | |||
container.querySelector('[data-action="copy"]').addEventListener('click', async () => { | |||
try { | |||
await navigator.clipboard.writeText(code); | |||
const btn = container.querySelector('[data-action="copy"]'); | |||
const old = btn.textContent; | |||
btn.textContent = 'Copiato!'; | |||
setTimeout(()=> btn.textContent = old, 1200); | |||
} catch(e) { alert('Copia non riuscita: ' + e.message); } | |||
}); | |||
container.querySelector('[data-action="download"]').addEventListener('click', () => { | |||
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' }); | |||
const a = document.createElement('a'); | |||
a.href = URL.createObjectURL(blob); | |||
a.download = filename || 'chapter_tool.php'; | |||
document.body.appendChild(a); a.click(); document.body.removeChild(a); | |||
setTimeout(()=> URL.revokeObjectURL(a.href), 1500); | |||
}); | |||
let wrapped = false; | |||
container.querySelector('[data-action="wrap"]').addEventListener('click', () => { | |||
wrapped = !wrapped; | |||
if (wrapped) codeEl.classList.add('wrap'); else codeEl.classList.remove('wrap'); | |||
}); | |||
return container; | |||
} | |||
// ==== C) Monta la card nella Dashboard ==== | |||
const mount = document.getElementById('chapter-tool-codecard'); | |||
if (mount) { | |||
const card = createCodeCard({ | |||
title: 'chapter_tool.php (endpoint API)', | |||
filename: 'chapter_tool.php', | |||
language: 'language-php', | |||
code: CODE_PHP | |||
}); | |||
mount.appendChild(card); | |||
} | |||
})(); | |||
</script> | |||
</html> | </html> | ||
Versione delle 22:21, 17 ott 2025
🔧 Dashboard Operativa – Masticationpedia
Centro di comando per progetti, API, file e backup