`).join('')} `} `; // Guardar items en window para el visor window._galeriaItems = items; } function abrirFotoCompleta(idx) { const items = window._galeriaItems || []; if (!items[idx]) return; window._galeriaIdx = idx; _renderVisorFoto(idx); } function _renderVisorFoto(idx) { const items = window._galeriaItems || []; const item = items[idx]; if (!item) return; const el = document.getElementById('screen'); el.innerHTML = `
${item.especie}
${item.fecha} · ${item.label}
${idx+1} / ${items.length}
`; } function _galeriaNav(dir) { const items = window._galeriaItems || []; const next = (window._galeriaIdx||0) + dir; if (next < 0 || next >= items.length) return; window._galeriaIdx = next; _renderVisorFoto(next); } // ══════════════════════════════════════════════════════════════════════════════ // ESTADÍSTICAS // ══════════════════════════════════════════════════════════════════════════════ async function renderStats(el) { const setas = STATE.setas; if (!setas.length) { el.innerHTML = `
📈

Registra setas para ver estadísticas.

`; return; } // Cargar índice MycoBank si no está aún (necesario para GBIF links en la tarta) if (!_mycoDB) { el.innerHTML = `

Cargando índice…

`; await cargarMycoDB(); } const porEspecie = {}, porMes = {}, porZona = {}, porComest = {}; setas.forEach(s => { if (!s.pendiente) { porEspecie[s.especie] = (porEspecie[s.especie]||0) + (s.cantidad||1); const c = s.taxInfo?.comestibilidad || 'Sin datos'; porComest[c] = (porComest[c]||0) + 1; } if (s.fecha) { const meses = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic']; const m = meses.find(m => s.fecha.toLowerCase().includes(m)); if (m) porMes[m] = (porMes[m]||0) + 1; } const zona = s.comunidad || s.poblacion || 'Sin ubicación'; porZona[zona] = (porZona[zona]||0) + (s.cantidad||1); }); const topEsp = Object.entries(porEspecie).sort((a,b)=>b[1]-a[1]).slice(0,8); // Zonas con MB# de la especie más frecuente en cada zona const topZonas = Object.entries(porZona).filter(([k])=>k!=='Sin ubicación').sort((a,b)=>b[1]-a[1]).slice(0,8); // Calcular MB# principal por zona (especie más frecuente) const zonaMB = {}; setas.forEach(s => { const zona = s.comunidad || s.poblacion || 'Sin ubicación'; if (zona==='Sin ubicación') return; if (!zonaMB[zona]) zonaMB[zona] = {}; const mb = s.taxInfo?.fuentes?.match(/MycoBank #(\d+)/)?.[1] || '—'; zonaMB[zona][mb] = (zonaMB[zona][mb]||0) + (s.cantidad||1); }); // Para la tarta: label = "Comunidad · MB# · N ud" const topZonasRich = topZonas.map(([zona, n]) => { const mbCounts = zonaMB[zona] || {}; const topMB = Object.entries(mbCounts).sort((a,b)=>b[1]-a[1])[0]; const mbLabel = topMB && topMB[0]!=='—' ? ' MB#'+topMB[0] : ''; return [zona + mbLabel, n]; }); const ordenMeses = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic']; const mesesData = ordenMeses.map(m => ({ m, n: porMes[m]||0 })); const maxMes = Math.max(...mesesData.map(x=>x.n), 1); const comestColors = {'Comestible excelente':'2E7D32','Comestible':'52B788','No comestible':'9E9E9E','Tóxica':'C62828','Mortal':'8B0000','Sin datos':'BDBDBD'}; el.innerHTML = `

📈 Estadísticas de recolección

