Persist remaining client state in disk cache files
Release rolling / release (push) Has been cancelled
Release rolling / release (push) Has been cancelled
Move templates, notifications, backups, table columns, peer envs, and sidebar/first-connect prefs onto ~/.config/peardock/cache (settings.json or dedicated JSON files) with legacy localStorage migration.
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Generic client JSON cache under ~/.config/peardock/cache/{name}.json
|
||||
*
|
||||
* Mirrors the peers/settings cache pattern:
|
||||
* - Primary: filesystem (desktop / Pear / Electron / Node)
|
||||
* - Fallback + session mirror: localStorage when FS unavailable
|
||||
* - One-time migration from a legacy localStorage key when provided
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
export const JSON_CACHE_VERSION = 1
|
||||
export const JSON_CACHE_LS_PREFIX = 'peardock.cache.'
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPeardockHome() {
|
||||
return (
|
||||
process.env.PEARDOCK_HOME ||
|
||||
process.env.HOME ||
|
||||
process.env.USERPROFILE ||
|
||||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPeardockCacheDir() {
|
||||
return path.join(getPeardockHome(), '.config', 'peardock', 'cache')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a cache name to a safe file basename (no path segments).
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
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
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getCacheFilePath(name) {
|
||||
return path.join(getPeardockCacheDir(), `${sanitizeCacheName(name)}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getCacheLocalStorageKey(name) {
|
||||
return JSON_CACHE_LS_PREFIX + sanitizeCacheName(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {unknown}
|
||||
*/
|
||||
export function parseCachePayload(raw) {
|
||||
let parsed = raw
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
// Allow top-level arrays (e.g. notifications history)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
return null
|
||||
}
|
||||
// Versioned envelope { version, updatedAt, data }
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(parsed, 'data') &&
|
||||
(parsed.version != null || parsed.updatedAt != null)
|
||||
) {
|
||||
return parsed.data
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} data
|
||||
* @returns {{ version: number, updatedAt: string, data: unknown }}
|
||||
*/
|
||||
export function buildCachePayload(data) {
|
||||
return {
|
||||
version: JSON_CACHE_VERSION,
|
||||
updatedAt: new Date().toISOString(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCacheDir() {
|
||||
const dir = getPeardockCacheDir()
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {unknown|null}
|
||||
*/
|
||||
export function readCacheFromFile(name) {
|
||||
const file = getCacheFilePath(name)
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readFileSync(file, 'utf8')
|
||||
return parseCachePayload(raw)
|
||||
} catch (err) {
|
||||
console.warn('[WARN] jsonCache: failed to read', name, err?.message || err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write of versioned envelope.
|
||||
* @param {string} name
|
||||
* @param {unknown} data
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function writeCacheToFile(name, data) {
|
||||
const file = getCacheFilePath(name)
|
||||
const json = JSON.stringify(buildCachePayload(data), null, 2)
|
||||
try {
|
||||
ensureCacheDir()
|
||||
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
||||
fs.writeFileSync(tmp, json, { encoding: 'utf8', mode: 0o600 })
|
||||
fs.renameSync(tmp, file)
|
||||
try {
|
||||
fs.chmodSync(file, 0o600)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[ERROR] jsonCache: failed to write', name, err?.message || err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {unknown|null}
|
||||
*/
|
||||
export function readCacheFromLocalStorage(name) {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') return null
|
||||
const raw = localStorage.getItem(getCacheLocalStorageKey(name))
|
||||
if (raw == null) return null
|
||||
return parseCachePayload(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {unknown} data
|
||||
*/
|
||||
export function mirrorCacheToLocalStorage(name, data) {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
localStorage.setItem(
|
||||
getCacheLocalStorageKey(name),
|
||||
JSON.stringify(buildCachePayload(data))
|
||||
)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEmptyData(value) {
|
||||
if (value == null) return true
|
||||
if (Array.isArray(value)) return value.length === 0
|
||||
if (typeof value === 'object') return Object.keys(value).length === 0
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cache: file first, then optional legacy localStorage key, then mirror key.
|
||||
* Migrates legacy → file when found.
|
||||
*
|
||||
* @param {string} name — cache file basename (e.g. "templates")
|
||||
* @param {{
|
||||
* fallback?: unknown,
|
||||
* legacyLocalStorageKey?: string,
|
||||
* isEmpty?: (v: unknown) => boolean,
|
||||
* }} [opts]
|
||||
* @returns {unknown}
|
||||
*/
|
||||
export function loadJsonCache(name, opts = {}) {
|
||||
const empty = typeof opts.isEmpty === 'function' ? opts.isEmpty : isEmptyData
|
||||
const fallback = opts.fallback !== undefined ? opts.fallback : null
|
||||
|
||||
let data = readCacheFromFile(name)
|
||||
if (!empty(data)) {
|
||||
mirrorCacheToLocalStorage(name, data)
|
||||
return data
|
||||
}
|
||||
|
||||
// Migrate explicit legacy key (e.g. peardock_templates)
|
||||
if (opts.legacyLocalStorageKey && typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
const raw = localStorage.getItem(opts.legacyLocalStorageKey)
|
||||
if (raw != null && raw !== '') {
|
||||
const migrated = parseCachePayload(raw)
|
||||
if (!empty(migrated)) {
|
||||
writeCacheToFile(name, migrated)
|
||||
mirrorCacheToLocalStorage(name, migrated)
|
||||
console.log(
|
||||
'[INFO] jsonCache: migrated',
|
||||
opts.legacyLocalStorageKey,
|
||||
'→',
|
||||
getCacheFilePath(name)
|
||||
)
|
||||
return migrated
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary mirror
|
||||
const fromLs = readCacheFromLocalStorage(name)
|
||||
if (!empty(fromLs)) {
|
||||
writeCacheToFile(name, fromLs)
|
||||
return fromLs
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist cache to file (+ localStorage mirror).
|
||||
* @param {string} name
|
||||
* @param {unknown} data
|
||||
* @returns {{ ok: boolean, path: string }}
|
||||
*/
|
||||
export function saveJsonCache(name, data) {
|
||||
const ok = writeCacheToFile(name, data)
|
||||
mirrorCacheToLocalStorage(name, data)
|
||||
return { ok, path: getCacheFilePath(name) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove file + mirrors.
|
||||
* @param {string} name
|
||||
* @param {{ legacyLocalStorageKey?: string }} [opts]
|
||||
*/
|
||||
export function clearJsonCache(name, opts = {}) {
|
||||
try {
|
||||
const file = getCacheFilePath(name)
|
||||
if (fs.existsSync(file)) fs.unlinkSync(file)
|
||||
} catch (err) {
|
||||
console.warn('[WARN] jsonCache: failed to clear file', name, err?.message || err)
|
||||
}
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(getCacheLocalStorageKey(name))
|
||||
if (opts.legacyLocalStorageKey) {
|
||||
localStorage.removeItem(opts.legacyLocalStorageKey)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getPeardockHome,
|
||||
getPeardockCacheDir,
|
||||
getCacheFilePath,
|
||||
loadJsonCache,
|
||||
saveJsonCache,
|
||||
clearJsonCache,
|
||||
parseCachePayload,
|
||||
buildCachePayload,
|
||||
JSON_CACHE_VERSION,
|
||||
JSON_CACHE_LS_PREFIX,
|
||||
}
|
||||
Reference in New Issue
Block a user