Browse Source

fix lefotver opengraph data stores. more efficient caching

master
Silberengel 1 month ago
parent
commit
6cb776ccb1
  1. 6
      README.md
  2. 4
      public/healthz.json
  3. 131
      src/lib/components/content/OpenGraphCard.svelte
  4. 67
      src/lib/components/write/CreateEventForm.svelte
  5. 46
      src/lib/modules/comments/CommentForm.svelte
  6. 93
      src/lib/services/cache/draft-store.ts
  7. 33
      src/lib/services/cache/indexeddb-store.ts
  8. 217
      src/lib/services/content/opengraph-fetcher.ts

6
README.md

@ -494,19 +494,19 @@ aitherboard/ @@ -494,19 +494,19 @@ aitherboard/
| Method | Implementation | Key Storage |
|--------|----------------|------------|
| **NIP-07** | Browser extension (Alby, nos2x, etc.) | No storage (extension manages) |
| **Nsec** | Direct bech32 nsec or hex private key, stored in the in-browser cache | **REQUIRED**: NIP-49 encrypted in localStorage |
| **Nsec** | Direct bech32 nsec or hex private key, stored in the in-browser cache | **REQUIRED**: NIP-49 encrypted in IndexedDB |
| **NIP-46 Bunker** | Remote signer via `bunker://` URI | No local storage |
| **Anonymous** | Generated on the fly when publishing | **REQUIRED**: NIP-49 encrypted in IndexedDB |
### Key Storage & Encryption
**CRITICAL**: NO SECRET KEYS STORED ON THE SERVER
- All keys stored client-side only (IndexedDB/localStorage)
- All keys stored client-side only in IndexedDB
- Server only serves static files
- All key management in browser
- **REQUIRED**: All nsec keys (including anonymous) MUST be encrypted with NIP-49 (password-based) before storage
- Store as ncryptsec format (never plaintext nsec)
- Anonymous keys persist in IndexedDB across sessions
- All encrypted keys persist in IndexedDB across sessions
- Users can provide their own anonymous key (must be encrypted)
### Anonymous User Behavior

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-05T06:58:07.669Z",
"buildTime": "2026-02-05T07:24:19.725Z",
"gitCommit": "unknown",
"timestamp": 1770274687669
"timestamp": 1770276259725
}

131
src/lib/components/content/OpenGraphCard.svelte