${[['🍄',setas.filter(s=>!s.pendiente).length,'Identificadas'],['📍',setas.filter(s=>s.coords).length,'Con GPS'],['❓',setas.filter(s=>s.pendiente).length,'Pendientes']].map(([ico,n,lbl])=>`
${ico}
${n}
${lbl}
`).join('')}
🏆 Especies — unidades recolectadas
📅 Épocas de recolección
${mesesData.map(({m,n}) => `
${n>0?`
${n}
`:''}
${m}
`).join('')}
🗺️ Zonas — unidades recolectadas
🍽️ Por comestibilidad
${Object.entries(porComest).sort((a,b)=>b[1]-a[1]).map(([c,n]) => `
${c}
${n}
`).join('')}
`; // Renderizar tartas fuera del template // Enriquecer topEsp con GBIF# del índice local const topEspRich = topEsp.map(([esp, n]) => { const hit = getMBFromLocal(esp); const gbifUrl = hit?.gbif ? 'https://www.gbif.org/species/'+hit.gbif : null; const label = gbifUrl ? esp + ' GBIF#'+hit.gbif+'' : esp; return [label, n, !!gbifUrl]; }); _renderPie('pie-especies', topEspRich, ['1B4332','2D6A4F','52B788','74C69D','95D5B2','C9A84C','6B4226','40916C'], false, null, true); _renderPie('pie-zonas', topZonasRich, ['2D6A4F','52B788','C9A84C','6B4226','1B4332','95D5B2','B7E4C7','74C69D'], true, 'mapa'); } function _renderPie(containerId, datos, palette, showMapBtn, mapTab, htmlLabels) { const el = document.getElementById(containerId); if (!el) return; if (!datos.length) { el.innerHTML = '

Sin datos todavía

'; return; } const total = datos.reduce((s,[,n])=>s+n,0); let angle = -Math.PI/2; const slices = datos.map(([label,n],i) => { const frac = n/total; const a1 = angle, a2 = angle + frac*2*Math.PI; angle = a2; const x1=Math.cos(a1), y1=Math.sin(a1), x2=Math.cos(a2), y2=Math.sin(a2); const path = 'M0,0 L'+x1+','+y1+' A1,1,0,'+(frac>0.5?1:0)+',1,'+x2+','+y2+'Z'; return {label, n, frac, path, col:palette[i%palette.length]}; }); const svgPaths = slices.map(s => '').join(''); const legend = slices.map(s => '
'+ '
'+ '
'+s.label+'
'+ '
'+s.n+'
'+ '
' ).join(''); const mapBtn = showMapBtn && mapTab ? '' : ''; el.innerHTML = '
'+ ''+svgPaths+''+ '
'+legend+mapBtn+'
'+ '
'; } // ══════════════════════════════════════════════════════════════════════════════ // MODO GALERÍA SMM — presentación en reunión (pantalla completa, pase de diapositivas) // ══════════════════════════════════════════════════════════════════════════════ function abrirModoGaleriaSMM(reunionId) { window._smmGaleriaReunionId = reunionId; window._smmGaleriaIdx = 0; _renderGaleriaSMM(reunionId, 0); } function _renderGaleriaSMM(reunionId, idx) { DB.getReuniones().then(function(reuniones) { var r = reuniones.find(function(x){return String(x.id)===String(reunionId);}); if (!r) return; var esp = (r.especimenes||[]).filter(function(e){return e.foto||e.especie;}); if (!esp.length) return; var e = esp[idx] || esp[0]; var col = getSMMColor(e.color); var el = document.getElementById('screen'); if (!el) return; var dots = esp.map(function(_,i){ var bg = i===idx ? '#fff' : 'rgba(255,255,255,.35)'; return '
'; }).join(''); var info = '
'+ (e.genero||'')+' '+(e.especie||'')+'
'+ (e.habitat?'
'+e.habitat+'
':'')+ (e.mb?'
MB#'+e.mb+(e.gbif?' GBIF#'+e.gbif:'')+'
':''); var foto = e.foto ? '' : '
🍄
'; el.innerHTML = '
'+ '
'+ ''+ '
'+(idx+1)+' / '+esp.length+'
'+ ''+ '
'+ '
'+ foto+ '
'+col.label+'
'+ '
'+ '
'+info+'
'+ '
'+ ''+ '
'+dots+'
'+ ''+ '
'; }); } function _smmGaleriaNav(dir) { DB.getReuniones().then(reuniones => { const r = reuniones.find(x=>String(x.id)===String(window._smmGaleriaReunionId)); const esp = (r?.especimenes||[]).filter(e=>e.foto||e.especie); const next = (window._smmGaleriaIdx||0) + dir; if (next < 0 || next >= esp.length) return; window._smmGaleriaIdx = next; _renderGaleriaSMM(window._smmGaleriaReunionId, next); }); } // ══════════════════════════════════════════════════════════════════════════════ // MAPA // ══════════════════════════════════════════════════════════════════════════════ function renderMapa(el) { const c = STATE.coche; const conCoords = STATE.setas.filter(s=>s.coords); window._mapaTodasSetas = conCoords; el.innerHTML = `
${conCoords.length > 0 ? ` ` : ''}
🚗
Mi coche
${!c?`
No marcado — pulsa el botón superior
`:""}
${c?`
${fmtCoord(c.lat)}, ${fmtCoord(c.lng)} ±${c.acc}m
${new Date(c.ts).toLocaleString("es-ES",{day:"numeric",month:"short",hour:"2-digit",minute:"2-digit"})}
🗺️ Google Maps 🧭 Waze
`:""}
Setales (${conCoords.length})
${conCoords.length===0 ? `
Las setas con GPS aparecerán aquí
` : conCoords.map(s=>`
${s.emoji||"🍄"}
${s.especie}
${s.fecha||""} · ${s.hora||""}
${fmtCoord(s.coords.lat)}, ${fmtCoord(s.coords.lng)} ${s.coords.acc>0?`±${s.coords.acc}m`:""} ${s.coordsManual?`(manual)`:""}
${s.lugar?`
📍 ${s.lugar}
`:""}
🗺️ Ver mapa 🧭 Navegar
`).join("")}
`; } function filtrarMapa() { const q = (document.getElementById('mapa-buscar')||{}).value||''; const ql = q.toLowerCase().trim(); const lista = document.getElementById('mapa-lista'); const counter = document.getElementById('mapa-count'); if (!lista || !window._mapaTodasSetas) return; const filtradas = ql ? window._mapaTodasSetas.filter(s => (s.especie||'').toLowerCase().includes(ql) || (s.comunidad||'').toLowerCase().includes(ql) || (s.poblacion||'').toLowerCase().includes(ql) || (s.lugar||'').toLowerCase().includes(ql) || (s.habitat||'').toLowerCase().includes(ql) || (s.taxInfo?.nombre_comun||'').toLowerCase().includes(ql) || (s.fecha||'').toLowerCase().includes(ql) ) : window._mapaTodasSetas; if (counter) counter.textContent = 'Setales (' + filtradas.length + (ql ? ' · filtradas' : '') + ')'; if (!filtradas.length) { lista.innerHTML = '

Sin resultados para "'+q+'"

'; return; } lista.innerHTML = filtradas.map(s => '
'+ '
'+ ''+(s.emoji||'🍄')+''+ '
'+ '
'+s.especie+'
'+ '
'+(s.fecha||'')+' · '+(s.hora||'')+'
'+ '
'+ '
'+ fmtCoord(s.coords.lat)+', '+fmtCoord(s.coords.lng)+ (s.coords.acc>0?'±'+s.coords.acc+'m':'')+ '
'+ (s.lugar||s.comunidad?'
📍 '+[s.lugar,s.comunidad].filter(Boolean).join(' · ')+'
':'')+ '
'+ '🗺️ Ver mapa'+ '🧭 Navegar'+ '
' ).join(''); } // ══════════════════════════════════════════════════════════════════════════════ // EXCEL // ══════════════════════════════════════════════════════════════════════════════ const COLS = ["ID","Fecha","Hora","Especie_Cientifica","Nombre_Comun","Acronimos", "Fuentes_Cientificas","Reino","Division","Clase","Orden","Familia","Genero","Epiteto", "Comestibilidad","Etimologia","Descripcion","Confusiones_Posibles", "Org_Sombrero","Org_Himenio","Org_Pie","Org_Carne", "Org_Olor","Org_Sabor","Org_Habitat","Org_Epoca","Org_Obs_Comestibilidad", "Cantidad","Peso_g","Estado","Latitud","Longitud","Precision_m","Coords_Manual", "Altitud_m","Temperatura_C","Humedad_pct","Habitat","Tipo_Arbol","Arbol_Sustrato","Olor_Campo","Sabor_Campo","Lugar_Paraje","Poblacion","Comunidad_Autonoma","Notas","Foto_Superior","Foto_Inferior","Foto_Lateral","Pendiente"]; function renderExcel(el) { el.innerHTML = `
📤 Exportar a Excel

