304 lines
9.6 KiB
TypeScript
304 lines
9.6 KiB
TypeScript
import { computed, ref, watch } from 'vue'
|
|
import { defineStore } from 'pinia'
|
|
import type { SearchHit } from '~/types'
|
|
|
|
/**
|
|
* Historial de "documentos vistos". Cada vez que el usuario abre el panel de
|
|
* detalle de un resultado, registramos el documento aquí.
|
|
*
|
|
* Diseño:
|
|
* - Misma arquitectura que `~/stores/favorites.ts` (persistencia en
|
|
* localStorage, hidratación post-SSR vía plugin, sync entre pestañas).
|
|
* - Una entrada por documento: si vuelve a abrirse, se mueve al inicio y se
|
|
* actualiza `visitedAt` (comportamiento "como el historial del navegador").
|
|
* - Límite duro de `HISTORY_LIMIT` entradas: cuando se supera, descartamos las
|
|
* más antiguas (FIFO al fondo de la lista).
|
|
* - El identificador de colección no es un set cerrado — funciona con
|
|
* cualquier colección que se sume en el futuro.
|
|
*/
|
|
export type Collection = string
|
|
|
|
export interface HistoryItem {
|
|
collection: Collection
|
|
_id: string | number
|
|
/** Snapshot del documento para poder mostrarlo en el historial sin volver a
|
|
* consultar al backend (igual que hacemos con favoritos). */
|
|
hit: SearchHit
|
|
/** Última vez que se abrió el detalle (epoch ms). Si el documento se reabre,
|
|
* este valor se actualiza y el item salta al inicio. */
|
|
visitedAt: number
|
|
}
|
|
|
|
export interface HistoryFile {
|
|
version: 1
|
|
exportedAt: string
|
|
items: HistoryItem[]
|
|
}
|
|
|
|
export interface ImportResult {
|
|
added: number
|
|
skipped: number
|
|
invalid: number
|
|
total: number
|
|
}
|
|
|
|
const STORAGE_KEY = 'lgcc:history:v1'
|
|
|
|
/** Tope práctico de entradas. 200 cubre de sobra una sesión de uso intensivo
|
|
* sin inflar el JSON ni convertir la lista en algo inmanejable. Si llegamos
|
|
* a este número, las entradas más antiguas se van descartando. */
|
|
export const HISTORY_LIMIT = 200
|
|
|
|
function isValidHistoryItem(x: unknown): x is HistoryItem {
|
|
if (!x || typeof x !== 'object') return false
|
|
const o = x as Record<string, unknown>
|
|
if (typeof o.collection !== 'string') return false
|
|
if (typeof o._id !== 'string' && typeof o._id !== 'number') return false
|
|
if (!o.hit || typeof o.hit !== 'object') return false
|
|
return true
|
|
}
|
|
|
|
function readStorage(): HistoryItem[] {
|
|
if (typeof window === 'undefined') return []
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
if (!raw) return []
|
|
const parsed: unknown = JSON.parse(raw)
|
|
if (!Array.isArray(parsed)) return []
|
|
return parsed.filter(isValidHistoryItem)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function writeStorage(items: HistoryItem[]) {
|
|
if (typeof window === 'undefined') return
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
|
} catch (e) {
|
|
console.warn('No se pudo guardar el historial', e)
|
|
}
|
|
}
|
|
|
|
function sameKey(a: { collection: Collection, _id: string | number }, b: { collection: Collection, _id: string | number }) {
|
|
return a.collection === b.collection && String(a._id) === String(b._id)
|
|
}
|
|
|
|
/**
|
|
* Store de Pinia para el historial de detalles abiertos.
|
|
*
|
|
* - Persiste en `localStorage` con la clave `lgcc:history:v1`.
|
|
* - Hidrata en cliente vía el plugin `~/plugins/history.client.ts` para que
|
|
* el payload SSR vacío no pise lo que ya hay en el navegador.
|
|
* - Sincroniza entre pestañas escuchando el evento `storage`.
|
|
*/
|
|
export const useHistoryStore = defineStore('history', () => {
|
|
const items = ref<HistoryItem[]>([])
|
|
const ready = ref(false)
|
|
let listenerAttached = false
|
|
let hydrated = false
|
|
|
|
function hydrate() {
|
|
if (typeof window === 'undefined') return
|
|
if (hydrated) return
|
|
items.value = readStorage()
|
|
ready.value = true
|
|
hydrated = true
|
|
if (!listenerAttached) {
|
|
window.addEventListener('storage', (e) => {
|
|
if (e.key !== STORAGE_KEY) return
|
|
items.value = readStorage()
|
|
})
|
|
listenerAttached = true
|
|
}
|
|
}
|
|
|
|
function commit(next: HistoryItem[]) {
|
|
// Aplicamos el límite al persistir: nunca dejamos más de HISTORY_LIMIT
|
|
// entradas. Como `visit()` siempre añade al inicio, recortamos por el final.
|
|
const trimmed = next.length > HISTORY_LIMIT ? next.slice(0, HISTORY_LIMIT) : next
|
|
items.value = trimmed
|
|
writeStorage(trimmed)
|
|
}
|
|
|
|
// Red de seguridad: cualquier mutación directa de `items.value` se persiste.
|
|
if (typeof window !== 'undefined') {
|
|
watch(items, (next) => {
|
|
if (!hydrated) return
|
|
writeStorage(next)
|
|
}, { deep: true })
|
|
}
|
|
|
|
// ---- Getters ----------------------------------------------------------
|
|
|
|
const total = computed(() => items.value.length)
|
|
|
|
const collections = computed<string[]>(() => {
|
|
const seen = new Set<string>()
|
|
for (const it of items.value) seen.add(it.collection)
|
|
return Array.from(seen).sort()
|
|
})
|
|
|
|
function inHistory(collection: Collection, id: string | number): boolean {
|
|
return items.value.some(it => sameKey(it, { collection, _id: id }))
|
|
}
|
|
|
|
function byCollection(collection: Collection) {
|
|
return computed(() => items.value.filter(it => it.collection === collection))
|
|
}
|
|
|
|
function countByCollection(collection: Collection) {
|
|
return computed(() => items.value.filter(it => it.collection === collection).length)
|
|
}
|
|
|
|
// ---- Acciones ---------------------------------------------------------
|
|
|
|
/**
|
|
* Registra (o re-registra) una visita a un documento.
|
|
*
|
|
* Comportamiento "tipo historial de navegador": si el documento ya estaba,
|
|
* se elimina de su posición previa y se reinserta al principio con el
|
|
* timestamp actualizado, así el orden de la lista refleja la última vez
|
|
* que se vio cada documento.
|
|
*/
|
|
function visit(collection: Collection, hit: SearchHit) {
|
|
if (!hit?._id) return
|
|
|
|
// Limpiamos campos volátiles de Meilisearch/Typesense que cambian entre
|
|
// búsquedas — sin esto el JSON exportable se llenaría de ruido y, peor,
|
|
// dos visitas idénticas se considerarían "diferentes" para el watcher
|
|
// de profundidad. Es la misma higiene que hace el store de favoritos.
|
|
const cleanHit = { ...hit } as SearchHit
|
|
delete (cleanHit as Record<string, unknown>)._matchesPosition
|
|
delete (cleanHit as Record<string, unknown>)._formatted
|
|
|
|
const key = { collection, _id: hit._id }
|
|
const rest = items.value.filter(it => !sameKey(it, key))
|
|
commit([
|
|
{ collection, _id: hit._id, hit: cleanHit, visitedAt: Date.now() },
|
|
...rest
|
|
])
|
|
}
|
|
|
|
function remove(collection: Collection, id: string | number) {
|
|
commit(items.value.filter(it => !sameKey(it, { collection, _id: id })))
|
|
}
|
|
|
|
function clear() {
|
|
commit([])
|
|
}
|
|
|
|
function clearCollection(collection: Collection) {
|
|
commit(items.value.filter(it => it.collection !== collection))
|
|
}
|
|
|
|
// ---- Export / Import --------------------------------------------------
|
|
|
|
function exportToJson(): string {
|
|
const file: HistoryFile = {
|
|
version: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
items: items.value
|
|
}
|
|
return JSON.stringify(file, null, 2)
|
|
}
|
|
|
|
function downloadJson(filename?: string) {
|
|
if (typeof window === 'undefined') return
|
|
const name = filename || `historial-${new Date().toISOString().slice(0, 10)}.json`
|
|
const blob = new Blob([exportToJson()], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = name
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
async function copyJsonToClipboard(): Promise<boolean> {
|
|
if (typeof window === 'undefined') return false
|
|
const text = exportToJson()
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function importFromJson(json: string, mode: 'merge' | 'replace' = 'merge'): ImportResult {
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(json)
|
|
} catch {
|
|
throw new Error('El archivo no es un JSON válido.')
|
|
}
|
|
|
|
let incoming: unknown[]
|
|
if (Array.isArray(parsed)) {
|
|
incoming = parsed
|
|
} else if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { items?: unknown[] }).items)) {
|
|
incoming = (parsed as { items: unknown[] }).items
|
|
} else {
|
|
throw new Error('El JSON no tiene la forma esperada (debe contener una lista de items de historial).')
|
|
}
|
|
|
|
const valid = incoming.filter(isValidHistoryItem)
|
|
const invalid = incoming.length - valid.length
|
|
|
|
if (mode === 'replace') {
|
|
const next = valid
|
|
.map(v => ({ ...v, visitedAt: (v as HistoryItem).visitedAt || Date.now() }))
|
|
// Importamos respetando el orden temporal (más reciente primero).
|
|
.sort((a, b) => b.visitedAt - a.visitedAt)
|
|
commit(next)
|
|
return { added: next.length, skipped: 0, invalid, total: items.value.length }
|
|
}
|
|
|
|
const next = [...items.value]
|
|
let added = 0
|
|
let skipped = 0
|
|
for (const inc of valid) {
|
|
if (next.some(it => sameKey(it, inc))) { skipped++; continue }
|
|
next.push({ ...inc, visitedAt: (inc as HistoryItem).visitedAt || Date.now() })
|
|
added++
|
|
}
|
|
// Reordenamos por visitedAt para mantener invariante "más reciente arriba".
|
|
next.sort((a, b) => b.visitedAt - a.visitedAt)
|
|
commit(next)
|
|
return { added, skipped, invalid, total: items.value.length }
|
|
}
|
|
|
|
async function importFromFile(file: File, mode: 'merge' | 'replace' = 'merge') {
|
|
const text = await file.text()
|
|
return importFromJson(text, mode)
|
|
}
|
|
|
|
return {
|
|
// state
|
|
items,
|
|
ready,
|
|
// getters
|
|
total,
|
|
collections,
|
|
// queries
|
|
inHistory,
|
|
byCollection,
|
|
countByCollection,
|
|
// mutations
|
|
hydrate,
|
|
visit,
|
|
remove,
|
|
clear,
|
|
clearCollection,
|
|
// export/import
|
|
exportToJson,
|
|
downloadJson,
|
|
copyJsonToClipboard,
|
|
importFromJson,
|
|
importFromFile
|
|
}
|
|
})
|