repetition woks
This commit is contained in:
1 parent
37b3b61f79
commit
36d2c0b48f
15 files changed
+905
-185
No files matched your search
@@ -1,3 +1,3 @@
|
|||||||
var ft=(()=>{var qe=import.meta.url;return function(oe={}){var fe,i=oe,ye,be,Oe=new Promise((e,t)=>{ye=e,be=t}),Xe=typeof window=="object",Ce=typeof importScripts=="function";typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";const Pe=globalThis.sqlite3InitModuleState||Object.assign(Object.create(null),{debugModule:()=>{}});delete globalThis.sqlite3InitModuleState,Pe.debugModule("globalThis.location =",globalThis.location);var He=Object.assign({},i),re="./this.program",me="";function Ie(e){return i.locateFile?i.locateFile(e,me):me+e}var De,je;(Xe||Ce)&&(Ce?me=self.location.href:typeof document<"u"&&document.currentScript&&(me=document.currentScript.src),qe&&(me=qe),me.startsWith("blob:")?me="":me=me.substr(0,me.replace(/[?#].*/,"").lastIndexOf("/")+1),Ce&&(je=e=>{var t=new XMLHttpRequest;return t.open("GET",e,!1),t.responseType="arraybuffer",t.send(null),new Uint8Array(t.response)}),De=e=>fetch(e,{credentials:"same-origin"}).then(t=>t.ok?t.arrayBuffer():Promise.reject(new Error(t.status+" : "+t.url))));var Ve=i.print||console.log.bind(console),Le=i.printErr||console.error.bind(console);Object.assign(i,He),He=null,i.arguments&&i.arguments,i.thisProgram&&(re=i.thisProgram);var Ye=i.wasmBinary,Re,dt=!1,xe,Be,Ge,ue,Ee,ke;function pt(){var e=Re.buffer;i.HEAP8=xe=new Int8Array(e),i.HEAP16=Ge=new Int16Array(e),i.HEAPU8=Be=new Uint8Array(e),i.HEAPU16=new Uint16Array(e),i.HEAP32=ue=new Int32Array(e),i.HEAPU32=Ee=new Uint32Array(e),i.HEAPF32=new Float32Array(e),i.HEAPF64=new Float64Array(e),i.HEAP64=ke=new BigInt64Array(e),i.HEAPU64=new BigUint64Array(e)}if(i.wasmMemory)Re=i.wasmMemory;else{var Pt=i.INITIAL_MEMORY||16777216;Re=new WebAssembly.Memory({initial:Pt/65536,maximum:32768})}pt();var ht=[],mt=[],gt=[];function Lt(){var e=i.preRun;e&&(typeof e=="function"&&(e=[e]),e.forEach(Rt)),ot(ht)}function Ct(){!i.noFSInit&&!a.initialized&&a.init(),a.ignorePermissions=!1,ot(mt)}function Dt(){var e=i.postRun;e&&(typeof e=="function"&&(e=[e]),e.forEach(Mt)),ot(gt)}function Rt(e){ht.unshift(e)}function Nt(e){mt.unshift(e)}function Mt(e){gt.unshift(e)}var Ne=0,$e=null;function tr(e){return e}function rt(e){Ne++,i.monitorRunDependencies?.(Ne)}function Ze(e){if(Ne--,i.monitorRunDependencies?.(Ne),Ne==0&&$e){var t=$e;$e=null,t()}}function st(e){i.onAbort?.(e),e="Aborted("+e+")",Le(e),dt=!0,e+=". Build with -sASSERTIONS for more info.";var t=new WebAssembly.RuntimeError(e);throw be(t),t}var jt="data:application/octet-stream;base64,",qt=e=>e.startsWith(jt);function Bt(){if(i.locateFile){var e="sqlite3.wasm";return qt(e)?e:Ie(e)}return new URL("/assets/sqlite3-DBpDb1lf.wasm",import.meta.url).href}var it;function bt(e){if(e==it&&Ye)return new Uint8Array(Ye);if(je)return je(e);throw"both async and sync fetching of the wasm failed"}function Ut(e){return Ye?Promise.resolve().then(()=>bt(e)):De(e).then(t=>new Uint8Array(t),()=>bt(e))}function yt(e,t,n){return Ut(e).then(r=>WebAssembly.instantiate(r,t)).then(n,r=>{Le(`failed to asynchronously prepare wasm: ${r}`),st(r)})}function zt(e,t,n,r){return!e&&typeof WebAssembly.instantiateStreaming=="function"&&!qt(t)&&typeof fetch=="function"?fetch(t,{credentials:"same-origin"}).then(s=>{var o=WebAssembly.instantiateStreaming(s,n);return o.then(r,function(p){return Le(`wasm streaming compile failed: ${p}`),Le("falling back to ArrayBuffer instantiation"),yt(t,n,r)})}):yt(t,n,r)}function Wt(){return{env:It,wasi_snapshot_preview1:It}}function Qt(){var e=Wt();function t(r,s){return h=r.exports,Nt(h.__wasm_call_ctors),Ze(),h}rt();function n(r){t(r.instance)}if(i.instantiateWasm)try{return i.instantiateWasm(e,t)}catch(r){Le(`Module.instantiateWasm callback failed with error: ${r}`),be(r)}return it??=Bt(),zt(Ye,it,e,n).catch(be),{}}var ot=e=>{e.forEach(t=>t(i))};i.noExitRuntime;var de={isAbs:e=>e.charAt(0)==="/",splitPath:e=>{var t=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return t.exec(e).slice(1)},normalizeArray:(e,t)=>{for(var n=0,r=e.length-1;r>=0;r--){var s=e[r];s==="."?e.splice(r,1):s===".."?(e.splice(r,1),n++):n&&(e.splice(r,1),n--)}if(t)for(;n;n--)e.unshift("..");return e},normalize:e=>{var t=de.isAbs(e),n=e.substr(-1)==="/";return e=de.normalizeArray(e.split("/").filter(r=>!!r),!t).join("/"),!e&&!t&&(e="."),e&&n&&(e+="/"),(t?"/":"")+e},dirname:e=>{var t=de.splitPath(e),n=t[0],r=t[1];return!n&&!r?".":(r&&(r=r.substr(0,r.length-1)),n+r)},basename:e=>{if(e==="/")return"/";e=de.normalize(e),e=e.replace(/\/$/,"");var t=e.lastIndexOf("/");return t===-1?e:e.substr(t+1)},join:(...e)=>de.normalize(e.join("/")),join2:(e,t)=>de.normalize(e+"/"+t)},Ht=()=>{if(typeof crypto=="object"&&typeof crypto.getRandomValues=="function")return e=>crypto.getRandomValues(e);st("initRandomDevice")},wt=e=>(wt=Ht())(e),Te={resolve:(...e)=>{for(var t="",n=!1,r=e.length-1;r>=-1&&!n;r--){var s=r>=0?e[r]:a.cwd();if(typeof s!="string")throw new TypeError("Arguments to path.resolve must be strings");if(!s)return"";t=s+"/"+t,n=de.iLine truncated
|
var ft=(()=>{var qe=import.meta.url;return function(oe={}){var fe,i=oe,ye,be,Oe=new Promise((e,t)=>{ye=e,be=t}),Xe=typeof window=="object",Ce=typeof importScripts=="function";typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";const Pe=globalThis.sqlite3InitModuleState||Object.assign(Object.create(null),{debugModule:()=>{}});delete globalThis.sqlite3InitModuleState,Pe.debugModule("globalThis.location =",globalThis.location);var He=Object.assign({},i),re="./this.program",me="";function Ie(e){return i.locateFile?i.locateFile(e,me):me+e}var De,je;(Xe||Ce)&&(Ce?me=self.location.href:typeof document<"u"&&document.currentScript&&(me=document.currentScript.src),qe&&(me=qe),me.startsWith("blob:")?me="":me=me.substr(0,me.replace(/[?#].*/,"").lastIndexOf("/")+1),Ce&&(je=e=>{var t=new XMLHttpRequest;return t.open("GET",e,!1),t.responseType="arraybuffer",t.send(null),new Uint8Array(t.response)}),De=e=>fetch(e,{credentials:"same-origin"}).then(t=>t.ok?t.arrayBuffer():Promise.reject(new Error(t.status+" : "+t.url))));var Ve=i.print||console.log.bind(console),Le=i.printErr||console.error.bind(console);Object.assign(i,He),He=null,i.arguments&&i.arguments,i.thisProgram&&(re=i.thisProgram);var Ye=i.wasmBinary,Re,dt=!1,xe,Be,Ge,ue,Ee,ke;function pt(){var e=Re.buffer;i.HEAP8=xe=new Int8Array(e),i.HEAP16=Ge=new Int16Array(e),i.HEAPU8=Be=new Uint8Array(e),i.HEAPU16=new Uint16Array(e),i.HEAP32=ue=new Int32Array(e),i.HEAPU32=Ee=new Uint32Array(e),i.HEAPF32=new Float32Array(e),i.HEAPF64=new Float64Array(e),i.HEAP64=ke=new BigInt64Array(e),i.HEAPU64=new BigUint64Array(e)}if(i.wasmMemory)Re=i.wasmMemory;else{var Pt=i.INITIAL_MEMORY||16777216;Re=new WebAssembly.Memory({initial:Pt/65536,maximum:32768})}pt();var ht=[],mt=[],gt=[];function Lt(){var e=i.preRun;e&&(typeof e=="function"&&(e=[e]),e.forEach(Rt)),ot(ht)}function Ct(){!i.noFSInit&&!a.initialized&&a.init(),a.ignorePermissions=!1,ot(mt)}function Dt(){var e=i.postRun;e&&(typeof e=="function"&&(e=[e]),e.forEach(Mt)),ot(gt)}function Rt(e){ht.unshift(e)}function Nt(e){mt.unshift(e)}function Mt(e){gt.unshift(e)}var Ne=0,$e=null;function tr(e){return e}function rt(e){Ne++,i.monitorRunDependencies?.(Ne)}function Ze(e){if(Ne--,i.monitorRunDependencies?.(Ne),Ne==0&&$e){var t=$e;$e=null,t()}}function st(e){i.onAbort?.(e),e="Aborted("+e+")",Le(e),dt=!0,e+=". Build with -sASSERTIONS for more info.";var t=new WebAssembly.RuntimeError(e);throw be(t),t}var jt="data:application/octet-stream;base64,",qt=e=>e.startsWith(jt);function Bt(){if(i.locateFile){var e="sqlite3.wasm";return qt(e)?e:Ie(e)}return new URL("/assets/sqlite3-DBpDb1lf.wasm",import.meta.url).href}var it;function bt(e){if(e==it&&Ye)return new Uint8Array(Ye);if(je)return je(e);throw"both async and sync fetching of the wasm failed"}function Ut(e){return Ye?Promise.resolve().then(()=>bt(e)):De(e).then(t=>new Uint8Array(t),()=>bt(e))}function yt(e,t,n){return Ut(e).then(r=>WebAssembly.instantiate(r,t)).then(n,r=>{Le(`failed to asynchronously prepare wasm: ${r}`),st(r)})}function zt(e,t,n,r){return!e&&typeof WebAssembly.instantiateStreaming=="function"&&!qt(t)&&typeof fetch=="function"?fetch(t,{credentials:"same-origin"}).then(s=>{var o=WebAssembly.instantiateStreaming(s,n);return o.then(r,function(p){return Le(`wasm streaming compile failed: ${p}`),Le("falling back to ArrayBuffer instantiation"),yt(t,n,r)})}):yt(t,n,r)}function Wt(){return{env:It,wasi_snapshot_preview1:It}}function Qt(){var e=Wt();function t(r,s){return h=r.exports,Nt(h.__wasm_call_ctors),Ze(),h}rt();function n(r){t(r.instance)}if(i.instantiateWasm)try{return i.instantiateWasm(e,t)}catch(r){Le(`Module.instantiateWasm callback failed with error: ${r}`),be(r)}return it??=Bt(),zt(Ye,it,e,n).catch(be),{}}var ot=e=>{e.forEach(t=>t(i))};i.noExitRuntime;var de={isAbs:e=>e.charAt(0)==="/",splitPath:e=>{var t=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return t.exec(e).slice(1)},normalizeArray:(e,t)=>{for(var n=0,r=e.length-1;r>=0;r--){var s=e[r];s==="."?e.splice(r,1):s===".."?(e.splice(r,1),n++):n&&(e.splice(r,1),n--)}if(t)for(;n;n--)e.unshift("..");return e},normalize:e=>{var t=de.isAbs(e),n=e.substr(-1)==="/";return e=de.normalizeArray(e.split("/").filter(r=>!!r),!t).join("/"),!e&&!t&&(e="."),e&&n&&(e+="/"),(t?"/":"")+e},dirname:e=>{var t=de.splitPath(e),n=t[0],r=t[1];return!n&&!r?".":(r&&(r=r.substr(0,r.length-1)),n+r)},basename:e=>{if(e==="/")return"/";e=de.normalize(e),e=e.replace(/\/$/,"");var t=e.lastIndexOf("/");return t===-1?e:e.substr(t+1)},join:(...e)=>de.normalize(e.join("/")),join2:(e,t)=>de.normalize(e+"/"+t)},Ht=()=>{if(typeof crypto=="object"&&typeof crypto.getRandomValues=="function")return e=>crypto.getRandomValues(e);st("initRandomDevice")},wt=e=>(wt=Ht())(e),Te={resolve:(...e)=>{for(var t="",n=!1,r=e.length-1;r>=-1&&!n;r--){var s=r>=0?e[r]:a.cwd();if(typeof s!="string")throw new TypeError("Arguments to path.resolve must be strings");if(!s)return"";t=s+"/"+t,n=de.iLine truncated
|
||||||
`)),!e)return null;lt=vt(e)}return lt.shift()},Me={ttys:[],init(){},shutdown(){},register(e,t){Me.ttys[e]={input:[],output:[],ops:t},a.registerDevice(e,Me.stream_ops)},stream_ops:{open(e){var t=Me.ttys[e.node.rdev];if(!t)throw new a.ErrnoError(43);e.tty=t,e.seekable=!1},close(e){e.tty.ops.fsync(e.tty)},fsync(e){e.tty.ops.fsync(e.tty)},read(e,t,n,r,s){if(!e.tty||!e.tty.ops.get_char)throw new a.ErrnoError(60);for(var o=0,p=0;p<r;p++){var g;try{g=e.tty.ops.get_char(e.tty)}catch{throw new a.ErrnoError(29)}if(g===void 0&&o===0)throw new a.ErrnoError(6);if(g==null)break;o++,t[n+p]=g}return o&&(e.node.timestamp=Date.now()),o},write(e,t,n,r,s){if(!e.tty||!e.tty.ops.put_char)throw new a.ErrnoError(60);try{for(var o=0;o<r;o++)e.tty.ops.put_char(e.tty,t[n+o])}catch{throw new a.ErrnoError(29)}return r&&(e.node.timestamp=Date.now()),o}},default_tty_ops:{get_char(e){return Vt()},put_char(e,t){t===null||t===10?(Ve(Ue(e.output)),e.output=[]):t!=0&&e.output.push(t)},fsync(e){e.output&&e.output.length>0&&(Ve(Ue(e.output)),e.output=[])},ioctl_tcgets(e){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(e,t,n){return 0},ioctl_tiocgwinsz(e){return[24,80]}},default_tty1_ops:{put_char(e,t){t===null||t===10?(Le(Ue(e.output)),e.output=[]):t!=0&&e.output.push(t)},fsync(e){e.output&&e.output.length>0&&(Le(Ue(e.output)),e.output=[])}}},Gt=(e,t)=>{Be.fill(0,e,e+t)},xt=(e,t)=>Math.ceil(e/t)*t,St=e=>{e=xt(e,65536);var t=kt(65536,e);return t&&Gt(t,e),t},le={ops_table:null,mount(e){return le.createNode(null,"/",16895,0)},createNode(e,t,n,r){if(a.isBlkdev(n)||a.isFIFO(n))throw new a.ErrnoError(63);le.ops_table||={dir:{node:{getattr:le.node_ops.getattr,setattr:le.node_ops.setattr,lookup:le.node_ops.lookup,mknod:le.node_ops.mknod,rename:le.node_ops.rename,unlink:le.node_ops.unlink,rmdir:le.node_ops.rmdir,readdir:le.node_ops.readdir,symlink:le.node_ops.symlink},stream:{llseek:le.stream_ops.llseek}},file:{node:{getattr:le.node_ops.getattr,setattr:le.node_ops.setattr},stream:{llseek:le.stream_ops.llseek,read:le.stream_ops.read,write:le.stream_ops.write,allocate:le.stream_ops.allocate,mmap:le.stream_ops.mmap,msync:le.stream_ops.msync}},link:{node:{getattr:le.node_ops.getattr,setattr:le.node_ops.setattr,readlink:le.node_ops.readlink},stream:{}},chrdev:{node:{getattr:le.node_ops.getattr,setattr:le.node_ops.setattr},stream:a.chrdev_stream_ops}};var s=a.createNode(e,t,n,r);return a.isDir(s.mode)?(s.node_ops=le.ops_table.dir.node,s.stream_ops=le.ops_table.dir.stream,s.contents={}):a.isFile(s.mode)?(s.node_ops=le.ops_table.file.node,s.stream_ops=le.ops_table.file.stream,s.usedBytes=0,s.contents=null):a.isLink(s.mode)?(s.node_ops=le.ops_table.link.node,s.stream_ops=le.ops_table.link.stream):a.isChrdev(s.mode)&&(s.node_ops=le.ops_table.chrdev.node,s.stream_ops=le.ops_table.chrdev.stream),s.timestamp=Date.now(),e&&(e.contents[t]=s,e.timestamp=s.timestamp),s},getFileDataAsTypedArray(e){return e.contents?e.contents.subarray?e.contents.subarray(0,e.usedBytes):new Uint8Array(e.contents):new Uint8Array(0)},expandFileStorage(e,t){var n=e.contents?e.contents.length:0;if(!(n>=t)){var r=1024*1024;t=Math.max(t,n*(n<r?2:1.125)>>>0),n!=0&&(t=Math.max(t,256));var s=e.contents;e.contents=new Uint8Array(t),e.usedBytes>0&&e.contents.set(s.subarray(0,e.usedBytes),0)}},resizeFileStorage(e,t){if(e.usedBytes!=t)if(t==0)e.contents=null,e.usedBytes=0;else{var n=e.contents;e.contents=new Uint8Array(t),n&&e.contents.set(n.subarray(0,Math.min(t,e.usedBytes))),e.usedBytes=t}},node_ops:{getattr(e){var t={};return t.dev=a.isChrdev(e.mode)?e.id:1,t.ino=e.id,t.mode=e.mode,t.nlink=1,t.uid=0,t.gid=0,t.rdev=e.rdev,a.isDir(e.mode)?t.size=4096:a.isFile(e.mode)?t.size=e.usedBytes:a.isLink(e.mode)?t.size=e.link.length:t.size=0,t.atime=new Date(e.timestamp),t.mtime=new Date(e.timestamp),t.ctime=new Date(e.timestamp),t.blksize=4096,t.blocks=Math.ceil(t.size/t.blksize),t},setattr(e,t){t.mode!==void 0&&(e.mode=t.mode),t.timestamp!==void 0&&(e.timestamp=t.timestamp),t.size!==void 0&&le.resizeFileStorage(e,t.size)},lookup(e,t){throw a.genericErrors[44]},mknod(e,t,n,r){return le.createNode(e,t,n,r)},rename(e,t,n){if(a.isDir(e.mode)){var r;try{r=a.lookupNode(t,n)}catch{}if(r)for(var s in r.contents)throw new a.ErrnoError(55)}delete e.parent.contents[e.name],e.parent.timestamp=Date.now(),e.name=n,t.contents[n]=e,t.timestamp=e.parent.timestamp},unlink(e,t){delete e.contents[t],e.timestamp=Date.now()},rmdir(e,t){var n=a.lookupNode(e,t);for(var r in n.contents)throw new a.ErrnoError(55);delete e.contents[t],e.timestamp=Date.now()},readdir(e){var t=[".",".."];for(var n of Object.keys(e.contents))t.push(n);return t},symlink(e,t,n){var r=le.createNode(e,t,41471,0);return r.link=n,r},readlink(e){if(!a.isLink(e.mode))throw new a.ErrnoError(28);return e.link}},stream_ops:{read(e,t,n,r,s){var o=e.node.contents;if(s>=e.node.usedBytes)return 0;var p=Math.min(e.node.usedBytes-s,r);if(p>8&&o.subarray)t.set(o.sub
Line truncated
|
`)),!e)return null;lt=vt(e)}return lt.shift()},Me={ttys:[],init(){},shutdown(){},register(e,t){Me.ttys[e]={input:[],output:[],ops:t},a.registerDevice(e,Me.stream_ops)},stream_ops:{open(e){var t=Me.ttys[e.node.rdev];if(!t)throw new a.ErrnoError(43);e.tty=t,e.seekable=!1},close(e){e.tty.ops.fsync(e.tty)},fsync(e){e.tty.ops.fsync(e.tty)},read(e,t,n,r,s){if(!e.tty||!e.tty.ops.get_char)throw new a.ErrnoError(60);for(var o=0,p=0;p<r;p++){var g;try{g=e.tty.ops.get_char(e.tty)}catch{throw new a.ErrnoError(29)}if(g===void 0&&o===0)throw new a.ErrnoError(6);if(g==null)break;o++,t[n+p]=g}return o&&(e.node.timestamp=Date.now()),o},write(e,t,n,r,s){if(!e.tty||!e.tty.ops.put_char)throw new a.ErrnoError(60);try{for(var o=0;o<r;o++)e.tty.ops.put_char(e.tty,t[n+o])}catch{throw new a.ErrnoError(29)}return r&&(e.node.timestamp=Date.now()),o}},default_tty_ops:{get_char(e){return Vt()},put_char(e,t){t===null||t===10?(Ve(Ue(e.output)),e.output=[]):t!=0&&e.output.push(t)},fsync(e){e.output&&e.output.length>0&&(Ve(Ue(e.output)),e.output=[])},ioctl_tcgets(e){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(e,t,n){return 0},ioctl_tiocgwinsz(e){return[24,80]}},default_tty1_ops:{put_char(e,t){t===null||t===10?(Le(Ue(e.output)),e.output=[]):t!=0&&e.output.push(t)},fsync(e){e.output&&e.output.length>0&&(Le(Ue(e.output)),e.output=[])}}},Gt=(e,t)=>{Be.fill(0,e,e+t)},xt=(e,t)=>Math.ceil(e/t)*t,St=e=>{e=xt(e,65536);var t=kt(65536,e);return t&&Gt(t,e),t},le={ops_table:null,mount(e){return le.createNode(null,"/",16895,0)},createNode(e,t,n,r){if(a.isBlkdev(n)||a.isFIFO(n))throw new a.ErrnoError(63);le.ops_table||={dir:{node:{getattr:le.node_ops.getattr,setattr:le.node_ops.setattr,lookup:le.node_ops.lookup,mknod:le.node_ops.mknod,rename:le.node_ops.rename,unlink:le.node_ops.unlink,rmdir:le.node_ops.rmdir,readdir:le.node_ops.readdir,symlink:le.node_ops.symlink},stream:{llseek:le.stream_ops.llseek}},file:{node:{getattr:le.node_ops.getattr,setattr:le.node_ops.setattr},stream:{llseek:le.stream_ops.llseek,read:le.stream_ops.read,write:le.stream_ops.write,allocate:le.stream_ops.allocate,mmap:le.stream_ops.mmap,msync:le.stream_ops.msync}},link:{node:{getattr:le.node_ops.getattr,setattr:le.node_ops.setattr,readlink:le.node_ops.readlink},stream:{}},chrdev:{node:{getattr:le.node_ops.getattr,setattr:le.node_ops.setattr},stream:a.chrdev_stream_ops}};var s=a.createNode(e,t,n,r);return a.isDir(s.mode)?(s.node_ops=le.ops_table.dir.node,s.stream_ops=le.ops_table.dir.stream,s.contents={}):a.isFile(s.mode)?(s.node_ops=le.ops_table.file.node,s.stream_ops=le.ops_table.file.stream,s.usedBytes=0,s.contents=null):a.isLink(s.mode)?(s.node_ops=le.ops_table.link.node,s.stream_ops=le.ops_table.link.stream):a.isChrdev(s.mode)&&(s.node_ops=le.ops_table.chrdev.node,s.stream_ops=le.ops_table.chrdev.stream),s.timestamp=Date.now(),e&&(e.contents[t]=s,e.timestamp=s.timestamp),s},getFileDataAsTypedArray(e){return e.contents?e.contents.subarray?e.contents.subarray(0,e.usedBytes):new Uint8Array(e.contents):new Uint8Array(0)},expandFileStorage(e,t){var n=e.contents?e.contents.length:0;if(!(n>=t)){var r=1024*1024;t=Math.max(t,n*(n<r?2:1.125)>>>0),n!=0&&(t=Math.max(t,256));var s=e.contents;e.contents=new Uint8Array(t),e.usedBytes>0&&e.contents.set(s.subarray(0,e.usedBytes),0)}},resizeFileStorage(e,t){if(e.usedBytes!=t)if(t==0)e.contents=null,e.usedBytes=0;else{var n=e.contents;e.contents=new Uint8Array(t),n&&e.contents.set(n.subarray(0,Math.min(t,e.usedBytes))),e.usedBytes=t}},node_ops:{getattr(e){var t={};return t.dev=a.isChrdev(e.mode)?e.id:1,t.ino=e.id,t.mode=e.mode,t.nlink=1,t.uid=0,t.gid=0,t.rdev=e.rdev,a.isDir(e.mode)?t.size=4096:a.isFile(e.mode)?t.size=e.usedBytes:a.isLink(e.mode)?t.size=e.link.length:t.size=0,t.atime=new Date(e.timestamp),t.mtime=new Date(e.timestamp),t.ctime=new Date(e.timestamp),t.blksize=4096,t.blocks=Math.ceil(t.size/t.blksize),t},setattr(e,t){t.mode!==void 0&&(e.mode=t.mode),t.timestamp!==void 0&&(e.timestamp=t.timestamp),t.size!==void 0&&le.resizeFileStorage(e,t.size)},lookup(e,t){throw a.genericErrors[44]},mknod(e,t,n,r){return le.createNode(e,t,n,r)},rename(e,t,n){if(a.isDir(e.mode)){var r;try{r=a.lookupNode(t,n)}catch{}if(r)for(var s in r.contents)throw new a.ErrnoError(55)}delete e.parent.contents[e.name],e.parent.timestamp=Date.now(),e.name=n,t.contents[n]=e,t.timestamp=e.parent.timestamp},unlink(e,t){delete e.contents[t],e.timestamp=Date.now()},rmdir(e,t){var n=a.lookupNode(e,t);for(var r in n.contents)throw new a.ErrnoError(55);delete e.contents[t],e.timestamp=Date.now()},readdir(e){var t=[".",".."];for(var n of Object.keys(e.contents))t.push(n);return t},symlink(e,t,n){var r=le.createNode(e,t,41471,0);return r.link=n,r},readlink(e){if(!a.isLink(e.mode))throw new a.ErrnoError(28);return e.link}},stream_ops:{read(e,t,n,r,s){var o=e.node.contents;if(s>=e.node.usedBytes)return 0;var p=Math.min(e.node.usedBytes-s,r);if(p>8&&o.subarray)t.set(o.sub
Line truncated
|
||||||
Total of`,C,"op(s) for",M,"ms (incl. "+J+" ms of waiting on the async side)"),t.config.log("Serialization metrics:",I.s11n),m.postMessage({type:"opfs-async-metrics"})},reset:function(){let E;const C=J=>J.count=J.time=J.wait=0;for(E in u.opIds)C(I[E]=Object.create(null));let M=I.s11n=Object.create(null);M=M.serialize=Object.create(null),M.count=M.time=0,M=I.s11n.deserialize=Object.create(null),M.count=M.time=0}};const c=new S,d=new N().addOnDispose(()=>c.dispose());let y;const B=E=>(y=!0,d.dispose(),O(E)),f=()=>(y=!1,g(t)),m=new Worker(new URL("/assets/sqlite3-opfs-async-proxy-B_ImRJXp.js",import.meta.url));setTimeout(()=>{y===void 0&&B(new Error("Timeout while waiting for OPFS async proxy worker."))},4e3),m._originalOnError=m.onerror,m.onerror=function(E){A("Error initializing OPFS asyncer:",E),B(new Error("Loading OPFS async Worker failed for unknown reasons."))};const l=q.sqlite3_vfs_find(null),_=l?new N(l):null;c.$iVersion=1,d.$iVersion=2,d.$szOsFile=q.sqlite3_file.structInfo.sizeof,d.$mxPathname=1024,d.$zName=x.allocCString("opfs"),d.$xDlOpen=d.$xDlError=d.$xDlSym=d.$xDlClose=null,d.addOnDispose("$zName",d.$zName,"cleanup default VFS wrapper",()=>_?_.dispose():null);const u=Object.create(null);u.verbose=s.verbose,u.littleEndian=(()=>{const E=new ArrayBuffer(2);return new DataView(E).setInt16(0,256,!0),new Int16Array(E)[0]===256})(),u.asyncIdleWaitTime=150,u.asyncS11nExceptions=1,u.fileBufferSize=1024*64,u.sabS11nOffset=u.fileBufferSize,u.sabS11nSize=d.$mxPathname*2,u.sabIO=new SharedArrayBuffer(u.fileBufferSize+u.sabS11nSize),u.opIds=Object.create(null);const I=Object.create(null);{let E=0;u.opIds.whichOp=E++,u.opIds.rc=E++,u.opIds.xAccess=E++,u.opIds.xClose=E++,u.opIds.xDelete=E++,u.opIds.xDeleteNoWait=E++,u.opIds.xFileSize=E++,u.opIds.xLock=E++,u.opIds.xOpen=E++,u.opIds.xRead=E++,u.opIds.xSleep=E++,u.opIds.xSync=E++,u.opIds.xTruncate=E++,u.opIds.xUnlock=E++,u.opIds.xWrite=E++,u.opIds.mkdir=E++,u.opIds["opfs-async-metrics"]=E++,u.opIds["opfs-async-shutdown"]=E++,u.opIds.retry=E++,u.sabOP=new SharedArrayBuffer(E*4),L.metrics.reset()}u.sq3Codes=Object.create(null),["SQLITE_ACCESS_EXISTS","SQLITE_ACCESS_READWRITE","SQLITE_BUSY","SQLITE_CANTOPEN","SQLITE_ERROR","SQLITE_IOERR","SQLITE_IOERR_ACCESS","SQLITE_IOERR_CLOSE","SQLITE_IOERR_DELETE","SQLITE_IOERR_FSYNC","SQLITE_IOERR_LOCK","SQLITE_IOERR_READ","SQLITE_IOERR_SHORT_READ","SQLITE_IOERR_TRUNCATE","SQLITE_IOERR_UNLOCK","SQLITE_IOERR_WRITE","SQLITE_LOCK_EXCLUSIVE","SQLITE_LOCK_NONE","SQLITE_LOCK_PENDING","SQLITE_LOCK_RESERVED","SQLITE_LOCK_SHARED","SQLITE_LOCKED","SQLITE_MISUSE","SQLITE_NOTFOUND","SQLITE_OPEN_CREATE","SQLITE_OPEN_DELETEONCLOSE","SQLITE_OPEN_MAIN_DB","SQLITE_OPEN_READONLY"].forEach(E=>{(u.sq3Codes[E]=q[E])===void 0&&D("Maintenance required: not found:",E)}),u.opfsFlags=Object.assign(Object.create(null),{OPFS_UNLOCK_ASAP:1,OPFS_UNLINK_BEFORE_OPEN:2,defaultUnlockAsap:!1});const U=(E,...C)=>{const M=u.opIds[E]||D("Invalid op ID:",E);u.s11n.serialize(...C),Atomics.store(u.sabOPView,u.opIds.rc,-1),Atomics.store(u.sabOPView,u.opIds.whichOp,M),Atomics.notify(u.sabOPView,u.opIds.whichOp);const J=performance.now();for(;Atomics.wait(u.sabOPView,u.opIds.rc,-1)!=="not-equal";);const G=Atomics.load(u.sabOPView,u.opIds.rc);if(I[E].wait+=performance.now()-J,G&&u.asyncS11nExceptions){const te=u.s11n.deserialize();te&&A(E+"() async error:",...te)}return G};L.debug={asyncShutdown:()=>{T("Shutting down OPFS async listener. The OPFS VFS will no longer work."),U("opfs-async-shutdown")},asyncRestart:()=>{T("Attempting to restart OPFS VFS async listener. Might work, might not."),m.postMessage({type:"opfs-async-restart"})}};const ee=()=>{if(u.s11n)return u.s11n;const E=new TextDecoder,C=new TextEncoder("utf-8"),M=new Uint8Array(u.sabIO,u.sabS11nOffset,u.sabS11nSize),J=new DataView(u.sabIO,u.sabS11nOffset,u.sabS11nSize);u.s11n=Object.create(null);const G=Object.create(null);G.number={id:1,size:8,getter:"getFloat64",setter:"setFloat64"},G.bigint={id:2,size:8,getter:"getBigInt64",setter:"setBigInt64"},G.boolean={id:3,size:4,getter:"getInt32",setter:"setInt32"},G.string={id:4};const te=Y=>G[typeof Y]||D("Maintenance required: this value type cannot be serialized.",Y),ae=Y=>{switch(Y){case G.number.id:return G.number;case G.bigint.id:return G.bigint;case G.boolean.id:return G.boolean;case G.string.id:return G.string;default:D("Invalid type ID:",Y)}};return u.s11n.deserialize=function(Y=!1){++I.s11n.deserialize.count;const we=performance.now(),F=M[0],V=F?[]:null;if(F){const K=[];let X=1,_e,ge,pe;for(_e=0;_e<F;++_e,++X)K.push(ae(M[X]));for(_e=0;_e<F;++_e){const he=K[_e];he.getter?(pe=J[he.getter](X,u.littleEndian),X+=he.size):(ge=J.getInt32(X,u.littleEndian),X+=4,pe=E.decode(M.slice(X,X+ge)),X+=ge),V.push(pe)}}return Y&&(M[0]=0),I.s11n.deserialize.time+=performance.now()-we,V},u.s11n.serialize=function(...Y){const we=performance.now();if(++I.s11n.serialize.count,Y.length){const F=[];let V=0,K=1;for(M[0]=Y.length&255;V<Y.length;++V,++K)F.push(te(Y[V])),M[K]=F[V].id;for(V
Line truncated
|
Total of`,C,"op(s) for",M,"ms (incl. "+J+" ms of waiting on the async side)"),t.config.log("Serialization metrics:",I.s11n),m.postMessage({type:"opfs-async-metrics"})},reset:function(){let E;const C=J=>J.count=J.time=J.wait=0;for(E in u.opIds)C(I[E]=Object.create(null));let M=I.s11n=Object.create(null);M=M.serialize=Object.create(null),M.count=M.time=0,M=I.s11n.deserialize=Object.create(null),M.count=M.time=0}};const c=new S,d=new N().addOnDispose(()=>c.dispose());let y;const B=E=>(y=!0,d.dispose(),O(E)),f=()=>(y=!1,g(t)),m=new Worker(new URL("/assets/sqlite3-opfs-async-proxy-B_ImRJXp.js",import.meta.url));setTimeout(()=>{y===void 0&&B(new Error("Timeout while waiting for OPFS async proxy worker."))},4e3),m._originalOnError=m.onerror,m.onerror=function(E){A("Error initializing OPFS asyncer:",E),B(new Error("Loading OPFS async Worker failed for unknown reasons."))};const l=q.sqlite3_vfs_find(null),_=l?new N(l):null;c.$iVersion=1,d.$iVersion=2,d.$szOsFile=q.sqlite3_file.structInfo.sizeof,d.$mxPathname=1024,d.$zName=x.allocCString("opfs"),d.$xDlOpen=d.$xDlError=d.$xDlSym=d.$xDlClose=null,d.addOnDispose("$zName",d.$zName,"cleanup default VFS wrapper",()=>_?_.dispose():null);const u=Object.create(null);u.verbose=s.verbose,u.littleEndian=(()=>{const E=new ArrayBuffer(2);return new DataView(E).setInt16(0,256,!0),new Int16Array(E)[0]===256})(),u.asyncIdleWaitTime=150,u.asyncS11nExceptions=1,u.fileBufferSize=1024*64,u.sabS11nOffset=u.fileBufferSize,u.sabS11nSize=d.$mxPathname*2,u.sabIO=new SharedArrayBuffer(u.fileBufferSize+u.sabS11nSize),u.opIds=Object.create(null);const I=Object.create(null);{let E=0;u.opIds.whichOp=E++,u.opIds.rc=E++,u.opIds.xAccess=E++,u.opIds.xClose=E++,u.opIds.xDelete=E++,u.opIds.xDeleteNoWait=E++,u.opIds.xFileSize=E++,u.opIds.xLock=E++,u.opIds.xOpen=E++,u.opIds.xRead=E++,u.opIds.xSleep=E++,u.opIds.xSync=E++,u.opIds.xTruncate=E++,u.opIds.xUnlock=E++,u.opIds.xWrite=E++,u.opIds.mkdir=E++,u.opIds["opfs-async-metrics"]=E++,u.opIds["opfs-async-shutdown"]=E++,u.opIds.retry=E++,u.sabOP=new SharedArrayBuffer(E*4),L.metrics.reset()}u.sq3Codes=Object.create(null),["SQLITE_ACCESS_EXISTS","SQLITE_ACCESS_READWRITE","SQLITE_BUSY","SQLITE_CANTOPEN","SQLITE_ERROR","SQLITE_IOERR","SQLITE_IOERR_ACCESS","SQLITE_IOERR_CLOSE","SQLITE_IOERR_DELETE","SQLITE_IOERR_FSYNC","SQLITE_IOERR_LOCK","SQLITE_IOERR_READ","SQLITE_IOERR_SHORT_READ","SQLITE_IOERR_TRUNCATE","SQLITE_IOERR_UNLOCK","SQLITE_IOERR_WRITE","SQLITE_LOCK_EXCLUSIVE","SQLITE_LOCK_NONE","SQLITE_LOCK_PENDING","SQLITE_LOCK_RESERVED","SQLITE_LOCK_SHARED","SQLITE_LOCKED","SQLITE_MISUSE","SQLITE_NOTFOUND","SQLITE_OPEN_CREATE","SQLITE_OPEN_DELETEONCLOSE","SQLITE_OPEN_MAIN_DB","SQLITE_OPEN_READONLY"].forEach(E=>{(u.sq3Codes[E]=q[E])===void 0&&D("Maintenance required: not found:",E)}),u.opfsFlags=Object.assign(Object.create(null),{OPFS_UNLOCK_ASAP:1,OPFS_UNLINK_BEFORE_OPEN:2,defaultUnlockAsap:!1});const U=(E,...C)=>{const M=u.opIds[E]||D("Invalid op ID:",E);u.s11n.serialize(...C),Atomics.store(u.sabOPView,u.opIds.rc,-1),Atomics.store(u.sabOPView,u.opIds.whichOp,M),Atomics.notify(u.sabOPView,u.opIds.whichOp);const J=performance.now();for(;Atomics.wait(u.sabOPView,u.opIds.rc,-1)!=="not-equal";);const G=Atomics.load(u.sabOPView,u.opIds.rc);if(I[E].wait+=performance.now()-J,G&&u.asyncS11nExceptions){const te=u.s11n.deserialize();te&&A(E+"() async error:",...te)}return G};L.debug={asyncShutdown:()=>{T("Shutting down OPFS async listener. The OPFS VFS will no longer work."),U("opfs-async-shutdown")},asyncRestart:()=>{T("Attempting to restart OPFS VFS async listener. Might work, might not."),m.postMessage({type:"opfs-async-restart"})}};const ee=()=>{if(u.s11n)return u.s11n;const E=new TextDecoder,C=new TextEncoder("utf-8"),M=new Uint8Array(u.sabIO,u.sabS11nOffset,u.sabS11nSize),J=new DataView(u.sabIO,u.sabS11nOffset,u.sabS11nSize);u.s11n=Object.create(null);const G=Object.create(null);G.number={id:1,size:8,getter:"getFloat64",setter:"setFloat64"},G.bigint={id:2,size:8,getter:"getBigInt64",setter:"setBigInt64"},G.boolean={id:3,size:4,getter:"getInt32",setter:"setInt32"},G.string={id:4};const te=Y=>G[typeof Y]||D("Maintenance required: this value type cannot be serialized.",Y),ae=Y=>{switch(Y){case G.number.id:return G.number;case G.bigint.id:return G.bigint;case G.boolean.id:return G.boolean;case G.string.id:return G.string;default:D("Invalid type ID:",Y)}};return u.s11n.deserialize=function(Y=!1){++I.s11n.deserialize.count;const we=performance.now(),F=M[0],V=F?[]:null;if(F){const K=[];let X=1,_e,ge,pe;for(_e=0;_e<F;++_e,++X)K.push(ae(M[X]));for(_e=0;_e<F;++_e){const he=K[_e];he.getter?(pe=J[he.getter](X,u.littleEndian),X+=he.size):(ge=J.getInt32(X,u.littleEndian),X+=4,pe=E.decode(M.slice(X,X+ge)),X+=ge),V.push(pe)}}return Y&&(M[0]=0),I.s11n.deserialize.time+=performance.now()-we,V},u.s11n.serialize=function(...Y){const we=performance.now();if(++I.s11n.serialize.count,Y.length){const F=[];let V=0,K=1;for(M[0]=Y.length&255;V<Y.length;++V,++K)F.push(te(Y[V])),M[K]=F[V].id;for(V
Line truncated
|
||||||
Vendored
+111
@@ -0,0 +1,111 @@
|
|||||||
|
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.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 Se=new Worker(new URL("/assets/db-worker-DFFhVJkA.js",import.meta.url),{type:"module"});let dr=0,je=null;const We=new Map;function _n(){return je||(je=new Promise((e,n)=>{Se.onmessage=o=>{const s=o.data;if(s.type==="ready")return;const c=We.get(s.id);c&&(We.delete(s.id),s.type=="success"?c.resolve(s.data):c.reject(new Error(s.error??s.message??"Неизвестная ошибка Worker")))},Se.onerror=o=>{console.error("[SQLite Worker] Ошибка:",o),n(new Error("Ошибка Worker"))};const a=++dr;We.set(a,{resolve:e,reject:n}),Se.postMessage({type:"start",id:a})}),je)}function fr(e,n){return new Promise((a,o)=>{if(!Se){o(new Error("Worker не создан"));return}const s=++dr;We.set(s,{resolve:a,reject:o}),Se.postMessage({type:e,id:s,...n})})}async function pe(e,n){return await fr("query",{sql:e,params:n??[]})}async function Pn(e,n){await fr("command",{sql:e,params:n??[]})}function Sn(e){if(e==null)return[];const n=String(e).trim();return n?n.split(/[,\s;]+/).map(a=>a.trim()).filter(a=>a.length>0):[]}function Rn(e){const n=new Map;if(e==null)return n;const a=String(e);if(!a)return n;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&&n.set(c,u)}return n}function pr(e){const n=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:n,key:a,value:o,tags:s,tagsList:Sn(s),more:Rn(e.more),group_id:Number.isFinite(c)?c:1}}function hr(e){const n=[],a=[],o=(e.search??"").trim();if(o){n.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&&(n.push("group_id = ?"),a.push(e.groupId)),{clause:n.length>0?`WHERE ${n.join(" AND ")}`:"",params:a}}async function In(e={}){const{clause:n,params:a}=hr(e),s=(await pe(`SELECT COUNT(*) as cnt FROM words ${n}`,a))[0]?.cnt;return typeof s=="number"?s:Number(s??0)}async function er(e={}){const{clause:n,params:a}=hr(e),o=Math.max(1,Math.min(e.limit??50,500)),s=Math.max(0,e.offset??0);return(await pe(`SELECT id, key, value, tags, more, group_id FROM words ${n} ORDER BY id LIMIT ? OFFSET ?`,[...a,o,s])).map(pr)}async function Jn(){return(await pe("SELECT id, name FROM word_groups ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??"")}))}async function On(){return(await pe("SELECT id, name, forward, backward, filter FROM decks ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??""),forward:String(n.forward??""),backward:String(n.backward??""),filter:String(n.filter??"")}))}async function Kn(e,n){return await Pn("UPDATE decks SET forward = ?, backward = ?, filter = ? WHERE id = ?",[n.forward,n.backward,n.filter,e]),Et(e)}async function Et(e){const a=(await pe("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 Xn(){const e=await pe("SELECT deck_id, COUNT(*) as cnt FROM states WHERE deck_id IS NOT NULL GROUP BY deck_id"),n=new Map;for(const a of e){if(a.deck_id==null)continue;const o=a.cnt;n.set(Number(a.deck_id),typeof o=="number"?o:Number(o??0))}return n}async function zn(e){const a=(await pe("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 tr(e){const n=new Map,a=[...new Set(e.filter(s=>Number.isFinite(s)))];if(a.length===0)return n;const o=400;for(let s=0;s<a.length;s+=o){const c=a.slice(s,s+o),u=c.map(()=>"?").join(","),g=await pe(`SELECT id, key, value, tags, more, group_id FROM words WHERE id IN (${u})`,c);for(const y of g.map(pr))n.set(y.id,y)}return n}function Dn(e,n){const a=new Map;a.set("key",e.key),a.set("value",e.value),a.set("tags",e.tagsList.join(", "));for(const o of n){const s=e.more.get(o.id)??"";if(s)for(const c of[o.identifier,o.name]){const u=c.trim().toLowerCase();!u||a.has(u)||a.set(u,s)}}return a}function rr(e,n){const a=[];for(const o of e.split(/\s+/)){const s=o.trim().toLowerCase();if(!s)continue;const c=(n.get(s)??"").trim();c&&a.push(c)}return a}async funLine 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"),g=e.querySelector("#words-count"),y=e.querySelector("#words-status"),m=e.querySelector("#words-list"),C=e.querySelector("#words-more");function N(w,x){a.hidden=!1,o.textContent=w,s.hidden=!1}function E(){a.hidden=!0}s.addEventListener("click",()=>{document.querySelector('.drawer-btn[data-page="sync"]')?.click()});let $=!1,T=new Map,v=0,M=[],S=null,O=0,Q=!1,L=0,U=0;function b(){const w=u.value;if(w==="")return{search:c.value.trim(),groupId:null};const x=Number(w);return{search:c.value.trim(),groupId:Number.isFinite(x)?x:null}}function z(){C.hidden=!($&&M.length<v),C.hidden||(C.textContent=`Показать ещё (${v-M.length} осталось)`)}function B(){m.innerHTML=M.map(w=>sr(w,T,w.id===S)).join(""),g.textContent=v>0?`Показано ${M.length} из ${v}`:"",z()}function ee(w){if(S===w){w!=null&&D(w),S=null;return}S!=null&&D(S),S=w,w!=null&&re(w)}function D(w){const x=m.querySelector(`.word-row-wrap[data-word-id="${w}"]`);if(!x)return;const q=x.querySelector(".word-row"),P=x.querySelector(".word-extra");q&&(q.classList.remove("open"),q.setAttribute("aria-expanded","false")),P&&(P.hidden=!0,P.innerHTML="")}function re(w){const x=M.find(he=>he.id===w),q=m.querySelector(`.word-row-wrap[data-word-id="${w}"]`);if(!x||!q)return;const P=q.querySelector(".word-row"),R=q.querySelector(".word-extra");P&&(P.classList.add("open"),P.setAttribute("aria-expanded","true")),R&&(R.hidden=!1,R.innerHTML=mr(x,T))}function Z(w){y.textContent=w,y.hidden=w===""}async function j(){if(!$||Q)return;Q=!0;const w=++L,x=b();C.disabled=!0,M.length===0&&Z("Загрузка слов…");try{const[q,P]=await Promise.all([In({search:x.search,groupId:x.groupId??void 0}),er({search:x.search,groupId:x.groupId??void 0,limit:ir,offset:0})]);if(w!==L)return;v=q,M=P,O=P.length,S!=null&&!M.some(R=>R.id===S)&&(S=null),B(),Z(P.length===0?"По этому запросу слов нет.":""),P.length===0&&v===0&&(g.textContent="Слов в базе пока нет.")}catch(q){if(w!==L)return;Z(`Не удалось загрузить слова: ${q instanceof Error?q.message:String(q)}`)}finally{w===L&&(Q=!1,C.disabled=!1,z())}}async function _(){if(!$||Q||M.length>=v)return;Q=!0;const w=++L,x=b();C.disabled=!0,C.textContent="Загрузка…";try{const q=await er({search:x.search,groupId:x.groupId??void 0,limit:ir,offset:O});if(w!==L)return;M=M.concat(q),O+=q.length,m.insertAdjacentHTML("beforeend",q.map(P=>sr(P,T,P.id===S)).join("")),g.textContent=`Показано ${M.length} из ${v}`,z()}catch(q){if(w!==L)return;Z(`Не удалось догрузить: ${q instanceof Error?q.message:String(q)}`)}finally{w===L&&(Q=!1,C.disabled=!1,z())}}c.addEventListener("input",()=>{window.clearTimeout(U),U=window.setTimeout(()=>{j()},300)}),c.addEventListener("keydown",w=>{w.key==="Enter"&&(window.clearTimeout(U),j())}),u.addEventListener("change",()=>{j()}),C.addEventListener("click",()=>{_()}),m.addEventListener("click",w=>{const x=w.target.closest(".word-row");if(!x||x.disabled)return;const q=Number(x.dataset.wordId);Number.isFinite(q)&&ee(S===q?null:q)}),(async()=>{if(!await tt()){N("Локальной базы нет — скачай сохранение."),Z("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),g.textContent="";return}try{const x=await Vn();if(!Ct(x)){N("Файл в OPFS не похож на SQLite — перекачай сохранение.",!0),Z("Файл в OPFS не похож на SQLite — перекачай сохранение.");return}E();try{const[P,R]=await Promise.all([Jn(),gr()]),he=P;T=new Map(R.map(le=>[le.id,le])),u.innerHTML='<option value="">Все группы</option>'+he.map(le=>`<option value="${le.id}">${Ce(le.name)}</option>`).join(""),$=!0,await j(),u.options[0].textContent=`Все группы (${v})`}catch(P){Z(`БД есть, но слова не прочитались: ${P instanceof Error?P.message:String(P)}`)}}catch(x){N(`Не удалось прочитать базу: ${x instanceof Error?x.message:String(x)}`)}})()}function or(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function Ua(e){const n=e%10,a=e%100;return n===1&&a!==11?"запись":n>=2&&n<=4&&(a<12||a>14)?"записи":"записей"}function $a(e,n){const a=or(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">${n} ${Ua(n)}</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 g=u.target.closest("[data-act]");if(!g)return;const y=Number(g.dataset.deckId);Number.isFinite(y)&&(g.dataset.act==="review"?n.goReview(y):n.goDeck(y))}),(async()=>{if(!await tt()){c("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),a.textContent="";return}c("Загрузка колод…");try{const[g,y]=await Promise.all([On(),Xn()]);if(g.length===0){a.textContent="Колоды пока отсутствуют.",c(""),s.innerHTML="";return}a.textContent=`Всего: ${g.length}`,c(""),s.innerHTML=g.map(m=>$a(m,y.get(m.id)??0)).join("")}catch(g){c(`Не удалось загрузить колоды: ${g instanceof Error?g.message:String(g)}`)}})()}function Ma(e){return e?Ta(e):'<span class="empty-val">—</span>'}function Ta(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function mt(e){return e.replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<").replace(/>/g,">")}function Na(e){const n=e%10,a=e%100;return n===1&&a!==11?"слово":n>=2&&n<=4&&(a<12||a>14)?"слова":"слов"}function qa(e,n,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 tt()){s.textContent="Колода недоступна",c.textContent="Нет локальной базы — скачай сохранение на странице «Синхронизация».";return}let y=null;try{y=await Et(n)}catch(U){s.textContent="Колода недоступна",c.textContent=`Не удалось загрузить колоду: ${U instanceof Error?U.message:String(U)}`;return}if(!y){s.textContent="Колода не найдена",c.textContent=`Колоды с id=${n} нет в локальной базе. Возможно, база обновилась — вернись к списку.`;return}const m=y;s.textContent=m.name||`Колода #${m.id}`,c.hidden=!0;let C=null;try{C=await zn(m.id)}catch(U){c.hidden=!1,c.textContent=`Данные колоды загружены, а количество слов — нет: ${U instanceof Error?U.message:String(U)}`}u.innerHTML=`
|
||||||
|
<dl class="word-extra-list">
|
||||||
|
<div class="word-extra-row"><dt>id</dt><dd>${m.id}</dd></div>
|
||||||
|
<div class="word-extra-row"><dt>name</dt><dd>${Ma(m.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="${mt(m.forward)}" />
|
||||||
|
<label class="field-label" for="deck-backward">backward</label>
|
||||||
|
<input id="deck-backward" class="token-input" type="text" value="${mt(m.backward)}" />
|
||||||
|
<label class="field-label" for="deck-filter">filter</label>
|
||||||
|
<input id="deck-filter" class="token-input" type="text" value="${mt(m.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">📚 ${C==null?"Количество слов неизвестно":`${C} ${Na(C)}`}</h3>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="deck-review" class="btn btn-primary" type="button"${C===0?" disabled":""}>▶ Повторять</button>
|
||||||
|
</div>
|
||||||
|
`;const N=u.querySelector("#deck-form"),E=u.querySelector("#deck-forward"),$=u.querySelector("#deck-backward"),T=u.querySelector("#deck-filter"),v=u.querySelector("#deck-save"),M=u.querySelector("#deck-form-status");u.querySelector("#deck-review").addEventListener("click",()=>a.goReview(m.id));const O=()=>`${E.value}
|
||||||
|
${$.value}
|
||||||
|
${T.value}`;let Q=O();function L(){v.disabled=O()===Q||v.dataset.busy==="1"}for(const U of[E,$,T])U.addEventListener("input",L);L(),N.addEventListener("submit",U=>{U.preventDefault(),!v.disabled&&(v.dataset.busy="1",v.disabled=!0,v.textContent="Сохранение…",M.textContent="",(async()=>{try{const b=await Kn(m.id,{forward:E.value,backward:$.value,filter:T.value});if(!b){M.textContent="Колода пропала из базы — вернись к списку.";return}E.value=b.forward,$.value=b.backward,T.value=b.filter,Q=O(),M.textContent="✅ Сохранено в локальную базу."}catch(b){M.textContent=`Не удалось сохранить: ${b instanceof Error?b.message:String(b)}`}finally{delete v.dataset.busy,v.textContent="💾 Сохранить",L()}})())})})()}function Ha(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const Fa=[{label:"😞 Не получилось",cls:"grade-fail"},{label:"😟 Тяжело",cls:"grade-hard"},{label:"🙂 Хорошо",cls:"grade-good"},{label:"🤩 Легко",cls:"grade-easy"}];function La(e,n,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"),g=e.querySelector("#review-body");o.addEventListener("click",()=>a.goDecks());function y(L){u.textContent=L,u.hidden=L===""}let m=[],C=new Map,N=[],E="",$="",T=0,v=!1;function M(L,U){return L.length===0?`<div class="review-side ${U}"><span class="empty-val">—</span></div>`:`<div class="review-side ${U}">`+L.map(b=>`<p class="review-line">${Ha(b)}</p>`).join("")+"</div>"}function S(){const L=C.get(m[T]);if(!L){g.innerHTML="",c.textContent="",y("Слово пропало из базы — пропускаю.");return}const U=Dn(L,N),b=rr(E,U),z=v?rr($,U):null;c.textContent=`Карточка ${T+1} из ${m.length}`,y("");const B=v?M(z??[],"review-back-side"):'<p class="review-tap">Тап — показать ответ</p>',ee=v?`<div class="review-grades" role="group" aria-label="Оценка повторения">
|
||||||
|
${Fa.map(D=>`<button class="btn review-grade ${D.cls}" type="button">${D.label}</button>`).join("")}
|
||||||
|
</div>`:"";g.innerHTML=`
|
||||||
|
<button id="review-card" class="review-card" type="button"
|
||||||
|
aria-expanded="${v}" aria-label="${v?"Карточка открыта":"Показать ответ"}">
|
||||||
|
${M(b,"review-front")}
|
||||||
|
${v?'<hr class="review-hr" />':""}
|
||||||
|
${B}
|
||||||
|
</button>
|
||||||
|
${ee}
|
||||||
|
`,g.querySelector("#review-card").addEventListener("click",()=>{v||(v=!0,S())}),g.querySelectorAll(".review-grade").forEach(D=>{D.addEventListener("click",()=>{T+=1,v=!1,T>=m.length?O():S()})})}function O(){c.textContent=`Готово: ${m.length} из ${m.length}`,y(""),g.innerHTML=`
|
||||||
|
<p class="card-hint">Все карточки сессии просмотрены. Запись оценок — следующим шагом.</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="review-again" class="btn btn-primary" type="button">🔀 Перемешать заново</button>
|
||||||
|
</div>
|
||||||
|
`,g.querySelector("#review-again").addEventListener("click",()=>{Q()})}async function Q(){if(y("Перемешиваю…"),g.innerHTML="",c.textContent="",m=await nr(n),T=0,v=!1,m.length===0){y("В колоде нет слов для повторения.");return}C=await tr(m),S()}(async()=>{if(!await tt()){s.textContent="Повторение недоступно",y("Нет локальной базы — скачай сохранение на странице «Синхронизация».");return}try{const[U,b]=await Promise.all([Et(n),gr()]);if(!U){s.textContent="Колода не найдена",y(`Колоды с id=${n} нет в локальной базе. Вернись к списку.`);return}if(N=b,E=U.forward,$=U.backward,s.textContent=U.name||`Колода #${U.id}`,m=await nr(U.id),m.length===0){c.textContent="",y("В колоде нет слов для повторения.");return}C=await tr(m),T=0,v=!1,S()}catch(U){s.textContent="Повторение недоступно",y(`Не удалось загрузить карточки: ${U instanceof Error?U.message:String(U)}`)}})()}const cr=24,Qa=/^[A-Za-z0-9]{24}$/,yr="japlearn_sync_token";function lr(e){return e.trim().replace(/\s+/g,"")}function Qe(e){return Qa.test(e)}function _a(){try{return localStorage.getItem(yr)??""}catch{return""}}function ur(e){try{localStorage.setItem(yr,e)}catch{}}const br="https://learning.micialware.ru";function Pa(e,n=br){return`${n.replace(/\/+$/,"")}/download/${encodeURIComponent(e)}`}async function Sa(e,n,a=br){const o=Pa(e,a);let s;try{s=await fetch(o,{method:"GET",...n})}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 Ra(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 n=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(E){c.textContent=E,console.log(`[JapLearn] ${E}`)}function g(){return lr(n.value)}function y(E){s.disabled=E||!Qe(g()),n.disabled=E}function m(){const E=g(),$=E.length;o.textContent=`${$} / ${cr}`;const T=Qe(E),v=$===0;n.classList.toggle("invalid",!v&&!T),n.setAttribute("aria-invalid",String(!v&&!T)),v?a.textContent="":$!==cr?a.textContent=`Нужно 24 символа, сейчас ${$}`:a.textContent=T?"":"Только латинские буквы и цифры",s.disabled=!T}async function C(){const E=g();if(Qe(E)){ur(E),y(!0),u("Скачивание сохранения...");try{const $=await Sa(E);u(`Скачано ${($.length/1024).toFixed(1)} КБ, распаковка...`);const T=await Ca($);u(`Распаковано ${(T.length/1024).toFixed(1)} КБ, сохранение в OPFS...`),await Bn(T),u(`Готово: база ${(T.length/1024).toFixed(1)} КБ сохранена локально (OPFS).`)}catch($){u(`Ошибка скачивания: ${$ instanceof Error?$.message:String($)}`)}finally{y(!1),m()}}}n.addEventListener("input",()=>{m()}),n.addEventListener("change",()=>{const E=g();n.value=E,Qe(E)&&(ur(E),u("Токен сохранён локально. Можно скачивать.")),m()}),s.addEventListener("click",()=>{C()});const N=_a();N&&(n.value=N),m(),Qe(lr(N))&&u("Токен восстановлен. Нажми «Скачать», чтобы получить базу.")}const Ia={home:"Главная",decks:"Колоды",deck:"Колода",review:"Повторение",sync:"Синхронизация"};function Je(e){const n=document.getElementById(e);if(!n)throw new Error(`[JapLearn] Не найден элемент #${e}`);return n}const kr=Je("burger-btn"),et=Je("drawer"),Cr=Je("drawer-overlay"),Ja=Je("page-title"),_e=Je("page");let xt=!1;function Oe(e){xt=e,et.classList.toggle("open",e),Cr.hidden=!e,kr.setAttribute("aria-expanded",String(e)),et.setAttribute("aria-hidden",String(!e)),document.body.classList.toggle("drawer-open",e)}function Oa(e){et.querySelectorAll(".drawer-btn").forEach(n=>{n.classList.toggle("active",n.dataset.page===e)})}function ke(e,n){Ja.textContent=Ia[e],Oa(e==="deck"||e==="review"?"decks":e),e==="home"?Ea(_e):e==="decks"?xa(_e,{goDeck:a=>ke("deck",a),goReview:a=>ke("review",a)}):e==="deck"?qa(_e,n??0,{goDecks:()=>ke("decks"),goReview:a=>ke("review",a)}):e==="review"?La(_e,n??0,{goDecks:()=>ke("decks")}):Ra(_e)}kr.addEventListener("click",()=>{Oe(!xt)});Cr.addEventListener("click",()=>{Oe(!1)});document.addEventListener("keydown",e=>{e.key==="Escape"&&xt&&Oe(!1)});et.querySelectorAll(".drawer-btn").forEach(e=>{e.addEventListener("click",()=>{const n=e.dataset.page;(n==="home"||n==="decks"||n==="sync")&&ke(n),Oe(!1)})});await _n();Oe(!1);ke("home");console.log("[JapLearn] crossOriginIsolated =",window.crossOriginIsolated);
|
||||||
@@ -1 +1 @@
|
|||||||
:root{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1e1b2e;background:#f4f1fa}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:flex;justify-content:center;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}#app{width:100%;max-width:480px;padding:24px 16px 32px;display:flex;flex-direction:column;gap:16px}.app-header h1{margin:0;font-size:1.75rem;letter-spacing:.02em}.app-subtitle{margin:4px 0 0;color:#6d6390}.top-bar{display:flex;align-items:center;gap:12px}.burger-btn{width:44px;height:44px;border:2px solid #d9d2f0;border-radius:12px;background:#fff;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;touch-action:manipulation;flex:none}.burger-btn span{display:block;width:20px;height:2px;border-radius:2px;background:#4c446b}.burger-btn[aria-expanded=true]{border-color:#7c3aed}.top-bar-title h1{margin:0;font-size:1.4rem;line-height:1.2}.top-bar-title p{margin:0;font-size:.9rem;color:#6d6390}.top-bar-spacer{flex:1}.app-content{display:flex;flex-direction:column;gap:16px}.drawer-overlay{position:fixed;inset:0;background:#1e1b2e73;z-index:40}.drawer-overlay[hidden]{display:none}.drawer{position:fixed;top:0;left:0;bottom:0;width:min(300px,85vw);background:#fff;z-index:50;transform:translate(-105%);transition:transform .22s ease;display:flex;flex-direction:column;gap:8px;padding:20px 14px;box-shadow:8px 0 32px #1e1b2e2e}.drawer.open{transform:translate(0)}.drawer-header{padding:4px 8px 14px;border-bottom:1px solid #eee9fb;margin-bottom:8px}.drawer-logo{font-size:1.3rem;font-weight:800;color:#7c3aed}.drawer-subtitle{font-size:.9rem;color:#6d6390}.drawer-btn{border:none;background:transparent;text-align:left;font-size:1.05rem;font-weight:600;color:#1e1b2e;padding:13px 12px;border-radius:12px;cursor:pointer;touch-action:manipulation}.drawer-btn.active{background:#f1eafe;color:#6d28d9}.drawer-btn:active{background:#e9defc}body.drawer-open{overflow:hidden}.card{background:#fff;border-radius:16px;padding:20px 16px;box-shadow:0 8px 24px #7c3aed1f;display:flex;flex-direction:column;gap:12px}.card-title{margin:0;font-size:1.25rem}.card-hint{margin:0;color:#6d6390;font-size:.95rem}.field-label{font-weight:600;font-size:.95rem}.token-input{width:100%;padding:14px 12px;font-size:1.05rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.04em;border:2px solid #d9d2f0;border-radius:12px;outline:none}.token-input:focus{border-color:#7c3aed}.token-input.invalid{border-color:#dc2626;background:#fef2f2}.token-meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;min-height:1.4em}.token-error{color:#dc2626;font-size:.9rem}.token-counter{color:#6d6390;font-variant-numeric:tabular-nums;white-space:nowrap}.btn-row{display:flex;gap:12px}.btn{flex:1;padding:14px 12px;font-size:1rem;font-weight:600;border-radius:12px;border:2px solid #7c3aed;background:#fff;color:#7c3aed;cursor:pointer;touch-action:manipulation}.btn-primary{background:#7c3aed;color:#fff}.btn:disabled{opacity:.4;cursor:not-allowed}.btn:not(:disabled):active{transform:scale(.98)}.status-bar{margin:0;padding:10px 12px;border-radius:10px;background:#f4f1fa;color:#4c446b;font-size:.95rem;word-break:break-word}.status-bar[hidden]{display:none}.db-banner{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;background:#fff7e6;color:#7a5b00;font-size:.85rem}.db-banner[hidden]{display:none}.db-banner-text{flex:1;min-width:0}.db-banner-link{flex:none;border:none;background:transparent;padding:4px;font:inherit;font-weight:700;color:#7c3aed;cursor:pointer;touch-action:manipulation}.db-banner-link[hidden]{display:none}.words-toolbar{display:flex;gap:8px}.words-search{flex:1;min-width:0;padding:12px;font-size:1rem;border:2px solid #d9d2f0;border-radius:12px;outline:none;background:#fff;color:inherit}.words-search:focus{border-color:#7c3aed}.words-group{flex:none;max-width:42%;padding:12px 8px;font-size:.95rem;font-weight:600;color:#4c446b;border:2px solid #d9d2f0;border-radius:12px;background:#fff;outline:none}.words-group:focus{border-color:#7c3aed}.words-list{display:flex;flex-direction:column}.word-row-wrap{border-bottom:1px solid #eee9fb}.word-row-wrap:last-child{border-bottom:none}.word-row{display:flex;align-items:baseline;gap:8px;width:100%;padding:11px 4px;border:none;background:transparent;font:inherit;color:inherit;text-align:left;cursor:pointer;touch-action:manipulation;border-radius:8px}.word-row:disabled{cursor:default}.word-row:not(:disabled):active{background:#f4f1fa}.word-row.open{background:#f4f1fa}.word-main{flex:1;min-width:0;overflow-wrap:break-word}.word-key{font-weight:700}.word-sep{color:#6d6390}.word-value{color:#4c446b}.word-tag{flex:none;max-width:38%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.8rem;color:#6d6390;text-align:right}.word-dot{flex:none;width:6px;height:6px;border-radius:50%;background:#7c3aedLine truncated
|
:root{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1e1b2e;background:#f4f1fa}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:flex;justify-content:center;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}#app{width:100%;max-width:480px;padding:24px 16px 32px;display:flex;flex-direction:column;gap:16px}.app-header h1{margin:0;font-size:1.75rem;letter-spacing:.02em}.app-subtitle{margin:4px 0 0;color:#6d6390}.top-bar{display:flex;align-items:center;gap:12px}.burger-btn{width:44px;height:44px;border:2px solid #d9d2f0;border-radius:12px;background:#fff;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;touch-action:manipulation;flex:none}.burger-btn span{display:block;width:20px;height:2px;border-radius:2px;background:#4c446b}.burger-btn[aria-expanded=true]{border-color:#7c3aed}.top-bar-title h1{margin:0;font-size:1.4rem;line-height:1.2}.top-bar-title p{margin:0;font-size:.9rem;color:#6d6390}.top-bar-spacer{flex:1}.app-content{display:flex;flex-direction:column;gap:16px}.drawer-overlay{position:fixed;inset:0;background:#1e1b2e73;z-index:40}.drawer-overlay[hidden]{display:none}.drawer{position:fixed;top:0;left:0;bottom:0;width:min(300px,85vw);background:#fff;z-index:50;transform:translate(-105%);transition:transform .22s ease;display:flex;flex-direction:column;gap:8px;padding:20px 14px;box-shadow:8px 0 32px #1e1b2e2e}.drawer.open{transform:translate(0)}.drawer-header{padding:4px 8px 14px;border-bottom:1px solid #eee9fb;margin-bottom:8px}.drawer-logo{font-size:1.3rem;font-weight:800;color:#7c3aed}.drawer-subtitle{font-size:.9rem;color:#6d6390}.drawer-btn{border:none;background:transparent;text-align:left;font-size:1.05rem;font-weight:600;color:#1e1b2e;padding:13px 12px;border-radius:12px;cursor:pointer;touch-action:manipulation}.drawer-btn.active{background:#f1eafe;color:#6d28d9}.drawer-btn:active{background:#e9defc}body.drawer-open{overflow:hidden}.card{background:#fff;border-radius:16px;padding:20px 16px;box-shadow:0 8px 24px #7c3aed1f;display:flex;flex-direction:column;gap:12px}.card-title{margin:0;font-size:1.25rem}.card-hint{margin:0;color:#6d6390;font-size:.95rem}.field-label{font-weight:600;font-size:.95rem}.token-input{width:100%;padding:14px 12px;font-size:1.05rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.04em;border:2px solid #d9d2f0;border-radius:12px;outline:none}.token-input:focus{border-color:#7c3aed}.token-input.invalid{border-color:#dc2626;background:#fef2f2}.token-meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;min-height:1.4em}.token-error{color:#dc2626;font-size:.9rem}.token-counter{color:#6d6390;font-variant-numeric:tabular-nums;white-space:nowrap}.btn-row{display:flex;gap:12px}.btn{flex:1;padding:14px 12px;font-size:1rem;font-weight:600;border-radius:12px;border:2px solid #7c3aed;background:#fff;color:#7c3aed;cursor:pointer;touch-action:manipulation}.btn-primary{background:#7c3aed;color:#fff}.btn:disabled{opacity:.4;cursor:not-allowed}.btn:not(:disabled):active{transform:scale(.98)}.status-bar{margin:0;padding:10px 12px;border-radius:10px;background:#f4f1fa;color:#4c446b;font-size:.95rem;word-break:break-word}.status-bar[hidden]{display:none}.db-banner{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;background:#fff7e6;color:#7a5b00;font-size:.85rem}.db-banner[hidden]{display:none}.db-banner-text{flex:1;min-width:0}.db-banner-link{flex:none;border:none;background:transparent;padding:4px;font:inherit;font-weight:700;color:#7c3aed;cursor:pointer;touch-action:manipulation}.db-banner-link[hidden]{display:none}.words-toolbar{display:flex;gap:8px}.words-search{flex:1;min-width:0;padding:12px;font-size:1rem;border:2px solid #d9d2f0;border-radius:12px;outline:none;background:#fff;color:inherit}.words-search:focus{border-color:#7c3aed}.words-group{flex:none;max-width:42%;padding:12px 8px;font-size:.95rem;font-weight:600;color:#4c446b;border:2px solid #d9d2f0;border-radius:12px;background:#fff;outline:none}.words-group:focus{border-color:#7c3aed}.words-list{display:flex;flex-direction:column}.word-row-wrap{border-bottom:1px solid #eee9fb}.word-row-wrap:last-child{border-bottom:none}.word-row{display:flex;align-items:baseline;gap:8px;width:100%;padding:11px 4px;border:none;background:transparent;font:inherit;color:inherit;text-align:left;cursor:pointer;touch-action:manipulation;border-radius:8px}.word-row:disabled{cursor:default}.word-row:not(:disabled):active{background:#f4f1fa}.word-row.open{background:#f4f1fa}.word-main{flex:1;min-width:0;overflow-wrap:break-word}.word-key{font-weight:700}.word-sep{color:#6d6390}.word-value{color:#4c446b}.word-tag{flex:none;max-width:38%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.8rem;color:#6d6390;text-align:right}.word-dot{flex:none;width:6px;height:6px;border-radius:50%;background:#7c3aedLine truncated
|
||||||
Vendored
-59
@@ -1,59 +0,0 @@
|
|||||||
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))s(o);new MutationObserver(o=>{for(const c of o)if(c.type==="childList")for(const h of c.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&s(h)}).observe(document,{childList:!0,subtree:!0});function i(o){const c={};return o.integrity&&(c.integrity=o.integrity),o.referrerPolicy&&(c.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?c.credentials="include":o.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function s(o){if(o.ep)return;o.ep=!0;const c=i(o);fetch(o.href,c)}})();const Pe=new Worker(new URL("/assets/db-worker-AHwpujSZ.js",import.meta.url),{type:"module"});let ir=0,Ge=null;const je=new Map;function Tn(){return Ge||(Ge=new Promise((t,n)=>{Pe.onmessage=s=>{const o=s.data;if(o.type==="ready")return;const c=je.get(o.id);c&&(je.delete(o.id),o.type=="success"?c.resolve(o.data):c.reject(new Error(o.error??o.message??"Неизвестная ошибка Worker")))},Pe.onerror=s=>{console.error("[SQLite Worker] Ошибка:",s),n(new Error("Ошибка Worker"))};const i=++ir;je.set(i,{resolve:t,reject:n}),Pe.postMessage({type:"start",id:i})}),Ge)}function Qn(t,n){return new Promise((i,s)=>{if(!Pe){s(new Error("Worker не создан"));return}const o=++ir;je.set(o,{resolve:i,reject:s}),Pe.postMessage({type:t,id:o,...n})})}async function Re(t,n){return await Qn("query",{sql:t,params:n??[]})}function Nn(t){if(t==null)return[];const n=String(t).trim();return n?n.split(/[,\s;]+/).map(i=>i.trim()).filter(i=>i.length>0):[]}function Fn(t){const n=new Map;if(t==null)return n;const i=String(t);if(!i)return n;const s=i.split("");for(let o=0;o+1<s.length;o+=2){const c=Number(s[o]),h=s[o+1]??"";!Number.isFinite(c)||!Number.isInteger(c)||h&&n.set(c,h)}return n}function Hn(t){const n=Number(t.id??0),i=String(t.key??""),s=String(t.value??""),o=t.tags==null?"":String(t.tags),c=Number(t.group_id??1);return{id:n,key:i,value:s,tags:o,tagsList:Nn(o),more:Fn(t.more),group_id:Number.isFinite(c)?c:1}}function or(t){const n=[],i=[],s=(t.search??"").trim();if(s){n.push("(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')");const o=`%${s.replace(/\\/g,"\\\\").replace(/%/g,"\\%").replace(/_/g,"\\_")}%`;i.push(o,o,o)}return t.groupId!=null&&(n.push("group_id = ?"),i.push(t.groupId)),{clause:n.length>0?`WHERE ${n.join(" AND ")}`:"",params:i}}async function qn(t={}){const{clause:n,params:i}=or(t),o=(await Re(`SELECT COUNT(*) as cnt FROM words ${n}`,i))[0]?.cnt;return typeof o=="number"?o:Number(o??0)}async function At(t={}){const{clause:n,params:i}=or(t),s=Math.max(1,Math.min(t.limit??50,500)),o=Math.max(0,t.offset??0);return(await Re(`SELECT id, key, value, tags, more, group_id FROM words ${n} ORDER BY id LIMIT ? OFFSET ?`,[...i,s,o])).map(Hn)}async function Pn(){return(await Re("SELECT id, name FROM word_groups ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??"")}))}async function Ln(){return(await Re("SELECT id, name, forward, backward, filter FROM decks ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??""),forward:String(n.forward??""),backward:String(n.backward??""),filter:String(n.filter??"")}))}async function _n(){return(await Re("SELECT id, name, identifier FROM custom_fields ORDER BY id")).map(n=>({id:Number(n.id),name:String(n.name??""),identifier:String(n.identifier??"")}))}const kt="japanese_app.db";async function Et(){return navigator.storage.getDirectory()}async function Rn(t){t[18]=1,t[19]=1;const i=await(await Et()).getFileHandle(kt,{create:!0}),s=i.createSyncAccessHandle;if(typeof s=="function")try{const c=await s.call(i);try{c.truncate(0),c.write(t,{at:0}),c.flush()}finally{c.close()}return}catch{}const o=await i.createWritable();try{await o.truncate(0);const c=new Uint8Array(t.length);c.set(t),await o.write(c)}finally{await o.close()}}async function Sn(){const t=await Et();let n;try{n=await t.getFileHandle(kt)}catch{throw new Error("Локальная база не найдена — сначала скачай сохранение")}const i=await n.getFile();if(i.size===0)throw new Error("Локальная база пуста");return new Uint8Array(await i.arrayBuffer())}async function Le(){try{return(await(await(await Et()).getFileHandle(kt)).getFile()).size>0}catch{return!1}}var In=ArrayBuffer,z=Uint8Array,We=Uint16Array,Jn=Int16Array,tt=Int32Array,ht=function(t,n,i){if(z.prototype.slice)return z.prototype.slice.call(t,n,i);(n==null||n<0)&&(n=0),(i==null||i>t.length)&&(i=t.length);var s=new z(i-n);return s.set(t.subarray(n,i)),s},_e=function(t,n,i,s){if(z.prototype.fill)return z.prototype.fill.call(t,n,i,s);for((i==null||i<0)&&(i=0),(s==null||s>t.length)&&(s=t.length);i<s;++i)t[i]=n;return t},Kn=function(t,n,i,s){if(z.prototype.copyWithin)return z.prototype.copyWithin.call(t,n,i,s);for((i==null||i<0)&&(i=0),(s==null||s>t.length)&&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 i=t.querySelector("#home-db-banner"),s=t.querySelector("#home-db-text"),o=t.querySelector("#home-db-go"),c=t.querySelector("#words-search"),h=t.querySelector("#words-group"),w=t.querySelector("#words-count"),C=t.querySelector("#words-status"),x=t.querySelector("#words-list"),m=t.querySelector("#words-more");function U(g,k){i.hidden=!1,s.textContent=g,o.hidden=!1}function E(){i.hidden=!0}o.addEventListener("click",()=>{document.querySelector('.drawer-btn[data-page="sync"]')?.click()});let y=!1,Q=new Map,v=0,$=[],S=null,O=0,R=!1,Y=0,K=0;function M(){const g=h.value;if(g==="")return{search:c.value.trim(),groupId:null};const k=Number(g);return{search:c.value.trim(),groupId:Number.isFinite(k)?k:null}}function X(){m.hidden=!(y&&$.length<v),m.hidden||(m.textContent=`Показать ещё (${v-$.length} осталось)`)}function D(){x.innerHTML=$.map(g=>rr(g,Q,g.id===S)).join(""),w.textContent=v>0?`Показано ${$.length} из ${v}`:"",X()}function ee(g){if(S===g){g!=null&&G(g),S=null;return}S!=null&&G(S),S=g,g!=null&&re(g)}function G(g){const k=x.querySelector(`.word-row-wrap[data-word-id="${g}"]`);if(!k)return;const T=k.querySelector(".word-row"),q=k.querySelector(".word-extra");T&&(T.classList.remove("open"),T.setAttribute("aria-expanded","false")),q&&(q.hidden=!0,q.innerHTML="")}function re(g){const k=$.find(fe=>fe.id===g),T=x.querySelector(`.word-row-wrap[data-word-id="${g}"]`);if(!k||!T)return;const q=T.querySelector(".word-row"),P=T.querySelector(".word-extra");q&&(q.classList.add("open"),q.setAttribute("aria-expanded","true")),P&&(P.hidden=!1,P.innerHTML=cr(k,Q))}function V(g){C.textContent=g,C.hidden=g===""}async function j(){if(!y||R)return;R=!0;const g=++Y,k=M();m.disabled=!0,$.length===0&&V("Загрузка слов…");try{const[T,q]=await Promise.all([qn({search:k.search,groupId:k.groupId??void 0}),At({search:k.search,groupId:k.groupId??void 0,limit:tr,offset:0})]);if(g!==Y)return;v=T,$=q,O=q.length,S!=null&&!$.some(P=>P.id===S)&&(S=null),D(),V(q.length===0?"По этому запросу слов нет.":""),q.length===0&&v===0&&(w.textContent="Слов в базе пока нет.")}catch(T){if(g!==Y)return;V(`Не удалось загрузить слова: ${T instanceof Error?T.message:String(T)}`)}finally{g===Y&&(R=!1,m.disabled=!1,X())}}async function H(){if(!y||R||$.length>=v)return;R=!0;const g=++Y,k=M();m.disabled=!0,m.textContent="Загрузка…";try{const T=await At({search:k.search,groupId:k.groupId??void 0,limit:tr,offset:O});if(g!==Y)return;$=$.concat(T),O+=T.length,x.insertAdjacentHTML("beforeend",T.map(q=>rr(q,Q,q.id===S)).join("")),w.textContent=`Показано ${$.length} из ${v}`,X()}catch(T){if(g!==Y)return;V(`Не удалось догрузить: ${T instanceof Error?T.message:String(T)}`)}finally{g===Y&&(R=!1,m.disabled=!1,X())}}c.addEventListener("input",()=>{window.clearTimeout(K),K=window.setTimeout(()=>{j()},300)}),c.addEventListener("keydown",g=>{g.key==="Enter"&&(window.clearTimeout(K),j())}),h.addEventListener("change",()=>{j()}),m.addEventListener("click",()=>{H()}),x.addEventListener("click",g=>{const k=g.target.closest(".word-row");if(!k||k.disabled)return;const T=Number(k.dataset.wordId);Number.isFinite(T)&&ee(S===T?null:T)}),(async()=>{if(!await Le()){U("Локальной базы нет — скачай сохранение."),V("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),w.textContent="";return}try{const k=await Sn();if(!Ct(k)){U("Файл в OPFS не похож на SQLite — перекачай сохранение.",!0),V("Файл в OPFS не похож на SQLite — перекачай сохранение.");return}E();try{const[q,P]=await Promise.all([Pn(),_n()]),fe=q;Q=new Map(P.map(ce=>[ce.id,ce])),h.innerHTML='<option value="">Все группы</option>'+fe.map(ce=>`<option value="${ce.id}">${Ue(ce.name)}</option>`).join(""),y=!0,await j(),h.options[0].textContent=`Все группы (${v})`}catch(q){V(`БД есть, но слова не прочитались: ${q instanceof Error?q.message:String(q)}`)}}catch(k){U(`Не удалось прочитать базу: ${k instanceof Error?k.message:String(k)}`)}})()}function Ae(t){return t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function ga(t,n){const i=[t.forward,t.backward].filter(s=>s.trim().length>0).join(" → ");return`<div class="word-row-wrap" data-deck-id="${t.id}"><button class="word-row" type="button" data-deck-id="${t.id}" aria-expanded="${n}" aria-controls="deck-extra-${t.id}"><span class="word-main"><span class="word-key">${Ae(t.name)}</span>`+(i?`<span class="word-sep"> · </span><span class="word-value">${Ae(i)}</span>`:"")+`</span></button><div class="word-extra" id="deck-extra-${t.id}"${n?"":" hidden"}>`+(n?ur(t):"")+"</div></div>"}function ur(t){return`<dl class="word-extra-list">${[["forward"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 n=t.querySelector("#decks-count"),i=t.querySelector("#decks-status"),s=t.querySelector("#decks-list");let o=[],c=null;function h(m){i.textContent=m,i.hidden=m===""}function w(m){const U=s.querySelector(`.word-row-wrap[data-deck-id="${m}"]`);if(!U)return;const E=U.querySelector(".word-row"),y=U.querySelector(".word-extra");E&&(E.classList.remove("open"),E.setAttribute("aria-expanded","false")),y&&(y.hidden=!0,y.innerHTML="")}function C(m){const U=o.find(v=>v.id===m),E=s.querySelector(`.word-row-wrap[data-deck-id="${m}"]`);if(!U||!E)return;const y=E.querySelector(".word-row"),Q=E.querySelector(".word-extra");y&&(y.classList.add("open"),y.setAttribute("aria-expanded","true")),Q&&(Q.hidden=!1,Q.innerHTML=ur(U))}function x(m){if(c===m){m!=null&&w(m),c=null;return}c!=null&&w(c),c=m,m!=null&&C(m)}s.addEventListener("click",m=>{const U=m.target.closest(".word-row");if(!U)return;const E=Number(U.dataset.deckId);Number.isFinite(E)&&x(c===E?null:E)}),(async()=>{if(!await Le()){h("Нет локальной базы — скачай сохранение на странице «Синхронизация»."),n.textContent="";return}h("Загрузка колод…");try{if(o=await Ln(),o.length===0){n.textContent="Колоды пока отсутствуют.",h(""),s.innerHTML="";return}n.textContent=`Всего: ${o.length}`,h(""),s.innerHTML=o.map(U=>ga(U,U.id===c)).join("")}catch(U){h(`Не удалось загрузить колоды: ${U instanceof Error?U.message:String(U)}`)}})()}const nr=24,ma=/^[A-Za-z0-9]{24}$/,dr="japlearn_sync_token";function ar(t){return t.trim().replace(/\s+/g,"")}function be(t){return ma.test(t)}function va(){try{return localStorage.getItem(dr)??""}catch{return""}}function mt(t){try{localStorage.setItem(dr,t)}catch{}}const pr="https://learning.micialware.ru";function ya(t,n=pr){return`${n.replace(/\/+$/,"")}/download/${encodeURIComponent(t)}`}async function ba(t,n,i=pr){const s=ya(t,i);let o;try{o=await fetch(s,{method:"GET",...n})}catch(h){throw new Error(`Нет связи с сервером: ${h instanceof Error?h.message:String(h)}`)}if(!o.ok)throw o.status===404?new Error("Сохранение не найдено (404). Проверь токен."):new Error(`Сервер ответил ${o.status} ${o.statusText}`);const c=await o.arrayBuffer();if(c.byteLength===0)throw new Error("Сервер вернул пустой файл");return new Uint8Array(c)}function Ua(t){t.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" type="button" disabled>📥 Скачать</button>
|
|
||||||
<button id="btn-upload" class="btn btn-primary" type="button" disabled>📤 Выгрузить</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
|
|
||||||
</main>
|
|
||||||
`;const n=t.querySelector("#token-input"),i=t.querySelector("#token-error"),s=t.querySelector("#token-counter"),o=t.querySelector("#btn-download"),c=t.querySelector("#btn-upload"),h=t.querySelector("#status");function w(y){h.textContent=y,console.log(`[JapLearn] ${y}`)}function C(){return ar(n.value)}function x(y){o.disabled=y||!be(C()),c.disabled=!0,n.disabled=y,y||Le().then(Q=>{!n.disabled&&be(C())&&(c.disabled=!Q)})}function m(){const y=C(),Q=y.length;s.textContent=`${Q} / ${nr}`;const v=be(y),$=Q===0;n.classList.toggle("invalid",!$&&!v),n.setAttribute("aria-invalid",String(!$&&!v)),$?i.textContent="":Q!==nr?i.textContent=`Нужно 24 символа, сейчас ${Q}`:i.textContent=v?"":"Только латинские буквы и цифры",o.disabled=!v,v?Le().then(S=>{n.disabled||(c.disabled=!S)}):c.disabled=!0}async function U(){const y=C();if(be(y)){mt(y),x(!0),w("Скачивание сохранения...");try{const Q=await ba(y);w(`Скачано ${(Q.length/1024).toFixed(1)} КБ, распаковка...`);const v=await fa(Q);w(`Распаковано ${(v.length/1024).toFixed(1)} КБ, сохранение в OPFS...`),await Rn(v),w(`Готово: база ${(v.length/1024).toFixed(1)} КБ сохранена локально (OPFS).`)}catch(Q){w(`Ошибка скачивания: ${Q instanceof Error?Q.message:String(Q)}`)}finally{x(!1),m()}}}n.addEventListener("input",()=>{m()}),n.addEventListener("change",()=>{const y=C();n.value=y,be(y)&&(mt(y),w("Токен сохранён локально. Можно скачивать или выгружать.")),m()}),o.addEventListener("click",()=>{U()}),c.addEventListener("click",()=>{const y=C();be(y)&&(mt(y),w("Выгрузка — следующий шаг (POST /upload/stream/{id})."))});const E=va();E&&(n.value=E),m(),be(ar(E))&&Le().then(y=>{w(y?"Токен восстановлен, локальная база на месте.":"Токен восстановлен. Нажми «Скачать», чтобы получить базу."),m()})}const Ca={home:"Главная",decks:"Колоды",sync:"Синхронизация"};function Ie(t){const n=document.getElementById(t);if(!n)throw new Error(`[JapLearn] Не найден элемент #${t}`);return n}const fr=Ie("burger-btn"),et=Ie("drawer"),hr=Ie("drawer-overlay"),ka=Ie("page-title"),vt=Ie("page");let xt=!1;function Je(t){xt=t,et.classList.toggle("open",t),hr.hidden=!t,fr.setAttribute("aria-expanded",String(t)),et.setAttribute("aria-hidden",String(!t)),document.body.classList.toggle("drawer-open",t)}function Ea(t){et.querySelectorAll(".drawer-btn").forEach(n=>{n.classList.toggle("active",n.dataset.page===t)})}function gr(t){ka.textContent=Ca[t],Ea(t),t==="home"?ha(vt):t==="decks"?wa(vt):Ua(vt)}fr.addEventListener("click",()=>{Je(!xt)});hr.addEventListener("click",()=>{Je(!1)});document.addEventListener("keydown",t=>{t.key==="Escape"&&xt&&Je(!1)});et.querySelectorAll(".drawer-btn").forEach(t=>{t.addEventListener("click",()=>{const n=t.dataset.page;(n==="home"||n==="decks"||n==="sync")&&gr(n),Je(!1)})});await Tn();Je(!1);gr("home");console.log("[JapLearn] crossOriginIsolated =",window.crossOriginIsolated);
|
|
||||||
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#7c3aed" />
|
<meta name="theme-color" content="#7c3aed" />
|
||||||
<title>JapLearn — интервальные повторения</title>
|
<title>JapLearn — интервальные повторения</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dqxd5Tgf.js"></script>
|
<script type="module" crossorigin src="/assets/index-Ban37OPN.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-u8F3m7-8.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DkBF5-AQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|||||||
+185
-1
@@ -7,7 +7,7 @@
|
|||||||
// т.е. количество дней с 1970-01-01, без времени;
|
// т.е. количество дней с 1970-01-01, без времени;
|
||||||
// - states.fsrs_memory: сырое INTEGER-значение FSRS (не дата, не трогаем).
|
// - states.fsrs_memory: сырое INTEGER-значение FSRS (не дата, не трогаем).
|
||||||
|
|
||||||
import { query } from "./sqlite-api.ts";
|
import { command, query } from "./sqlite-api.ts";
|
||||||
|
|
||||||
export interface CustomField {
|
export interface CustomField {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -177,6 +177,190 @@ export async function getDecks(): Promise<Deck[]> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет редактируемые поля колоды (forward / backward / filter).
|
||||||
|
* whitelist колонок захардкожен — sql-инъекция через имя поля невозможна.
|
||||||
|
* Пустые строки разрешены: схема decks требует NOT NULL, но не запрещает ''.
|
||||||
|
*/
|
||||||
|
export async function updateDeckFields(
|
||||||
|
id: number,
|
||||||
|
patch: { forward: string; backward: string; filter: string },
|
||||||
|
): Promise<Deck | null> {
|
||||||
|
await command("UPDATE decks SET forward = ?, backward = ?, filter = ? WHERE id = ?", [
|
||||||
|
patch.forward,
|
||||||
|
patch.backward,
|
||||||
|
patch.filter,
|
||||||
|
id,
|
||||||
|
] as never[]);
|
||||||
|
return getDeck(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Одна колода по id. null — нет такой строки. */
|
||||||
|
export async function getDeck(id: number): Promise<Deck | null> {
|
||||||
|
const rows = await query(
|
||||||
|
"SELECT id, name, forward, backward, filter FROM decks WHERE id = ? LIMIT 1",
|
||||||
|
[id] as never[],
|
||||||
|
);
|
||||||
|
const r = (rows as Record<string, unknown>[])[0];
|
||||||
|
if (!r) return null;
|
||||||
|
return {
|
||||||
|
id: Number(r["id"]),
|
||||||
|
name: String(r["name"] ?? ""),
|
||||||
|
forward: String(r["forward"] ?? ""),
|
||||||
|
backward: String(r["backward"] ?? ""),
|
||||||
|
filter: String(r["filter"] ?? ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько строк states привязано к каждой колоде (states.deck_id).
|
||||||
|
* Один запрос на весь список — кладёт counts.get(deckId) ?? 0 в строку колоды.
|
||||||
|
*/
|
||||||
|
export async function getDeckStateCounts(): Promise<Map<number, number>> {
|
||||||
|
const rows = await query(
|
||||||
|
"SELECT deck_id, COUNT(*) as cnt FROM states WHERE deck_id IS NOT NULL GROUP BY deck_id",
|
||||||
|
);
|
||||||
|
const m = new Map<number, number>();
|
||||||
|
for (const r of rows as Record<string, unknown>[]) {
|
||||||
|
if (r["deck_id"] == null) continue;
|
||||||
|
const cnt = r["cnt"];
|
||||||
|
m.set(Number(r["deck_id"]), typeof cnt === "number" ? cnt : Number(cnt ?? 0));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько states у одной колоды (для списка колод). */
|
||||||
|
export async function getDeckStateCount(deckId: number): Promise<number> {
|
||||||
|
const rows = await query("SELECT COUNT(*) as cnt FROM states WHERE deck_id = ?", [
|
||||||
|
deckId,
|
||||||
|
] as never[]);
|
||||||
|
const cnt = (rows[0] as Record<string, unknown> | undefined)?.["cnt"];
|
||||||
|
return typeof cnt === "number" ? cnt : Number(cnt ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько уникальных слов связано с колодой (states.word_id при deck_id).
|
||||||
|
* Именно это показываем на странице колоды как «количество слов».
|
||||||
|
*/
|
||||||
|
export async function getDeckWordCount(deckId: number): Promise<number> {
|
||||||
|
const rows = await query(
|
||||||
|
"SELECT COUNT(DISTINCT word_id) as cnt FROM states WHERE deck_id = ?",
|
||||||
|
[deckId] as never[],
|
||||||
|
);
|
||||||
|
const cnt = (rows[0] as Record<string, unknown> | undefined)?.["cnt"];
|
||||||
|
return typeof cnt === "number" ? cnt : Number(cnt ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все строки states одной колоды, свежие первыми. */
|
||||||
|
export async function getDeckStates(deckId: number): Promise<WordState[]> {
|
||||||
|
const rows = await query(
|
||||||
|
"SELECT id, word_id, deck_id, score, last_opened, fsrs_opened, fsrs_memory, fsrs_open_day FROM states WHERE deck_id = ? ORDER BY last_opened DESC, id DESC",
|
||||||
|
[deckId] as never[],
|
||||||
|
);
|
||||||
|
return (rows as Record<string, unknown>[]).map((r) => ({
|
||||||
|
id: Number(r["id"]),
|
||||||
|
word_id: Number(r["word_id"]),
|
||||||
|
deck_id: r["deck_id"] == null ? null : Number(r["deck_id"]),
|
||||||
|
score: Number(r["score"] ?? 0),
|
||||||
|
last_opened: Number(r["last_opened"] ?? 0),
|
||||||
|
fsrs_opened: Number(r["fsrs_opened"] ?? 0),
|
||||||
|
fsrs_memory: Number(r["fsrs_memory"] ?? 0),
|
||||||
|
fsrs_open_day: Number(r["fsrs_open_day"] ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пакетная подгрузка слов по id (для страницы колоды: показать key/value рядом со states).
|
||||||
|
* Возвращает Map word_id -> Word, чтобы не делать N+1 запросов.
|
||||||
|
*/
|
||||||
|
export async function getWordsByIds(ids: number[]): Promise<Map<number, Word>> {
|
||||||
|
const m = new Map<number, Word>();
|
||||||
|
const uniq = [...new Set(ids.filter((n) => Number.isFinite(n)))];
|
||||||
|
if (uniq.length === 0) return m;
|
||||||
|
// SQLite-плейсхолдеры строим чанками, чтобы не упереться в лимиты.
|
||||||
|
const CHUNK = 400;
|
||||||
|
for (let i = 0; i < uniq.length; i += CHUNK) {
|
||||||
|
const part = uniq.slice(i, i + CHUNK);
|
||||||
|
const holders = part.map(() => "?").join(",");
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT id, key, value, tags, more, group_id FROM words WHERE id IN (${holders})`,
|
||||||
|
part as never[],
|
||||||
|
);
|
||||||
|
for (const w of (rows as Record<string, unknown>[]).map(mapRowToWord)) m.set(w.id, w);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Значения «именованных» полей слова для шаблонов forward/backward.
|
||||||
|
* Ключи: встроенные key/value/tags + identifier и name каждого custom_fields (lowercase).
|
||||||
|
* Пустые значения пропускаем на этапе рендера.
|
||||||
|
*/
|
||||||
|
export function wordFieldValues(
|
||||||
|
word: Word,
|
||||||
|
fields: CustomField[],
|
||||||
|
): Map<string, string> {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
m.set("key", word.key);
|
||||||
|
m.set("value", word.value);
|
||||||
|
m.set("tags", word.tagsList.join(", "));
|
||||||
|
for (const f of fields) {
|
||||||
|
const v = word.more.get(f.id) ?? "";
|
||||||
|
if (!v) continue;
|
||||||
|
// identifier — каноническое имя из десктопа (напр. "reading").
|
||||||
|
// name тоже кладём — вдруг шаблон ссылается на отображаемое имя.
|
||||||
|
// Первое непустое побеждает: не затираем уже положенное.
|
||||||
|
for (const k of [f.identifier, f.name]) {
|
||||||
|
const key = k.trim().toLowerCase();
|
||||||
|
if (!key || m.has(key)) continue;
|
||||||
|
m.set(key, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендер стороны карточки: список имён полей через пробел ("reading value").
|
||||||
|
* Неизвестные/пустые поля пропускаем. Возвращает непустые значения по порядку.
|
||||||
|
*/
|
||||||
|
export function renderCardSide(
|
||||||
|
template: string,
|
||||||
|
values: Map<string, string>,
|
||||||
|
): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const tok of template.split(/\s+/)) {
|
||||||
|
const t = tok.trim().toLowerCase();
|
||||||
|
if (!t) continue;
|
||||||
|
const v = (values.get(t) ?? "").trim();
|
||||||
|
if (v) out.push(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Случайная выборка word_id из states колоды без повторений (Fisher–Yates).
|
||||||
|
* Один запрос DISTINCT word_id, тасуем в памяти, режем до limit.
|
||||||
|
*/
|
||||||
|
export async function sampleDeckWordIds(
|
||||||
|
deckId: number,
|
||||||
|
limit?: number,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const rows = await query(
|
||||||
|
"SELECT DISTINCT word_id as word_id FROM states WHERE deck_id = ?",
|
||||||
|
[deckId] as never[],
|
||||||
|
);
|
||||||
|
const ids = (rows as Record<string, unknown>[])
|
||||||
|
.map((r) => Number(r["word_id"]))
|
||||||
|
.filter((n) => Number.isFinite(n));
|
||||||
|
// Тасование Фишера–Йетса: равномерная перестановка за O(n).
|
||||||
|
for (let i = ids.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[ids[i], ids[j]] = [ids[i]!, ids[j]!];
|
||||||
|
}
|
||||||
|
if (limit != null && limit >= 0) return ids.slice(0, limit);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCustomFields(): Promise<CustomField[]> {
|
export async function getCustomFields(): Promise<CustomField[]> {
|
||||||
const rows = await query("SELECT id, name, identifier FROM custom_fields ORDER BY id");
|
const rows = await query("SELECT id, name, identifier FROM custom_fields ORDER BY id");
|
||||||
return (rows as Record<string, unknown>[]).map((r) => ({
|
return (rows as Record<string, unknown>[]).map((r) => ({
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ self.onmessage = async (e) => {
|
|||||||
}
|
}
|
||||||
case 'command': {
|
case 'command': {
|
||||||
if (db === null) throw new Error('БД не инициализирована');
|
if (db === null) throw new Error('БД не инициализирована');
|
||||||
db.exec(sql, params ?? []);
|
db.exec({ sql, bind: params ?? [] });
|
||||||
self.postMessage({ type: 'success', id, data: null });
|
self.postMessage({ type: 'success', id, data: null });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Страница одной колоды: данные строки decks + количество связанных слов.
|
||||||
|
// forward / backward / filter — редактируемые, сохраняются в локальную БД.
|
||||||
|
// Никакого списка states здесь нет. Навигация: «← Назад к колодам» в список.
|
||||||
|
|
||||||
|
import { getDeck, getDeckWordCount, updateDeckFields } from './db-types.ts';
|
||||||
|
import { hasDatabaseFile } from './opfs.ts';
|
||||||
|
|
||||||
|
export type DeckNav = {
|
||||||
|
goDecks: () => void;
|
||||||
|
goReview: (deckId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtOrDash(s: string): string {
|
||||||
|
return s ? escapeHtml(s) : '<span class="empty-val">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** attribute-escape для подстановки текущих значений в value="...". */
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralWords(n: number): string {
|
||||||
|
const m10 = n % 10;
|
||||||
|
const m100 = n % 100;
|
||||||
|
if (m10 === 1 && m100 !== 11) return 'слово';
|
||||||
|
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'слова';
|
||||||
|
return 'слов';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav): void {
|
||||||
|
root.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 backBtn = root.querySelector<HTMLButtonElement>('#deck-back')!;
|
||||||
|
const titleEl = root.querySelector<HTMLElement>('#deck-title')!;
|
||||||
|
const statusEl = root.querySelector<HTMLElement>('#deck-status')!;
|
||||||
|
const bodyEl = root.querySelector<HTMLElement>('#deck-body')!;
|
||||||
|
|
||||||
|
backBtn.addEventListener('click', () => nav.goDecks());
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const has = await hasDatabaseFile();
|
||||||
|
if (!has) {
|
||||||
|
titleEl.textContent = 'Колода недоступна';
|
||||||
|
statusEl.textContent = 'Нет локальной базы — скачай сохранение на странице «Синхронизация».';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let deck = null;
|
||||||
|
try {
|
||||||
|
deck = await getDeck(deckId);
|
||||||
|
} catch (err) {
|
||||||
|
titleEl.textContent = 'Колода недоступна';
|
||||||
|
statusEl.textContent = `Не удалось загрузить колоду: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!deck) {
|
||||||
|
titleEl.textContent = 'Колода не найдена';
|
||||||
|
statusEl.textContent = `Колоды с id=${deckId} нет в локальной базе. Возможно, база обновилась — вернись к списку.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = deck;
|
||||||
|
titleEl.textContent = d.name || `Колода #${d.id}`;
|
||||||
|
statusEl.hidden = true;
|
||||||
|
|
||||||
|
let wordCount: number | null = null;
|
||||||
|
try {
|
||||||
|
wordCount = await getDeckWordCount(d.id);
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.hidden = false;
|
||||||
|
statusEl.textContent = `Данные колоды загружены, а количество слов — нет: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyEl.innerHTML = `
|
||||||
|
<dl class="word-extra-list">
|
||||||
|
<div class="word-extra-row"><dt>id</dt><dd>${d.id}</dd></div>
|
||||||
|
<div class="word-extra-row"><dt>name</dt><dd>${fmtOrDash(d.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="${escapeAttr(d.forward)}" />
|
||||||
|
<label class="field-label" for="deck-backward">backward</label>
|
||||||
|
<input id="deck-backward" class="token-input" type="text" value="${escapeAttr(d.backward)}" />
|
||||||
|
<label class="field-label" for="deck-filter">filter</label>
|
||||||
|
<input id="deck-filter" class="token-input" type="text" value="${escapeAttr(d.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">📚 ${wordCount == null ? 'Количество слов неизвестно' : `${wordCount} ${pluralWords(wordCount)}`}</h3>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="deck-review" class="btn btn-primary" type="button"${wordCount === 0 ? ' disabled' : ''}>▶ Повторять</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const form = bodyEl.querySelector<HTMLFormElement>('#deck-form')!;
|
||||||
|
const forwardInput = bodyEl.querySelector<HTMLInputElement>('#deck-forward')!;
|
||||||
|
const backwardInput = bodyEl.querySelector<HTMLInputElement>('#deck-backward')!;
|
||||||
|
const filterInput = bodyEl.querySelector<HTMLInputElement>('#deck-filter')!;
|
||||||
|
const saveBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-save')!;
|
||||||
|
const formStatus = bodyEl.querySelector<HTMLElement>('#deck-form-status')!;
|
||||||
|
const reviewBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-review')!;
|
||||||
|
|
||||||
|
reviewBtn.addEventListener('click', () => nav.goReview(d.id));
|
||||||
|
|
||||||
|
// Кнопка активна только при отличии от сохранённого.
|
||||||
|
const snapshot = (): string =>
|
||||||
|
`${forwardInput.value}\n${backwardInput.value}\n${filterInput.value}`;
|
||||||
|
let savedSnapshot = snapshot();
|
||||||
|
function refreshDirty(): void {
|
||||||
|
saveBtn.disabled = snapshot() === savedSnapshot || saveBtn.dataset.busy === '1';
|
||||||
|
}
|
||||||
|
for (const el of [forwardInput, backwardInput, filterInput]) {
|
||||||
|
el.addEventListener('input', refreshDirty);
|
||||||
|
}
|
||||||
|
refreshDirty();
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (saveBtn.disabled) return;
|
||||||
|
saveBtn.dataset.busy = '1';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Сохранение…';
|
||||||
|
formStatus.textContent = '';
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const updated = await updateDeckFields(d.id, {
|
||||||
|
forward: forwardInput.value,
|
||||||
|
backward: backwardInput.value,
|
||||||
|
filter: filterInput.value,
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
formStatus.textContent = 'Колода пропала из базы — вернись к списку.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forwardInput.value = updated.forward;
|
||||||
|
backwardInput.value = updated.backward;
|
||||||
|
filterInput.value = updated.filter;
|
||||||
|
savedSnapshot = snapshot();
|
||||||
|
formStatus.textContent = '✅ Сохранено в локальную базу.';
|
||||||
|
} catch (err) {
|
||||||
|
formStatus.textContent = `Не удалось сохранить: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
|
} finally {
|
||||||
|
delete saveBtn.dataset.busy;
|
||||||
|
saveBtn.textContent = '💾 Сохранить';
|
||||||
|
refreshDirty();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
+34
-76
@@ -1,10 +1,15 @@
|
|||||||
// Страница «Колоды»: плоский список строк из таблицы decks 1:1.
|
// Страница «Колоды»: плоский список — только название + количество states.
|
||||||
// Никаких связей (states/history/words) здесь не додумываем — только колонки таблицы.
|
// Раскрытия по нажатию нет: тап переходит на страницу колоды, где все данные.
|
||||||
|
|
||||||
import { getDecks } from './db-types.ts';
|
import { getDecks, getDeckStateCounts } from './db-types.ts';
|
||||||
import type { Deck } from './db-types.ts';
|
import type { Deck } from './db-types.ts';
|
||||||
import { hasDatabaseFile } from './opfs.ts';
|
import { hasDatabaseFile } from './opfs.ts';
|
||||||
|
|
||||||
|
export type DecksNav = {
|
||||||
|
goDeck: (deckId: number) => void;
|
||||||
|
goReview: (deckId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s
|
return s
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -14,34 +19,28 @@ function escapeHtml(s: string): string {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Одна колода — плоская строка: имя слева, направление справа.
|
function plural(n: number): string {
|
||||||
function deckRowHtml(d: Deck, expanded: boolean): string {
|
const m10 = n % 10;
|
||||||
const dir = [d.forward, d.backward].filter((s) => s.trim().length > 0).join(' → ');
|
const m100 = n % 100;
|
||||||
return `<div class="word-row-wrap" data-deck-id="${d.id}">`
|
if (m10 === 1 && m100 !== 11) return 'запись';
|
||||||
+ `<button class="word-row" type="button" data-deck-id="${d.id}"`
|
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'записи';
|
||||||
+ ` aria-expanded="${expanded}" aria-controls="deck-extra-${d.id}">`
|
return 'записей';
|
||||||
+ `<span class="word-main"><span class="word-key">${escapeHtml(d.name)}</span>`
|
|
||||||
+ (dir ? `<span class="word-sep"> · </span><span class="word-value">${escapeHtml(dir)}</span>` : '')
|
|
||||||
+ `</span></button>`
|
|
||||||
+ `<div class="word-extra" id="deck-extra-${d.id}"${expanded ? '' : ' hidden'}>`
|
|
||||||
+ (expanded ? deckExtraHtml(d) : '')
|
|
||||||
+ `</div></div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Раскрытие — сырые поля строки decks (имена колонок как в dbschema.sql).
|
// Одна колода — плоская строка: имя + количество слева, «▶» справа.
|
||||||
function deckExtraHtml(d: Deck): string {
|
// Тап по имени — на страницу колоды, тап по «▶» — сразу в повторение.
|
||||||
const rows: Array<[string, string]> = [
|
function deckRowHtml(d: Deck, count: number): string {
|
||||||
['forward', d.forward],
|
const name = escapeHtml(d.name) || `<span class="empty-val">Колода #${d.id}</span>`;
|
||||||
['backward', d.backward],
|
return `<div class="word-row-wrap deck-row" data-deck-id="${d.id}">`
|
||||||
['filter', d.filter],
|
+ `<button class="word-row deck-name" type="button" data-act="open" data-deck-id="${d.id}">`
|
||||||
];
|
+ `<span class="word-main"><span class="word-key">${name}</span></span>`
|
||||||
const html = rows
|
+ `<span class="word-tag">${count} ${plural(count)}</span>`
|
||||||
.map(([k, v]) => `<div class="word-extra-row"><dt>${escapeHtml(k)}</dt><dd>${v ? escapeHtml(v) : '<span class="empty-val">—</span>'}</dd></div>`)
|
+ `</button>`
|
||||||
.join('');
|
+ `<button class="deck-play" type="button" data-act="review" data-deck-id="${d.id}" aria-label="Повторять: ${escapeHtml(d.name) || `колода ${d.id}`}">▶</button>`
|
||||||
return `<dl class="word-extra-list">${html}</dl>`;
|
+ `</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderDecksPage(root: HTMLElement): void {
|
export function renderDecksPage(root: HTMLElement, nav: DecksNav): void {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<section class="card" aria-labelledby="decks-title">
|
<section class="card" aria-labelledby="decks-title">
|
||||||
<h2 id="decks-title" class="card-title">🗂️ Колоды</h2>
|
<h2 id="decks-title" class="card-title">🗂️ Колоды</h2>
|
||||||
@@ -55,62 +54,18 @@ export function renderDecksPage(root: HTMLElement): void {
|
|||||||
const statusEl = root.querySelector<HTMLElement>('#decks-status')!;
|
const statusEl = root.querySelector<HTMLElement>('#decks-status')!;
|
||||||
const listEl = root.querySelector<HTMLElement>('#decks-list')!;
|
const listEl = root.querySelector<HTMLElement>('#decks-list')!;
|
||||||
|
|
||||||
let decks: Deck[] = [];
|
|
||||||
let expandedId: number | null = null;
|
|
||||||
|
|
||||||
function setStatus(msg: string): void {
|
function setStatus(msg: string): void {
|
||||||
statusEl.textContent = msg;
|
statusEl.textContent = msg;
|
||||||
statusEl.hidden = msg === '';
|
statusEl.hidden = msg === '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseRow(id: number): void {
|
|
||||||
const wrap = listEl.querySelector(`.word-row-wrap[data-deck-id="${id}"]`);
|
|
||||||
if (!wrap) return;
|
|
||||||
const btn = wrap.querySelector<HTMLButtonElement>('.word-row');
|
|
||||||
const extra = wrap.querySelector<HTMLElement>('.word-extra');
|
|
||||||
if (btn) {
|
|
||||||
btn.classList.remove('open');
|
|
||||||
btn.setAttribute('aria-expanded', 'false');
|
|
||||||
}
|
|
||||||
if (extra) {
|
|
||||||
extra.hidden = true;
|
|
||||||
extra.innerHTML = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandRow(id: number): void {
|
|
||||||
const d = decks.find((x) => x.id === id);
|
|
||||||
const wrap = listEl.querySelector(`.word-row-wrap[data-deck-id="${id}"]`);
|
|
||||||
if (!d || !wrap) return;
|
|
||||||
const btn = wrap.querySelector<HTMLButtonElement>('.word-row');
|
|
||||||
const extra = wrap.querySelector<HTMLElement>('.word-extra');
|
|
||||||
if (btn) {
|
|
||||||
btn.classList.add('open');
|
|
||||||
btn.setAttribute('aria-expanded', 'true');
|
|
||||||
}
|
|
||||||
if (extra) {
|
|
||||||
extra.hidden = false;
|
|
||||||
extra.innerHTML = deckExtraHtml(d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setExpanded(id: number | null): void {
|
|
||||||
if (expandedId === id) {
|
|
||||||
if (id != null) collapseRow(id);
|
|
||||||
expandedId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (expandedId != null) collapseRow(expandedId);
|
|
||||||
expandedId = id;
|
|
||||||
if (id != null) expandRow(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
listEl.addEventListener('click', (e) => {
|
listEl.addEventListener('click', (e) => {
|
||||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.word-row');
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-act]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const id = Number(btn.dataset.deckId);
|
const id = Number(btn.dataset.deckId);
|
||||||
if (!Number.isFinite(id)) return;
|
if (!Number.isFinite(id)) return;
|
||||||
setExpanded(expandedId === id ? null : id);
|
if (btn.dataset.act === 'review') nav.goReview(id);
|
||||||
|
else nav.goDeck(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -122,7 +77,7 @@ export function renderDecksPage(root: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
setStatus('Загрузка колод…');
|
setStatus('Загрузка колод…');
|
||||||
try {
|
try {
|
||||||
decks = await getDecks();
|
const [decks, counts] = await Promise.all([getDecks(), getDeckStateCounts()]);
|
||||||
if (decks.length === 0) {
|
if (decks.length === 0) {
|
||||||
countEl.textContent = 'Колоды пока отсутствуют.';
|
countEl.textContent = 'Колоды пока отсутствуют.';
|
||||||
setStatus('');
|
setStatus('');
|
||||||
@@ -131,9 +86,12 @@ export function renderDecksPage(root: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
countEl.textContent = `Всего: ${decks.length}`;
|
countEl.textContent = `Всего: ${decks.length}`;
|
||||||
setStatus('');
|
setStatus('');
|
||||||
listEl.innerHTML = decks.map((d) => deckRowHtml(d, d.id === expandedId)).join('');
|
listEl.innerHTML = decks
|
||||||
|
.map((d) => deckRowHtml(d, counts.get(d.id) ?? 0))
|
||||||
|
.join('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Не удалось загрузить колоды: ${err instanceof Error ? err.message : String(err)}`);
|
setStatus(`Не удалось загрузить колоды: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
+20
-5
@@ -1,16 +1,20 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import { renderHomePage } from './home-page.ts';
|
import { renderHomePage } from './home-page.ts';
|
||||||
import { renderDecksPage } from './decks-page.ts';
|
import { renderDecksPage } from './decks-page.ts';
|
||||||
|
import { renderDeckPage } from './deck-page.ts';
|
||||||
|
import { renderReviewPage } from './review-page.ts';
|
||||||
import { renderSyncPage } from './sync-page.ts';
|
import { renderSyncPage } from './sync-page.ts';
|
||||||
import { initWorker } from './sqlite-api.ts';
|
import { initWorker } from './sqlite-api.ts';
|
||||||
|
|
||||||
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Синхронизация).
|
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Колода / Повторение / Синхронизация).
|
||||||
|
|
||||||
type Page = 'home' | 'decks' | 'sync';
|
type Page = 'home' | 'decks' | 'deck' | 'review' | 'sync';
|
||||||
|
|
||||||
const PAGE_TITLES: Record<Page, string> = {
|
const PAGE_TITLES: Record<Page, string> = {
|
||||||
home: 'Главная',
|
home: 'Главная',
|
||||||
decks: 'Колоды',
|
decks: 'Колоды',
|
||||||
|
deck: 'Колода',
|
||||||
|
review: 'Повторение',
|
||||||
sync: 'Синхронизация',
|
sync: 'Синхронизация',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,13 +47,24 @@ function setActiveButton(page: Page): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(page: Page): void {
|
function renderPage(page: Page, deckId?: number): void {
|
||||||
pageTitle.textContent = PAGE_TITLES[page];
|
pageTitle.textContent = PAGE_TITLES[page];
|
||||||
setActiveButton(page);
|
// Страницы колоды и повторения — подразделы «Колод»: в меню подсвечиваем «Колоды».
|
||||||
|
setActiveButton(page === 'deck' || page === 'review' ? 'decks' : page);
|
||||||
if (page === 'home') {
|
if (page === 'home') {
|
||||||
renderHomePage(pageRoot, {});
|
renderHomePage(pageRoot, {});
|
||||||
} else if (page === 'decks') {
|
} else if (page === 'decks') {
|
||||||
renderDecksPage(pageRoot);
|
renderDecksPage(pageRoot, {
|
||||||
|
goDeck: (id) => renderPage('deck', id),
|
||||||
|
goReview: (id) => renderPage('review', id),
|
||||||
|
});
|
||||||
|
} else if (page === 'deck') {
|
||||||
|
renderDeckPage(pageRoot, deckId ?? 0, {
|
||||||
|
goDecks: () => renderPage('decks'),
|
||||||
|
goReview: (id) => renderPage('review', id),
|
||||||
|
});
|
||||||
|
} else if (page === 'review') {
|
||||||
|
renderReviewPage(pageRoot, deckId ?? 0, { goDecks: () => renderPage('decks') });
|
||||||
} else {
|
} else {
|
||||||
renderSyncPage(pageRoot);
|
renderSyncPage(pageRoot);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Страница повторения колоды: карточка за карточкой по случайной выборке.
|
||||||
|
// Лицевая сторона — поля из deck.forward, по тапу дорисовывается deck.backward.
|
||||||
|
// Внизу 4 кнопки самооценки: "не получилось" / "тяжело" / "хорошо" / "легко".
|
||||||
|
// Пока без записи ответов: только навигация по очереди (история — следующий шаг).
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCustomFields,
|
||||||
|
getDeck,
|
||||||
|
getWordsByIds,
|
||||||
|
renderCardSide,
|
||||||
|
sampleDeckWordIds,
|
||||||
|
wordFieldValues,
|
||||||
|
} from './db-types.ts';
|
||||||
|
import type { CustomField, Word } from './db-types.ts';
|
||||||
|
import { hasDatabaseFile } from './opfs.ts';
|
||||||
|
|
||||||
|
export type ReviewNav = {
|
||||||
|
goDecks: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRADES = [
|
||||||
|
{ label: '😞 Не получилось', cls: 'grade-fail' },
|
||||||
|
{ label: '😟 Тяжело', cls: 'grade-hard' },
|
||||||
|
{ label: '🙂 Хорошо', cls: 'grade-good' },
|
||||||
|
{ label: '🤩 Легко', cls: 'grade-easy' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewNav): void {
|
||||||
|
root.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 backBtn = root.querySelector<HTMLButtonElement>('#review-back')!;
|
||||||
|
const titleEl = root.querySelector<HTMLElement>('#review-title')!;
|
||||||
|
const progressEl = root.querySelector<HTMLElement>('#review-progress')!;
|
||||||
|
const statusEl = root.querySelector<HTMLElement>('#review-status')!;
|
||||||
|
const bodyEl = root.querySelector<HTMLElement>('#review-body')!;
|
||||||
|
|
||||||
|
backBtn.addEventListener('click', () => nav.goDecks());
|
||||||
|
|
||||||
|
function setStatus(msg: string): void {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.hidden = msg === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очередь сессии: перетасованные id без повторений + подгруженные слова.
|
||||||
|
let queue: number[] = [];
|
||||||
|
let words = new Map<number, Word>();
|
||||||
|
let fields: CustomField[] = [];
|
||||||
|
let forwardTpl = '';
|
||||||
|
let backwardTpl = '';
|
||||||
|
let pos = 0;
|
||||||
|
let revealed = false;
|
||||||
|
|
||||||
|
function sideHtml(values: string[], cls: string): string {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return `<div class="review-side ${cls}"><span class="empty-val">—</span></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="review-side ${cls}">`
|
||||||
|
+ values.map((v) => `<p class="review-line">${escapeHtml(v)}</p>`).join('')
|
||||||
|
+ `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function paint(): void {
|
||||||
|
const w = words.get(queue[pos]!);
|
||||||
|
if (!w) {
|
||||||
|
bodyEl.innerHTML = '';
|
||||||
|
progressEl.textContent = '';
|
||||||
|
setStatus('Слово пропало из базы — пропускаю.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = wordFieldValues(w, fields);
|
||||||
|
const front = renderCardSide(forwardTpl, values);
|
||||||
|
const back = revealed ? renderCardSide(backwardTpl, values) : null;
|
||||||
|
progressEl.textContent = `Карточка ${pos + 1} из ${queue.length}`;
|
||||||
|
setStatus('');
|
||||||
|
const backHtml = !revealed
|
||||||
|
? `<p class="review-tap">Тап — показать ответ</p>`
|
||||||
|
: sideHtml(back ?? [], 'review-back-side');
|
||||||
|
// Оценки — только после открытия (вместе с backward), до этого лишь тап по карточке.
|
||||||
|
const gradesHtml = revealed
|
||||||
|
? `<div class="review-grades" role="group" aria-label="Оценка повторения">
|
||||||
|
${GRADES.map((g) => `<button class="btn review-grade ${g.cls}" type="button">${g.label}</button>`).join('')}
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
bodyEl.innerHTML = `
|
||||||
|
<button id="review-card" class="review-card" type="button"
|
||||||
|
aria-expanded="${revealed}" aria-label="${revealed ? 'Карточка открыта' : 'Показать ответ'}">
|
||||||
|
${sideHtml(front, 'review-front')}
|
||||||
|
${revealed ? `<hr class="review-hr" />` : ''}
|
||||||
|
${backHtml}
|
||||||
|
</button>
|
||||||
|
${gradesHtml}
|
||||||
|
`;
|
||||||
|
bodyEl.querySelector<HTMLButtonElement>('#review-card')!.addEventListener('click', () => {
|
||||||
|
if (!revealed) {
|
||||||
|
revealed = true;
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bodyEl.querySelectorAll<HTMLButtonElement>('.review-grade').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
// Запись ответов (history/states) — следующим шагом, пока просто дальше.
|
||||||
|
pos += 1;
|
||||||
|
revealed = false;
|
||||||
|
if (pos >= queue.length) paintDone();
|
||||||
|
else paint();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintDone(): void {
|
||||||
|
progressEl.textContent = `Готово: ${queue.length} из ${queue.length}`;
|
||||||
|
setStatus('');
|
||||||
|
bodyEl.innerHTML = `
|
||||||
|
<p class="card-hint">Все карточки сессии просмотрены. Запись оценок — следующим шагом.</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="review-again" class="btn btn-primary" type="button">🔀 Перемешать заново</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
bodyEl.querySelector<HTMLButtonElement>('#review-again')!.addEventListener('click', () => {
|
||||||
|
void reshuffle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reshuffle(): Promise<void> {
|
||||||
|
setStatus('Перемешиваю…');
|
||||||
|
bodyEl.innerHTML = '';
|
||||||
|
progressEl.textContent = '';
|
||||||
|
queue = await sampleDeckWordIds(deckId);
|
||||||
|
pos = 0;
|
||||||
|
revealed = false;
|
||||||
|
if (queue.length === 0) {
|
||||||
|
setStatus('В колоде нет слов для повторения.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
words = await getWordsByIds(queue);
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const has = await hasDatabaseFile();
|
||||||
|
if (!has) {
|
||||||
|
titleEl.textContent = 'Повторение недоступно';
|
||||||
|
setStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [deck, customFields] = await Promise.all([getDeck(deckId), getCustomFields()]);
|
||||||
|
if (!deck) {
|
||||||
|
titleEl.textContent = 'Колода не найдена';
|
||||||
|
setStatus(`Колоды с id=${deckId} нет в локальной базе. Вернись к списку.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fields = customFields;
|
||||||
|
forwardTpl = deck.forward;
|
||||||
|
backwardTpl = deck.backward;
|
||||||
|
titleEl.textContent = deck.name || `Колода #${deck.id}`;
|
||||||
|
queue = await sampleDeckWordIds(deck.id);
|
||||||
|
if (queue.length === 0) {
|
||||||
|
progressEl.textContent = '';
|
||||||
|
setStatus('В колоде нет слов для повторения.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Слова грузим одним батчем (чанки внутри getWordsByIds), очередь уже перемешана.
|
||||||
|
words = await getWordsByIds(queue);
|
||||||
|
pos = 0;
|
||||||
|
revealed = false;
|
||||||
|
paint();
|
||||||
|
} catch (err) {
|
||||||
|
titleEl.textContent = 'Повторение недоступно';
|
||||||
|
setStatus(`Не удалось загрузить карточки: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
+182
@@ -477,3 +477,185 @@ body.drawer-open {
|
|||||||
.empty-val {
|
.empty-val {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Кнопка-строка колоды: div-обёртка + button в одном элементе.
|
||||||
|
.word-row сбрасывает border — возвращаем разделитель. */
|
||||||
|
button.word-row-wrap {
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #eee9fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.word-row-wrap:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.word-row-wrap:not(:disabled):hover {
|
||||||
|
background: #f4f1fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Призрачная кнопка «назад» + подзаголовок на странице колоды */
|
||||||
|
.btn-ghost {
|
||||||
|
align-self: flex-start;
|
||||||
|
flex: none;
|
||||||
|
border: 2px solid #d9d2f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #4c446b;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:active {
|
||||||
|
background: #f1eafe;
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Форма редактирования колоды: инпуты + статус. id/name — только чтение. */
|
||||||
|
.deck-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-form .field-label {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-form .btn-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-form .card-hint:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-sub {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4c446b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Строка колоды: div-обёртка — name слева тянется, «▶» справа фиксирован. */
|
||||||
|
.deck-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-play {
|
||||||
|
flex: none;
|
||||||
|
align-self: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 2px solid #d9d2f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f1eafe;
|
||||||
|
color: #6d28d9;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-play:active {
|
||||||
|
background: #e9defc;
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Повторение: карточка + 4 кнопки оценок --- */
|
||||||
|
|
||||||
|
#review-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Карточка — вся кнопка: сначала только forward, по тапу дорисовывается backward. */
|
||||||
|
.review-card {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 2px solid #d9d2f0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fbfaff;
|
||||||
|
padding: 20px 16px;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card:active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-line {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-front .review-line:first-child {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #eee9fb;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-tap {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6d6390;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-grades {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-grade {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Оценки — плоские оттенки смысла, текст всегда тёмный для контраста. */
|
||||||
|
.grade-fail {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-hard {
|
||||||
|
background: #ffedd5;
|
||||||
|
color: #9a3412;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-good {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-easy {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-grade:active {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
+5
-37
@@ -1,6 +1,5 @@
|
|||||||
// Страница «Синхронизация»: токен + Скачать/Выгрузить.
|
// Страница «Синхронизация»: токен + Скачать.
|
||||||
// Скачивание: GET /download/{id} -> zstd -> проверка SQLite -> запись в OPFS.
|
// Скачивание: GET /download/{id} -> zstd -> проверка SQLite -> запись в OPFS.
|
||||||
// Выгрузка: следующий шаг (POST /upload/stream/{id}).
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TOKEN_LENGTH,
|
TOKEN_LENGTH,
|
||||||
@@ -11,7 +10,7 @@ import {
|
|||||||
} from './token.ts';
|
} from './token.ts';
|
||||||
import { fetchBackupBytes } from './sync.ts';
|
import { fetchBackupBytes } from './sync.ts';
|
||||||
import { decompressBackup } from './zstd.ts';
|
import { decompressBackup } from './zstd.ts';
|
||||||
import { hasDatabaseFile, writeDatabaseFile } from './opfs.ts';
|
import { writeDatabaseFile } from './opfs.ts';
|
||||||
|
|
||||||
export function renderSyncPage(root: HTMLElement): void {
|
export function renderSyncPage(root: HTMLElement): void {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -40,8 +39,7 @@ export function renderSyncPage(root: HTMLElement): void {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button id="btn-download" class="btn" type="button" disabled>📥 Скачать</button>
|
<button id="btn-download" class="btn btn-primary" type="button" disabled>📥 Скачать</button>
|
||||||
<button id="btn-upload" class="btn btn-primary" type="button" disabled>📤 Выгрузить</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
|
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
|
||||||
@@ -52,7 +50,6 @@ export function renderSyncPage(root: HTMLElement): void {
|
|||||||
const tokenError = root.querySelector<HTMLElement>('#token-error')!;
|
const tokenError = root.querySelector<HTMLElement>('#token-error')!;
|
||||||
const tokenCounter = root.querySelector<HTMLElement>('#token-counter')!;
|
const tokenCounter = root.querySelector<HTMLElement>('#token-counter')!;
|
||||||
const btnDownload = root.querySelector<HTMLButtonElement>('#btn-download')!;
|
const btnDownload = root.querySelector<HTMLButtonElement>('#btn-download')!;
|
||||||
const btnUpload = root.querySelector<HTMLButtonElement>('#btn-upload')!;
|
|
||||||
const statusEl = root.querySelector<HTMLElement>('#status')!;
|
const statusEl = root.querySelector<HTMLElement>('#status')!;
|
||||||
|
|
||||||
function setStatus(msg: string): void {
|
function setStatus(msg: string): void {
|
||||||
@@ -66,15 +63,7 @@ export function renderSyncPage(root: HTMLElement): void {
|
|||||||
|
|
||||||
function setBusy(busy: boolean): void {
|
function setBusy(busy: boolean): void {
|
||||||
btnDownload.disabled = busy || !isValidToken(currentToken());
|
btnDownload.disabled = busy || !isValidToken(currentToken());
|
||||||
btnUpload.disabled = true;
|
|
||||||
tokenInput.disabled = busy;
|
tokenInput.disabled = busy;
|
||||||
if (!busy) {
|
|
||||||
void hasDatabaseFile().then((has) => {
|
|
||||||
if (!tokenInput.disabled && isValidToken(currentToken())) {
|
|
||||||
btnUpload.disabled = !has;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshUI(): void {
|
function refreshUI(): void {
|
||||||
@@ -93,13 +82,6 @@ export function renderSyncPage(root: HTMLElement): void {
|
|||||||
tokenError.textContent = ok ? '' : 'Только латинские буквы и цифры';
|
tokenError.textContent = ok ? '' : 'Только латинские буквы и цифры';
|
||||||
}
|
}
|
||||||
btnDownload.disabled = !ok;
|
btnDownload.disabled = !ok;
|
||||||
if (ok) {
|
|
||||||
void hasDatabaseFile().then((has) => {
|
|
||||||
if (!tokenInput.disabled) btnUpload.disabled = !has;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
btnUpload.disabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload(): Promise<void> {
|
async function handleDownload(): Promise<void> {
|
||||||
@@ -132,7 +114,7 @@ export function renderSyncPage(root: HTMLElement): void {
|
|||||||
tokenInput.value = token;
|
tokenInput.value = token;
|
||||||
if (isValidToken(token)) {
|
if (isValidToken(token)) {
|
||||||
saveToken(token);
|
saveToken(token);
|
||||||
setStatus('Токен сохранён локально. Можно скачивать или выгружать.');
|
setStatus('Токен сохранён локально. Можно скачивать.');
|
||||||
}
|
}
|
||||||
refreshUI();
|
refreshUI();
|
||||||
});
|
});
|
||||||
@@ -141,26 +123,12 @@ export function renderSyncPage(root: HTMLElement): void {
|
|||||||
void handleDownload();
|
void handleDownload();
|
||||||
});
|
});
|
||||||
|
|
||||||
btnUpload.addEventListener('click', () => {
|
|
||||||
const token = currentToken();
|
|
||||||
if (!isValidToken(token)) return;
|
|
||||||
saveToken(token);
|
|
||||||
setStatus('Выгрузка — следующий шаг (POST /upload/stream/{id}).');
|
|
||||||
});
|
|
||||||
|
|
||||||
const saved = loadSavedToken();
|
const saved = loadSavedToken();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
tokenInput.value = saved;
|
tokenInput.value = saved;
|
||||||
}
|
}
|
||||||
refreshUI();
|
refreshUI();
|
||||||
if (isValidToken(normalizeToken(saved))) {
|
if (isValidToken(normalizeToken(saved))) {
|
||||||
void hasDatabaseFile().then((has) => {
|
setStatus('Токен восстановлен. Нажми «Скачать», чтобы получить базу.');
|
||||||
setStatus(
|
|
||||||
has
|
|
||||||
? 'Токен восстановлен, локальная база на месте.'
|
|
||||||
: 'Токен восстановлен. Нажми «Скачать», чтобы получить базу.',
|
|
||||||
);
|
|
||||||
refreshUI();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
// Сеть: скачивание сохранения с бэкенда.
|
// Сеть: скачивание сохранения с бэкенда.
|
||||||
// GET https://learning.micialware.ru/download/{id} -> zstd-сжатый .sqlite (бинарь).
|
// GET https://learning.micialware.ru/download/{id} -> zstd-сжатый .sqlite (бинарь).
|
||||||
// Выгрузка (POST /upload/stream/{id}) — в следующем шаге.
|
|
||||||
|
|
||||||
export const API_BASE_URL = 'https://learning.micialware.ru';
|
export const API_BASE_URL = 'https://learning.micialware.ru';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// Токен сохранения: ровно 24 символа, только латиница и цифры.
|
// Токен сохранения: ровно 24 символа, только латиница и цифры.
|
||||||
// Это НЕ JWT — просто короткий идентификатор для download/upload .sqlite.
|
// Просто короткий идентификатор для download .sqlite.
|
||||||
|
|
||||||
export const TOKEN_LENGTH = 24;
|
export const TOKEN_LENGTH = 24;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user