Descarga un ZIP con el Excel completo (Recolecciones + GPS + Coche + Lunes Micológicos) y las fotos de cada seta. Compatible con Microsoft Access — importa directamente por ID.

📂 Importar desde Excel

Carga el ZIP exportado por esta app para recuperar sesiones anteriores incluyendo las fotos. También puedes cargar solo el Excel si no tienes el ZIP. Los datos se fusionan sin borrar los actuales.

📦 Importar ZIP (Excel + fotos)
📊 Importar solo Excel (sin fotos)
💡 Flujo campo → casa → campo
${["Sal al campo y registra setas con la app", "Al volver: exporta el ZIP (Excel + fotos) y súbelo a Google Drive", "En el PC: descomprime el ZIP, abre el Excel. Las fotos están en la carpeta fotos/", "Siguiente sesión: importa el ZIP en la app para recuperar datos y fotos completos" ].map((t,i)=>`
${i+1}
${t}
`).join("")}
`; } // Convierte base64 a Uint8Array para JSZip function base64ToBytes(b64) { const raw = b64.split(",")[1] || b64; const bin = atob(raw); const arr = new Uint8Array(bin.length); for (let i=0;i { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); } async function exportarExcel() { if (!STATE.setas.length) return; const btn = document.getElementById("btn-export"); if (btn) btn.innerHTML=" Generando…"; const zip = new JSZip(); const fotosFolder = zip.folder("fotos"); const vistas = ["superior","inferior","lateral"]; const filas = STATE.setas.map(s=>{ const t=s.taxInfo||{}, tax=t.taxonomia||{}, org=t.organoleptica||{}; const fotos = s.fotos||[s.foto,null,null]; const nombreBase = String(s.id)+"_"+s.especie.replace(/[^a-zA-Z0-9]/g,"_").slice(0,20); // Guardar fotos en ZIP const rutasFotos = fotos.map((f,i)=>{ if (!f) return ""; const ext = imgExt(f); const nombre = nombreBase+"_"+vistas[i]+"."+ext; fotosFolder.file(nombre, base64ToBytes(f)); return "fotos/"+nombre; }); return { ID:s.id, Fecha:s.fecha||"", Hora:s.hora||"", Especie_Cientifica:s.especie, Nombre_Comun:t.nombre_comun||"", Acronimos:(t.acronimos||[]).join(", "), Fuentes_Cientificas:t.fuentes||"", Reino:tax.reino||"", Division:tax.division||"", Clase:tax.clase||"", Orden:tax.orden||"", Familia:tax.familia||"", Genero:tax.genero||"", Epiteto:tax.especie||"", Comestibilidad:t.comestibilidad||"", Etimologia:t.etimologia||"", Descripcion:t.descripcion_breve||"", Confusiones_Posibles:t.confusiones||"", Org_Sombrero:org.sombrero||"", Org_Himenio:org.himenio||"", Org_Pie:org.pie||"", Org_Carne:org.carne||"", Org_Olor:org.olor||"", Org_Sabor:org.sabor||"", Org_Habitat:org.habitat||"", Org_Epoca:org.epoca||"", Org_Obs_Comestibilidad:org.observaciones_comestibilidad||"", Cantidad:s.cantidad||1, Peso_g:s.peso||"", Estado:s.estado||"", Latitud:s.coords?.lat||"", Longitud:s.coords?.lng||"", Precision_m:s.coords?.acc||"", Coords_Manual:s.coords?(s.coordsManual?"Si":"No"):"", Altitud_m:s.altitud||"", Temperatura_C:s.temperatura||"", Humedad_pct:s.humedad||"", Habitat:s.habitat||"", Tipo_Arbol:s.tipoArbol||"", Arbol_Sustrato:s.arbolSustrato||"", Olor_Campo:s.olorCampo||"", Sabor_Campo:s.saborCampo||"", Lugar_Paraje:s.lugar||"", Poblacion:s.poblacion||"", Comunidad_Autonoma:s.comunidad||"", Notas:s.notas||"", Foto_Superior:rutasFotos[0]||"", Foto_Inferior:rutasFotos[1]||"", Foto_Lateral:rutasFotos[2]||"", Pendiente:s.pendiente?"Si":"No" }; }); // Construir Excel const wb = XLSX.utils.book_new(); const ws = XLSX.utils.json_to_sheet(filas, {header:COLS}); ws["!cols"] = COLS.map(c=>({wch:Math.max(c.length,14)})); XLSX.utils.book_append_sheet(wb, ws, "Recolecciones"); const conCoords = STATE.setas.filter(s=>s.coords); if (conCoords.length) { const wsM = XLSX.utils.json_to_sheet(conCoords.map(s=>({ Especie:s.especie, Fecha:s.fecha, Hora:s.hora, Latitud:s.coords.lat, Longitud:s.coords.lng, Precision_m:s.coords.acc, Coords_Manual:s.coordsManual?"Si":"No", Lugar:s.lugar||"", Maps:"https://www.google.com/maps?q="+s.coords.lat+","+s.coords.lng }))); XLSX.utils.book_append_sheet(wb, wsM, "Setales GPS"); } if (STATE.coche) { const c=STATE.coche; const wsC = XLSX.utils.json_to_sheet([{ Tipo:"coche", Latitud:c.lat, Longitud:c.lng, Precision_m:c.acc, Fecha_Marcado:new Date(c.ts).toLocaleString("es-ES"), Maps:"https://www.google.com/maps?q="+c.lat+","+c.lng }]); XLSX.utils.book_append_sheet(wb, wsC, "Coche"); } // Hoja Lunes Micológicos SMM try { const reunionesSMM = await DB.getReuniones(); if (reunionesSMM.length) { const filasR = []; reunionesSMM.forEach(r => { (r.especimenes||[]).forEach(e => { const colLabel = Object.values(SMM_COLORES).find(v=>v.bg===(getSMMColor(e.color)?.bg))?.label || e.color || ''; filasR.push({ Reunion_Titulo: r.titulo||'', Reunion_Fecha: r.fecha||'', Reunion_Lugar: r.lugar||'', Reunion_Notas: r.notas||'', Especie_Genero: e.genero||'', Especie_Epiteto: e.especie||'', Nombre_Completo: ((e.genero||'')+' '+(e.especie||'')).trim(), Cartulina_Color: e.color||'', Cartulina_Label: getSMMColor(e.color)?.label||'', MycoBank_Num: e.mb||'', MycoBank_GBIF: e.gbif||'', Orden: e.orden||'', Familia: e.familia||'', Habitat_Cartulina: e.habitat||'', Notas_Especimen: e.notas||'', Tiene_Foto: e.foto?'Si':'No', }); }); }); if (filasR.length) { const wsSMM = XLSX.utils.json_to_sheet(filasR); wsSMM["!cols"] = Object.keys(filasR[0]).map(c=>({wch:Math.max(c.length,14)})); XLSX.utils.book_append_sheet(wb, wsSMM, "Lunes Micologicos"); } } } catch(ex) { console.warn('Error exportando reuniones SMM:', ex); } // Generar Excel como binario y meterlo en el ZIP const xlsxBin = XLSX.write(wb, {bookType:"xlsx", type:"array"}); const fecha = new Date().toLocaleDateString("es-ES",{day:"2-digit",month:"2-digit",year:"numeric"}).replace(/[/]/g,"-"); const xlsxNombre = "RECOLECTA_"+fecha+"_"+STATE.setas.length+"setas.xlsx"; zip.file(xlsxNombre, xlsxBin); // Generar y descargar el ZIP const zipBlob = await zip.generateAsync({type:"blob", compression:"DEFLATE"}); const nombreZip = "RECOLECTA_"+fecha+".zip"; // Intentar Web Share API primero (mejor en móvil Android) if (navigator.share && navigator.canShare && navigator.canShare({files:[new File([zipBlob], nombreZip)]})) { try { await navigator.share({ files:[new File([zipBlob], nombreZip, {type:"application/zip"})], title:"Recolecta Export" }); } catch(shareErr) { if (shareErr.name !== "AbortError") _descargarBlob(zipBlob, nombreZip); } } else { _descargarBlob(zipBlob, nombreZip); } if (btn) setTimeout(()=>{ btn.innerHTML="📊 Descargar ZIP+Excel ("+STATE.setas.length+" seta"+(STATE.setas.length!==1?"s":"")+")"; },2000); } async function importarZip(inp) { const file = inp.files[0]; if (!file) return; showImportMsg("Leyendo ZIP…","info"); try { const zip = await JSZip.loadAsync(file); // 1. Buscar el Excel dentro del ZIP const xlsxFile = Object.keys(zip.files).find(n => n.endsWith(".xlsx") && !n.includes("/")); if (!xlsxFile) { showImportMsg("No se encontró el archivo Excel dentro del ZIP.","error"); inp.value=""; return; } // 2. Leer el Excel const xlsxBin = await zip.files[xlsxFile].async("arraybuffer"); const wb = XLSX.read(xlsxBin, {type:"array"}); const sheetName = wb.SheetNames.find(n=>n==="Recolecciones")||wb.SheetNames[0]; const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], {defval:""}); if (!rows.length) { showImportMsg("El Excel no contiene datos.","error"); inp.value=""; return; } // 3. Leer fotos del ZIP indexadas por nombre de archivo const fotosMap = {}; const fotoFiles = Object.keys(zip.files).filter(n => n.startsWith("fotos/") && !zip.files[n].dir); await Promise.all(fotoFiles.map(async path => { const b64 = await zip.files[path].async("base64"); const ext = path.split(".").pop().toLowerCase(); const mime = ext==="png"?"image/png":ext==="webp"?"image/webp":"image/jpeg"; fotosMap[path] = "data:"+mime+";base64,"+b64; })); // 4. Construir registros fusionando datos + fotos const nuevas = rows.map(r => { const id = r.ID || Date.now()+Math.random(); // Buscar fotos usando las rutas del Excel (columnas Foto_Superior, Foto_Inferior, Foto_Lateral) const fotoSup = r.Foto_Superior ? (fotosMap[r.Foto_Superior]||null) : null; const fotoInf = r.Foto_Inferior ? (fotosMap[r.Foto_Inferior]||null) : null; const fotoLat = r.Foto_Lateral ? (fotosMap[r.Foto_Lateral]||null) : null; // También buscar por ID si las rutas no coinciden exactamente const fotosById = fotoFiles.filter(p=>p.includes(String(id))); const fotoSupFallback = fotoSup || (fotosById.find(p=>p.includes("superior")) ? fotosMap[fotosById.find(p=>p.includes("superior"))] : null); const fotoInfFallback = fotoInf || (fotosById.find(p=>p.includes("inferior")) ? fotosMap[fotosById.find(p=>p.includes("inferior"))] : null); const fotoLatFallback = fotoLat || (fotosById.find(p=>p.includes("lateral")) ? fotosMap[fotosById.find(p=>p.includes("lateral"))] : null); return { id, especie: r.Especie_Cientifica||"Desconocida", emoji:"🍄", pendiente: !r.Especie_Cientifica||r.Especie_Cientifica==="Sin identificar", cantidad:parseInt(r.Cantidad)||1, peso:r.Peso_g||"", estado:r.Estado||"", altitud:r.Altitud_m||"", temperatura:r.Temperatura_C||"", humedad:r.Humedad_pct||"", habitat:r.Habitat||"", tipoArbol:r.Tipo_Arbol||"", arbolSustrato:r.Arbol_Sustrato||"", olorCampo:r.Olor_Campo||"", saborCampo:r.Sabor_Campo||"", lugar:r.Lugar_Paraje||"", poblacion:r.Poblacion||"", comunidad:r.Comunidad_Autonoma||"", notas:r.Notas||"", fotos:[fotoSupFallback, fotoInfFallback, fotoLatFallback], fecha:r.Fecha||"", hora:r.Hora||"", coords:r.Latitud&&r.Longitud?{lat:parseFloat(r.Latitud),lng:parseFloat(r.Longitud),acc:parseInt(r.Precision_m)||0}:null, coordsManual:r.Coords_Manual==="Si", taxInfo:{ nombre_comun:r.Nombre_Comun||"", fuentes:r.Fuentes_Cientificas||"", acronimos:r.Acronimos?r.Acronimos.split(",").map(a=>a.trim()).filter(Boolean):[], comestibilidad:r.Comestibilidad||"", etimologia:r.Etimologia||"", descripcion_breve:r.Descripcion||"", confusiones:r.Confusiones_Posibles||"", taxonomia:{reino:r.Reino||"",division:r.Division||"",clase:r.Clase||"",orden:r.Orden||"",familia:r.Familia||"",genero:r.Genero||"",especie:r.Epiteto||""}, organoleptica:{sombrero:r.Org_Sombrero||"",himenio:r.Org_Himenio||"",pie:r.Org_Pie||"",carne:r.Org_Carne||"",olor:r.Org_Olor||"",sabor:r.Org_Sabor||"",habitat:r.Org_Habitat||"",epoca:r.Org_Epoca||"",observaciones_comestibilidad:r.Org_Obs_Comestibilidad||""} } }; }); // 5. Fusionar sin duplicados const idsActuales = new Set(STATE.setas.map(s=>String(s.id))); const sinDup = nuevas.filter(s=>!idsActuales.has(String(s.id))); STATE.setas = [...STATE.setas, ...sinDup]; DB.setSetas(STATE.setas); updateHeader(); const nFotos = nuevas.reduce((acc,s)=>acc+s.fotos.filter(f=>f).length,0); showImportMsg("✓ "+sinDup.length+" registros fusionados con "+nFotos+" fotos restauradas.","ok"); } catch(err) { showImportMsg("Error al leer el ZIP: "+err.message,"error"); } inp.value=""; } function importarExcel(inp) { const file = inp.files[0]; if(!file) return; const msgEl = document.getElementById("msg-import"); const reader = new FileReader(); reader.onload = e => { try { const wb = XLSX.read(e.target.result, {type:"array"}); const sheetName = wb.SheetNames.find(n=>n==="Recolecciones")||wb.SheetNames[0]; const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], {defval:""}); if (!rows.length) { showImportMsg("El Excel no contiene datos en la hoja Recolecciones.","error"); return; } const nuevas = rows.map(r=>({ id: r.ID||Date.now()+Math.random(), especie: r.Especie_Cientifica||"Desconocida", emoji: "🍄", cantidad:parseInt(r.Cantidad)||1, peso:r.Peso_g||"", estado:r.Estado||"", altitud:r.Altitud_m||"", temperatura:r.Temperatura_C||"", humedad:r.Humedad_pct||"", habitat:r.Habitat||"", tipoArbol:r.Tipo_Arbol||"", arbolSustrato:r.Arbol_Sustrato||"", olorCampo:r.Olor_Campo||"", saborCampo:r.Sabor_Campo||"", lugar:r.Lugar_Paraje||"", poblacion:r.Poblacion||"", comunidad:r.Comunidad_Autonoma||"", notas:r.Notas||"", fotos:[null,null,null], // fotos no se restauran desde Excel (están en el ZIP) fecha:r.Fecha||"", hora:r.Hora||"", coords:r.Latitud&&r.Longitud?{lat:parseFloat(r.Latitud),lng:parseFloat(r.Longitud),acc:parseInt(r.Precision_m)||0}:null, coordsManual:r.Coords_Manual==="Si", taxInfo:{ nombre_comun:r.Nombre_Comun||"", acronimos:r.Acronimos?r.Acronimos.split(",").map(a=>a.trim()).filter(Boolean):[], comestibilidad:r.Comestibilidad||"", etimologia:r.Etimologia||"", descripcion_breve:r.Descripcion||"", taxonomia:{reino:r.Reino||"",division:r.Division||"",clase:r.Clase||"",orden:r.Orden||"",familia:r.Familia||"",genero:r.Genero||"",especie:r.Epiteto||""}, organoleptica:{sombrero:r.Org_Sombrero||"",himenio:r.Org_Himenio||"",pie:r.Org_Pie||"",carne:r.Org_Carne||"",olor:r.Org_Olor||"",sabor:r.Org_Sabor||"",habitat:r.Org_Habitat||"",epoca:r.Org_Epoca||"",observaciones_comestibilidad:r.Org_Obs_Comestibilidad||""} } })); // Fusionar sin duplicados const idsActuales = new Set(STATE.setas.map(s=>String(s.id))); const sinDup = nuevas.filter(s=>!idsActuales.has(String(s.id))); STATE.setas = [...STATE.setas, ...sinDup]; DB.setSetas(STATE.setas); updateHeader(); showImportMsg("✓ "+sinDup.length+" registros nuevos fusionados ("+nuevas.length+" en el archivo).","ok"); } catch(err) { showImportMsg("Error al leer el Excel: "+err.message,"error"); } inp.value=""; }; reader.readAsArrayBuffer(file); } function showImportMsg(txt, tipo) { const el = document.getElementById("msg-import"); if(!el) return; el.textContent=txt; el.style.display="block"; if (tipo==="ok") { el.style.background="rgba(34,139,34,.1)"; el.style.border="1.5px solid rgba(34,139,34,.3)"; el.style.color="var(--comest)"; } else if (tipo==="info") { el.style.background="rgba(74,124,89,.08)"; el.style.border="1.5px solid rgba(74,124,89,.25)"; el.style.color="var(--verde2)"; } else { el.style.background="rgba(178,34,34,.1)"; el.style.border="1.5px solid rgba(178,34,34,.3)"; el.style.color="var(--toxico)"; } } // ══════════════════════════════════════════════════════════════════════════════ // CONFIGURACIÓN // ══════════════════════════════════════════════════════════════════════════════ function renderConfig() { // Llamar tras renderizar para rellenar info provider setTimeout(actualizarInfoProvider, 50); const el = document.getElementById("screen"); el.innerHTML = `
CONFIGURACIÓN
🤖 Proveedor de IA

