warraq / index.html
Fulwah's picture
Update index.html
deeda5a verified
Raw
History Blame Contribute Delete
42.8 kB
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>الورّاق — نسخة مفتوحة</title>
<link rel="manifest" href="data:application/json,{%22name%22:%22الورّاق%22,%22short_name%22:%22الورّاق%22,%22display%22:%22standalone%22,%22background_color%22:%22%230E1B2B%22,%22theme_color%22:%22%230E1B2B%22,%22start_url%22:%22.%22}">
<meta name="theme-color" content="#0E1B2B">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Amiri:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--lapis:#16283F; --lapis-deep:#0E1B2B; --slab:#1C3149;
--gold:#C9A227; --gold-soft:#E3C566;
--page:#F2ECDD; --page-dim:#DCD3BE; --ink:#241F17;
--rule:rgba(201,162,39,.28);
--sans:"IBM Plex Sans Arabic",system-ui,sans-serif;
--serif:"Amiri",Georgia,serif;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{background:var(--lapis-deep);color:var(--page);font-family:var(--sans);font-weight:300;min-height:100vh}
button,input,textarea,select{font-family:inherit;font-size:inherit}
:focus-visible{outline:2px solid var(--gold-soft);outline-offset:2px}
.masthead{display:flex;align-items:baseline;gap:.9rem;flex-wrap:wrap;padding:1.1rem 1.4rem .9rem;border-bottom:1px solid var(--rule);background:linear-gradient(180deg,var(--lapis),var(--lapis-deep))}
.brand{font-family:var(--serif);font-size:1.9rem;font-weight:700;color:var(--gold-soft)}
.brand span{color:var(--page);opacity:.55;font-size:.9rem;font-family:var(--sans);font-weight:300;margin-inline-start:.6rem}
.masthead .spacer{flex:1}
.shell{display:grid;grid-template-columns:290px 1fr;min-height:calc(100vh - 68px)}
@media(max-width:860px){.shell{grid-template-columns:1fr}.stack{order:2}}
.stack{border-inline-start:1px solid var(--rule);background:var(--lapis);padding:1.1rem;display:flex;flex-direction:column;gap:.7rem}
.stack h2,.pane h2{font-size:.72rem;letter-spacing:.18em;font-weight:500;text-transform:uppercase;color:var(--gold);margin:0 0 .2rem}
.leaf{position:relative;display:flex;align-items:center;gap:.6rem;padding:.6rem .7rem;background:var(--slab);border:1px solid transparent;border-radius:2px;cursor:pointer;width:100%;color:inherit;text-align:start;transition:background .15s,border-color .15s}
.leaf::before{content:"";position:absolute;inset-inline-end:0;top:0;bottom:0;width:4px;background:linear-gradient(90deg,var(--page-dim),var(--page));opacity:.5}
.leaf:hover{background:#22405F}
.leaf.on{border-color:var(--gold);background:#22405F}
.leaf.on::before{opacity:1}
.leaf .n{font-family:var(--serif);color:var(--gold-soft);min-width:1.4rem}
.leaf .t{flex:1;font-size:.87rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.leaf .dot{width:7px;height:7px;border-radius:50%;background:#6b7c90}
.leaf .dot.done{background:var(--gold)}
.leaf .dot.busy{background:var(--gold-soft);animation:pulse 1s infinite}
@keyframes pulse{50%{opacity:.25}}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
.btn{border:1px solid var(--rule);background:transparent;color:var(--page);padding:.5rem .9rem;border-radius:2px;cursor:pointer;font-size:.85rem;transition:background .15s,border-color .15s}
.btn:hover{background:rgba(201,162,39,.12);border-color:var(--gold)}
.btn.solid{background:var(--gold);color:#1B1405;border-color:var(--gold);font-weight:500}
.btn.solid:hover{background:var(--gold-soft)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.row{display:flex;gap:.5rem;flex-wrap:wrap;align-items:center}
input[type=text],input[type=password],select,textarea{background:var(--lapis-deep);color:var(--page);border:1px solid var(--rule);border-radius:2px;padding:.5rem .7rem;width:100%}
select{width:auto}
textarea{min-height:200px;line-height:1.9;resize:vertical}
label.fld{display:block;font-size:.75rem;color:var(--gold);letter-spacing:.08em;margin:.85rem 0 .3rem}
.pane{padding:1.2rem 1.4rem 3rem;max-width:900px}
.tabs{display:flex;gap:.2rem;border-bottom:1px solid var(--rule);margin-bottom:1.1rem;flex-wrap:wrap}
.tab{background:none;border:none;color:var(--page);opacity:.55;cursor:pointer;padding:.6rem .9rem;font-size:.9rem;border-bottom:2px solid transparent}
.tab.on{opacity:1;border-bottom-color:var(--gold);color:var(--gold-soft)}
.hint{font-size:.8rem;opacity:.55;line-height:1.75;margin:.5rem 0 0}
.status{font-size:.82rem;color:var(--gold-soft);min-height:1.3em;margin-top:.7rem}
.bar{height:2px;background:var(--slab);margin-top:.5rem;overflow:hidden}
.bar i{display:block;height:100%;background:var(--gold);width:0;transition:width .3s}
.sheet{background:var(--page);color:var(--ink);padding:2.4rem 2.2rem;border-radius:2px;font-family:var(--serif);font-size:1.16rem;line-height:2.15;box-shadow:0 18px 40px rgba(0,0,0,.35)}
@media(max-width:600px){.sheet{padding:1.4rem 1.1rem;font-size:1.06rem}}
.sheet h3{font-size:1.5rem;margin:0 0 1.2rem}
.sheet h3::after{content:"";display:block;width:60px;height:2px;background:var(--gold);margin-top:.7rem}
.sheet p{margin:0 0 1.05em;text-align:justify}
.sheet .orig{opacity:.5;font-size:.9rem;direction:ltr;text-align:left;border-top:1px dashed #b9ae96;margin-top:2rem;padding-top:1rem}
.thumbs{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.7rem}
.thumbs img{height:72px;border:1px solid var(--rule)}
.empty{opacity:.55;font-size:.88rem;line-height:1.9;padding:1rem 0}
.card{border:1px solid var(--rule);border-radius:2px;padding:.9rem 1rem;margin-bottom:.8rem;background:rgba(28,49,73,.5)}
.card h4{margin:0 0 .3rem;font-size:.95rem;font-weight:500;color:var(--gold-soft)}
.notice{font-size:.75rem;opacity:.45;line-height:1.7;border-top:1px solid var(--rule);margin-top:2.5rem;padding-top:.8rem}
#printArea{display:none}
@media print{
body{background:#fff;color:#000}
.masthead,.shell{display:none!important}
#printArea{display:block;font-family:var(--serif);color:#000;font-size:12.5pt;line-height:1.95}
#printArea .title-page{text-align:center;padding-top:26vh;break-after:page}
#printArea .title-page h1{font-size:30pt;margin:0 0 .6rem}
#printArea .title-page .by{font-size:13pt;color:#444}
#printArea .title-page .mark{margin-top:2rem;font-size:11pt;color:#7a6a2e;letter-spacing:.3em}
#printArea .toc{break-after:page}
#printArea .toc h2,#printArea .chap h2{font-size:17pt;margin:0 0 1rem;border-bottom:1.5pt solid #7a6a2e;padding-bottom:.4rem}
#printArea .chap{break-before:page}
#printArea p{margin:0 0 .85em;text-align:justify}
@page{margin:2cm 2.2cm}
}
</style>
</head>
<body>
<div class="masthead">
<div class="brand">الورّاق<span>نسخة مفتوحة — بلا اعتماد على مزوّد واحد</span></div>
<div class="spacer"></div>
<div class="row">
<select id="bookSel" style="min-width:150px"></select>
<button class="btn" id="newBook">رواية جديدة</button>
</div>
</div>
<div class="shell">
<aside class="stack">
<h2>الفصول</h2>
<div id="leaves" style="display:flex;flex-direction:column;gap:.3rem"></div>
<button class="btn" id="addChap">+ فصل جديد</button>
<div style="flex:1;min-height:1rem"></div>
<h2>الكتاب</h2>
<button class="btn solid" id="exportPdf">تصدير PDF</button>
<button class="btn" id="exportEpub">تصدير EPUB</button>
<button class="btn" id="exportJson">نسخة احتياطية</button>
<button class="btn" id="importJson">استعادة نسخة</button>
<input type="file" id="importFile" accept="application/json" hidden>
</aside>
<main class="pane">
<div class="tabs">
<button class="tab on" data-tab="text">لصق نص</button>
<button class="tab" data-tab="image">رفع صور</button>
<button class="tab" data-tab="read">القراءة</button>
<button class="tab" data-tab="engine">المحرّك</button>
<button class="tab" data-tab="meta">بيانات الرواية</button>
</div>
<section id="tab-text">
<label class="fld">عنوان الفصل</label>
<input type="text" id="chapTitle" placeholder="الفصل الأول">
<label class="fld">النص الأصلي</label>
<textarea id="srcText" placeholder="الصق هنا نص الفصل…"></textarea>
<div class="row" style="margin-top:.8rem">
<button class="btn solid" id="runText">ترجمة الفصل</button>
<span style="font-size:.8rem;opacity:.6" id="engLabel"></span>
</div>
<div class="status" id="st1"></div>
<div class="bar"><i id="pb1"></i></div>
</section>
<section id="tab-image" hidden>
<label class="fld">عنوان الفصل</label>
<input type="text" id="chapTitleImg" placeholder="الفصل الأول">
<label class="fld">صور الصفحات</label>
<input type="file" id="files" accept="image/*" multiple>
<div class="thumbs" id="thumbs"></div>
<div class="row" style="margin-top:.8rem">
<button class="btn solid" id="runImg">قراءة الصور وترجمتها</button>
</div>
<div class="status" id="st2"></div>
<div class="bar"><i id="pb2"></i></div>
<p class="hint">تُقرأ الصور محليًا بمحرّك Tesseract، ثم يُترجَم النص المستخرج بالمحرّك المختار. جودة القراءة تعتمد على وضوح الصورة — الصور المستقيمة عالية التباين تعطي أفضل نتيجة.</p>
</section>
<section id="tab-read" hidden>
<div class="row" style="margin-bottom:.9rem">
<button class="btn" id="prevCh">السابق</button>
<button class="btn" id="nextCh">التالي</button>
<label style="font-size:.82rem;opacity:.7"><input type="checkbox" id="showOrig"> إظهار الأصل</label>
<button class="btn" id="editCh">تحرير الترجمة</button>
<button class="btn" id="delCh">حذف الفصل</button>
</div>
<div class="sheet" id="sheet"></div>
</section>
<section id="tab-engine" hidden>
<div class="card">
<h4>محرّك الترجمة</h4>
<div class="row" style="margin-top:.5rem">
<select id="engine">
<option value="local">محلي في المتصفح — Transformers.js + NLLB-200</option>
<option value="openai">خادم متوافق مع OpenAI — Ollama / Groq / OpenRouter / Space خاص</option>
<option value="libre">LibreTranslate — خادم مفتوح</option>
</select>
</div>
</div>
<div class="card" id="cfg-local">
<h4>الوضع المحلي</h4>
<p class="hint">يُنزَّل النموذج مرة واحدة (≈ 300 ميغابايت بصيغة q8) ويُخزَّن في المتصفح، ثم يعمل بلا إنترنت. مناسب للحاسب؛ على الجوال أبطأ بوضوح.</p>
<label class="fld">النموذج</label>
<select id="localModel">
<option value="Xenova/nllb-200-distilled-600M">NLLB-200 distilled 600M — لغات كثيرة</option>
<option value="Xenova/opus-mt-en-ar">opus-mt-en-ar — إنجليزي فقط، أخف وأسرع</option>
</select>
<label class="fld">لغة المصدر</label>
<select id="srcLang">
<option value="eng_Latn">إنجليزي</option>
<option value="fra_Latn">فرنسي</option>
<option value="spa_Latn">إسباني</option>
<option value="deu_Latn">ألماني</option>
<option value="rus_Cyrl">روسي</option>
<option value="tur_Latn">تركي</option>
<option value="jpn_Jpan">ياباني</option>
<option value="kor_Hang">كوري</option>
<option value="zho_Hans">صيني مبسّط</option>
<option value="ind_Latn">إندونيسي</option>
</select>
<div class="row" style="margin-top:.9rem">
<button class="btn" id="preload">تنزيل النموذج الآن</button>
<span class="status" id="stLoad" style="margin:0"></span>
</div>
<div class="bar"><i id="pbLoad"></i></div>
</div>
<div class="card" id="cfg-openai" hidden>
<h4>خادم متوافق مع OpenAI</h4>
<p class="hint">Ollama محليًا: <code dir="ltr">http://localhost:11434/v1</code> — ‏Groq: <code dir="ltr">https://api.groq.com/openai/v1</code> — ‏OpenRouter: <code dir="ltr">https://openrouter.ai/api/v1</code></p>
<label class="fld">عنوان الخادم</label>
<input type="text" id="oaBase" dir="ltr" placeholder="http://localhost:11434/v1">
<label class="fld">اسم النموذج</label>
<input type="text" id="oaModel" dir="ltr" placeholder="qwen2.5:7b-instruct">
<label class="fld">المفتاح (اتركيه فارغًا مع Ollama)</label>
<input type="password" id="oaKey" dir="ltr">
<p class="hint">المفتاح يُحفظ في متصفحك فقط ولا يُرسل لغير الخادم الذي تحدّدينه.</p>
<div class="row" style="margin-top:.8rem"><button class="btn" id="testConn">اختبار الاتصال</button></div>
<pre id="testOut" dir="ltr" style="white-space:pre-wrap;font-size:.78rem;opacity:.85;margin:.7rem 0 0"></pre>
</div>
<div class="card" id="cfg-libre" hidden>
<h4>LibreTranslate</h4>
<label class="fld">عنوان الخادم</label>
<input type="text" id="ltBase" dir="ltr" placeholder="https://libretranslate.com">
<label class="fld">المفتاح (إن لزم)</label>
<input type="password" id="ltKey" dir="ltr">
<label class="fld">رمز لغة المصدر</label>
<input type="text" id="ltSrc" dir="ltr" value="en">
</div>
<div class="card">
<h4>أسلوب الترجمة</h4>
<select id="style">
<option value="literary">أدبي سلس</option>
<option value="faithful">أمين قريب من الأصل</option>
<option value="simple">مبسّط</option>
</select>
<p class="hint">يُطبَّق في وضع الخادم المتوافق مع OpenAI فقط؛ النماذج المحلية لا تقبل توجيهًا أسلوبيًا.</p>
</div>
<div class="card">
<h4>فحص التشغيل</h4>
<p class="hint">يفحص بيئة المتصفح ويعرض النتيجة نصًا — انسخيه لي لو بقيت المشكلة.</p>
<div class="row" style="margin-top:.6rem"><button class="btn" id="diag">شغّلي الفحص</button></div>
<pre id="diagOut" dir="ltr" style="white-space:pre-wrap;font-size:.78rem;opacity:.8;margin:.7rem 0 0"></pre>
</div>
<div class="row"><button class="btn solid" id="saveEngine">حفظ الإعدادات</button><span class="status" id="stEng" style="margin:0"></span></div>
</section>
<section id="tab-meta" hidden>
<label class="fld">اسم الرواية</label>
<input type="text" id="mTitle">
<label class="fld">المؤلف</label>
<input type="text" id="mAuthor">
<label class="fld">مسرد الأسماء (اسم = الترجمة، سطر لكل مدخل)</label>
<textarea id="mGloss" style="min-height:130px" placeholder="Elias = إلياس"></textarea>
<p class="hint">يُطبَّق المسرد على كل الفصول: في وضع الخادم يُرسل ضمن التعليمات، وفي الوضع المحلي يُستبدل في النص بعد الترجمة.</p>
<div class="row" style="margin-top:.9rem">
<button class="btn solid" id="saveMeta">حفظ</button>
<button class="btn" id="delBook">حذف الرواية</button>
</div>
<p class="notice">استعملي الورّاق مع نصوص تملكين حق استخدامها: الأعمال في الملك العام، أو كتاباتك، أو ما رخّصه ناشره. الترجمة الناتجة للاستعمال الشخصي.</p>
</section>
</main>
</div>
<div id="printArea"></div>
<script type="module">
const $ = s => document.querySelector(s);
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2,6);
const esc = s => (s||"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
/* ═══ إظهار الأخطاء بدل ابتلاعها ═══ */
function fail(msg){
let b = document.getElementById("errBar");
if(!b){
b = document.createElement("div"); b.id = "errBar";
b.style.cssText = "background:#5a2020;color:#F2ECDD;padding:.7rem 1.4rem;font-size:.82rem;line-height:1.8;direction:ltr;text-align:left;white-space:pre-wrap";
document.body.insertBefore(b, document.body.firstChild);
}
b.textContent = String(msg);
}
window.addEventListener("error", e => fail("خطأ: " + (e.message || e.error)));
window.addEventListener("unhandledrejection", e => fail("خطأ: " + (e.reason?.message || e.reason)));
/* ═══ تحميل مكتبة عند الحاجة، مع مصدر بديل ═══ */
const loaded = {};
function loadLib(name, urls){
if(loaded[name]) return loaded[name];
loaded[name] = new Promise((res, rej) => {
const attempt = i => {
if(i >= urls.length) return rej(new Error("تعذّر تحميل مكتبة " + name + " — تحقّقي من الاتصال أو من مانع الإعلانات."));
const s = document.createElement("script");
s.src = urls[i]; s.onload = () => res(); s.onerror = () => attempt(i+1);
document.head.appendChild(s);
};
attempt(0);
});
return loaded[name];
}
/* ═══ تخزين على IndexedDB مباشرة ═══ */
const store = (() => {
let dbp = null;
const open = () => dbp || (dbp = (async () => {
const first = await new Promise((res, rej) => {
const r = indexedDB.open("alwarraq_store");
r.onupgradeneeded = () => { if(!r.result.objectStoreNames.contains("kv")) r.result.createObjectStore("kv"); };
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
if(first.objectStoreNames.contains("kv")) return first;
const next = first.version + 1;
first.close();
return new Promise((res, rej) => {
const r = indexedDB.open("alwarraq_store", next);
r.onupgradeneeded = () => { if(!r.result.objectStoreNames.contains("kv")) r.result.createObjectStore("kv"); };
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
})());
const run = async (mode, fn) => {
const db = await open();
return new Promise((res, rej) => {
const t = db.transaction("kv", mode);
const req = fn(t.objectStore("kv"));
t.oncomplete = () => res(req ? req.result : undefined);
t.onerror = () => rej(t.error);
t.onabort = () => rej(t.error);
});
};
const ls = {
get: k => { try{ return JSON.parse(localStorage.getItem("aw:"+k)); }catch(e){ return null; } },
set: (k,v) => localStorage.setItem("aw:"+k, JSON.stringify(v)),
del: k => localStorage.removeItem("aw:"+k)
};
const usable = typeof indexedDB !== "undefined";
return {
get: async k => usable ? (await run("readonly", s => s.get(k))) ?? null : ls.get(k),
set: async (k,v) => usable ? run("readwrite", s => s.put(v,k)) : ls.set(k,v),
del: async k => usable ? run("readwrite", s => s.delete(k)) : ls.del(k)
};
})();
let lib = [], book = null, curCh = 0, pending = [];
let cfg = { engine:"local", localModel:"Xenova/nllb-200-distilled-600M", srcLang:"eng_Latn",
oaBase:"", oaModel:"", oaKey:"", ltBase:"https://libretranslate.com", ltKey:"", ltSrc:"en", style:"literary" };
/* ═══ المحرّكات ═══ */
let translator = null, translatorKey = "";
async function getLocal(onProg){
const key = cfg.localModel;
if(translator && translatorKey === key) return translator;
let mod;
try{
mod = await import("https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2");
}catch(e){
mod = await import("https://unpkg.com/@xenova/transformers@2.17.2");
}
const { pipeline, env } = mod;
env.allowLocalModels = false;
translator = await pipeline("translation", key, { quantized: true, progress_callback: onProg });
translatorKey = key;
return translator;
}
/* NLLB يعمل أفضل على مستوى الجملة */
function sentences(text){
return text.split(/\n\s*\n/).map(p =>
(p.match(/[^.!?。?!]+[.!?。?!]*\s*/g) || [p]).map(s=>s.trim()).filter(Boolean)
);
}
async function translateLocal(text, prog, note){
const t = await getLocal(p => {
if(p.status === "progress" && note){
const mb = n => (n/1048576).toFixed(1);
const size = p.total ? ` (${mb(p.loaded||0)} / ${mb(p.total)} ميغابايت)` : "";
note(`تنزيل النموذج — ${p.file||""} ${Math.round(p.progress||0)}%${size}`);
}
else if(p.status === "download" && note) note("بدء تنزيل " + (p.file||""));
else if(p.status === "ready" && note) note("النموذج جاهز، تبدأ الترجمة…");
});
const paras = sentences(text);
const total = paras.reduce((a,p)=>a+p.length,0);
let done = 0; const out = [];
const isNllb = cfg.localModel.includes("nllb");
for(const para of paras){
const parts = [];
for(const s of para){
const r = isNllb
? await t(s, { src_lang: cfg.srcLang, tgt_lang: "arb_Arab" })
: await t(s);
parts.push((r[0].translation_text || "").trim());
done++; prog(done/total);
}
out.push(parts.join(" "));
}
return applyGloss(out.join("\n\n"));
}
const STYLES = {
literary:"ترجمة أدبية سلسة تحافظ على الصورة والإيقاع بعربية فصحى معاصرة.",
faithful:"ترجمة أمينة قريبة من الأصل، بلا حذف ولا إضافة.",
simple:"ترجمة بعربية مبسّطة واضحة."
};
function cleanReply(raw){
let s = raw || "";
s = s.replace(/<think>[\s\S]*?<\/think>/gi, "");
s = s.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, "");
if(/<think>/i.test(s)){
const i = s.toLowerCase().lastIndexOf("</think>");
s = i >= 0 ? s.slice(i + 8) : "";
}
s = s.replace(/^\s*```[a-z]*\s*\n?/i, "").replace(/\n?```\s*$/,"");
s = s.trim();
if(!s) throw new Error("النموذج أعاد تفكيره الداخلي بلا ترجمة نهائية. اختاري نموذجًا بلا وضع تفكير.");
return s;
}
async function callOpenAI(messages){
const h = { "Content-Type":"application/json" };
if(cfg.oaKey) h.Authorization = "Bearer " + cfg.oaKey;
const mkBody = quiet => JSON.stringify(Object.assign({
model: cfg.oaModel, messages, temperature: 0.6, max_completion_tokens: 4000
}, quiet ? { reasoning_format: "hidden", reasoning_effort: "none" } : {}));
let quiet = true;
for(let attempt=0; attempt<5; attempt++){
const res = await fetch(cfg.oaBase.replace(/\/$/,"") + "/chat/completions",
{ method:"POST", headers:h, body: mkBody(quiet) });
if(res.ok){
const d = await res.json();
const m = d.choices?.[0]?.message || {};
return cleanReply(m.content);
}
let detail = "";
try{ const j = await res.json(); detail = j.error?.message || JSON.stringify(j); }
catch(e){ detail = await res.text().catch(()=>""); }
if(quiet && res.status === 400 && /reasoning|unrecognized|unsupported|invalid/i.test(detail)){
quiet = false;
continue;
}
if(res.status === 429 && attempt < 4){
const wait = Number(res.headers.get("retry-after")) || (8 * (attempt+1));
if(typeof window.__note === "function")
window.__note(`تجاوز حد الاستخدام — إعادة المحاولة بعد ${wait} ثانية.`);
await new Promise(r => setTimeout(r, wait*1000));
continue;
}
throw new Error(`الخادم رفض الطلب (${res.status}): ${detail}`);
}
throw new Error("تعذّر إتمام الطلب بعد عدة محاولات.");
}
async function translateOpenAI(text){
const g = (book.gloss||"").trim();
const sys = ["أنت مترجم أدبي إلى العربية.", STYLES[cfg.style],
"حافظ على تقسيم الفقرات والحوار.", g?("التزم بهذا المسرد:\n"+g):"",
"أعد الترجمة فقط دون تعليق."].filter(Boolean).join("\n");
return callOpenAI([{role:"system",content:sys},{role:"user",content:text}]);
}
async function translateLibre(text){
const res = await fetch(cfg.ltBase.replace(/\/$/,"") + "/translate", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ q:text, source:cfg.ltSrc||"auto", target:"ar", format:"text", api_key:cfg.ltKey||undefined })
});
if(!res.ok) throw new Error("خادم LibreTranslate لم يستجب ("+res.status+").");
const d = await res.json();
return applyGloss(d.translatedText || "");
}
function applyGloss(txt){
const lines = (book.gloss||"").split("\n").map(l=>l.split("=")).filter(p=>p.length===2);
for(const [a,b] of lines){
const from = a.trim(), to = b.trim();
if(from) txt = txt.split(from).join(to);
}
return txt;
}
function chunk(text, size=1400){
const paras = text.split(/\n\s*\n/); const out=[]; let cur="";
for(const p of paras){
if(cur && (cur.length+p.length)>size){ out.push(cur); cur=p; }
else cur = cur ? cur+"\n\n"+p : p;
}
if(cur.trim()) out.push(cur);
return out;
}
async function translate(text, prog, note){
window.__note = note || null;
if(cfg.engine === "local") return translateLocal(text, prog, note);
const pieces = chunk(text); const out=[];
for(let i=0;i<pieces.length;i++){
out.push(cfg.engine==="openai" ? await translateOpenAI(pieces[i]) : await translateLibre(pieces[i]));
prog((i+1)/pieces.length);
}
return out.join("\n\n");
}
/* ═══ المكتبة ═══ */
async function loadAll(){
cfg = Object.assign(cfg, await store.get("cfg") || {});
fillEngine();
lib = await store.get("library:index") || [];
if(!lib.length){
const b = { id:uid(), title:"روايتي الأولى", author:"", gloss:"", chapters:[] };
lib = [{id:b.id,title:b.title}];
await store.set("library:index",lib); await store.set("book:"+b.id,b);
}
await openBook(lib[0].id); renderSel();
}
function renderSel(){
$("#bookSel").innerHTML = lib.map(b=>`<option value="${b.id}" ${book&&b.id===book.id?"selected":""}>${esc(b.title)}</option>`).join("");
}
async function openBook(id){ book = await store.get("book:"+id); curCh=0; renderLeaves(); fillMeta(); renderSheet(); }
async function saveBook(){
await store.set("book:"+book.id, book);
const e = lib.find(b=>b.id===book.id); if(e) e.title = book.title;
await store.set("library:index", lib); renderSel();
}
function renderLeaves(){
const box = $("#leaves");
if(!book.chapters.length){ box.innerHTML = '<p class="empty">لا فصول بعد.</p>'; return; }
box.innerHTML = book.chapters.map((c,i)=>`<button class="leaf ${i===curCh?"on":""}" data-i="${i}">
<span class="n">${i+1}</span><span class="t">${esc(c.title||"بلا عنوان")}</span>
<span class="dot ${c.busy?"busy":(c.ar?"done":"")}"></span></button>`).join("");
box.querySelectorAll(".leaf").forEach(b=>b.onclick=()=>{curCh=+b.dataset.i;renderLeaves();renderSheet();tab("read");});
}
function renderSheet(){
const c = book.chapters[curCh];
if(!c){ $("#sheet").innerHTML='<p style="opacity:.5">لا يوجد فصل معروض.</p>'; return; }
const body = (c.ar||"").split(/\n\s*\n/).map(p=>`<p>${esc(p)}</p>`).join("");
const orig = $("#showOrig").checked && c.src ? `<div class="orig">${esc(c.src).replace(/\n/g,"<br>")}</div>` : "";
$("#sheet").innerHTML = `<h3>${esc(c.title||"")}</h3>${body||'<p style="opacity:.5">بلا ترجمة بعد.</p>'}${orig}`;
}
function fillMeta(){ $("#mTitle").value=book.title||""; $("#mAuthor").value=book.author||""; $("#mGloss").value=book.gloss||""; }
function fillEngine(){
$("#engine").value=cfg.engine; $("#localModel").value=cfg.localModel; $("#srcLang").value=cfg.srcLang;
$("#oaBase").value=cfg.oaBase; $("#oaModel").value=cfg.oaModel; $("#oaKey").value=cfg.oaKey;
$("#ltBase").value=cfg.ltBase; $("#ltKey").value=cfg.ltKey; $("#ltSrc").value=cfg.ltSrc;
$("#style").value=cfg.style; showCfg();
}
function showCfg(){
$("#cfg-local").hidden = $("#engine").value!=="local";
$("#cfg-openai").hidden = $("#engine").value!=="openai";
$("#cfg-libre").hidden = $("#engine").value!=="libre";
const names={local:"محرّك محلي",openai:"خادم خاص",libre:"LibreTranslate"};
$("#engLabel").textContent = "المحرّك: " + names[cfg.engine];
}
$("#engine").onchange = showCfg;
$("#saveEngine").onclick = async () => {
cfg = { engine:$("#engine").value, localModel:$("#localModel").value, srcLang:$("#srcLang").value,
oaBase:$("#oaBase").value.trim(), oaModel:$("#oaModel").value.trim(), oaKey:$("#oaKey").value.trim(),
ltBase:$("#ltBase").value.trim(), ltKey:$("#ltKey").value.trim(), ltSrc:$("#ltSrc").value.trim(), style:$("#style").value };
translator=null; await store.set("cfg",cfg); showCfg();
$("#stEng").textContent="حُفظت الإعدادات."; setTimeout(()=>$("#stEng").textContent="",1500);
};
$("#preload").onclick = async () => {
$("#preload").disabled=true;
try{
await getLocal(p=>{
if(p.status==="progress"){ $("#pbLoad").style.width=Math.round(p.progress)+"%"; $("#stLoad").textContent=`تنزيل ${p.file||""}${Math.round(p.progress)}%`; }
else if(p.status==="ready") $("#stLoad").textContent="النموذج جاهز.";
});
$("#pbLoad").style.width="100%"; $("#stLoad").textContent="النموذج جاهز ومخزَّن في المتصفح.";
}catch(e){ $("#stLoad").textContent="تعذّر التنزيل: "+e.message; }
$("#preload").disabled=false;
};
/* ═══ التبويبات ═══ */
function tab(n){
document.querySelectorAll(".tab").forEach(t=>t.classList.toggle("on",t.dataset.tab===n));
["text","image","read","engine","meta"].forEach(x=>$("#tab-"+x).hidden = x!==n);
}
document.querySelectorAll(".tab").forEach(t=>t.onclick=()=>tab(t.dataset.tab));
/* ═══ ترجمة نص ═══ */
$("#runText").onclick = async () => {
const src = $("#srcText").value.trim();
if(!src){ $("#st1").textContent="الصق نص الفصل أولًا."; return; }
const ch = { id:uid(), title:$("#chapTitle").value.trim()||("الفصل "+(book.chapters.length+1)), src, ar:"", busy:true };
book.chapters.push(ch); curCh=book.chapters.length-1; renderLeaves();
$("#runText").disabled=true; $("#st1").textContent="جارٍ التحضير…";
try{
ch.ar = await translate(src,
p => { $("#pb1").style.width=Math.round(p*100)+"%"; $("#st1").textContent=`تُترجم… ${Math.round(p*100)}%`; },
msg => { $("#st1").textContent = msg; });
ch.busy=false; $("#st1").textContent="تمت ترجمة الفصل.";
$("#srcText").value=""; $("#chapTitle").value="";
await saveBook(); renderLeaves(); renderSheet(); tab("read");
}catch(e){ ch.busy=false; $("#st1").textContent=e.message; await saveBook(); renderLeaves(); }
$("#runText").disabled=false;
};
/* ═══ صور ═══ */
$("#files").onchange = e => {
pending=[...e.target.files];
$("#thumbs").innerHTML = pending.map(f=>`<img src="${URL.createObjectURL(f)}" alt="">`).join("");
};
const OCR_LANG = { eng_Latn:"eng", fra_Latn:"fra", spa_Latn:"spa", deu_Latn:"deu", rus_Cyrl:"rus",
tur_Latn:"tur", jpn_Jpan:"jpn", kor_Hang:"kor", zho_Hans:"chi_sim", ind_Latn:"ind" };
$("#runImg").onclick = async () => {
if(!pending.length){ $("#st2").textContent="اختاري صور الصفحات أولًا."; return; }
const ch = { id:uid(), title:$("#chapTitleImg").value.trim()||("الفصل "+(book.chapters.length+1)), src:"", ar:"", busy:true };
book.chapters.push(ch); curCh=book.chapters.length-1; renderLeaves();
$("#runImg").disabled=true;
try{
$("#st2").textContent="تحميل محرّك القراءة…";
await loadLib("tesseract", [
"https://cdn.jsdelivr.net/npm/tesseract.js@5.1.0/dist/tesseract.min.js",
"https://unpkg.com/tesseract.js@5.1.0/dist/tesseract.min.js"
]);
const worker = await Tesseract.createWorker(OCR_LANG[cfg.srcLang] || "eng");
let raw = "";
for(let i=0;i<pending.length;i++){
$("#st2").textContent=`قراءة الصفحة ${i+1} من ${pending.length}…`;
$("#pb2").style.width=Math.round(i/pending.length*50)+"%";
const { data } = await worker.recognize(pending[i]);
raw += (raw?"\n\n":"") + data.text.trim();
}
await worker.terminate();
ch.src = raw; renderSheet();
$("#st2").textContent="جارٍ الترجمة…";
ch.ar = await translate(raw, p => { $("#pb2").style.width=(50+Math.round(p*50))+"%"; });
ch.busy=false; $("#st2").textContent="تمت ترجمة الصفحات.";
pending=[]; $("#files").value=""; $("#thumbs").innerHTML=""; $("#chapTitleImg").value="";
await saveBook(); renderLeaves(); renderSheet(); tab("read");
}catch(e){ ch.busy=false; $("#st2").textContent=e.message; await saveBook(); renderLeaves(); }
$("#runImg").disabled=false;
};
/* ═══ أدوات ═══ */
$("#prevCh").onclick=()=>{if(curCh>0){curCh--;renderLeaves();renderSheet();}};
$("#nextCh").onclick=()=>{if(curCh<book.chapters.length-1){curCh++;renderLeaves();renderSheet();}};
$("#showOrig").onchange=renderSheet;
$("#editCh").onclick=()=>{
const c=book.chapters[curCh]; if(!c) return;
$("#sheet").innerHTML=`<textarea id="ed" style="width:100%;min-height:60vh;background:#fff;color:#241F17;border:1px solid #c9bfa6;font-family:var(--serif);font-size:1.1rem;line-height:2">${esc(c.ar)}</textarea>
<div class="row" style="margin-top:.8rem"><button class="btn solid" id="okEd">حفظ التعديل</button></div>`;
$("#okEd").onclick=async()=>{ c.ar=$("#ed").value; await saveBook(); renderSheet(); };
};
$("#delCh").onclick=async()=>{
if(!book.chapters[curCh]||!confirm("حذف هذا الفصل نهائيًا؟"))return;
book.chapters.splice(curCh,1); curCh=Math.max(0,curCh-1);
await saveBook(); renderLeaves(); renderSheet();
};
$("#addChap").onclick=()=>tab("text");
$("#newBook").onclick=async()=>{
const t=prompt("اسم الرواية:"); if(!t)return;
const b={id:uid(),title:t,author:"",gloss:"",chapters:[]};
lib.push({id:b.id,title:t}); await store.set("library:index",lib); await store.set("book:"+b.id,b);
book=b; curCh=0; renderSel(); renderLeaves(); fillMeta(); renderSheet(); tab("meta");
};
$("#bookSel").onchange=e=>openBook(e.target.value);
$("#saveMeta").onclick=async()=>{
book.title=$("#mTitle").value.trim()||book.title; book.author=$("#mAuthor").value.trim(); book.gloss=$("#mGloss").value;
await saveBook(); $("#saveMeta").textContent="حُفظ"; setTimeout(()=>$("#saveMeta").textContent="حفظ",1200);
};
$("#delBook").onclick=async()=>{
if(!confirm("حذف الرواية بكل فصولها؟"))return;
await store.del("book:"+book.id); lib=lib.filter(b=>b.id!==book.id);
await store.set("library:index",lib);
if(lib.length) await openBook(lib[0].id); else location.reload();
renderSel();
};
/* ═══ التصدير ═══ */
function buildPrint(){
const toc = book.chapters.map((c,i)=>`<li>${esc(c.title||"فصل "+(i+1))}</li>`).join("");
const chaps = book.chapters.map(c=>`<div class="chap"><h2>${esc(c.title||"")}</h2>${
(c.ar||"").split(/\n\s*\n/).map(p=>`<p>${esc(p)}</p>`).join("")}</div>`).join("");
$("#printArea").innerHTML = `<div class="title-page"><h1>${esc(book.title)}</h1>
${book.author?`<div class="by">${esc(book.author)}</div>`:""}<div class="mark">ترجمة عربية</div></div>
<div class="toc"><h2>المحتويات</h2><ol>${toc}</ol></div>${chaps}`;
}
$("#exportPdf").onclick=()=>{
if(!book.chapters.length){alert("لا فصول للتصدير.");return;}
buildPrint(); setTimeout(()=>window.print(),120);
};
function dl(blob,name){ const a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=name; a.click(); }
$("#exportEpub").onclick=async()=>{
if(!book.chapters.length){alert("لا فصول للتصدير.");return;}
await loadLib("jszip", [
"https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js",
"https://unpkg.com/jszip@3.10.1/dist/jszip.min.js"
]);
const zip=new JSZip();
zip.file("mimetype","application/epub+zip",{compression:"STORE"});
zip.folder("META-INF").file("container.xml",
`<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`);
const o=zip.folder("OEBPS");
o.file("style.css",`body{font-family:serif;direction:rtl;text-align:justify;line-height:1.9;margin:1em}h2{border-bottom:2px solid #7a6a2e;padding-bottom:.3em}p{margin:0 0 1em;text-indent:1.2em}`);
book.chapters.forEach((c,i)=>{
o.file(`ch${i+1}.xhtml`,
`<?xml version="1.0" encoding="utf-8"?><!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ar" dir="rtl"><head><meta charset="utf-8"/><title>${esc(c.title)}</title><link rel="stylesheet" href="style.css"/></head><body><h2>${esc(c.title)}</h2>${
(c.ar||"").split(/\n\s*\n/).map(p=>`<p>${esc(p)}</p>`).join("")}</body></html>`);
});
const items=book.chapters.map((c,i)=>`<item id="c${i+1}" href="ch${i+1}.xhtml" media-type="application/xhtml+xml"/>`).join("");
const refs=book.chapters.map((c,i)=>`<itemref idref="c${i+1}"/>`).join("");
const nav=book.chapters.map((c,i)=>`<li><a href="ch${i+1}.xhtml">${esc(c.title)}</a></li>`).join("");
o.file("nav.xhtml",`<?xml version="1.0" encoding="utf-8"?><!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="ar" dir="rtl"><head><meta charset="utf-8"/><title>الفهرس</title></head><body><nav epub:type="toc"><h1>المحتويات</h1><ol>${nav}</ol></nav></body></html>`);
o.file("content.opf",`<?xml version="1.0" encoding="utf-8"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bid"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="bid">urn:uuid:${book.id}</dc:identifier><dc:title>${esc(book.title)}</dc:title><dc:language>ar</dc:language><dc:creator>${esc(book.author||"")}</dc:creator><meta property="dcterms:modified">${new Date().toISOString().split(".")[0]}Z</meta></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="css" href="style.css" media-type="text/css"/>${items}</manifest><spine>${refs}</spine></package>`);
dl(await zip.generateAsync({type:"blob",mimeType:"application/epub+zip"}), (book.title||"رواية")+".epub");
};
$("#exportJson").onclick=()=>dl(new Blob([JSON.stringify(book,null,2)],{type:"application/json"}),(book.title||"رواية")+".json");
$("#importJson").onclick=()=>$("#importFile").click();
$("#importFile").onchange=async e=>{
const f=e.target.files[0]; if(!f)return;
const b=JSON.parse(await f.text()); b.id=uid();
lib.push({id:b.id,title:b.title}); await store.set("library:index",lib); await store.set("book:"+b.id,b);
book=b; curCh=0; renderSel(); renderLeaves(); fillMeta(); renderSheet();
};
$("#testConn").onclick = async () => {
const base = $("#oaBase").value.trim().replace(/\/$/,"");
const model = $("#oaModel").value.trim();
const key = $("#oaKey").value.trim();
$("#testOut").textContent = "…";
const h = { "Content-Type":"application/json" };
if(key) h.Authorization = "Bearer " + key;
try{
const res = await fetch(base + "/chat/completions", { method:"POST", headers:h,
body: JSON.stringify({ model, messages:[{role:"user",content:"Translate to Arabic: The cat sat by the window."}] }) });
const raw = await res.text();
if(res.ok){
let reply = raw;
try{
const content = JSON.parse(raw).choices?.[0]?.message?.content || raw;
const thinky = /<think>/i.test(content);
let clean = content;
try{ clean = cleanReply(content); }catch(e){ clean = "(بلا ترجمة نهائية)"; }
reply = (thinky ? "تنبيه: هذا نموذج تفكير — يكتب مسوّدته قبل الجواب.\n\n" : "") + clean;
}catch(e){}
$("#testOut").textContent = "HTTP " + res.status + " — نجح\n\n" + reply;
} else {
$("#testOut").textContent = "HTTP " + res.status + "\n\n" + raw;
}
}catch(e){ $("#testOut").textContent = "فشل الاتصال: " + e.message; }
};
$("#diag").onclick = async () => {
const out = [];
out.push("protocol: " + location.protocol);
out.push("browser: " + navigator.userAgent);
out.push("indexedDB: " + (typeof indexedDB !== "undefined"));
out.push("secureContext: " + window.isSecureContext);
out.push("WebAssembly: " + (typeof WebAssembly !== "undefined"));
out.push("WebGPU: " + ("gpu" in navigator));
try{ await store.set("__test", 1); await store.del("__test"); out.push("storage write: ok"); }
catch(e){ out.push("storage write: FAIL — " + e.message); }
out.push("books stored: " + lib.length);
try{
const r = await fetch("https://cdn.jsdelivr.net/npm/jszip@3.10.1/package.json", {method:"GET"});
out.push("jsdelivr reachable: " + r.ok);
}catch(e){ out.push("jsdelivr reachable: FAIL — " + e.message); }
try{
const r = await fetch("https://huggingface.co/Xenova/nllb-200-distilled-600M/resolve/main/config.json");
out.push("huggingface reachable: " + r.ok);
}catch(e){ out.push("huggingface reachable: FAIL — " + e.message); }
out.push("engine: " + cfg.engine + " / " + cfg.localModel);
$("#diagOut").textContent = out.join("\n");
};
loadAll().catch(e => fail("فشل بدء التطبيق: " + e.message));
if(location.protocol === "file:"){
const w = document.createElement("div");
w.style.cssText = "background:#5a2020;color:#F2ECDD;padding:.7rem 1.4rem;font-size:.85rem;line-height:1.8;border-bottom:1px solid var(--rule)";
w.innerHTML = 'الملف مفتوح مباشرة من القرص، والمحرّك المحلي لن يعمل في هذا الوضع. شغّلي خادمًا بسيطًا في مجلد الملف: <code dir="ltr">python3 -m http.server 8000</code> ثم افتحي <code dir="ltr">http://localhost:8000</code>';
document.body.insertBefore(w, document.body.firstChild);
}
</script>
</body>
</html>