1/* Sube tu lista · Editorial GEU â lógica de la página. 2 Separado del HTML para que el módulo lo sirva como asset cacheable. 3 El catálogo NO va aquÃ: se lee de #catalog, que la plantilla rellena. */ 4 5/* ===== Motor de reconocimiento de listas · GEU ===== 6 Entrada: texto libre (una lÃnea por material) + catálogo [{id,t,s,core,num,isbn,...}] 7 Salida: por lÃnea {raw, status, product, candidates, qty, via, note} 8 status: match | review | geu-unknown | code-unknown | other | ignored */ 9const Engine = (() => { 10 const STOP = new Set('de del la las el los y e o u a al en con para por un una mi mis sus su tu the with of and n no num nº libro libros cuaderno cuadernos cuadernillo cuadernillos editorial ed isbn ean ref'.split(' ')); 11 // palabras que aparecen en cualquier lista: por sà solas no identifican un producto GEU 12 const GENERIC = new Set('matematicas lengua castellana literatura ingles primaria secundaria eso infantil calculo numeros lectura escritura refuerzo curso pack'.split(' ')); 13 const KEEP_SHORT = new Set(['r', 'que', 'por']); 14 // Material de papelerÃa que sale en todas las listas. Aunque alguna de estas palabras 15 // aparezca en un producto GEU («Dime por qué - Carpeta 1»), sola no basta para proponerlo. 16 const PAPELERIA = new Set(('lapiz lapices carpeta carpetas agenda escolar bloc dibujo goma gomas sacapuntas tijeras ' + 17 'pegamento regla estuche libreta libretas folios rotuladores pinturas ceras plastilina flauta mochila ' + 18 'archivador fundas funda cartulina cartulinas acuarelas pincel compas calculadora diccionario papel forro ' + 19 'etiquetas bolsa babi pañuelos toallitas colores boligrafo boligrafos anillas grapadora sobres').split(' ')); 20 21 const deacc = s => s.normalize('NFD').replace(/[Ì-ͯ]/g, ''); 22 const norm = s => deacc(String(s).toLowerCase()); 23 const words = s =>
23 norm(s).replace(/[^a-z0-9ñ]+/g, ' ').split(' ').filter(Boolean); 24 25 function eanOk(d) { 26 if (!/^\d{13}$/.test(d)) return false; 27 let s = 0; for (let i = 0; i < 12; i++) s += +d[i] * (i % 2 ? 3 : 1); 28 return (10 - s % 10) % 10 === +d[12]; 29 } 30 function isbn10to13(d) { 31 const x = d.toUpperCase(); 32 if (!/^\d{9}[\dX]$/.test(x)) return null; 33 let s = 0; for (let i = 0; i < 10; i++) s += (x[i] === 'X' ? 10 : +x[i]) * (10 - i); 34 if (s % 11) return null; 35 const b = '978' + x.slice(0, 9); let t = 0; 36 for (let i = 0; i < 12; i++) t += +b[i] * (i % 2 ? 3 : 1); 37 return b + ((10 - t % 10) % 10); 38 } 39 // Los ISBN vienen escritos de mil maneras: con guiones (también los largos de Word), 40 // espacios, puntos, barras, pegados a otras palabras o con la cantidad al lado. 41 const SEP = '[ \\-\\u2010-\\u2015._/]'; 42 const CODE_RE = new RegExp('\\d(?:' + SEP + '?\\d){8,16}', 'g'); 43 const ISBN10X_RE = new RegExp('\\d(?:' + SEP + '?\\d){8}' + SEP + '?[Xx]', 'g'); 44 // Devuelve {codes:[ean13], bad:[texto], rest: texto sin códigos} 45 function extractCodes(line) { 46 const codes = [], bad = []; 47 // sustituye solo cuando el trozo no está pegado a otras cifras (evita partir un código largo) 48 const scan = (text, re, fn) => text.replace(re, (m, off, whole) => { 49 const before = whole[off - 1] || '', after = whole[off + m.length] || ''; 50 if (/[0-9]/.test(before) || /[0-9]/.test(after)) return m; 51 return fn(m); 52 }); 53 // primero los ISBN-10 que acaban en X, que el patrón de cifras no reconoce 54 let txt = scan(line, ISBN10X_RE, m => { 55 const d = m.replace(/[^0-9Xx]/g, '').toUpperCase(); 56 const c = isbn10to13(d); 57 if (c) { codes.push(c); return ' '; } 58 bad.push(m.trim()); return ' '; 59 }); 60 const rest = scan(txt, CODE_RE, m => { 61 const d = m.replace(/[^0-9Xx]/g, '').toUpperCase(); 62 // candidatos: el código entero, y sus 13 o 10 últimas/primeras cifras 63 const tries = [d]; 64 if (d.length > 13) tries.push(d.slice(-13), d.slice(0, 13)); 65 if (d.length > 10) tries.push(d.slice(-10), d.slice(0, 10)); 66 for (const t of tries) { 67 if (t.length === 13 && eanOk(t)) { codes.push(t); return ' '; } 68 if (t.length === 10) { const c = isbn10to13(t); if (c) { codes.push(c); return ' '; } } 69 } 70 // 13 cifras con pinta de ISBN pero con el dÃgito de control mal: seguramente está mal copiado 71 if (/^(97[89]|84)/.test(d) && (d.length === 13 || d.length === 10)) { bad.push(m.trim()); return ' '; } 72 return m; 73 }); 74 return { codes, bad, rest }; 75 } 76 function extractQty(text) { 77 let qty = 1; 78 const pats = [ 79 /(?:^|\s)(?:x|Ã)\s?(\d{1,2})(?=\s|$|[.,;)])/i, 80 /(?:^|\s)(\d{1,2})\s?(?:x|Ã)(?=\s)/i, 81 /\b(\d{1,2})\s?(?:uds?\.?|unidades|ejemplares|copias)\b/i, 82 /\bcant(?:idad)?\.?:?\s?(\d{1,2})\b/i, 83 /\((\d{1,2})\s*(?:uds?\.?)?\)\s*$/i 84 ]; 85 for (const p of pats) { 86 const m = text.match(p); 87 if (m) { qty = Math.max(1, Math.min(99, +m[1])); text = text.replace(m[0], ' '); break; } 88 } 89 return { qty, text }; 90 } 91 // Números útiles para distinguir cuadernillos: "5", "2.1", curso "3º" â "3" 92 function extractNums(text) { 93 let t = ' ' + norm(text) + ' '; 94 t = t.replace(/\b(19|20)\d{2}\s*[\/\-]\s*(20)?\d{2}\b/g, ' ') // cursos 2026/2027 95 .replace(/\b(19|20)\d{2}\b/g, ' ') // años 96 .replace(/\d+[.,]\d{2}\s*(â¬|eur)/g, ' ') // precios 97 .replace(/\b\d+\s*(mm|cm|hojas|paginas|pags?)\b/g, ' ') // medidas 98 .replace(/n\s*\.?\s*[ºo°]\s*/g, ' '); // nº 5 â 5 99 const set = new Set(), ints = []; 100 const re = /(\d{1,2})(?:\s*[.,]\s*(\d)(?!\d))?\s*(?:º|ª|°|\.º|er|o\b)?/g; 101 let m; 102 while ((m = re.exec(t))) { 103 const a = String(+m[1]); 104 if (m[2] !== undefined) { set.add(a + '.' + m[2]); set.add(a); } 105 else { set.add(a); ints.push(a); } 106 } 107 for (let i = 0; i + 1 < ints.length; i++) set.add(ints[i] + '.' + ints[i + 1]); 108 if (/\binfantil\b/.test(t)) set.add('infantil'); 109 return set; 110 } 111 const tokMatch = (lt, c) => lt === c || (Math.min(lt.length, c.length) >= 4 && (lt.startsWith(c) || c.startsWith(lt))); 112 113 function build(catalog) { 114 const df = {}; const cores = new Map(); 115 for (const p of catalog) { 116 p._core = words(p.core).filter(w => KEEP_SHORT.has(w) || (w.length > 1 && !STOP.has(w))); 117 cores.set(p.core, p._core); 118 } 119 for (const ws of cores.values()) for (const w of new Set(ws)) df[w] = (df[w] || 0) + 1; 120 const N = cores.size, W = {}; 121 for (const w in df) W[w] = Math.log(1 + N / df[w]) * (GENERIC.has(w) ? 0.6 : 1); 122 // Un mismo código puede ser de varias fichas (combinaciones sin código propio): 123 // por eso cada código apunta a una lista. 124 const byCode = new Map(); 125 for (const p of catalog) { 126 if (!p.isbn) continue; 127 if (!byCode.has(p.isbn)) byCode.set(p.isbn, []); 128 byCode.get(p.isbn).push(p); 129 } 130 const prefixes = new Set(catalog.filter(p => p.isbn).map(p => p.isbn.startsWith('843') ? p.isbn.slice(0, 7) : p.isbn.slice(0, 10))); 131 const bySeries = {}; 132 for (const p of catalog) (bySeries[p.core] ||= []).push(p); 133 // Palabras de variante (montessori, cuadricula, mayusculaâ¦): si la lÃnea trae alguna, 134 // sirve para elegir entre versiones del mismo cuadernillo. 135 const VARTOK = new Set(); 136 for (const p of catalog) { p._vk = p.vk ? words(p.vk) : []; for (const w of p._vk) VARTOK.add(w); } 137 // Colecciones: las del filtro «Colecciones destacadas» de la tienda. Son la forma 138 // fiable de saber qué cuadernos van juntos, aunque sus tÃtulos no se parezcan 139 // («Nivel 1. Cuaderno 4» y «Nivel adultos. Cuaderno 4» son la misma colección). 140 const COLS = new Map(); 141 for (const p of catalog) { 142 p._f = p.f || {}; 143 p._col = (p._f.c && p._f.c[0]) || ''; 144 for (const c of (p._f.c || [])) {
145 const k = norm(c); 146 if (!COLS.has(k)) COLS.set(k, { name: c, w: words(c).filter(w => w.length > 1 && !STOP.has(w)), prods: [] }); 147 COLS.get(k).prods.push(p); 148 } 149 } 150 return { catalog, W, DF: df, byCode, prefixes, bySeries, VARTOK, COLS }; 151 } 152 153 const numOrdenE = n => n == null ? 999 : n === 'infantil' ? -1 : (parseInt(n, 10) || 0) + (String(n).includes('.') ? +String(n).split('.')[1] / 1000 : 0); 154 // Se queda con los que mejor casan con el número de la lÃnea: exacto, luego el entero 155 // (2 â 2.1, 2.2â¦) y, dentro de una colección, el cuaderno de cada nivel (4 â 1.4, 2.4). 156 function filtraNum(arr, nums) { 157 const st = arr.map(p => { 158 let e = numState(p, nums); 159 if (e === 'no' && String(p.num).includes('.') && nums.has(String(p.num).split('.')[1])) { 160 // «Nivel 1. Cuaderno 4»: el 4 es tan suyo como del «Nivel adultos. Cuaderno 4». 161 e = /\bnivel\b/i.test(p.n) ? 'yes' : 'menor'; 162 } 163 return [p, e]; 164 }); 165 let out = st.filter(([, e]) => e !== 'no'); 166 if (out.some(([, e]) => e === 'yes')) { 167 out = out.filter(([, e]) => e === 'yes'); 168 const ex = out.filter(([p]) => String(p.num).includes('.') && nums.has(String(p.num))); 169 if (ex.length) out = ex; 170 } else if (out.some(([, e]) => e === 'partial')) out = out.filter(([, e]) => e === 'partial'); 171 else if (out.some(([, e]) => e === 'menor')) out = out.filter(([, e]) => e === 'menor'); 172 return out.map(([p]) => p); 173 } 174 function numState(p, nums) { 175 if (p.num == null) return 'none'; 176 if (nums.has(String(p.num))) return 'yes'; 177 if (nums.size === 0) return 'unknown'; 178 if (String(p.num).includes('.') && nums.has(String(p.num).split('.')[0])) return 'partial'; 179 return 'no'; 180 } 181 182 // Colecciones que nombra la lÃnea. Vale si la lÃnea trae casi todo el nombre de la 183 // colección («estimulación funciones cognitivas»), o si todo lo que dice la lÃnea está en 184 // ese nombre y alguna palabra es propia («cognitivas»). 185 function matchCols(lt, ltUtil) { 186 if (!IDX.COLS.size || !ltUtil.length) return []; 187 const out = []; 188 for (const col of IDX.COLS.values()) { 189 if (!col.w.length) continue; 190 const hit = col.w.filter(c => lt.some(w => tokMatch(w, c))); 191 const propia = hit.some(c => !GENERIC.has(c) && !PAPELERIA.has(c) && c.length >= 4); 192 if (!propia) continue; 193 const cov = hit.length / col.w.length; 194 const deLinea = ltUtil.filter(w => col.w.some(c => tokMatch(w, c))).length / ltUtil.length; 195 const larga = hit.some(c => !GENERIC.has(c) && !PAPELERIA.has(c) && c.length >= 5); 196 if ((cov >= 0.6 && deLinea >= 0.5) || (deLinea === 1 && larga)) out.push({ col, cov, deLinea }); 197 } 198 if (!out.length) return []; 199 const top = Math.max(...out.map(o => o.cov + o.deLinea)); 200 return out.filter(o => o.cov + o.deLinea >= top - 0.34).map(o => o.col); 201 } 202 203 // Los filtros de la tienda que la lÃnea ya dice («adultos», «montessori»): se aplican 204 // solos. Los genéricos («primaria») no, que salen en todas las listas. 205 const GRUPOS_AUTO = ['cu', 'pa', 'id']; 206 function autoFiltros(lista, lt) { 207 for (const g of GRUPOS_AUTO) { 208 const valores = new Set(lista.flatMap(p => (p._f && p._f[g]) || [])); 209 for (const v of valores) { 210 const vw = words(v).filter(w => !/^\d+$/.test(w) && w.length >= 4 && !STOP.has(w)); 211 if (!vw.length || vw.every(w => GENERIC.has(w))) continue; 212 if (!vw.every(c => lt.some(w => tokMatch(w, c)))) continue; 213 const quedan = lista.filter(p => ((p._f && p._f[g]) || []).includes(v)); 214 if (quedan.length) lista = quedan; 215 } 216 } 217 return lista; 218 } 219 220 const OTHER_PUB =/\b(santillana|anaya|sm|edelvives|vicens ?vives|oxford|bruno|everest|edebe|teide|casals|mc ?graw|pearson|macmillan|cambridge|rubio|lamela|vox|larousse|algaida|barcanova|baula|editex|la galera|micho)\b/; 221 const HEADER = /^(lista|listado|relaci[oó]n|curso|colegio|ceip|c\.e\.i\.p|cp |ies |nombre|alumn|tutor|fecha|observaciones|nota|material(es)? (escolar|de|para)|libros de texto|cuadernillos|otros materiales|papeler[iÃ]a)\b/i; 222 223 function analyzeLine(raw, idx) { 224 const line = raw.replace(/^[\s\-âââ¢Â·*·âªâââ¦>]+|\s+$/g, '').replace(/^\d{1,2}[.)]\s+(?=\D)/, ''); 225 const letters = (line.match(/[a-záéÃóúñü]/gi) || []).length; 226 if (!line || (letters < 3 && !/\d{10,}/.test(line.replace(/\D/g, '')))) return { raw, status: 'ignored' }; 227 const isCapsHeader = line === line.toUpperCase() && !/\d/.test(line) && line.split(/\s+/).length <= 4; 228 if (isCapsHeader || /:\s*$/.test(line) || (HEADER.test(line) && !/\d{10,}/.test(line.replace(/[\s-]/g, '')))) return { raw, status: 'ignored', header: true }; 229 230 const { codes, bad, rest } = extractCodes(line); 231 const { qty, text } = extractQty(rest); 232 const res = { raw: line, qty, status: 'other', candidates: [], codes, bad }; 233 const hasGeu = /\bgeu\b/i.test(line); 234 const otherPub = !hasGeu && OTHER_PUB.test(norm(line)); 235 236 // 1) Código: se busca, pero no se decide todavÃa. Lo escrito manda cuando es más concreto, 237 // porque el ISBN se copia mal con facilidad y el profesor escribe la variante a conciencia. 238 const codeHits = [...new Set(codes.flatMap(c => IDX.byCode.get(c) || []))]; 239 let pCode = codeHits.length === 1 ? codeHits[0] : null; 240 const geuPrefix = codes.find(c => IDX.prefixes.has(c.startsWith('843') ? c.slice(0, 7) : c.slice(0, 10))); 241 242 if (otherPub && !pCode) return Object.assign(res, { note: 'Es de otra editorial.' }); 243 // 2) TÃtulo 244 const lt = words(text); 245 // Palabras de la lÃnea que cuentan para saber «de qué trata» (sin vacÃas
245ni números). 246 const ltUtil = lt.filter(w => !/^\d+$/.test(w) && (KEEP_SHORT.has(w) || (w.length > 1 && !STOP.has(w)))); 247 const nums = extractNums(text); 248 const scored = []; 249 for (const p of IDX.catalog) { 250 let tot = 0, got = 0, specific = false, rara = false; 251 for (const c of p._core) { 252 tot += IDX.W[c]; 253 if (lt.some(w => tokMatch(w, c))) { 254 got += IDX.W[c]; 255 if (!GENERIC.has(c)) specific = true; 256 // Una palabra que solo sale en una o dos colecciones («enjoying», «prolexyco», 257 // «pictocuaderno») identifica la colección aunque se escriba sola. 258 if (!GENERIC.has(c) && !PAPELERIA.has(c) && c.length >= 5 && IDX.DF[c] <= 2) rara = true; 259 } 260 } 261 const cov = tot ? got / tot : 0; 262 // La palabra rara solo vale si la lÃnea trata de eso: más de la mitad de sus palabras 263 // tienen que ser de esta colección. «enjoying» sÃ; «carpeta de anillas» no. 264 if (rara) { 265 const deLaColeccion = ltUtil.filter(w => p._core.some(c => tokMatch(w, c))).length; 266 if (!ltUtil.length || deLaColeccion / ltUtil.length <= 0.5) rara = false; 267 } 268 if ((cov < 0.4 && !rara) || !specific) continue; 269 scored.push({ p, cov, n: numState(p, nums), weight: tot }); 270 } 271 // «Activity book 1» del nivel 1 es el 1.1: si coincide un número con decimal, los que 272 // solo coinciden en el entero dejan de contar como exactos. 273 if (scored.some(s => s.n === 'yes' && String(s.p.num).includes('.'))) { 274 for (const s of scored) if (s.n === 'yes' && !String(s.p.num).includes('.')) s.n = 'partial'; 275 } 276 277 // Variantes del mismo cuadernillo (cuadrÃcula, Montessori, mayúscula, digitalâ¦). 278 // Si la lÃnea dice cuál, se queda esa. Si no lo dice, las versiones secundarias 279 // (digital, edición antigua) se apartan mientras haya una de papel, y entre las demás 280 // se pregunta. 281 const lineV = lt.filter(w => IDX.VARTOK.has(w)); 282 const vScore = p => { 283 if (!p._vk.length) return 0; 284 let got = 0; for (const c of p._vk) if (lt.some(w => tokMatch(w, c))) got++; 285 // Las digitales solo si la lÃnea lo dice entero: «cuaderno del alumno» no es 286 // «digital alumno». 287 if (p.sec && got < p._vk.length) return 0; 288 return got ? got * 10 - (p._vk.length - got) : 0; 289 }; 290 // Se decide dentro de cada colección y número: «con pictogramas» elige entre las 291 // versiones de Lecturas comprensivas 1, no entre todo el catálogo. 292 const porVarianteUna = arr => { 293 if (lineV.length) { 294 const best = Math.max(0, ...arr.map(s => vScore(s.p || s))); 295 if (best > 0) return arr.filter(s => vScore(s.p || s) === best); 296 } 297 const papel = arr.filter(s => !(s.p || s).sec); 298 return papel.length ? papel : arr; 299 }; 300 const porVariante = arr => { 301 const g = new Map(); 302 for (const s of arr) { const q = s.p || s, c = q.core + '|' + q.num; if (!g.has(c)) g.set(c, []); g.get(c).push(s); } 303 return [...g.values()].flatMap(porVarianteUna); 304 }; 305 scored.splice(0, scored.length, ...porVariante(scored)); 306 // El ISBN de la lista es de varias combinaciones: se elige por la variante si la dice. 307 if (!pCode && codeHits.length > 1) { 308 const quedan = porVariante(codeHits); 309 if (quedan.length === 1) pCode = quedan[0]; 310 else { 311 return Object.assign(res, { status: 'review', candidates: quedan, 312 note: 'Ese ISBN tiene varias versiones. Elige la de tu lista.' }); 313 } 314 } 315 const full = scored.filter(s => s.cov > 0.999); 316 const pool = full.length ? full : scored; 317 // lo más especÃfico gana (p. ej. "pictocuaderno de grafomotricidad" frente a "grafomotricidad") 318 const maxW = Math.max(0, ...pool.filter(s => s.n !== 'no').map(s => s.weight)); 319 let best = pool.filter(s => s.n !== 'no' && s.weight >= maxW - 1e-9); 320 const exact = best.filter(s => s.n === 'yes' || s.n === 'none'); 321 322 const coreWeight = p => p._core.reduce((a, c) => a + IDX.W[c], 0); 323 const pTitle = (full.length && exact.length === 1 && (exact[0].n === 'none' || exact[0].n === 'yes')) ? exact[0].p : null; 324 325 if (pTitle && pCode && pTitle !== pCode) { 326 // el texto y el ISBN señalan cuadernillos distintos: gana el más concreto de los dos 327 const ganaTexto = coreWeight(pTitle) >= coreWeight(pCode); 328 return Object.assign(res, { 329 status: 'match', 330 product: ganaTexto ? pTitle : pCode, 331 via: ganaTexto ? 'title' : 'isbn', 332 alt: ganaTexto ? pCode : pTitle, 333 note: ganaTexto 334 ? `Hemos hecho caso a lo que pone tu lista. El ISBN que trae es el de «${pCode.n}».` 335 : `El ISBN de tu lista es el de «${pCode.n}», más concreto que el tÃtulo escrito.` 336 }); 337 } 338 if (pTitle) { 339 return Object.assign(res, { 340 status: 'match', product: pTitle, via: pCode ? 'isbn' : 'title', 341 note: (codes.length && !pCode) ? 'El ISBN de tu lista no está en el catálogo cargado: lo hemos buscado por el tÃtulo.' : undefined 342 }); 343 } 344 if (pCode) return Object.assign(res, { status: 'match', product: pCode, via: 'isbn' }); 345 if (codes.length && !geuPrefix && !full.length) { 346 // ISBN correcto que no está en este catálogo: puede ser de otra editorial o faltarnos el producto 347 return Object.assign(res, { status: 'code-unknown', note: 'Este ISBN no está en el catálogo cargado.' }); 348 }
349 let cands = (full.length ? full : scored).filter(s => s.n !== 'no'); 350 // si hay número y alguno coincide, quedarse con esos 351 if (cands.some(s => s.n === 'yes')) cands = cands.filter(s => s.n === 'yes' || s.n === 'none'); 352 // «Nivel 2 cuaderno 4» casa exacto con el 2.4: los que no llevan número sobran. 353 const decimalExacto = cands.some(s => s.n === 'yes' && String(s.p.num).includes('.')); 354 if (decimalExacto) cands = cands.filter(s => s.n === 'yes'); 355 cands.sort((a, b) => (b.cov - a.cov) || (b.n === 'yes') - (a.n === 'yes') || (+String(a.p.num).replace('infantil', '0') - +String(b.p.num).replace('infantil', '0'))); 356 const topCov = cands.length ? cands[0].cov : 0; 357 cands = cands.filter(s => s.cov >= topCov - 0.2); 358 // Una misma colección puede tener 16 números à 4 versiones: ahà se pasan todas y la 359 // pantalla pregunta primero el número y luego la versión. Si son colecciones 360 // distintas, con una docena basta. 361 // Los que llevan en el tÃtulo TODO lo que dice la lÃnea, aunque tengan más palabras: 362 // «funciones cognitivas» también es «⦠Nivel adultos. Cuaderno 4». Antes se quedaban 363 // fuera por no cubrir el tÃtulo entero. 364 if (cands.length && !decimalExacto && ltUtil.length >= 2 && ltUtil.some(w => !GENERIC.has(w) && w.length >= 5)) { 365 const ya = new Set(cands.map(s => s.p)); 366 const conSi = false; 367 let extra = IDX.catalog.filter(p => !ya.has(p) && ltUtil.every(w => p._core.some(c => tokMatch(w, c)))); 368 extra = filtraNum(extra, nums).map(p => ({ p, cov: 0, n: numState(p, nums), weight: 0 })) 369 .filter(s => !conSi || s.n === 'yes'); 370 extra = porVariante(extra).filter(s => !s.p.sec || vScore(s.p) > 0); 371 extra.sort((a, b) => a.p.core.localeCompare(b.p.core) || (numOrdenE(a.p.num) - numOrdenE(b.p.num))); 372 cands = cands.concat(extra); 373 } 374 const unaColeccion = new Set(cands.map(s => s.p.core)).size === 1; 375 cands = cands.slice(0, unaColeccion ? 120 : 60).map(s => s.p); 376 377 // Colecciones de la tienda. Si la lÃnea nombra una (o casi todos los candidatos son 378 // de la misma), se ofrecen TODOS sus cuadernos que casen con el número, no solo los 379 // que se parecen en el tÃtulo. Asà salen también los de adultos, los de otro nivel⦠380 let cols = matchCols(lt, ltUtil); 381 const porNombre = cols.length > 0; 382 if (!cols.length && cands.length) { 383 const cuenta = new Map(); 384 for (const p of cands) if (p._col) cuenta.set(p._col, (cuenta.get(p._col) || 0) + 1); 385 const [top, veces] = [...cuenta.entries()].sort((a, b) => b[1] - a[1])[0] || []; 386 if (top && veces >= cands.length / 2) cols = [IDX.COLS.get(norm(top))].filter(Boolean); 387 } 388 if (cols.length) { 389 let pool = [...new Set(cols.flatMap(c => c.prods))]; 390 pool = filtraNum(pool, nums); 391 pool = porVariante(pool.map(p => ({ p }))).map(s => s.p); 392 // Digitales y ediciones antiguas solo si la lÃnea las pide o no hay otra cosa. 393 const papel = pool.filter(p => !p.sec || vScore(p) > 0); 394 if (papel.length) pool = papel; 395 pool.sort((a, b) => (a._col || '').localeCompare(b._col || '') || (numOrdenE(a.num) - numOrdenE(b.num)) || a.n.localeCompare(b.n, 'es')); 396 if (pool.length) { 397 // Si la lÃnea nombra la colección, mandan sus cuadernos; si solo se dedujo de los 398 // candidatos, se suman a ellos. 399 cands = [...new Set([...cands, ...pool])]; 400 // Todo de la misma colección: en orden de la colección, no de parecido. 401 if (new Set(cands.map(p => p._col || p.core)).size === 1) { 402 cands.sort((a, b) => a.core.localeCompare(b.core) || (numOrdenE(a.num) - numOrdenE(b.num)) || a.n.localeCompare(b.n, 'es')); 403 } 404 } 405 } 406 // Una sola palabra propia («cognitivas», «grafomotricidad»): si no ha salido nada, 407 // se ofrecen los cuadernos que la llevan en el tÃtulo, para elegir con los filtros. 408 if (!cands.length && ltUtil.length === 1) { 409 const w = ltUtil[0]; 410 if (w.length >= 6 && !GENERIC.has(w) && !PAPELERIA.has(w)) { 411 let q = filtraNum(IDX.catalog.filter(p => p._core.some(c => tokMatch(w, c))), nums); 412 q = porVariante(q.map(p => ({ p }))).map(s => s.p); 413 const papel = q.filter(p => !p.sec); 414 if (papel.length) q = papel; 415 q.sort((a, b) =>
415 a.core.localeCompare(b.core) || (numOrdenE(a.num) - numOrdenE(b.num)) || a.n.localeCompare(b.n, 'es')); 416 if (q.length && q.length <= 250) cands = q; 417 } 418 } 419 if (cands.length) { 420 const antes = cands; 421 cands = autoFiltros(cands, lt); 422 // Con lo que dice la lÃnea queda uno solo y el número casa: es ese. 423 const e1 = cands.length === 1 ? numState(cands[0], nums) : ''; 424 if (cands.length === 1 && ((e1 === 'yes' && String(cands[0].num).includes('.')) || 425 ((antes.length > 1 || cols.length) && (e1 === 'yes' || (nums.size && cands[0].num != null))))) { 426 return Object.assign(res, { status: 'match', product: cands[0], via: 'title' }); 427 } 428 cands = cands.slice(0, 250); 429 } 430 if (cands.length) { 431 const sameSeries = new Set(cands.map(p => p.core)).size === 1; 432 res.status = 'review'; res.candidates = cands; 433 res.note = sameSeries && nums.size === 0 ? '¿Qué número de la serie pide tu lista?' 434 : sameSeries ? 'Revisa el número del cuadernillo.' 435 : 'Hay varias colecciones parecidas. Elige la de tu lista.'; 436 return res; 437 } 438 // serie reconocida pero número fuera de la muestra 439 if (bad.length) { 440 return Object.assign(res, { status: 'code-unknown', note: `El código ${bad[0]} no es un ISBN válido: revisa si falta o sobra alguna cifra.` }); 441 } 442 const seriesHit = scored.filter(s => s.cov > 0.999 && s.n === 'no'); 443 if (seriesHit.length || geuPrefix || hasGeu) { 444 res.status = 'geu-unknown'; 445 res.series = seriesHit[0] ? seriesHit[0].p.s : null; 446 res.note = seriesHit[0] ? `Parece de la colección «${seriesHit[0].p.s}», pero ese número no está en este catálogo.` 447 : geuPrefix ? 'El ISBN es de Editorial GEU, pero no está en este catálogo.' 448 : 'Menciona GEU, pero no hemos identificado el material.'; 449 return res; 450 } 451 return res; 452 } 453 454 // Muchas listas escriben el tÃtulo en una lÃnea y el ISBN en la siguiente: 455 // se unen para que el producto no salga dos veces. 456 function joinCodeLines(lines) { 457 const out = []; 458 for (const raw of lines) { 459 const solo = raw.replace(/[0-9Xx\s\-\u2010-\u2015._/:()]/g, '').replace(/(isbn|ean|ref|c[oó]d(igo)?)/gi, ''); 460 const tieneCodigo = extractCodes(raw).codes.length > 0; 461 let j = out.length - 1; 462 while (j >= 0 && !out[j].trim()) j--; // salta lÃneas en blanco 463 const prev = j >= 0 ? out[j] : ''; 464 const prevSirve = prev && !extractCodes(prev).codes.length && !/:\s*$/.test(prev) && 465 (prev.match(/[a-záéÃóúñü]/gi) || []).length >= 3 && 466 !(prev === prev.toUpperCase() && !/\d/.test(prev) && prev.split(/\s+/).length <= 4); 467 // Solo se unen si la lÃnea del código lleva etiqueta (ISBN/EAN) o si ese código 468 // es justo el producto que nombra la lÃnea anterior. Si no, son dos materiales distintos. 469 let mismaFicha = false; 470 if (tieneCodigo && solo.length <= 2 && prevSirve) { 471 const etiqueta = /(isbn|ean|ref|c[oó]d)/i.test(raw); 472 if (etiqueta) mismaFicha = true; 473 else { 474 const p = IDX && (IDX.byCode.get(extractCodes(raw).codes[0]) || [])[0]; 475 if (p) { 476 const lt = words(prev); 477 mismaFicha = p._core.some(c => !GENERIC.has(c) && lt.some(w => tokMatch(w, c))); 478 } 479 } 480 } 481 if (mismaFicha) out[j] = prev + ' · ' + raw.trim(); 482 else out.push(raw); 483 } 484 return out; 485 } 486 487 let IDX = null; 488 function analyze(text, catalog) { 489 if (!IDX || IDX.catalog !== catalog) IDX = build(catalog); 490 const lines = joinCodeLines(String(text).split(/\r?\n|;|â¢/)); 491 return lines.map(analyzeLine).filter(r => r.status !== 'ignored' || r.header); 492 } 493 return { analyze, eanOk, isbn10to13, extractNums, words }; 494})(); 495 496(async () => { 497 // El catálogo va incrustado en la página o, si no, en data/catalogo-geu.json 498 const inline = document.getElementById('catalog'); 499 const CATALOG = inline 500 ? JSON.parse(inline.textContent) 501 : await fetch(window.GEU_CATALOGO_URL || 'data/catalogo-geu.json').then(r => r.json()); 502 // Cada ficha tiene su clave: el producto, o producto-combinación si tiene combinaciones. 503 const keyOf = p => String(p.k != null ? p.k : p.id); 504 const byKey = new Map(CATALOG.map(p => [keyOf(p), p])); 505 // Dentro del módulo siempre estamos en la propia tienda y en la misma sesión, asà que 506 // la cesta se rellena por detrás. El apaño de abrir la tienda en otra pestaña era del 507 // prototipo, que vivÃa fuera; aquà sobra. 508 const CFG = window.GEU || {}; 509 const FREE_SHIP = 11; 510 const EXAMPLE = [
511 'Lista de material · 2º de Primaria · Curso 2026/2027', 512 'Lengua Castellana 2º Primaria. Ed. Anaya', 513 'Matemáticas 2º Primaria. Ed. Anaya', 514 'Lecturas comprensivas 7 · ISBN 978-84-17748-40-1', 515 'OrtografÃa divertida nº 4', 516 'Matemáticas fáciles 5 (GEU)', 517 'Mis problemas favoritos 2.1', 518 'CaligrafÃa 5', 519 'Diccionario escolar Vox Primaria', 520 '2 lápices HB, goma y sacapuntas' 521 ].join('\n'); 522 523 const $ = s => document.querySelector(s); 524 const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); 525 const eur = n => n.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' â¬'; 526 const wait = ms => new Promise(r => setTimeout(r, ms)); 527 528 /* ---------- estado ---------- */ 529 const choice = new Map(); // lÃnea â clave elegida | 'none' | {num} (número elegido, falta la versión) 530 const filt = new Map(); // lÃnea â {grupo: valor} de los filtros de la tienda que ha marcado 531 const mas = new Set(); // lÃneas en las que ha pedido ver todas las opciones 532 const qtyOv = new Map(); // lÃnea â cantidad 533 const off = new Set(); // lÃneas quitadas 534 let rows = []; 535 const list = $('#list'); 536 const statusEl = $('#status'); 537 538 function resetChoices() { choice.clear(); qtyOv.clear(); off.clear(); filt.clear(); mas.clear(); } 539 540 // Filtros de la tienda que se ofrecen para afinar (por orden). 541 const GRUPOS = [['c', 'Colección'], ['cu', 'Curso'], ['pa', 'Pauta'], ['id', 'Idioma']]; 542 const valoresDe = (p, g) => (p._f && p._f[g]) || []; 543 544 function analyze() { 545 rows = Engine.analyze(list.value, CATALOG).filter(r => r.status !== 'ignored'); 546 for (const r of rows) { 547 const c = choice.get(r.raw); 548 // Primero los filtros marcados (Curso, Pautaâ¦), luego el número y la versión. 549 const fs = filt.get(r.raw); 550 if (fs && r.status === 'review') { 551 let q = r.candidates; 552 for (const g in fs) { 553 const quedan = q.filter(p => valoresDe(p, g).includes(fs[g])); 554 if (quedan.length) q = quedan; 555 } 556 r.candidates = q; 557 r.filtros = fs; 558 if (q.length === 1 && !c) { r.status = 'match'; r.product = q[0]; r.note = undefined; } 559 } 560 if (c === 'none') r.status = 'other'; 561 else if (c && typeof c === 'object' && r.status === 'review') { 562 // Ya ha elegido el número: quedan las versiones de ese número. 563 const delNum = r.candidates.filter(p => String(p.num) === c.num); 564 if (delNum.length === 1) { r.status = 'match'; r.product = delNum[0]; r.note = undefined; } 565 else if (delNum.length) { r.candidates = delNum; r.pickedNum = c.num; } 566 } 567 else if (c && byKey.has(c)) { r.status = 'match'; r.product = byKey.get(c); r.note = undefined; r.alt = undefined; } 568 if (qtyOv.has(r.raw)) r.qty = qtyOv.get(r.raw); 569 } 570 render(); 571 } 572 573 /* ---------- pintado ---------- */ 574 const coverNum = p => p.num === 'infantil' ? 'Inf' : (p.num ?? Engine.words(p.s)[0][0].toUpperCase()); 575 const cover = p => `<span class="cover c-${p.cat}" aria-hidden="true">${esc(coverNum(p))}</span>`; 576 const lvl = p => (p.lvl || '').split(/\s\/\s|\s\(|,\s(?=[A-Z])/)[0]; 577 578 function rowMatch(r, i) { 579 const p = r.product, q = r.qty || 1, isOff = off.has(r.raw); 580 return `<li class="item${isOff ? ' off' : ''}"> 581 ${cover(p)} 582 <div><div class="name">${esc(p.n)}</div><div class="sub">${esc(lvl(p))}</div>${r.note ? `<div class="sub warnline">${esc(r.note)}</div>` : ''}${r.alt ? `<div class="choices"><button type="button" class="choice" data-act="pick" data-i="${i}" data-k="${esc(keyOf(r.alt))}">Prefiero «${esc(r.alt.n)}»</button></div>` : ''}</div> 583 <div class="qty" role="group" aria-label="Cantidad"><button type="button" data-act="minus" data-i="${i}" aria-label="Uno menos">â</button><output>${q}</output><button type="button" data-act="plus" data-i="${i}" aria-label="Uno más">+</button></div> 584 <div class="price">${eur(p.p * q)}</div> 585 <button type="button" class="x" data-act="toggle" data-i="${i}" aria-label="${isOff ? 'Volver a añadir' : 'Quitar'} ${esc(p.n)}">${isOff ? 'âº' : 'Ã'}</button> 586 </li>`; 587 } 588 589 const numTxt = n => n === 'infantil' ? 'Infantil' : 'Nº ' + n; 590 const numOrden = n => n === '' ? 999 : n === 'infantil' ? -1 : (parseInt(n, 10) || 0) + (String(n).includes('.') ? +String(n).split('.')[1] / 1000 : 0); 591 592 // Lo que va tras los dos puntos del nombre («â¦Cuaderno 4: Atención» â « · Atención»). 593 const apellido = p => {
594 const k = p.n.indexOf(':'); 595 const t = k > 0 ? p.n.slice(k + 1).trim() : ''; 596 return t && t.length <= 40 ? ' · ' + t : ''; 597 }; 598 599 // Lo que empiezan igual todos los nombres, cortado en una palabra entera. 600 function prefijoComun(ns) { 601 let pre = ns[0] || ''; 602 for (const n of ns) { let k = 0; while (k < pre.length && k < n.length && pre[k].toLowerCase() === n[k].toLowerCase()) k++; pre = pre.slice(0, k); } 603 const corte = pre.search(/[\s.,:;·\-ââ][^\s.,:;·\-ââ]*$/); 604 if (ns.every(n => n.length === pre.length || /[\s.,:;·\-ââ]/.test(n[pre.length] || ''))) return pre.length >= 8 ? pre : ''; 605 return corte >= 8 ? pre.slice(0, corte) : ''; 606 } 607 608 // Orden natural del curso: infantil, primaria, ESO, adultos. 609 const ordenCurso = v => { 610 const t = Engine.words(v).join(' '), n = parseInt(t, 10) || 0; 611 if (/infantil/.test(t)) return n; 612 if (/primaria/.test(t)) return 10 + n; 613 if (/eso|secundaria/.test(t)) return 20 + n; 614 if (/adult/.test(t)) return 30; 615 return 40; 616 }; 617 618 // Barra con los filtros de la tienda: solo los que sirven para separar las opciones. 619 function barraFiltros(r, i) { 620 const cands = r.candidates, act = r.filtros || {}; 621 if (cands.length <= 6 && !Object.keys(act).length) return ''; 622 const grupos = []; 623 for (const [g, nombre] of GRUPOS) { 624 const cuenta = new Map(); 625 for (const p of cands) for (const v of valoresDe(p, g)) cuenta.set(v, (cuenta.get(v) || 0) + 1); 626 let vals = [...cuenta.entries()]; 627 // Un valor que tienen todas no separa nada; salvo que sea el que ya ha marcado. 628 if (!act[g] && (vals.length < 2 || vals.every(([, n]) => n === cands.length))) continue; 629 if (g === 'cu') vals.sort((a, b) => ordenCurso(a[0]) - ordenCurso(b[0])); 630 else vals.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'es')); 631 if (act[g]) vals = vals.filter(([v]) => v === act[g]); 632 const chips = vals.map(([v, n]) => act[g] === v 633 ? `<button type="button" class="choice f on" data-act="unfilt" data-i="${i}" data-g="${g}" aria-pressed="true">${esc(v)} â</button>` 634 : `<button type="button" class="choice f" data-act="filt" data-i="${i}" data-g="${g}" data-v="${esc(v)}">${esc(v)} <small>${n}</small></button>`).join(''); 635 if (chips) grupos.push(`<div class="fg"><b>${nombre}</b>${chips}</div>`); 636 } 637 return grupos.length ? `<div class="filtros" role="group" aria-label="Afinar la búsqueda">${grupos.join('')}</div>` : ''; 638 } 639 640 function rowAsk(r, i) { 641 const cands = r.candidates; 642 // Misma colección: el mismo tÃtulo, o la misma colección de la tienda con números 643 // sencillos (1, 2, 3â¦). Si casi todos son «nivel 1 cuaderno 4» se enseñan los nombres. 644 const colsVistas = new Set(cands.map(p =>
644 p._col).filter(Boolean)); 645 const unaCol = colsVistas.size === 1 && 646 cands.filter(p => p.num == null).length <= cands.length * 0.2 && 647 cands.filter(p => String(p.num).includes('.')).length < cands.length * 0.2; 648 const same = (new Set(cands.map(p => p.core)).size === 1 || unaCol) && cands.length > 1; 649 // agrupadas por número, en orden 650 const grupos = new Map(); 651 for (const p of cands) { 652 const n = p.num == null ? '' : String(p.num); 653 if (!grupos.has(n)) grupos.set(n, []); 654 grupos.get(n).push(p); 655 } 656 const nums = [...grupos.keys()].sort((a, b) => numOrden(a) - numOrden(b)); 657 const hayVersiones = [...grupos.values()].some(g => g.length > 1); 658 659 let q, chips; 660 if (same && nums.length > 1 && hayVersiones) { 661 // Paso 1: el número. Al elegirlo se preguntan las versiones que tiene. 662 q = '¿Qué número?'; 663 chips = nums.map(n => { 664 const g = grupos.get(n); 665 return g.length === 1 666 ? `<button type="button" class="choice" data-act="pick" data-i="${i}" data-k="${esc(keyOf(g[0]))}" title="${esc(g[0].n)}">${esc(n ? numTxt(n) : g[0].n)}${g[0].v ? ' · ' + esc(g[0].v) : esc(apellido(g[0]))}</button>` 667 : `<button type="button" class="choice" data-act="num" data-i="${i}" data-num="${esc(n)}">${esc(n ? numTxt(n) : 'Sin número')} <small>(${g.length} versiones)</small></button>`; 668 }).join(''); 669 } else if (same && nums.length === 1 && cands.length > 1) { 670 // Paso 2 (o el número ya venÃa en la lista): la versión. 671 q = '¿Qué versión?'; 672 // Si dos versiones se llamarÃan igual (4 A y 4 B, las dos con pictogramas), se 673 // enseña el nombre entero para que se distingan. 674 const etiquetas = cands.map(p => p.v || p.n); 675 const veces = etiquetas.reduce((m, e) => m.set(e, (m.get(e) || 0) + 1), new Map()); 676 chips = cands.map((p, j) => 677 `<button type="button" class="choice" data-act="pick" data-i="${i}" data-k="${esc(keyOf(p))}">${esc(veces.get(etiquetas[j]) > 1 ? p.n : etiquetas[j])}</button>`).join(''); 678 if (r.pickedNum) chips += `<button type="button" class="choice" data-act="renum" data-i="${i}">â Otro número</button>`; 679 } else { 680 q = same ? '¿Qué número?' : cands.length === 1 ? '¿Es este?' : '¿Cuál es?'; 681 // Todas de la misma colección: se quita lo que tienen en común para que se lea lo 682 // que las distingue («Nivel adultos. Cuaderno 4: Atención»). 683 const comun = cands.length > 1 && colsVistas.size === 1 ? prefijoComun(cands.map(p => p.n)) : ''; 684 const LIM = 24; 685 const ver = mas.has(r.raw) ? cands : cands.slice(0, LIM); 686 chips = ver.map(p => { 687 let label = same && p.num != null && !String(p.num).includes('.') 688 ? numTxt(String(p.num)) + (p.v ? ' · ' + p.v : apellido(p)) 689 : p.n; 690 if (!same && comun) { 691 const resto = p.n.slice(comun.length).replace(/^[\s.,:;·\-ââ]+/, ''); 692 if (resto) label = resto; 693 } 694 return `<button type="button" class="choice" data-act="pick" data-i="${i}" data-k="${esc(keyOf(p))}" title="${esc(p.n)}">${esc(label)}</button>`; 695 }).join(''); 696 if (cands.length > ver.length) chips += `<button type="button" class="choice" data-act="mas" data-i="${i}">Ver las ${cands.length}</button>`; 697 if (comun && !same) q = `${esc(comun.replace(/[\s.,:;·\-ââ]+$/, ''))} · ¿cuál?`; 698 } 699 const titulo = r.pickedNum ? r.raw + ' · ' + numTxt(r.pickedNum) : r.raw; 700 return `<li class="item ask"> 701 <span class="cover c-ask" aria-hidden="true">?</span> 702 <div><div class="name">${esc(titulo)}</div>${barraFiltros(r, i)}<div class="sub">${q}</div> 703 <div class="choices">${chips}<button type="button" class="choice" data-act="none" data-i="${i}" style="color:var(--texto-3, #8F8F8F)">Ninguno</button></div></div> 704 </li>`; 705 } 706 707 function render() { 708 const box = $('#results'); 709 if (!rows.length) { box.hidden = true; renderBar(); return; } 710 const found = rows.filter(r => r.status === 'match'); 711 const asks = rows.filter(r => r.status === 'review'); 712 const notFound = rows.filter(r => r.status === 'geu-unknown' || r.status === 'code-unknown'); 713 const rest = rows.filter(r => r.status === 'other'); 714 const n = found.length + asks.length; 715 if (!n && !notFound.length && !rest.length) { box.hidden = true; renderBar(); return; } 716 let h = n 717 ? `<h2>Hemos encontrado <em>${n}</em> ${n === 1 ? 'material' : 'materiales'} de GEU</h2>` 718 : `<h2>No hemos encontrado materiales de GEU</h2><div class="empty">Comprueba que la lista incluye el tÃtulo o el ISBN, o <a href="${CFG.urlTienda}">mira el catálogo</a>.</div>`; 719 if (n) h += `<ul class="items">${rows.map((r, i) => r.status === 'review' ? rowAsk(r, i) : '').join('')}${rows.map((r, i) => r.status === 'match' ? rowMatch(r, i) : '').join('')}</ul>`; 720 if (notFound.length) { 721 h += `<ul class="items">${notFound.map(r => `<li class="item ask">
722 <span class="cover c-ask" aria-hidden="true">?</span> 723 <div><div class="name">${esc(r.raw)}</div><div class="sub">${esc(r.note || 'No lo hemos encontrado en nuestro cat\u00e1logo.')}</div> 724 <div class="choices"><a class="choice" href="${CFG.urlBuscar}${encodeURIComponent(r.raw.replace(/\(?\bGEU\b\)?/gi, '').trim())}">Buscarlo en la tienda</a></div></div> 725 </li>`).join('')}</ul>`; 726 } 727 if (rest.length) { 728 h += `<details class="nope"><summary>${rest.length} ${rest.length === 1 ? 'material no es' : 'materiales no son'} de GEU</summary><ul>${rest.map(r => `<li>${esc(r.raw)}</li>`).join('')}</ul></details>`; 729 } 730 box.innerHTML = h; 731 box.hidden = false; 732 renderBar(); 733 } 734 735 function basket() { 736 const map = new Map(); 737 for (const r of rows) { 738 if (r.status !== 'match' || off.has(r.raw)) continue; 739 const k = keyOf(r.product); 740 const cur = map.get(k); 741 if (cur) cur.qty += r.qty || 1; else map.set(k, { p: r.product, qty: r.qty || 1 }); 742 } 743 return [...map.values()]; 744 } 745 function renderBar() { 746 const b = basket(); 747 const total = b.reduce((s, x) => s + x.p.p * x.qty, 0); 748 const n = b.reduce((s, x) => s + x.qty, 0); 749 $('#bar').classList.toggle('show', n > 0); 750 $('#barTotal').textContent = eur(total); 751 const info = $('#barInfo'); 752 info.className = total >= FREE_SHIP ? 'free' : ''; 753 info.textContent = `${n} ${n === 1 ? 'producto' : 'productos'} · ` + (total >= FREE_SHIP ? 'envÃo gratis' : `te faltan ${eur(FREE_SHIP - total)} para el envÃo gratis`); 754 } 755 756 $('#results').addEventListener('click', e => { 757 const t = e.target.closest('[data-act]'); 758 if (!t) return; 759 const r = rows[+t.dataset.i]; if (!r) return; 760 const a = t.dataset.act; 761 if (a === 'plus' || a === 'minus') qtyOv.set(r.raw, Math.max(1, Math.min(99, (r.qty || 1) + (a === 'plus' ? 1 : -1)))); 762 else if (a === 'toggle') { if (off.has(r.raw)) off.delete(r.raw); else off.add(r.raw); } 763 else if (a === 'pick') choice.set(r.raw, t.dataset.k); 764 else if (a === 'num') choice.set(r.raw, { num: t.dataset.num }); 765 else if (a === 'renum') choice.delete(r.raw); 766 else if (a === 'none') choice.set(r.raw, 'none'); 767 else if (a === 'filt') { const fs = Object.assign({}, filt.get(r.raw)); fs[t.dataset.g] = t.dataset.v; filt.set(r.raw, fs); choice.delete(r.raw); } 768 else if (a === 'unfilt') { const fs = Object.assign({}, filt.get(r.raw)); delete fs[t.dataset.g]; if (Object.keys(fs).length) filt.set(r.raw, fs); else filt.delete(r.raw); choice.delete(r.raw); } 769 else if (a === 'mas') mas.add(r.raw); 770 analyze(); 771 }); 772 773 /* ---------- caja de texto ---------- */ 774 let deb; 775 list.addEventListener('input', () => { clearTimeout(deb); deb = setTimeout(analyze, 500); }); 776 $('#btnAnalyze').addEventListener('click', () => { 777 analyze(); 778 if (rows.length) $('#results').scrollIntoView({ behavior: 'smooth', block: 'start' }); 779 else list.focus(); 780 }); 781 $('#btnExample').addEventListener('click', () => { 782 resetChoices(); list.value = EXAMPLE; setStatus('', ''); 783 analyze(); $('#results').scrollIntoView({ behavior: 'smooth', block: 'start' }); 784 }); 785 786 function setStatus(kind, msg) { 787 statusEl.className = 'status' + (kind === 'err' ? ' err' : ''); 788 statusEl.innerHTML = msg ? (kind === 'loading' ? '<span class="spin" aria-hidden="true"></span>' : '') + `<span>${esc(msg)}</span>` : ''; 789 } 790 function loadText(text, msg) { 791 resetChoices(); 792 list.value = text; 793 analyze(); 794 setStatus('ok', msg); 795 if (rows.length) $('#results').scrollIntoView({ behavior: 'smooth', block: 'start' }); 796 } 797 798 /* ---------- archivos ---------- */ 799 const LIBS = { 800 pdf: [['https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'], 801 ['https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.min.js', 'https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.worker.min.js']], 802 xlsx: [['https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js'], ['https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js']], 803 docx: [['https://cdn.jsdelivr.net/npm/[email protected]/mammoth.browser.min.js'], ['https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js']] 804 }; 805 const loaded = {}; 806 const loadScript = src => new Promise((res, rej) => { const s = document.createElement('script'); s.src = src; s.onload = res; s.onerror = rej; document.head.appendChild(s); }); 807 async function need(name) { 808 if (loaded[name]) return loaded[name]; 809 for (const set of LIBS[name]) {
810 try { for (const u of set) await loadScript(u); loaded[name] = set; return set; } catch (e) { /* siguiente CDN */ } 811 } 812 const cual = { pdf: 'PDF', xlsx: 'Excel', docx: 'Word' }[name] || name; 813 throw new Error(`No hemos podido cargar el lector de ${cual}: comprueba la conexión o pega el texto de la lista.`); 814 } 815 816 async function pdfText(file) { 817 const set = await need('pdf'); 818 const lib = window.pdfjsLib; 819 lib.GlobalWorkerOptions.workerSrc = set[1]; 820 const pdf = await lib.getDocument({ data: new Uint8Array(await file.arrayBuffer()) }).promise; 821 const out = []; 822 for (let i = 1; i <= Math.min(pdf.numPages, 12); i++) { 823 const tc = await (await pdf.getPage(i)).getTextContent(); 824 const lines = []; 825 for (const it of tc.items) { 826 if (!it.str || !it.str.trim()) continue; 827 const y = it.transform[5]; 828 let ln = lines.find(l => Math.abs(l.y - y) < 3); 829 if (!ln) lines.push(ln = { y, parts: [] }); 830 ln.parts.push({ x: it.transform[4], s: it.str }); 831 } 832 lines.sort((a, b) => b.y - a.y).forEach(l => out.push(l.parts.sort((a, b) => a.x - b.x).map(o => o.s).join(' ').replace(/\s+/g, ' ').trim())); 833 } 834 const text = out.join('\n').trim(); 835 if (text.replace(/\s/g, '').length > 20) return text; 836 // PDF escaneado: se lee como foto 837 if (!ENDPOINT) throw new Error('Este PDF es una imagen escaneada (no lleva texto) y la lectura de fotos no está disponible. Pega el texto de la lista.'); 838 const blobs = []; 839 for (let i = 1; i <= Math.min(pdf.numPages, 3); i++) { 840 const page = await pdf.getPage(i); 841 const vp = page.getViewport({ scale: 1.6 }); 842 const c = document.createElement('canvas'); c.width = vp.width; c.height = vp.height; 843 await page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise; 844 blobs.push(await new Promise(r => c.toBlob(r, 'image/jpeg', 0.85))); 845 } 846 await readPhoto(blobs); 847 return null; 848 } 849 850 async function handleFile(file) { 851 if (!file) return; 852 const ext = (file.name.split('.').pop() || '').toLowerCase(); 853 if ((file.type || '').startsWith('image/') || ['jpg', 'jpeg', 'png', 'webp', 'gif', 'heic'].includes(ext)) return readPhoto([file]); 854 setStatus('loading', `Leyendo ${file.name}â¦`); 855 try { 856 let text = ''; 857 if (ext === 'txt' || ext === 'csv') text = await file.text(); 858 else if (ext === 'docx') { await need('docx'); text = (await window.mammoth.extractRawText({ arrayBuffer: await file.arrayBuffer() })).value; } 859 else if (['xlsx', 'xls', 'ods'].includes(ext)) { 860 await need('xlsx'); 861 const wb = window.XLSX.read(await file.arrayBuffer(), { type: 'array' }); 862 text = wb.SheetNames.map(n => window.XLSX.utils.sheet_to_json(wb.Sheets[n], { header: 1, blankrows: false, raw: true }) 863 .map(r => r.filter(c => c != null && String(c).trim() !== '').map(String).join(' · ')).filter(Boolean).join('\n')).join('\n'); 864 } 865 else if (ext === 'pdf') { text = await pdfText(file); if (text === null) return; } 866 else if (ext === 'doc') throw new Error('Guarda el archivo como .docx o PDF y vuelve a subirlo.'); 867 else throw new Error('Usa un PDF, Word, Excel, CSV, TXT o una foto.'); 868 text = text.replace(/ /g, ' ').replace(/\n{3,}/g, '\n\n').trim(); 869 if (!text) throw new Error('No hemos encontrado texto. Si es una lista escaneada, súbela como foto.'); 870 loadText(text, `Lista leÃda de ${file.name}`); 871 } catch (e) { 872 setStatus('err', e.message || 'No hemos podido leer el archivo. Prueba a pegar el texto.'); 873 } 874 } 875 876 const fileIn = $('#fileIn'); 877 fileIn.addEventListener('change', () => { handleFile(fileIn.files[0]); fileIn.value = ''; }); 878 const boxEl = $('#box'); 879 ['dragenter', 'dragover'].forEach(ev => boxEl.addEventListener(ev, e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); boxEl.classList.add('over'); } })); 880 ['dragleave', 'drop'].forEach(ev => boxEl.addEventListener(ev, () => boxEl.classList.remove('over'))); 881 boxEl.addEventListener('drop', e => { if (e.dataTransfer.files.length) { e.preventDefault(); handleFile(e.dataTransfer.files[0]); } }); 882 883 /* ---------- fotos ---------- */ 884 // El lector de fotos se pide cuando hace falta, no al cargar: asà no se contesta 885 // que no antes de que el navegador haya tenido tiempo de responder. 886 /* ------------------------------------------------------------------------------ 887 El lector de fotos vive en el servidor, nunca aquÃ: la clave de la API no puede 888 estar en una página, porque cualquiera que mire el código fuente la verÃa y podrÃa 889 gastar el saldo. La foto va al controlador del módulo y él llama a la API. 890 891 Si el módulo no tiene clave puesta, la plantilla manda lectorFotos vacÃo y el botón 892 de "Hacer foto" ni se enseña. El cliente nunca ve un botón que no funciona. 893 ------------------------------------------------------------------------------ */ 894 const ENDPOINT = ((window.GEU && window.GEU.lectorFotos) || '').trim(); 895 896 async function leerConServidor(imgs) { 897 const fd = new FormData();
898 imgs.forEach((img, i) => fd.append('foto' + (i || ''), img, 'lista' + (i || '') + '.jpg')); 899 let r; 900 try { 901 r = await fetch(ENDPOINT, { method: 'POST', body: fd, credentials: 'same-origin' }); 902 } catch (e) { throw { code: 'sin_red' }; } 903 if (r.status === 429) throw { code: 'rate_limited' }; 904 if (r.status === 413) throw { code: 'prompt_too_large' }; 905 if (!r.ok) throw { code: 'servidor' }; 906 let data; 907 try { data = await r.json(); } catch (e) { throw { code: 'servidor' }; } 908 if (data && data.error) throw { code: data.error }; 909 return data; 910 } 911 912 function quitarFoto() { 913 /* Se quita del documento, no se oculta con `hidden`. 914 Motivo: el botón es un <label class="btn"> y la regla .btn{display:inline-flex} 915 gana al display:none que el navegador aplica a [hidden]. Con `hidden` el botón 916 seguÃa viéndose â que es exactamente lo que no puede volver a pasar. Fuera del 917 DOM no hay CSS del tema que lo devuelva. */ 918 const b = document.getElementById('photoBtn'); 919 const i = document.getElementById('photoIn'); 920 if (b && b.parentNode) b.parentNode.removeChild(b); 921 if (i && i.parentNode) i.parentNode.removeChild(i); 922 const pie = document.querySelector('#geulista .below span'); 923 if (pie) pie.textContent = 'PDF, Word, Excel, CSV o texto'; 924 } 925 // quitarFoto() se llama más abajo, después de enganchar los oyentes. 926 927 /* El prompt de lectura vive en controllers/front/leerfoto.php, que es quien habla 928 con la API. Aquà no se repite: dos copias acaban desafinadas. */ 929 930 async function shrink(file) { 931 try { 932 const bmp = await createImageBitmap(file); 933 const k = Math.min(1, 2000 / Math.max(bmp.width, bmp.height)); 934 const c = document.createElement('canvas'); c.width = Math.round(bmp.width * k); c.height = Math.round(bmp.height * k); 935 c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height); 936 return await new Promise(r => c.toBlob(r, 'image/jpeg', 0.86)); 937 } catch (e) { return file; } 938 } 939 const PHOTO_ERR = { 940 sin_lector: 'La lectura de fotos no está disponible ahora mismo. Pega el texto de la lista o sube el archivo (PDF, Word, Excel o CSV).', 941 prompt_too_large: 'La foto es demasiado grande. Prueba con una foto más ajustada a la hoja.', 942 rate_limited: 'Demasiadas fotos seguidas. Espera un momento.', 943 image_rejected: 'No hemos podido abrir esa imagen. Prueba con otra foto.', 944 nolines: 'No vemos una lista en la foto. Prueba con la hoja entera y buena luz.', 945 sin_red: 'No hemos podido enviar la foto. Revisa tu conexión e inténtalo otra vez.', 946 servidor: 'No hemos podido leer la foto ahora mismo. Pega el texto de la lista o sube el archivo.' 947 }; 948 let busy = false; 949 async function readPhoto(files) { 950 if (busy) return; 951 busy = true; 952 setStatus('loading', 'Leyendo la foto⦠puede tardar hasta un minuto.'); 953 try { 954 if (!ENDPOINT) { quitarFoto(); throw { code: 'sin_lector' }; } 955 // Se reducen antes de enviarlas: lo que cuesta cada foto depende de su resolución. 956 const imgs = await Promise.all([...files].slice(0, 3).map(shrink)); 957 const data = await leerConServidor(imgs); 958 const lines = (data && Array.isArray(data.lineas) ? data.lineas : []) 959 .map(l => [String(l.texto || '').trim(), l.isbn ? 'ISBN ' + String(l.isbn).replace(/[^0-9Xx]/g, '') : '', l.cantidad > 1 ? 'x' + l.cantidad : ''].filter(Boolean).join(' · ')) 960 .filter(Boolean); 961 if (!lines.length) throw { code: 'nolines' }; 962 loadText(lines.join('\n'), 'Lista leÃda de la foto. Revisa el texto si algo no cuadra.'); 963 } catch (e) { 964 const code = e && e.code; 965 const base = PHOTO_ERR[code] || 'No hemos podido leer la foto. Vuelve a intentarlo o pega el texto de la lista.'; 966 setStatus('err', base); // el motivo técnico no se le enseña al cliente 967 } finally { busy = false; } 968 } 969 const photoIn = $('#photoIn'); 970 if (photoIn) { 971 photoIn.addEventListener('change', () => { if (photoIn.files.length) readPhoto(photoIn.files); photoIn.value = ''; }); 972 } 973 // Sin lector en el servidor no se ofrece la foto. AquÃ, ya con todo enganchado. 974 if (!ENDPOINT) quitarFoto(); 975 976 const foot = $('#foot'); 977 // Ya no es un prototipo: el catálogo lo sirve la tienda. Se deja el recuento porque
978 // es la forma más rápida de ver desde fuera si el módulo está leyendo bien la base. 979 if (foot) foot.textContent = `Buscando entre ${CATALOG.length} materiales de Editorial GEU`; 980 981 /* ---------- añadir a la cesta de la tienda ---------- */ 982 983 /* El token de PrestaShop. Llega de la plantilla; si por lo que sea faltara, se coge del 984 objeto que la propia tienda publica en todas sus páginas. Sin token, PrestaShop 985 rechaza el alta y el cliente se quedarÃa con la cesta vacÃa sin saber por qué. */ 986 function token() { 987 if (CFG.token) return CFG.token; 988 if (window.prestashop && window.prestashop.static_token) return window.prestashop.static_token; 989 return ''; 990 } 991 992 const dlg = $('#dlg'); 993 $('#dlgClose').addEventListener('click', () => dlg.close()); 994 995 async function anadirUno(x, tk) { 996 const r = await fetch(CFG.urlCarrito, { 997 method: 'POST', 998 credentials: 'same-origin', 999 headers: { 1000 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 1001 Accept: 'application/json' 1002 }, 1003 body: new URLSearchParams({ 1004 token: tk, id_product: x.p.id, id_product_attribute: x.p.ipa || 0, id_customization: 0, 1005 qty: x.qty, add: 1, action: 'update', ajax: 1 1006 }) 1007 }); 1008 if (!r.ok) throw new Error('http ' + r.status); 1009 // PrestaShop contesta JSON con hasError/errors cuando algo no cuadra (sin stock, por 1010 // ejemplo). Si no se mira, se le dice al cliente que está todo bien y no lo está. 1011 let d = null; 1012 try { d = await r.json(); } catch (e) { return; } 1013 if (d && (d.hasError || (d.errors && d.errors.length))) { 1014 throw new Error([].concat(d.errors || []).join(' · ') || 'rechazado'); 1015 } 1016 } 1017 1018 async function addAll() { 1019 const items = basket(); 1020 if (!items.length) return; 1021 1022 const btn = $('#btnCart'); 1023 btn.disabled = true; 1024 const tk = token(); 1025 1026 $('#dlgTitle').textContent = 'A\u00f1adiendo a tu cesta\u2026'; 1027 $('#dlgMsg').textContent = 'Un momento, estamos metiendo los materiales.'; 1028 $('#dlgList').innerHTML = items.map(x => `<li>${esc(x.p.n)} \u00d7 ${x.qty}</li>`).join(''); 1029 $('#dlgBar').style.width = '0'; 1030 dlg.showModal(); 1031 1032 const lis = $('#dlgList').children; 1033 const fallidos = []; 1034 1035 for (let i = 0; i < items.length; i++) { 1036 try { 1037 await anadirUno(items[i], tk); 1038 lis[i].insertAdjacentText('beforeend', ' \u2713'); 1039 } catch (e) { 1040 fallidos.push(items[i]); 1041 lis[i].insertAdjacentText('beforeend', ' \u2014 no se ha podido'); 1042 lis[i].style.color = 'var(--warn, #b4342a)'; 1043 } 1044 $('#dlgBar').style.width = ((i + 1) / items.length * 100) + '%'; 1045 } 1046 1047 btn.disabled = false; 1048 1049 if (!fallidos.length) { 1050 $('#dlgTitle').textContent = '\u00a1Listo! Tu cesta est\u00e1 preparada'; 1051 $('#dlgMsg').textContent = 'Te llevamos a la cesta para terminar la compra.'; 1052 await wait(600); 1053 window.location.href = CFG.urlCesta; 1054 return; 1055 } 1056 1057 if (fallidos.length === items.length) { 1058 $('#dlgTitle').textContent = 'No hemos podido preparar la cesta'; 1059 $('#dlgMsg').textContent = 'Vuelve a intentarlo dentro de un momento. Si sigue sin ' + 1060 'funcionar, ll\u00e1manos al 958 80 05 80 y te la preparamos nosotros.'; 1061 return; 1062 } 1063 1064 $('#dlgTitle').textContent = 'Casi todo listo'; 1065 $('#dlgMsg').textContent = 'Hemos a\u00f1adido ' + (items.length - fallidos.length) + 1066 ' de ' + items.length + ' materiales. Los marcados no estaban disponibles.'; 1067 } 1068 1069 $('#btnCart').addEventListener('click', addAll); 1070})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.