| <!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=>({"&":"&","<":"<",">":">",'"':"""}[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]; |
| } |
| |
| 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; |
| } |
| |
| 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> |