348 lines
9.4 KiB
JavaScript
348 lines
9.4 KiB
JavaScript
/**
|
||
* Paper Burner - KaTeX 缓存持久化系统
|
||
* Phase 4.2+: 自动保存和恢复公式缓存到 localStorage
|
||
*
|
||
* 功能:
|
||
* - 页面关闭时自动保存缓存
|
||
* - 页面加载时自动恢复缓存
|
||
* - 自动清理过期缓存(7 天)
|
||
* - 管理存储配额(最多 5MB)
|
||
*
|
||
* 预期收益:
|
||
* - 二次访问时公式渲染速度提升 99%
|
||
* - 完全消除重复公式的渲染时间
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
const STORAGE_CONFIG = {
|
||
KEY: 'paperburner_katex_cache',
|
||
VERSION: 1,
|
||
MAX_AGE_DAYS: 7, // 缓存有效期(天)
|
||
MAX_SIZE_MB: 5, // 最大存储空间(MB)
|
||
AUTO_SAVE_INTERVAL: 30000, // 自动保存间隔(30秒)
|
||
ENABLE_AUTO_SAVE: true, // 是否启用自动保存
|
||
ENABLE_DEBUG: false // 调试模式
|
||
};
|
||
|
||
/**
|
||
* KaTeX 缓存持久化管理器
|
||
*/
|
||
class KaTeXCachePersistence {
|
||
constructor(cache, options = {}) {
|
||
this.cache = cache;
|
||
this.storageKey = options.storageKey || STORAGE_CONFIG.KEY;
|
||
this.maxAgeDays = options.maxAgeDays || STORAGE_CONFIG.MAX_AGE_DAYS;
|
||
this.maxSizeMB = options.maxSizeMB || STORAGE_CONFIG.MAX_SIZE_MB;
|
||
this.autoSaveInterval = options.autoSaveInterval || STORAGE_CONFIG.AUTO_SAVE_INTERVAL;
|
||
this.enableAutoSave = options.enableAutoSave !== false;
|
||
this.enableDebug = options.enableDebug || STORAGE_CONFIG.ENABLE_DEBUG;
|
||
|
||
this.autoSaveTimer = null;
|
||
this.lastSaveTime = 0;
|
||
this.saveCount = 0;
|
||
|
||
this.init();
|
||
}
|
||
|
||
/**
|
||
* 初始化持久化系统
|
||
*/
|
||
init() {
|
||
// 加载缓存
|
||
this.load();
|
||
|
||
// 设置自动保存
|
||
if (this.enableAutoSave) {
|
||
this.startAutoSave();
|
||
}
|
||
|
||
// 页面卸载时保存
|
||
window.addEventListener('beforeunload', () => {
|
||
this.save();
|
||
});
|
||
|
||
// 页面隐藏时保存(移动端)
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden) {
|
||
this.save();
|
||
}
|
||
});
|
||
|
||
this.log('Persistence system initialized', 'info');
|
||
}
|
||
|
||
/**
|
||
* 从 localStorage 加载缓存
|
||
*/
|
||
load() {
|
||
try {
|
||
const stored = localStorage.getItem(this.storageKey);
|
||
if (!stored) {
|
||
this.log('No cached data found', 'info');
|
||
return false;
|
||
}
|
||
|
||
const data = JSON.parse(stored);
|
||
|
||
// 验证版本
|
||
if (data.version !== STORAGE_CONFIG.VERSION) {
|
||
this.log(`Version mismatch: ${data.version} vs ${STORAGE_CONFIG.VERSION}`, 'warn');
|
||
this.clear();
|
||
return false;
|
||
}
|
||
|
||
// 检查过期时间
|
||
const age = Date.now() - data.timestamp;
|
||
const maxAge = this.maxAgeDays * 24 * 60 * 60 * 1000;
|
||
|
||
if (age > maxAge) {
|
||
this.log(`Cache expired: ${Math.round(age / 86400000)} days old`, 'warn');
|
||
this.clear();
|
||
return false;
|
||
}
|
||
|
||
// 导入缓存
|
||
const success = this.cache.import(data.cacheData);
|
||
|
||
if (success) {
|
||
const size = this.estimateSize(stored);
|
||
this.log(`✅ Loaded ${data.cacheData.entries.length} formulas from cache (${size} KB, ${Math.round(age / 60000)} min old)`, 'success');
|
||
return true;
|
||
} else {
|
||
this.log('Failed to import cache data', 'error');
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
this.log(`Load error: ${error.message}`, 'error');
|
||
this.clear();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存缓存到 localStorage
|
||
*/
|
||
save() {
|
||
try {
|
||
const cacheData = this.cache.export();
|
||
|
||
const data = {
|
||
version: STORAGE_CONFIG.VERSION,
|
||
timestamp: Date.now(),
|
||
cacheData: cacheData
|
||
};
|
||
|
||
const json = JSON.stringify(data);
|
||
const size = this.estimateSize(json);
|
||
|
||
// 检查大小限制
|
||
if (size > this.maxSizeMB * 1024) {
|
||
this.log(`Cache too large: ${size} KB > ${this.maxSizeMB * 1024} KB`, 'warn');
|
||
|
||
// 尝试清理旧条目
|
||
this.pruneCache(cacheData);
|
||
return this.save(); // 递归保存清理后的缓存
|
||
}
|
||
|
||
localStorage.setItem(this.storageKey, json);
|
||
|
||
this.lastSaveTime = Date.now();
|
||
this.saveCount++;
|
||
|
||
this.log(`💾 Saved ${cacheData.entries.length} formulas (${size} KB) [#${this.saveCount}]`, 'success');
|
||
return true;
|
||
} catch (error) {
|
||
if (error.name === 'QuotaExceededError') {
|
||
this.log('Storage quota exceeded, clearing old cache', 'warn');
|
||
this.clear();
|
||
return false;
|
||
}
|
||
|
||
this.log(`Save error: ${error.message}`, 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理缓存(保留最近使用的条目)
|
||
*/
|
||
pruneCache(cacheData) {
|
||
const maxEntries = Math.floor(cacheData.entries.length * 0.7); // 保留 70%
|
||
const prunedEntries = cacheData.entries.slice(-maxEntries);
|
||
|
||
this.log(`Pruning cache: ${cacheData.entries.length} → ${prunedEntries.length}`, 'warn');
|
||
|
||
cacheData.entries = prunedEntries;
|
||
this.cache.import(cacheData);
|
||
}
|
||
|
||
/**
|
||
* 清空缓存
|
||
*/
|
||
clear() {
|
||
try {
|
||
localStorage.removeItem(this.storageKey);
|
||
this.cache.clear();
|
||
this.log('Cache cleared', 'info');
|
||
return true;
|
||
} catch (error) {
|
||
this.log(`Clear error: ${error.message}`, 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 启动自动保存
|
||
*/
|
||
startAutoSave() {
|
||
if (this.autoSaveTimer) {
|
||
clearInterval(this.autoSaveTimer);
|
||
}
|
||
|
||
this.autoSaveTimer = setInterval(() => {
|
||
const stats = this.cache.getStats();
|
||
|
||
// 只有缓存有更新时才保存
|
||
if (stats.hits > 0 || stats.misses > 0) {
|
||
this.save();
|
||
}
|
||
}, this.autoSaveInterval);
|
||
|
||
this.log(`Auto-save enabled: every ${this.autoSaveInterval / 1000}s`, 'info');
|
||
}
|
||
|
||
/**
|
||
* 停止自动保存
|
||
*/
|
||
stopAutoSave() {
|
||
if (this.autoSaveTimer) {
|
||
clearInterval(this.autoSaveTimer);
|
||
this.autoSaveTimer = null;
|
||
this.log('Auto-save disabled', 'info');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 估算数据大小(KB)
|
||
*/
|
||
estimateSize(data) {
|
||
const bytes = new Blob([data]).size;
|
||
return Math.round(bytes / 1024);
|
||
}
|
||
|
||
/**
|
||
* 获取持久化统计信息
|
||
*/
|
||
getStats() {
|
||
try {
|
||
const stored = localStorage.getItem(this.storageKey);
|
||
if (!stored) {
|
||
return {
|
||
exists: false,
|
||
size: 0,
|
||
age: 0,
|
||
entries: 0
|
||
};
|
||
}
|
||
|
||
const data = JSON.parse(stored);
|
||
const size = this.estimateSize(stored);
|
||
const age = Date.now() - data.timestamp;
|
||
|
||
return {
|
||
exists: true,
|
||
size: size,
|
||
sizeFormatted: `${size} KB`,
|
||
age: age,
|
||
ageFormatted: this.formatAge(age),
|
||
entries: data.cacheData?.entries?.length || 0,
|
||
version: data.version,
|
||
lastSaveTime: this.lastSaveTime,
|
||
saveCount: this.saveCount
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
exists: false,
|
||
error: error.message
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 格式化时间差
|
||
*/
|
||
formatAge(ms) {
|
||
const minutes = Math.floor(ms / 60000);
|
||
const hours = Math.floor(minutes / 60);
|
||
const days = Math.floor(hours / 24);
|
||
|
||
if (days > 0) return `${days} 天前`;
|
||
if (hours > 0) return `${hours} 小时前`;
|
||
if (minutes > 0) return `${minutes} 分钟前`;
|
||
return '刚刚';
|
||
}
|
||
|
||
/**
|
||
* 日志输出
|
||
*/
|
||
log(message, type = 'info') {
|
||
if (!this.enableDebug && type !== 'error') return;
|
||
|
||
const prefix = '[KaTeXCache Persistence]';
|
||
const timestamp = new Date().toLocaleTimeString();
|
||
|
||
switch (type) {
|
||
case 'success':
|
||
console.log(`%c${prefix} ${message}`, 'color: #48bb78; font-weight: bold');
|
||
break;
|
||
case 'warn':
|
||
console.warn(`${prefix} ${message}`);
|
||
break;
|
||
case 'error':
|
||
console.error(`${prefix} ${message}`);
|
||
break;
|
||
default:
|
||
console.log(`${prefix} ${message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 自动初始化持久化系统
|
||
if (window.katexCache && !window.katexCachePersistence) {
|
||
window.KaTeXCachePersistence = KaTeXCachePersistence;
|
||
window.katexCachePersistence = new KaTeXCachePersistence(window.katexCache, {
|
||
enableAutoSave: STORAGE_CONFIG.ENABLE_AUTO_SAVE,
|
||
enableDebug: STORAGE_CONFIG.ENABLE_DEBUG
|
||
});
|
||
|
||
console.log('[KaTeXCache] Cache persistence enabled (auto-save every 30s)');
|
||
|
||
// 提供全局查看方法
|
||
window.getKatexPersistenceStats = function() {
|
||
const stats = window.katexCachePersistence.getStats();
|
||
console.log('KaTeX 缓存持久化统计:');
|
||
console.table(stats);
|
||
return stats;
|
||
};
|
||
|
||
// 提供手动保存方法
|
||
window.saveKatexCache = function() {
|
||
return window.katexCachePersistence.save();
|
||
};
|
||
|
||
// 提供手动清除方法
|
||
window.clearKatexCache = function() {
|
||
return window.katexCachePersistence.clear();
|
||
};
|
||
} else if (!window.katexCache) {
|
||
console.warn('[KaTeXCache Persistence] Cannot initialize: katexCache not found');
|
||
}
|
||
})();
|
||
|
||
console.log('[KaTeXCache Persistence] System loaded');
|
||
console.log(' Stats: getKatexPersistenceStats()');
|
||
console.log(' Manual save: saveKatexCache()');
|
||
console.log(' Clear cache: clearKatexCache()');
|