Selecciona el servicio y pega tu API Key. Se guarda solo en este dispositivo.

⚠️ Nunca compartas tu API Key públicamente.

🌿 Especies guardadas en el selector

Gestiona las especies buscadas por la IA. Elimina duplicados o datos incorrectos.

`; } function guardarApiKey() { const k = (document.getElementById("inp-apikey")||{}).value||""; const sel = document.getElementById("sel-provider"); const prov = sel ? sel.value : DB.getProvider(); DB.setApiKey(k.trim()); DB.setProvider(prov); const msg = document.getElementById("msg-apikey"); if (msg) { msg.textContent="✓ Configuración guardada"; msg.style.display="block"; setTimeout(()=>msg.style.display="none",2500); } } function actualizarInfoProvider() { const sel = document.getElementById("sel-provider"); const info = document.getElementById("info-provider"); const inst = document.getElementById("bloque-instrucciones"); if (!sel||!info||!inst) return; const prov = sel.value; if (prov==="openrouter") { info.innerHTML="OpenRouter es 100% gratuito ⭐, sin tarjeta, funciona en España y toda la UE. Soporta texto e identificación por foto con Gemma 4 31B (visión). Límite generoso de peticiones gratuitas al día."; inst.innerHTML=`
Cómo obtener la API Key de OpenRouter
${[ "Ve a openrouter.ai", "Crea cuenta con Google o email", "Ve a Keys → Create Key", "Copia la clave (empieza por sk-or-...)", "¡Completamente gratis, sin tarjeta, funciona en España!" ].map((t,i)=>`
${i+1}
${t}
`).join("")}`; } else if (prov==="gemini") { info.innerHTML="Google Gemini es completamente gratuito ⭐, sin tarjeta de crédito. Soporta texto e identificación por foto con el mismo modelo. 1.500 peticiones/día gratuitas."; inst.innerHTML=`
Cómo obtener la API Key de Google Gemini
${["Ve a aistudio.google.com","Inicia sesión con tu cuenta de Google","Haz clic en Get API Key → Create API Key","Copia la clave (empieza por AIza...)","¡Gratis, sin tarjeta, funciona para texto y fotos!"].map((t,i)=>`
${i+1}
${t}
`).join("")}`; } else if (prov==="groq") { info.innerHTML="Groq es completamente gratuito y no requiere tarjeta de crédito. Solo texto (no soporta fotos). Límite: 14.400 peticiones/día."; inst.innerHTML=`
Cómo obtener la API Key de Groq
${["Ve a console.groq.com","Crea cuenta con Google o email","Ve a API Keys → Create API Key","Copia la clave (empieza por gsk_...)","¡Completamente gratis, sin tarjeta!"].map((t,i)=>`
${i+1}
${t}
`).join("")}`; } else { info.innerHTML="Anthropic ofrece 5$ de crédito inicial. Requiere tarjeta de crédito para activar. Usa Claude Haiku."; inst.innerHTML=`
Cómo obtener la API Key de Anthropic
${["Ve a console.anthropic.com","Crea cuenta y añade tarjeta de crédito","Ve a API Keys → Create Key","Copia la clave (empieza por sk-ant-...)","Tienes 5$ de crédito gratis para empezar"].map((t,i)=>`
${i+1}
${t}
`).join("")}`; } } // Convierte MB# y GBIF# en enlaces clicables function _mbNumToRid(mbNum) { // Devuelve el rid (ID interno MycoBank para URL) dado un MB# if (!_mycoDB) return mbNum; const hit = _mycoDB.find(r => r.m === mbNum); return (hit && hit.rid) ? hit.rid : mbNum; } function fuentesConLinks(fuentes) { if (!fuentes) return String(fuentes || ''); return String(fuentes) .replace(/MycoBank #(\d+)/g, function(_, mbNum) { const urlId = _mbNumToRid(mbNum); // rid correcto para la URL return 'MycoBank #'+mbNum+''; }) .replace(/GBIF taxon (\d+)/g, function(_, id) { return 'GBIF taxon '+id+''; }) .replace(/GBIF#(\d+)/g, function(_, id) { return 'GBIF#'+id+''; }); } // ── Arranque ────────────────────────────────────────────────────────────────── // Mostrar splash mientras carga IndexedDB document.getElementById("screen").innerHTML = `
🍄
Cargando datos…
`; init();