@ -1,131 +0,0 @@ @@ -1,131 +0,0 @@
<script lang="ts">
import type { OpenGraphData } from '../../services/content/opengraph-fetcher.js';
interface Props {
data: OpenGraphData;
url: string;
}
let { data, url }: Props = $props();
</script>
<a href={url} target="_blank" rel="noopener noreferrer" class="opengraph-card">
{#if data.image}
<div class="opengraph-image">
<img src={data.image} alt={data.title || ''} loading="lazy" />
</div>
{/if}
<div class="opengraph-content">
{#if data.siteName}
<div class="opengraph-site">{data.siteName}</div>
{/if}
{#if data.title}
<div class="opengraph-title">{data.title}</div>
{/if}
{#if data.description}
<div class="opengraph-description">{data.description}</div>
{/if}
</div>
</a>
<style>
.opengraph-card {
display: flex;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
margin: 1rem 0;
text-decoration: none;
color: inherit;
transition: all 0.2s;
background: var(--fog-post, #ffffff);
}
:global(.dark) .opengraph-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.opengraph-card:hover {
border-color: var(--fog-accent, #64748b);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.dark) .opengraph-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.opengraph-image {
flex-shrink: 0;
width: 200px;
height: 150px;
overflow: hidden;
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .opengraph-image {
background: var(--fog-dark-border, #374151);
}
.opengraph-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.opengraph-content {
flex: 1;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.opengraph-site {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
:global(.dark) .opengraph-site {
color: var(--fog-dark-text-light, #9ca3af);
}
.opengraph-title {
font-size: 1rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
line-height: 1.4;
}
:global(.dark) .opengraph-title {
color: var(--fog-dark-text, #f9fafb);
}
.opengraph-description {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
:global(.dark) .opengraph-description {
color: var(--fog-dark-text-light, #9ca3af);
}
@media (max-width: 640px) {
.opengraph-card {
flex-direction: column;
}
.opengraph-image {
width: 100%;
height: 200px;
}
}
</style>

67
src/lib/components/write/CreateEventForm.svelte

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
import { uploadFileToServer, buildImetaTag } from '../../services/nostr/file-upload.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import { getDraft, saveDraft, deleteDraft } from '../../services/cache/draft-store.js';
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte';
import MarkdownRenderer from '../content/MarkdownRenderer.svelte';
import MediaAttachments from '../content/MediaAttachments.svelte';
@ -42,7 +43,7 @@ @@ -42,7 +43,7 @@
let { initialKind = null, initialContent: propInitialContent = null, initialTags: propInitialTags = null }: Props = $props();
const STORAGE_KEY = 'aitherboard_writeForm_draft';
const DRAFT_ID = 'write';
let selectedKind = $state<number>(1);
let customKindId = $state<string>('');
@ -50,51 +51,51 @@ @@ -50,51 +51,51 @@
let tags = $state<string[][]>([]);
let publishing = $state(false);
// Restore draft from localStorage on mount (only if no initial props)
// Restore draft from IndexedDB on mount (only if no initial props)
$effect(() => {
if (typeof window === 'undefined') return;
// Only restore if no initial content/tags were provided (from highlight feature)
if (propInitialContent === null && propInitialTags === null) {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const draft = JSON.parse(saved);
if (draft.content !== undefined && content === '') {
content = draft.content;
}
if (draft.tags && draft.tags.length > 0 && tags.length === 0) {
tags = draft.tags;
}
if (draft.selectedKind !== undefined && initialKind === null) {
selectedKind = draft.selectedKind;
(async () => {
try {
const draft = await getDraft(DRAFT_ID);
if (draft) {
if (draft.content !== undefined && content === '') {
content = draft.content;
}
if (draft.tags && draft.tags.length > 0 && tags.length === 0) {
tags = draft.tags;
}
if (draft.selectedKind !== undefined && initialKind === null) {
selectedKind = draft.selectedKind;
}
}
} catch (error) {
console.error('Error restoring draft:', error);
}
} catch (error) {
console.error('Error restoring draft:', error);
}
})();
}
});
// Save draft to localStorage when content or tags change
// Save draft to IndexedDB when content or tags change
$effect(() => {
if (typeof window === 'undefined') return;
if (publishing) return; // Don't save while publishing
// Debounce saves to avoid excessive localStorage writes
const timeoutId = setTimeout(() => {
// Debounce saves to avoid excessive IndexedDB writes
const timeoutId = setTimeout(async () => {
try {
const draft = {
content,
tags,
selectedKind
};
// Only save if there's actual content
if (content.trim() || tags.length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
await saveDraft(DRAFT_ID, {
content,
tags,
selectedKind
});
} else {
// Clear if empty
localStorage.removeItem(STORAGE_KEY);
await deleteDraft(DRAFT_ID);
}
} catch (error) {
console.error('Error saving draft:', error);
@ -681,10 +682,8 @@ @@ -681,10 +682,8 @@
content = '';
tags = [];
uploadedFiles = []; // Clear uploaded files after successful publish
// Clear draft from localStorage after successful publish
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
// Clear draft from IndexedDB after successful publish
await deleteDraft(DRAFT_ID);
setTimeout(() => {
goto(`/event/${signedEvent.id}`);
}, 5000);
@ -717,11 +716,9 @@ @@ -717,11 +716,9 @@
// Reset the initial props applied flag
initialPropsApplied = false;
// Clear draft from localStorage after clearing state
// Clear draft from IndexedDB after clearing state
// This prevents the save effect from running with old data
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
await deleteDraft(DRAFT_ID);
// Reset formCleared flag after a brief delay to allow effects to settle
setTimeout(() => {

46
src/lib/modules/comments/CommentForm.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { fetchRelayLists } from '../../services/user-data.js';
import { getDraft, saveDraft, deleteDraft } from '../../services/cache/draft-store.js';
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import GifPicker from '../../components/content/GifPicker.svelte';
import EmojiPicker from '../../components/content/EmojiPicker.svelte';
@ -26,43 +27,42 @@ @@ -26,43 +27,42 @@
let { threadId, rootEvent, parentEvent, onPublished, onCancel }: Props = $props();
// Create unique storage key based on thread and parent
const STORAGE_KEY = $derived(`aitherboard_commentForm_${threadId}_${parentEvent?.id || 'root'}`);
// Create unique draft ID based on thread and parent
const DRAFT_ID = $derived(`comment_${threadId}_${parentEvent?.id || 'root'}`);
let content = $state('');
let publishing = $state(false);
// Restore draft from localStorage on mount
// Restore draft from IndexedDB on mount
$effect(() => {
if (typeof window === 'undefined') return;
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const draft = JSON.parse(saved);
if (draft.content !== undefined && content === '') {
(async () => {
try {
const draft = await getDraft(DRAFT_ID);
if (draft && draft.content !== undefined && content === '') {
content = draft.content;
}
} catch (error) {
console.error('Error restoring comment draft:', error);
}
} catch (error) {
console.error('Error restoring comment draft:', error);
}
})();
});
// Save draft to localStorage when content changes
// Save draft to IndexedDB when content changes
$effect(() => {
if (typeof window === 'undefined') return;
if (publishing) return; // Don't save while publishing
// Debounce saves to avoid excessive localStorage writes
const timeoutId = setTimeout(() => {
// Debounce saves to avoid excessive IndexedDB writes
const timeoutId = setTimeout(async () => {
try {
// Only save if there's actual content
if (content.trim()) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ content }));
await saveDraft(DRAFT_ID, { content });
} else {
// Clear if empty
localStorage.removeItem(STORAGE_KEY);
await deleteDraft(DRAFT_ID);
}
} catch (error) {
console.error('Error saving comment draft:', error);
@ -241,10 +241,8 @@ @@ -241,10 +241,8 @@
if (result.success.length > 0) {
content = '';
uploadedFiles = []; // Clear uploaded files after successful publish
// Clear draft from localStorage after successful publish
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
// Clear draft from IndexedDB after successful publish
await deleteDraft(DRAFT_ID);
onPublished?.();
}
} catch (error) {
@ -261,14 +259,12 @@ @@ -261,14 +259,12 @@
}
}
function clearForm() {
async function clearForm() {
if (confirm('Are you sure you want to clear the comment? This will delete all unsaved content.')) {
content = '';
uploadedFiles = [];
// Clear draft from localStorage
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
// Clear draft from IndexedDB
await deleteDraft(DRAFT_ID);
}
}

93
src/lib/services/cache/draft-store.ts vendored

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
/**
* Draft storage in IndexedDB
* Replaces localStorage for better scalability and structure
*/
import { getDB } from './indexeddb-store.js';
export interface DraftData {
id: string; // e.g., 'write', 'comment_<eventId>'
content: string;
tags?: string[][];
selectedKind?: number;
updatedAt: number;
}
/**
* Save a draft
*/
export async function saveDraft(
id: string,
data: Omit<DraftData, 'id' | 'updatedAt'>
): Promise<void> {
try {
const db = await getDB();
const draft: DraftData = {
id,
...data,
updatedAt: Date.now()
};
await db.put('drafts', draft);
} catch (error) {
console.warn('Error saving draft to IndexedDB:', error);
}
}
/**
* Get a draft by ID
*/
export async function getDraft(id: string): Promise<DraftData | null> {
try {
const db = await getDB();
const draft = await db.get('drafts', id);
return (draft as DraftData) || null;
} catch (error) {
console.warn('Error reading draft from IndexedDB:', error);
return null;
}
}
/**
* Delete a draft
*/
export async function deleteDraft(id: string): Promise<void> {
try {
const db = await getDB();
await db.delete('drafts', id);
} catch (error) {
console.warn('Error deleting draft from IndexedDB:', error);
}
}
/**
* List all drafts
*/
export async function listDrafts(): Promise<DraftData[]> {
try {
const db = await getDB();
const drafts: DraftData[] = [];
const tx = db.transaction('drafts', 'readonly');
for await (const cursor of tx.store.iterate()) {
drafts.push(cursor.value as DraftData);
}
await tx.done;
return drafts;
} catch (error) {
console.warn('Error listing drafts from IndexedDB:', error);
return [];
}
}
/**
* Clear all drafts
*/
export async function clearAllDrafts(): Promise<void> {
try {
const db = await getDB();
await db.clear('drafts');
} catch (error) {
console.warn('Error clearing all drafts:', error);
}
}

33
src/lib/services/cache/indexeddb-store.ts vendored

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard';
const DB_VERSION = 3; // Incremented to add deletion_requests store
const DB_VERSION = 6; // Version 5: Added preferences and drafts stores. Version 6: Removed opengraph store
export interface DatabaseSchema {
events: {
@ -25,6 +25,14 @@ export interface DatabaseSchema { @@ -25,6 +25,14 @@ export interface DatabaseSchema {
key: string;
value: unknown;
};
preferences: {
key: string; // preference key
value: unknown;
};
drafts: {
key: string; // draft id (e.g., 'write', 'comment_<eventId>')
value: unknown;
};
}
let dbInstance: IDBPDatabase<DatabaseSchema> | null = null;
@ -37,7 +45,12 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -37,7 +45,12 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
try {
dbInstance = await openDB<DatabaseSchema>(DB_NAME, DB_VERSION, {
upgrade(db) {
upgrade(db, oldVersion) {
// Migration: Remove opengraph store (was added in version 4, removed in version 6)
if (db.objectStoreNames.contains('opengraph')) {
db.deleteObjectStore('opengraph');
}
// Events store
if (!db.objectStoreNames.contains('events')) {
const eventStore = db.createObjectStore('events', { keyPath: 'id' });
@ -60,6 +73,16 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -60,6 +73,16 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
if (!db.objectStoreNames.contains('search')) {
db.createObjectStore('search', { keyPath: 'id' });
}
// Preferences store
if (!db.objectStoreNames.contains('preferences')) {
db.createObjectStore('preferences', { keyPath: 'key' });
}
// Drafts store
if (!db.objectStoreNames.contains('drafts')) {
db.createObjectStore('drafts', { keyPath: 'id' });
}
},
blocked() {
console.warn('IndexedDB is blocked - another tab may have it open');
@ -77,7 +100,9 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -77,7 +100,9 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
if (!dbInstance.objectStoreNames.contains('events') ||
!dbInstance.objectStoreNames.contains('profiles') ||
!dbInstance.objectStoreNames.contains('keys') ||
!dbInstance.objectStoreNames.contains('search')) {
!dbInstance.objectStoreNames.contains('search') ||
!dbInstance.objectStoreNames.contains('preferences') ||
!dbInstance.objectStoreNames.contains('drafts')) {
// Database is corrupted - close and delete it, then recreate
console.warn('Database missing required stores, recreating...');
dbInstance.close();
@ -107,6 +132,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -107,6 +132,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
db.createObjectStore('profiles', { keyPath: 'pubkey' });
db.createObjectStore('keys', { keyPath: 'id' });
db.createObjectStore('search', { keyPath: 'id' });
db.createObjectStore('preferences', { keyPath: 'key' });
db.createObjectStore('drafts', { keyPath: 'id' });
},
blocked() {
console.warn('IndexedDB is blocked - another tab may have it open');

217
src/lib/services/content/opengraph-fetcher.ts

@ -1,217 +0,0 @@ @@ -1,217 +0,0 @@
/**
* OpenGraph metadata fetcher service
* Fetches OpenGraph metadata from URLs and caches results
*/
export interface OpenGraphData {
title?: string;
description?: string;
image?: string;
url?: string;
siteName?: string;
type?: string;
cachedAt: number;
}
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
const CACHE_KEY_PREFIX = 'opengraph_';
/**
* Fetch OpenGraph metadata from a URL
* Uses a CORS proxy if needed, caches results in localStorage
*/
export async function fetchOpenGraph(url: string): Promise<OpenGraphData | null> {
// Check cache first
const cached = getCachedOpenGraph(url);
if (cached && Date.now() - cached.cachedAt < CACHE_DURATION) {
return cached;
}
try {
// Try to fetch the page HTML
// Note: Direct fetch may fail due to CORS, so we'll use a simple approach
// In production, you might want to use a backend proxy or service
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': 'Mozilla/5.0 (compatible; aitherboard/1.0)'
},
mode: 'cors',
cache: 'no-cache'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
const ogData = parseOpenGraph(html, url);
// Cache the result
if (ogData) {
cacheOpenGraph(url, ogData);
}
return ogData;
} catch (error) {
console.warn('Failed to fetch OpenGraph data:', error);
// Return cached data even if expired, or null
return cached || null;
}
}
/**
* Parse OpenGraph metadata from HTML
*/
function parseOpenGraph(html: string, url: string): OpenGraphData | null {
const og: Partial<OpenGraphData> = {
cachedAt: Date.now()
};
// Extract OpenGraph meta tags
const ogTitleMatch = html.match(/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:title["']/i);
if (ogTitleMatch) {
og.title = decodeHtmlEntities(ogTitleMatch[1]);
}
const ogDescriptionMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:description["']/i);
if (ogDescriptionMatch) {
og.description = decodeHtmlEntities(ogDescriptionMatch[1]);
}
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:image["']/i);
if (ogImageMatch) {
og.image = ogImageMatch[1];
// Make image URL absolute if relative
if (og.image && !og.image.startsWith('http')) {
try {
const baseUrl = new URL(url);
og.image = new URL(og.image, baseUrl).href;
} catch {
// Invalid URL, keep as is
}
}
}
const ogUrlMatch = html.match(/<meta\s+property=["']og:url["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:url["']/i);
if (ogUrlMatch) {
og.url = ogUrlMatch[1];
} else {
og.url = url;
}
const ogSiteNameMatch = html.match(/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:site_name["']/i);
if (ogSiteNameMatch) {
og.siteName = decodeHtmlEntities(ogSiteNameMatch[1]);
}
const ogTypeMatch = html.match(/<meta\s+property=["']og:type["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:type["']/i);
if (ogTypeMatch) {
og.type = ogTypeMatch[1];
}
// Fallback to regular meta tags if OpenGraph not available
if (!og.title) {
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch) {
og.title = decodeHtmlEntities(titleMatch[1].trim());
}
}
if (!og.description) {
const metaDescriptionMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i);
if (metaDescriptionMatch) {
og.description = decodeHtmlEntities(metaDescriptionMatch[1]);
}
}
// Return null if we have no useful data
if (!og.title && !og.description && !og.image) {
return null;
}
return og as OpenGraphData;
}
/**
* Decode HTML entities
*/
function decodeHtmlEntities(text: string): string {
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
/**
* Get cached OpenGraph data
*/
function getCachedOpenGraph(url: string): OpenGraphData | null {
if (typeof window === 'undefined') return null;
try {
const key = CACHE_KEY_PREFIX + url;
const cached = localStorage.getItem(key);
if (cached) {
return JSON.parse(cached) as OpenGraphData;
}
} catch (error) {
console.warn('Error reading cached OpenGraph data:', error);
}
return null;
}
/**
* Cache OpenGraph data
*/
function cacheOpenGraph(url: string, data: OpenGraphData): void {
if (typeof window === 'undefined') return;
const key = CACHE_KEY_PREFIX + url;
try {
localStorage.setItem(key, JSON.stringify(data));
} catch (error) {
console.warn('Error caching OpenGraph data:', error);
// If storage is full, try to clear old entries
try {
clearOldCacheEntries();
localStorage.setItem(key, JSON.stringify(data));
} catch {
// Give up
}
}
}
/**
* Clear old cache entries to free up space
*/
function clearOldCacheEntries(): void {
if (typeof window === 'undefined') return;
const now = Date.now();
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(CACHE_KEY_PREFIX)) {
try {
const data = JSON.parse(localStorage.getItem(key) || '{}') as OpenGraphData;
if (now - data.cachedAt > CACHE_DURATION) {
keysToRemove.push(key);
}
} catch {
keysToRemove.push(key);
}
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
}
Loading…
Cancel
Save