font update

This commit is contained in:
micialware committed 2026-10-05 09:56:09 +03:00
1 parent f754cafbe4
commit 17731364bd
66 files changed
+10342 -76

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))o(s);new MutationObserver(s=>{for(const c of s)if(c.type==="childList")for(const u of c.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&o(u)}).observe(document,{childList:!0,subtree:!0});function a(s){const c={};return s.integrity&&(c.integrity=s.integrity),s.referrerPolicy&&(c.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?c.credentials="include":s.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function o(s){if(s.ep)return;s.ep=!0;const c=a(s);fetch(s.href,c)}})();const Qe=new Worker(new URL("/assets/db-worker-DFFhVJkA.js",import.meta.url),{type:"module"});let pr=0,Ae=null;const et=new Map;function On(){return Ae||(Ae=new Promise((e,r)=>{Qe.onmessage=o=>{const s=o.data;if(s.type==="ready")return;const c=et.get(s.id);c&&(et.delete(s.id),s.type=="success"?c.resolve(s.data):c.reject(new Error(s.error??s.message??"Неизвестная ошибка Worker")))},Qe.onerror=o=>{console.error("[SQLite Worker] Ошибка:",o),r(new Error("Ошибка Worker"))};const a=++pr;et.set(a,{resolve:e,reject:r}),Qe.postMessage({type:"start",id:a})}),Ae)}function hr(e,r){return new Promise((a,o)=>{if(!Qe){o(new Error("Worker не создан"));return}const s=++pr;et.set(s,{resolve:a,reject:o}),Qe.postMessage({type:e,id:s,...r})})}async function ie(e,r){return await hr("query",{sql:e,params:r??[]})}async function wr(e,r){await hr("command",{sql:e,params:r??[]})}function gr(e){return new Date(e*1e3)}function Jn(){return Math.floor(Date.now()/1e3)}function Kn(e){const r=gr(e),a=String(r.getDate()).padStart(2,"0"),o=String(r.getMonth()+1).padStart(2,"0");return`${a}.${o}.${r.getFullYear()}`}function Dn(e){const r=gr(e),a=String(r.getHours()).padStart(2,"0"),o=String(r.getMinutes()).padStart(2,"0");return`${Kn(e)} ${a}:${o}`}function Xn(e){if(e==null)return[];const r=String(e).trim();return r?r.split(/[,\s;]+/).map(a=>a.trim()).filter(a=>a.length>0):[]}function zn(e){const r=new Map;if(e==null)return r;const a=String(e);if(!a)return r;const o=a.split("");for(let s=0;s+1<o.length;s+=2){const c=Number(o[s]),u=o[s+1]??"";!Number.isFinite(c)||!Number.isInteger(c)||u&&r.set(c,u)}return r}function mr(e){const r=Number(e.id??0),a=String(e.key??""),o=String(e.value??""),s=e.tags==null?"":String(e.tags),c=Number(e.group_id??1);return{id:r,key:a,value:o,tags:s,tagsList:Xn(s),more:zn(e.more),group_id:Number.isFinite(c)?c:1}}function vr(e){const r=[],a=[],o=(e.search??"").trim();if(o){r.push("(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')");const s=`%${o.replace(/\\/g,"\\\\").replace(/%/g,"\\%").replace(/_/g,"\\_")}%`;a.push(s,s,s)}return e.groupId!=null&&(r.push("group_id = ?"),a.push(e.groupId)),{clause:r.length>0?`WHERE ${r.join(" AND ")}`:"",params:a}}async function Bn(e={}){const{clause:r,params:a}=vr(e),s=(await ie(`SELECT COUNT(*) as cnt FROM words ${r}`,a))[0]?.cnt;return typeof s=="number"?s:Number(s??0)}async function tr(e={}){const{clause:r,params:a}=vr(e),o=Math.max(1,Math.min(e.limit??50,500)),s=Math.max(0,e.offset??0);return(await ie(`SELECT id, key, value, tags, more, group_id FROM words ${r} ORDER BY id LIMIT ? OFFSET ?`,[...a,o,s])).map(mr)}async function Vn(){return(await ie("SELECT id, name FROM word_groups ORDER BY id")).map(r=>({id:Number(r.id),name:String(r.name??"")}))}async function Yn(){return(await ie("SELECT id, name, forward, backward, filter FROM decks ORDER BY id")).map(r=>({id:Number(r.id),name:String(r.name??""),forward:String(r.forward??""),backward:String(r.backward??""),filter:String(r.filter??"")}))}async function Zn(e,r){return await wr("UPDATE decks SET forward = ?, backward = ?, filter = ? WHERE id = ?",[r.forward,r.backward,r.filter,e]),nt(e)}async function nt(e){const a=(await ie("SELECT id, name, forward, backward, filter FROM decks WHERE id = ? LIMIT 1",[e]))[0];return a?{id:Number(a.id),name:String(a.name??""),forward:String(a.forward??""),backward:String(a.backward??""),filter:String(a.filter??"")}:null}async function Gn(){const e=await ie("SELECT deck_id, COUNT(*) as cnt FROM states WHERE deck_id IS NOT NULL GROUP BY deck_id"),r=new Map;for(const a of e){if(a.deck_id==null)continue;const o=a.cnt;r.set(Number(a.deck_id),typeof o=="number"?o:Number(o??0))}return r}async function jn(e){const a=(await ie("SELECT COUNT(DISTINCT word_id) as cnt FROM states WHERE deck_id = ?",[e]))[0]?.cnt;return typeof a=="number"?a:Number(a??0)}async function rr(e){const r=new Map,a=[...new Set(e.filter(s=>Number.isFinite(s)))];if(a.length===0)return r;const o=400;for(let s=0;s<a.length;s+=o){const c=a.slice(s,s+o),u=c.map(()=>"?").join(","),h=await ie(`SELECT id, key, value, tags, more, group_id FROM words WHERE id IN (${u})`,c);for(const v of h.map(mr))r.set(v.id,v)}return r}function Wn(e,r){const a=new Map;a.set("key",e.key),a.set("value"Line truncated
w.key as wkey, w.value as wvalue
FROM history h LEFT JOIN words w ON w.id = h.word_id
WHERE h.deck_id = ? ORDER BY h.timestamp DESC, h.id DESC LIMIT ? OFFSET ?`,[e,a,o])).map(c=>({id:Number(c.id),deck_id:Number(c.deck_id),word_id:Number(c.word_id),timestamp:Number(c.timestamp??0),answer:Number(c.answer??0),before:Number(c.before??0),after:Number(c.after??0),fsrs:Number(c.fsrs??0),wordKey:c.wkey==null?"":String(c.wkey),wordValue:c.wvalue==null?"":String(c.wvalue)}))}const Ut="japanese_app.db";async function xt(){return navigator.storage.getDirectory()}async function na(e){e[18]=1,e[19]=1;const a=await(await xt()).getFileHandle(Ut,{create:!0}),o=a.createSyncAccessHandle;if(typeof o=="function")try{const c=await o.call(a);try{c.truncate(0),c.write(e,{at:0}),c.flush()}finally{c.close()}return}catch{}const s=await a.createWritable();try{await s.truncate(0);const c=new Uint8Array(e.length);c.set(e),await s.write(c)}finally{await s.close()}}async function aa(){const e=await xt();let r;try{r=await e.getFileHandle(Ut)}catch{throw new Error("Локальная база не найдена — сначала скачай сохранение")}const a=await r.getFile();if(a.size===0)throw new Error("Локальная база пуста");return new Uint8Array(await a.arrayBuffer())}async function Oe(){try{return(await(await(await xt()).getFileHandle(Ut)).getFile()).size>0}catch{return!1}}var sa=ArrayBuffer,G=Uint8Array,tt=Uint16Array,ia=Int16Array,at=Int32Array,mt=function(e,r,a){if(G.prototype.slice)return G.prototype.slice.call(e,r,a);(r==null||r<0)&&(r=0),(a==null||a>e.length)&&(a=e.length);var o=new G(a-r);return o.set(e.subarray(r,a)),o},Ie=function(e,r,a,o){if(G.prototype.fill)return G.prototype.fill.call(e,r,a,o);for((a==null||a<0)&&(a=0),(o==null||o>e.length)&&(o=e.length);a<o;++a)e[a]=r;return e},oa=function(e,r,a,o){if(G.prototype.copyWithin)return G.prototype.copyWithin.call(e,r,a,o);for((a==null||a<0)&&(a=0),(o==null||o>e.length)&&(o=e.length);a<o;)e[r++]=e[a++]},ca=["invalid zstd data","window size too large (>2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],A=function(e,r,a){var o=new Error(r||ca[e]);if(o.code=e,Error.captureStackTrace&&Error.captureStackTrace(o,A),!a)throw o;return o},sr=function(e,r,a){for(var o=0,s=0;o<a;++o)s|=e[r++]<<(o<<3);return s},la=function(e,r){return(e[r]|e[r+1]<<8|e[r+2]<<16|e[r+3]<<24)>>>0},ua=function(e,r){var a=e[0]|e[1]<<8|e[2]<<16;if(a==3126568&&e[3]==253){var o=e[4],s=o>>5&1,c=o>>2&1,u=o&3,h=o>>6;o&8&&A(0);var v=6-s,p=u==3?4:u,y=sr(e,v,p);v+=p;var C=h?1<<h:s,k=sr(e,v,C)+(h==1&&256),E=k;if(!s){var $=1<<10+(e[5]>>3);E=$+($>>3)*(e[5]&7)}E>2145386496&&A(1);var w=new G((r==1?k||E:r?0:E)+12);return w[0]=1,w[4]=4,w[8]=8,{b:v+C,y:0,l:0,d:y,w:r&&r!=1?r:w.subarray(12),e:E,o:new at(w.buffer,0,3),u:k,c,m:Math.min(131072,E)}}else if((a>>4|e[3]<<20)==25481893)return la(e,4)+8;A(0)},Ce=function(e){for(var r=0;1<<r<=e;++r);return r-1},Je=function(e,r,a){var o=(r<<3)+4,s=(e[r]&15)+5;s>a&&A(3);for(var c=1<<s,u=c,h=-1,v=-1,p=-1,y=c,C=new sa(512+(c<<2)),k=new ia(C,0,256),E=new tt(C,0,256),$=new tt(C,512,c),w=512+(c<<1),U=new G(C,w,c),I=new G(C,w+c);h<255&&u>0;){var O=Ce(u+1),H=o>>3,z=(1<<O+1)-1,R=(e[H]|e[H+1]<<8|e[H+2]<<16)>>(o&7)&z,x=(1<<O)-1,F=z-u-1,Q=R&x;if(Q<F?(o+=O,R=Q):(o+=O+1,R>x&&(R-=F)),k[++h]=--R,R==-1?(u+=R,U[--y]=h):u-=R,!R)do{var q=o>>3;v=(e[q]|e[q+1]<<8)>>(o&7)&3,o+=2,h+=v}while(v==3)}(h>255||u)&&A(0);for(var X=0,j=(c>>1)+(c>>3)+3,V=c-1,B=0;B<=h;++B){var L=k[B];if(L<1){E[B]=-L;continue}for(p=0;p<L;++p){U[X]=B;do X=X+j&V;while(X>=y)}}for(X&&A(0),p=0;p<c;++p){var b=E[U[p]]++,S=I[p]=s-Ce(b);$[p]=(b<<S)-c}return[o+7>>3,{b:s,s:U,n:I,t:$}]},da=function(e,r){var a=0,o=-1,s=new G(292),c=e[r],u=s.subarray(0,256),h=s.subarray(256,268),v=new tt(s.buffer,268);if(c<128){var p=Je(e,r+1,6),y=p[0],C=p[1];r+=c;var k=y<<3,E=e[r];E||A(0);for(var $=0,w=0,U=C.b,I=U,O=(++r<<3)-8+Ce(E);O-=U,!(O<k);){var H=O>>3;if($+=(e[H]|e[H+1]<<8)>>(O&7)&(1<<U)-1,u[++o]=C.s[$],O-=I,O<k)break;H=O>>3,w+=(e[H]|e[H+1]<<8)>>(O&7)&(1<<I)-1,u[++o]=C.s[w],U=C.n[$],$=C.t[$],I=C.n[w],w=C.t[w]}++o>255&&A(0)}else{for(o=c-127;a<o;a+=2){var z=e[++r];u[a]=z>>4,u[a+1]=z&15}++r}var R=0;for(a=0;a<o;++a){var x=u[a];x>11&&A(0),R+=x&&1<<x-1}var F=Ce(R)+1,Q=1<<F,q=Q-R;for(q&q-1&&A(0),u[o++]=Ce(q)+1,a=0;a<o;++a){var x=u[a];++h[u[a]=x&&F+1-x]}var X=new G(Q<<1),j=X.subarray(0,Q),V=X.subarray(Q);for(v[F]=0,a=F;a>0;--a){var B=v[a];Ie(V,a,B,v[a-1]=B+h[a]*(1<<F-a))}for(v[0]!=Q&&A(0),a=0;a<o;++a){var L=u[a];if(L){var b=v[L];Ie(j,a,b,v[L]=b+(1<<F-L))}}return[r,{n:V,b:F,s:j}]},fa=Je(new G([81,16,99,140,49,198,24,99,12,33,196,24,99,102,102,134,70,146,4]),0,6)[1],pa=Je(new G([33,20,196,24,99,140,33,132,16,66,8,33,132,16,66,8,33,68,68,68,68,68,68,68,68,36,9]),0,6)[1],ha=Je(new G([32,132,16,66,102,70,68,68,68,68,36,73,2]),0,5)[1],br=function(e,r){for(var a=e.length,o=new at(a),s=0;s<a;++s)o[s]=r,r+=1<<e[s];return o},kt=new G(new at([0,0,0,0,16843009,50528770,134678020,202050057,269422093]).buffer,0,36),wa=br(kt,0),Et=new G(new at([0,0,0,0,0,0,0,0,16843009,50528770,11776 Line truncated
<div id="home-db-banner" class="db-banner" role="status" hidden>
<span id="home-db-text" class="db-banner-text">Проверка локальной базы…</span>
<button id="home-db-go" class="db-banner-link" type="button">Синхронизация →</button>
</div>
<section class="card" aria-labelledby="words-title">
<h2 id="words-title" class="card-title">Слова</h2>
<div class="words-toolbar">
<input id="words-search" class="words-search" type="search" placeholder="Поиск: ключ, перевод, тег…"
autocomplete="off" aria-label="Поиск по словам" />
<select id="words-group" class="words-group" aria-label="Фильтр по группе">
<option value="">Все группы</option>
</select>
</div>
<p id="words-count" class="card-hint" role="status" aria-live="polite"></p>
<div id="words-status" class="status-bar" role="status">База ещё не проверена.</div>
<div id="words-list" class="words-list" aria-live="polite"></div>
<div class="btn-row"><button id="words-more" class="btn" type="button" hidden>Показать ещё</button></div>
</section>
`;const a=e.querySelector("#home-db-banner"),o=e.querySelector("#home-db-text"),s=e.querySelector("#home-db-go"),c=e.querySelector("#words-search"),u=e.querySelector("#words-group"),h=e.querySelector("#words-count"),v=e.querySelector("#words-status"),p=e.querySelector("#words-list"),y=e.querySelector("#words-more");function C(b,S){a.hidden=!1,o.textContent=b,s.hidden=!1}function k(){a.hidden=!0}s.addEventListener("click",()=>{document.querySelector('.drawer-btn[data-page="sync"]')?.click()});let E=!1,$=new Map,w=0,U=[],I=null,O=0,H=!1,z=0,R=0;function x(){const b=u.value;if(b==="")return{search:c.value.trim(),groupId:null};const S=Number(b);return{search:c.value.trim(),groupId:Number.isFinite(S)?S:null}}function F(){y.hidden=!(E&&U.length<w),y.hidden||(y.textContent=`Показать ещё (${w-U.length} осталось)`)}function Q(){p.innerHTML=U.map(b=>or(b,$,b.id===I)).join(""),h.textContent=w>0?`Показано ${U.length} из ${w}`:"",F()}function q(b){if(I===b){b!=null&&X(b),I=null;return}I!=null&&X(I),I=b,b!=null&&j(b)}function X(b){const S=p.querySelector(`.word-row-wrap[data-word-id="${b}"]`);if(!S)return;const T=S.querySelector(".word-row"),P=S.querySelector(".word-extra");T&&(T.classList.remove("open"),T.setAttribute("aria-expanded","false")),P&&(P.hidden=!0,P.innerHTML="")}function j(b){const S=U.find(he=>he.id===b),T=p.querySelector(`.word-row-wrap[data-word-id="${b}"]`);if(!S||!T)return;const P=T.querySelector(".word-row"),J=T.querySelector(".word-extra");P&&(P.classList.add("open"),P.setAttribute("aria-expanded","true")),J&&(J.hidden=!1,J.innerHTML=Er(S,$))}function V(b){v.textContent=b,v.hidden=b===""}async function B(){if(!E||H)return;H=!0;const b=++z,S=x();y.disabled=!0,U.length===0&&V("Загрузка слов…");try{const[T,P]=await Promise.all([Bn({search:S.search,groupId:S.groupId??void 0}),tr({search:S.search,groupId:S.groupId??void 0,limit:ir,offset:0})]);if(b!==z)return;w=T,U=P,O=P.length,I!=null&&!U.some(J=>J.id===I)&&(I=null),Q(),V(P.length===0?"По этому запросу слов нет.":""),P.length===0&&w===0&&(h.textContent="Слов в базе пока нет.")}catch(T){if(b!==z)return;V(`Не удалось загрузить слова: ${T instanceof Error?T.message:String(T)}`)}finally{b===z&&(H=!1,y.disabled=!1,F())}}async function L(){if(!E||H||U.length>=w)return;H=!0;const b=++z,S=x();y.disabled=!0,y.textContent="Загрузка…";try{const T=await tr({search:S.search,groupId:S.groupId??void 0,limit:ir,offset:O});if(b!==z)return;U=U.concat(T),O+=T.length,p.insertAdjacentHTML("beforeend",T.map(P=>or(P,$,P.id===I)).join("")),h.textContent=`Показано ${U.length} из ${w}`,F()}catch(T){if(b!==z)return;V(`Не удалось догрузить: ${T instanceof Error?T.message:String(T)}`)}finally{b===z&&(H=!1,y.disabled=!1,F())}}c.addEventListener("input",()=>{window.clearTimeout(R),R=window.setTimeout(()=>{B()},300)}),c.addEventListener("keydown",b=>{b.key==="Enter"&&(window.clearTimeout(R),B())}),u.addEventListener("change",()=>{B()}),y.addEventListener("click",()=>{L()}),p.addEventListener("click",b=>{const S=b.target.closest(".word-row");if(!S||S.disabled)return;const T=Number(S.dataset.wordId);Number.isFinite(T)&&q(I===T?null:T)}),(async()=>{if(!await Oe()){C("Локальной базы нет — скачай сохранение."),V("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),h.textContent="";return}try{const S=await aa();if(!$t(S)){C("Файл в OPFS не похож на SQLite — перекачай сохранение.",!0),V("Файл в OPFS не похож на SQLite — перекачай сохранение.");return}k();try{const[P,J]=await Promise.all([Vn(),yr()]),he=P;$=new Map(J.map(ue=>[ue.id,ue])),u.innerHTML='<option value="">Все группы</option>'+he.map(ue=>`<option value="${ue.id}">${Ee(ue.name)}</option>`).join(""),E=!0,await B(),u.options[0].textContent=`Все группы (${w})`}catch(P){V(`БД есть, но слова не прочитались: ${P instanceof Error?P.message:String(P)}`)}}catch(S){C(`Не удалось прочитать базу: ${S instanceof Error?S.message:String(S)}`)}})()}function cr(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function Fa(e){const r=e%10,a=e%100;return r===1&&a!==11?"запись":r>=2&&r<=4&&(a<12||a>14)?"записи":"записей"}function Pa(e,r){const a=cr(e.name)||`<span class="empty-val">Колода #${e.id}</span>`;return`<div class="word-row-wrap deck-row" data-deck-id="${e.id}"><button class="word-row deck-name" type="button" data-act="open" data-deck-id="${e.id}"><span class="word-main"><span class="word-key">${a}</span></span><span class="word-tag">${r} ${Fa(r)}</span></button><button class="deck-play" type="button" data-act="review" data-deck-id="${e.id}" aria-label="ПовторLine truncated
<section class="card" aria-labelledby="decks-title">
<h2 id="decks-title" class="card-title">Колоды</h2>
<p id="decks-count" class="card-hint" role="status" aria-live="polite"></p>
<div id="decks-status" class="status-bar" role="status">Проверка локальной базы…</div>
<div id="decks-list" class="words-list" aria-live="polite"></div>
</section>
`;const a=e.querySelector("#decks-count"),o=e.querySelector("#decks-status"),s=e.querySelector("#decks-list");function c(u){o.textContent=u,o.hidden=u===""}s.addEventListener("click",u=>{const h=u.target.closest("[data-act]");if(!h)return;const v=Number(h.dataset.deckId);Number.isFinite(v)&&(h.dataset.act==="review"?r.goReview(v):r.goDeck(v))}),(async()=>{if(!await Oe()){c("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),a.textContent="";return}c("Загрузка колод…");try{const[h,v]=await Promise.all([Yn(),Gn()]);if(h.length===0){a.textContent="Колоды пока отсутствуют.",c(""),s.innerHTML="";return}a.textContent=`Всего: ${h.length}`,c(""),s.innerHTML=h.map(p=>Pa(p,v.get(p.id)??0)).join("")}catch(h){c(`Не удалось загрузить колоды: ${h instanceof Error?h.message:String(h)}`)}})()}function Qa(e){return e?Ia(e):'<span class="empty-val">—</span>'}function Ia(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function bt(e){return e.replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}function Oa(e){const r=e%10,a=e%100;return r===1&&a!==11?"слово":r>=2&&r<=4&&(a<12||a>14)?"слова":"слов"}function Ja(e,r,a){e.innerHTML=`
<button id="deck-back" class="btn btn-ghost" type="button">← Назад к колодам</button>
<section class="card" aria-labelledby="deck-title">
<h2 id="deck-title" class="card-title">Колода…</h2>
<div id="deck-status" class="status-bar" role="status">Загрузка колоды…</div>
<div id="deck-body"></div>
</section>
`;const o=e.querySelector("#deck-back"),s=e.querySelector("#deck-title"),c=e.querySelector("#deck-status"),u=e.querySelector("#deck-body");o.addEventListener("click",()=>a.goDecks()),(async()=>{if(!await Oe()){s.textContent="Колода недоступна",c.textContent="Нет локальной базы — скачай сохранение на странице «Синхронизация».";return}let v=null;try{v=await nt(r)}catch(x){s.textContent="Колода недоступна",c.textContent=`Не удалось загрузить колоду: ${x instanceof Error?x.message:String(x)}`;return}if(!v){s.textContent="Колода не найдена",c.textContent=`Колоды с id=${r} нет в локальной базе. Возможно, база обновилась — вернись к списку.`;return}const p=v;s.textContent=p.name||`Колода #${p.id}`,c.hidden=!0;let y=null;try{y=await jn(p.id)}catch(x){c.hidden=!1,c.textContent=`Данные колоды загружены, а количество слов — нет: ${x instanceof Error?x.message:String(x)}`}u.innerHTML=`
<dl class="word-extra-list">
<div class="word-extra-row"><dt>id</dt><dd>${p.id}</dd></div>
<div class="word-extra-row"><dt>name</dt><dd>${Qa(p.name)}</dd></div>
</dl>
<form id="deck-form" class="deck-form" autocomplete="off">
<label class="field-label" for="deck-forward">forward</label>
<input id="deck-forward" class="token-input" type="text" value="${bt(p.forward)}" />
<label class="field-label" for="deck-backward">backward</label>
<input id="deck-backward" class="token-input" type="text" value="${bt(p.backward)}" />
<label class="field-label" for="deck-filter">filter</label>
<input id="deck-filter" class="token-input" type="text" value="${bt(p.filter)}" />
<div class="btn-row">
<button id="deck-save" class="btn btn-primary" type="submit">Сохранить</button>
</div>
<p id="deck-form-status" class="card-hint" role="status" aria-live="polite"></p>
</form>
<h3 class="deck-sub">${y==null?"Количество слов неизвестно":`${y} ${Oa(y)}`}</h3>
<div class="btn-row">
<button id="deck-review" class="btn btn-primary" type="button"${y===0?" disabled":""}>Повторять</button>
</div>
<div class="btn-row">
<button id="deck-history" class="btn" type="button">История ответов</button>
</div>
`;const C=u.querySelector("#deck-form"),k=u.querySelector("#deck-forward"),E=u.querySelector("#deck-backward"),$=u.querySelector("#deck-filter"),w=u.querySelector("#deck-save"),U=u.querySelector("#deck-form-status"),I=u.querySelector("#deck-review"),O=u.querySelector("#deck-history");I.addEventListener("click",()=>a.goReview(p.id)),O.addEventListener("click",()=>a.goHistory(p.id));const H=()=>`${k.value}
${E.value}
${$.value}`;let z=H();function R(){w.disabled=H()===z||w.dataset.busy==="1"}for(const x of[k,E,$])x.addEventListener("input",R);R(),C.addEventListener("submit",x=>{x.preventDefault(),!w.disabled&&(w.dataset.busy="1",w.disabled=!0,w.textContent="Сохранение…",U.textContent="",(async()=>{try{const F=await Zn(p.id,{forward:k.value,backward:E.value,filter:$.value});if(!F){U.textContent="Колода пропала из базы — вернись к списку.";return}k.value=F.forward,E.value=F.backward,$.value=F.filter,z=H(),U.textContent="Сохранено в локальную базу."}catch(F){U.textContent=`Не удалось сохранить: ${F instanceof Error?F.message:String(F)}`}finally{delete w.dataset.busy,w.textContent="Сохранить",R()}})())})})()}function Fe(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}const Ka=50,Da=[{label:"Не получилось",cls:"grade-fail"},{label:"Тяжело",cls:"grade-hard"},{label:"Хорошо",cls:"grade-good"},{label:"Легко",cls:"grade-easy"}];function Xa(e){const r=Da[e.answer]??{label:`Оценка ${e.answer}`,cls:""},a=e.wordKey?`<span class="word-key">${Fe(e.wordKey)}</span>`+(e.wordValue?`<span class="word-sep"> — </span><span class="word-value">${Fe(e.wordValue)}</span>`:""):`<span class="empty-val">Слово #${e.word_id} (удалено)</span>`,o=e.before===e.after?`${e.before}`:`${e.before}→${e.after}`;return`<div class="hist-row-wrap"><div class="hist-main">${a}</div><div class="hist-meta"><span class="hist-grade ${r.cls}">${Fe(r.label)}</span><span class="word-tag">${Fe(Dn(e.timestamp))} · score ${Fe(o)}</span></div></div>`}function za(e,r,a){e.innerHTML=`
<button id="hist-back" class="btn btn-ghost" type="button">← Назад к колоде</button>
<section class="card" aria-labelledby="hist-title">
<h2 id="hist-title" class="card-title">История…</h2>
<p id="hist-count" class="card-hint" role="status" aria-live="polite"></p>
<div id="hist-status" class="status-bar" role="status">Загрузка истории…</div>
<div id="hist-list" class="words-list" aria-live="polite"></div>
<div class="btn-row"><button id="hist-more" class="btn" type="button" hidden>Показать ещё</button></div>
</section>
`;const o=e.querySelector("#hist-back"),s=e.querySelector("#hist-title"),c=e.querySelector("#hist-count"),u=e.querySelector("#hist-status"),h=e.querySelector("#hist-list"),v=e.querySelector("#hist-more");o.addEventListener("click",()=>a.goDeck(r));function p(w){u.textContent=w,u.hidden=w===""}let y=0,C=0,k=!1;function E(){const w=y-C;v.hidden=w<=0,w>0&&(v.textContent=`Показать ещё (${w} осталось)`)}async function $(){if(!(k||C>=y)){k=!0,v.disabled=!0,v.textContent="Загрузка…";try{const w=await ra(r,{limit:Ka,offset:C});C+=w.length,h.insertAdjacentHTML("beforeend",w.map(Xa).join("")),c.textContent=`Показано ${C} из ${y}`}catch(w){p(`Не удалось загрузить историю: ${w instanceof Error?w.message:String(w)}`)}finally{k=!1,v.disabled=!1,E()}}}v.addEventListener("click",()=>{$()}),(async()=>{if(!await Oe()){s.textContent="История недоступна",p("Нет локальной базы — скачай сохранение на странице «Синхронизация».");return}try{const U=await nt(r);if(!U){s.textContent="Колода не найдена",p(`Колоды с id=${r} нет в локальной базе.`);return}if(s.textContent=U.name||`Колода #${U.id}`,y=await ta(U.id),y===0){c.textContent="Ответов пока нет — начни повторение.",p("");return}p(""),await $()}catch(U){s.textContent="История недоступна",p(`Не удалось загрузить историю: ${U instanceof Error?U.message:String(U)}`)}})()}function Ba(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}const Va=[{value:0,label:"Не получилось",cls:"grade-fail"},{value:1,label:"Тяжело",cls:"grade-hard"},{value:2,label:"Хорошо",cls:"grade-good"},{value:3,label:"Легко",cls:"grade-easy"}];function Ya(e,r,a){e.innerHTML=`
<button id="review-back" class="btn btn-ghost" type="button">← Назад к колодам</button>
<section class="card" aria-labelledby="review-title">
<h2 id="review-title" class="card-title">Повторение…</h2>
<p id="review-progress" class="card-hint" role="status" aria-live="polite"></p>
<div id="review-status" class="status-bar" role="status">Загрузка карточек…</div>
<div id="review-body"></div>
</section>
`;const o=e.querySelector("#review-back"),s=e.querySelector("#review-title"),c=e.querySelector("#review-progress"),u=e.querySelector("#review-status"),h=e.querySelector("#review-body");o.addEventListener("click",()=>a.goDecks());function v(Q){u.textContent=Q,u.hidden=Q===""}let p=[],y=new Map,C=[],k="",E="",$=0,w=!1,U=null,I=0,O=!1;function H(Q,q){return Q.length===0?`<div class="review-side ${q}"><span class="empty-val">—</span></div>`:`<div class="review-side ${q}">`+Q.map(X=>`<p class="review-line">${Ba(X)}</p>`).join("")+"</div>"}async function z(){const Q=++I,q=p[$],X=y.get(q);if(!X){h.innerHTML="",c.textContent="",v("Слово пропало из базы — пропускаю.");return}U=null;let j="";try{U=await An(r,q)??1}catch{U=1,j=" (score прочитать не удалось — пишу before/after=1)"}if(Q!==I)return;O=!1;const V=Wn(X,C),B=nr(k,V),L=w?nr(E,V):null;c.textContent=`Карточка ${$+1} из ${p.length}`,v("");const b=w?H(L??[],"review-back-side"):'<p class="review-tap">Тап — показать ответ</p>',S=w?`<div class="review-grades" role="group" aria-label="Оценка повторения">
${Va.map(T=>`<button class="btn review-grade ${T.cls}" type="button" data-grade="${T.value}">${T.label}</button>`).join("")}
</div>`:"";h.innerHTML=`
<button id="review-card" class="review-card" type="button"
aria-expanded="${w}" aria-label="${w?"Карточка открыта":"Показать ответ"}">
${H(B,"review-front")}
${w?'<hr class="review-hr" />':""}
${b}
</button>
${S}
`,h.querySelector("#review-card").addEventListener("click",()=>{w||(w=!0,z())}),h.querySelectorAll(".review-grade").forEach(T=>{T.addEventListener("click",()=>{const P=Number(T.dataset.grade);R(P,j)})})}async function R(Q,q){if(O)return;O=!0;const X=p[$],j=U??1,V=[...h.querySelectorAll(".review-grade")];for(const B of V)B.disabled=!0;try{await ea({deckId:r,wordId:X,answer:Q,before:j,after:j})}catch(B){v(`Ответ не записался: ${B instanceof Error?B.message:String(B)}${q}`),O=!1;for(const L of V)L.disabled=!1;return}$+=1,w=!1,U=null,$>=p.length?x():await z()}function x(){c.textContent=`Готово: ${p.length} из ${p.length}`,v(""),h.innerHTML=`
<p class="card-hint">Все карточки сессии просмотрены, ответы записаны в history.</p>
<div class="btn-row">
<button id="review-again" class="btn btn-primary" type="button">Перемешать заново</button>
</div>
`,h.querySelector("#review-again").addEventListener("click",()=>{F()})}async function F(){if(v("Перемешиваю…"),h.innerHTML="",c.textContent="",p=await ar(r),$=0,w=!1,p.length===0){v("В колоде нет слов для повторения.");return}y=await rr(p),z()}(async()=>{if(!await Oe()){s.textContent="Повторение недоступно",v("Нет локальной базы — скачай сохранение на странице «Синхронизация».");return}try{const[q,X]=await Promise.all([nt(r),yr()]);if(!q){s.textContent="Колода не найдена",v(`Колоды с id=${r} нет в локальной базе. Вернись к списку.`);return}if(C=X,k=q.forward,E=q.backward,s.textContent=q.name||`Колода #${q.id}`,p=await ar(q.id),p.length===0){c.textContent="",v("В колоде нет слов для повторения.");return}y=await rr(p),$=0,w=!1,await z()}catch(q){s.textContent="Повторение недоступно",v(`Не удалось загрузить карточки: ${q instanceof Error?q.message:String(q)}`)}})()}const lr=24,Za=/^[A-Za-z0-9]{24}$/,Cr="japlearn_sync_token";function ur(e){return e.trim().replace(/\s+/g,"")}function Pe(e){return Za.test(e)}function Ga(){try{return localStorage.getItem(Cr)??""}catch{return""}}function dr(e){try{localStorage.setItem(Cr,e)}catch{}}const $r="https://learning.micialware.ru";function ja(e,r=$r){return`${r.replace(/\/+$/,"")}/download/${encodeURIComponent(e)}`}async function Wa(e,r,a=$r){const o=ja(e,a);let s;try{s=await fetch(o,{method:"GET",...r})}catch(u){throw new Error(`Нет связи с сервером: ${u instanceof Error?u.message:String(u)}`)}if(!s.ok)throw s.status===404?new Error("Сохранение не найдено (404). Проверь токен."):new Error(`Сервер ответил ${s.status} ${s.statusText}`);const c=await s.arrayBuffer();if(c.byteLength===0)throw new Error("Сервер вернул пустой файл");return new Uint8Array(c)}function Aa(e){e.innerHTML=`
<main class="card" aria-labelledby="sync-title">
<h2 id="sync-title" class="card-title">Синхронизация</h2>
<p class="card-hint">Токен сохранения — 24 символа: латинские буквы и цифры.</p>
<label class="field-label" for="token-input">Токен сохранения</label>
<input
id="token-input"
class="token-input"
type="text"
inputmode="latin"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
maxlength="24"
minlength="24"
placeholder="Например: aB3dEf7hJk9mN2pQr5tUvWx1z"
aria-describedby="token-error token-counter"
/>
<div class="token-meta">
<span id="token-error" class="token-error" role="alert"></span>
<span id="token-counter" class="token-counter" aria-live="polite">0 / 24</span>
</div>
<div class="btn-row">
<button id="btn-download" class="btn btn-primary" type="button" disabled>Скачать</button>
</div>
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
</main>
`;const r=e.querySelector("#token-input"),a=e.querySelector("#token-error"),o=e.querySelector("#token-counter"),s=e.querySelector("#btn-download"),c=e.querySelector("#status");function u(k){c.textContent=k,console.log(`[JapLearn] ${k}`)}function h(){return ur(r.value)}function v(k){s.disabled=k||!Pe(h()),r.disabled=k}function p(){const k=h(),E=k.length;o.textContent=`${E} / ${lr}`;const $=Pe(k),w=E===0;r.classList.toggle("invalid",!w&&!$),r.setAttribute("aria-invalid",String(!w&&!$)),w?a.textContent="":E!==lr?a.textContent=`Нужно 24 символа, сейчас ${E}`:a.textContent=$?"":"Только латинские буквы и цифры",s.disabled=!$}async function y(){const k=h();if(Pe(k)){dr(k),v(!0),u("Скачивание сохранения...");try{const E=await Wa(k);u(`Скачано ${(E.length/1024).toFixed(1)} КБ, распаковка...`);const $=await Ha(E);u(`Распаковано ${($.length/1024).toFixed(1)} КБ, сохранение в OPFS...`),await na($),u(`Готово: база ${($.length/1024).toFixed(1)} КБ сохранена локально (OPFS).`)}catch(E){u(`Ошибка скачивания: ${E instanceof Error?E.message:String(E)}`)}finally{v(!1),p()}}}r.addEventListener("input",()=>{p()}),r.addEventListener("change",()=>{const k=h();r.value=k,Pe(k)&&(dr(k),u("Токен сохранён локально. Можно скачивать.")),p()}),s.addEventListener("click",()=>{y()});const C=Ga();C&&(r.value=C),p(),Pe(ur(C))&&u("Токен восстановлен. Нажми «Скачать», чтобы получить базу.")}const es="modulepreload",ts=function(e){return"/"+e},fr={},rs=function(r,a,o){let s=Promise.resolve();if(a&&a.length>0){let v=function(p){return Promise.all(p.map(y=>Promise.resolve(y).then(C=>({status:"fulfilled",value:C}),C=>({status:"rejected",reason:C}))))};document.getElementsByTagName("link");const u=document.querySelector("meta[property=csp-nonce]"),h=u?.nonce||u?.getAttribute("nonce");s=v(a.map(p=>{if(p=ts(p),p in fr)return;fr[p]=!0;const y=p.endsWith(".css"),C=y?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${p}"]${C}`))return;const k=document.createElement("link");if(k.rel=y?"stylesheet":es,y||(k.as="script"),k.crossOrigin="",k.href=p,h&&k.setAttribute("nonce",h),document.head.appendChild(k),y)return new Promise((E,$)=>{k.addEventListener("load",E),k.addEventListener("error",()=>$(new Error(`Unable to preload CSS for ${p}`)))})}))}function c(u){const h=new Event("vite:preloadError",{cancelable:!0});if(h.payload=u,window.dispatchEvent(h),!h.defaultPrevented)throw u}return s.then(u=>{for(const h of u||[])h.status==="rejected"&&c(h.reason);return r().catch(c)})};function ns(e={}){const{immediate:r=!1,onNeedReload:a,onNeedRefresh:o,onOfflineReady:s,onRegistered:c,onRegisteredSW:u,onRegisterError:h}=e;let v,p,y;const C=async(E=!0)=>{await p,y?.()};async function k(){if("serviceWorker"in navigator){if(v=await rs(async()=>{const{Workbox:E}=await import("./workbox-window.prod.es5-BBnX5xw4.js");return{Workbox:E}},[]).then(({Workbox:E})=>new E("/sw.js",{scope:"/",type:"classic"})).catch(E=>{h?.(E)}),!v)return;y=()=>{v?.messageSkipWaiting()};{let E=!1;const $=()=>{E=!0,v?.addEventListener("controlling",w=>{w.isUpdate&&(a?a():window.location.reload())}),o?.()};v.addEventListener("installed",w=>{typeof w.isUpdate>"u"?typeof w.isExternal<"u"&&w.isExternal?$():!E&&s?.():w.isUpdate||s?.()}),v.addEventListener("waiting",$)}v.register({immediate:r}).then(E=>{u?u("/sw.js",E):c?.(E)}).catch(E=>{h?.(E)})}}return p=k(),C}const Ur="japlearn_ios_install_hint_seen";function as(){const e=navigator;return window.matchMedia("(display-mode: standalone)").matches||e.standalone===!0}function ss(){const e=navigator.userAgent;return(/iPhone|iPad|iPod/.test(e)||navigator.platform==="MacIntel"&&navigator.maxTouchPoints>1)&&/WebKit/.test(e)&&!/CriOS|FxiOS|EdgiOS|OPiOS/.test(e)}function is(){try{return localStorage.getItem(Ur)==="1"}catch{return!0}}function os(){const e=document.getElementById("pwa-banner");if(!e)return;let r=null,a=null,o=!1;function s(){e.hidden=!0,e.replaceChildren()}function c(p,y){const C=document.createElement("span");C.className="pwa-banner-text",C.textContent=p;const k=document.createElement("div");k.className="pwa-actions";for(const E of y){const $=document.createElement("button");$.type="button",$.className=E.primary?"btn btn-primary pwa-btn":"btn pwa-btn",$.textContent=E.label,$.addEventListener("click",E.onClick),k.append($)}e.replaceChildren(C,k),e.hidden=!1}function u(){if(r){c("Доступна новая версия приложения",[{label:"Обновить",primary:!0,onClick:()=>{r?.()}},{label:"Позже",onClick:()=>{r=null,u()}}]);return}if(a){c("Установить JapLearn на домашний экран?",[{label:"Установить",primary:!0,onClick:()=>{h()}},{label:"Не сейчас",onClick:()=>{a=null,u()}}]);return}if(ss()&&!as()&&!is()){c("На iPhone: «Поделитьс Line truncated