PageSourceSearch

https://www.editorialgeu.com/modules/geulista/views/js/geulista.js

js editorialgeu.com collected 2026-09-26 12:02:41 UTC 58,022 bytes, 1,070 lines download raw bytes

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.