/** * Atomic JSON cache under ~/.config/peardata/cache/{name}.json * (+ optional localStorage mirror / legacy migration). */ import fs from 'fs' import path from 'path' import { getPeardataCacheDir } from './paths.js' export const JSON_CACHE_VERSION = 1 export const JSON_CACHE_LS_PREFIX = 'peardata.cache.' /** * @param {string} name */ export function sanitizeCacheName(name) { const s = String(name || '') .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') if (!s || s === '.' || s === '..') throw new Error(`Invalid cache name: ${name}`) return s } /** * @param {string} name */ export function getCacheFilePath(name) { return path.join(getPeardataCacheDir(), `${sanitizeCacheName(name)}.json`) } /** * @param {string} name */ export function getCacheLocalStorageKey(name) { return JSON_CACHE_LS_PREFIX + sanitizeCacheName(name) } /** * @param {string} file * @param {object} payload */ export function atomicWriteJson(file, payload) { const dir = path.dirname(file) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) const json = JSON.stringify(payload, null, 2) const tmp = `${file}.${process.pid}.${Date.now()}.tmp` fs.writeFileSync(tmp, json, { mode: 0o600 }) fs.renameSync(tmp, file) try { fs.chmodSync(file, 0o600) } catch { // ignore } } /** * @param {string} name * @param {unknown} data * @param {{ mirrorLocalStorage?: boolean }} [opts] */ export function saveJsonCache(name, data, opts = {}) { const file = getCacheFilePath(name) const payload = { version: JSON_CACHE_VERSION, updatedAt: new Date().toISOString(), data, } try { atomicWriteJson(file, payload) } catch (err) { console.warn('[WARN] jsonCache: write failed', name, err?.message || err) } if (opts.mirrorLocalStorage !== false) { try { if (typeof localStorage !== 'undefined') { localStorage.setItem(getCacheLocalStorageKey(name), JSON.stringify(payload)) } } catch { // ignore } } return data } /** * @param {string} name * @param {{ legacyLocalStorageKey?: string, defaultValue?: unknown }} [opts] */ export function loadJsonCache(name, opts = {}) { const file = getCacheFilePath(name) try { if (fs.existsSync(file)) { const raw = JSON.parse(fs.readFileSync(file, 'utf8')) if (raw && typeof raw === 'object' && 'data' in raw) return raw.data return raw } } catch { // fall through } // Legacy localStorage migration if (opts.legacyLocalStorageKey) { try { if (typeof localStorage !== 'undefined') { const legacy = localStorage.getItem(opts.legacyLocalStorageKey) if (legacy) { const parsed = JSON.parse(legacy) const data = parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed saveJsonCache(name, data ?? opts.defaultValue ?? null) return data ?? opts.defaultValue ?? null } } } catch { // ignore } } try { if (typeof localStorage !== 'undefined') { const mir = localStorage.getItem(getCacheLocalStorageKey(name)) if (mir) { const parsed = JSON.parse(mir) return parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed } } } catch { // ignore } return opts.defaultValue ?? null }