import type { PostImportOrigin, PostImportRow, PostImportSession, PostImportStatus, PostImportSkipReason, StorageErrorHandler } from '@/lib/postImportTypes' const SESSION_VERSION = 2 const SESSION_PREFIX = 'post-import-session:' const SOURCE_DRAFT_KEY = 'post-import-source-draft' const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000 const ATTRIBUTE_KEYS = [ 'title', 'thumbnailBase', 'originalCreatedFrom', 'originalCreatedBefore', 'duration', 'videoMs', 'tags', 'parentPostIds'] as const const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const const TAG_SOURCE_KEYS = ['automatic', 'manual'] as const const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value != null && !(Array.isArray (value)) const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }` const readStorage = ( key: string, onError?: StorageErrorHandler, ): string | null => { if (typeof window === 'undefined') return null try { return sessionStorage.getItem (key) } catch { onError?.('保存済みデータを読み込めませんでした.') return null } } const writeStorage = ( key: string, value: string, onError?: StorageErrorHandler, ): boolean => { if (typeof window === 'undefined') return false try { sessionStorage.setItem (key, value) return true } catch { onError?.('ブラウザへ保存できませんでした.') return false } } const removeStorage = ( key: string, onError?: StorageErrorHandler, ) => { if (typeof window === 'undefined') return try { sessionStorage.removeItem (key) } catch { onError?.('保存済みデータを削除できませんでした.') } } const ensureStringListRecord = (value: unknown): Record | null => { if (!(isPlainObject (value))) return null const result: Record = { } for (const [key, entry] of Object.entries (value)) { if (!(Array.isArray (entry)) || !(entry.every (_1 => typeof _1 === 'string'))) return null result[key] = entry } return result } const isValidStatus = ( value: unknown, ): value is PostImportRow['status'] => value === 'ready' || value === 'warning' || value === 'error' const isValidImportStatus = ( value: unknown, ): value is PostImportStatus => value === 'pending' || value === 'created' || value === 'skipped' || value === 'failed' const isValidOrigin = ( value: unknown, ): value is PostImportOrigin => value === 'automatic' || value === 'manual' const isValidSkipReason = ( value: unknown, ): value is PostImportSkipReason => value === 'existing' const isPositiveInteger = (value: unknown): value is number => Number.isInteger (value) && Number (value) > 0 const hasOnlyKeys = ( value: Record, allowedKeys: readonly string[], ): boolean => Object.keys (value).every (key => allowedKeys.includes (key)) const sanitiseRow = (value: unknown): PostImportRow | null => { if (!(isPlainObject (value))) return null if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0) return null if (typeof value.url !== 'string') return null if (!(isPlainObject (value.attributes))) return null if (!(isPlainObject (value.provenance))) return null if (!(isValidStatus (value.status))) return null if (value.importStatus != null && !(isValidImportStatus (value.importStatus))) return null if (value.skipReason != null && !(isValidSkipReason (value.skipReason))) return null if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId))) return null if (value.skipReason !== 'existing' && value.existingPostId != null) return null if (value.importStatus === 'created' && !(isPositiveInteger (value.createdPostId))) return null if (value.importStatus !== 'created' && value.createdPostId != null) return null const validationErrors = ensureStringListRecord (value.validationErrors) const fieldWarnings = ensureStringListRecord (value.fieldWarnings) if (validationErrors == null || fieldWarnings == null) return null const importErrors = value.importErrors == null ? undefined : ensureStringListRecord (value.importErrors) if (value.importErrors != null && importErrors == null) return null if (!(Array.isArray (value.baseWarnings)) || !(value.baseWarnings.every (_1 => typeof _1 === 'string'))) return null const provenanceEntries = Object.entries (value.provenance) if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS))) return null if (!(Object.values (value.attributes).every (entry => typeof entry === 'string' || typeof entry === 'number'))) return null if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS))) return null if (!(provenanceEntries.every (([, origin]) => isValidOrigin (origin)))) return null if (value.tagSources != null) { if (!(isPlainObject (value.tagSources))) return null if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) return null if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string'))) return null } return { sourceRow: Number (value.sourceRow), url: value.url, attributes: value.attributes as Record, fieldWarnings, baseWarnings: value.baseWarnings, validationErrors, importErrors, provenance: value.provenance as Record, tagSources: value.tagSources as Record | undefined, status: value.status, skipReason: value.skipReason, existingPostId: isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined, metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined, createdPostId: isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined, importStatus: value.importStatus } } const isExpiredSession = (savedAt: string): boolean => { const value = Date.parse (savedAt) return Number.isNaN (value) || Date.now () - value > SESSION_MAX_AGE_MS } export const createPostImportSessionId = (): string => typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID () : `${ Date.now () }-${ Math.random ().toString (36).slice (2) }` export const cleanupExpiredPostImportSessions = ( onError?: StorageErrorHandler, ) => { if (typeof window === 'undefined') return try { for (let i = 0; i < sessionStorage.length; ++i) { const key = sessionStorage.key (i) if (key == null || !(key.startsWith (SESSION_PREFIX))) continue const raw = sessionStorage.getItem (key) if (raw == null) continue try { const value = JSON.parse (raw) as { savedAt?: string } if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt)) { sessionStorage.removeItem (key) --i } } catch { sessionStorage.removeItem (key) --i } } } catch { onError?.('保存済みデータを整理できませんでした.') } } export const loadPostImportSourceDraft = ( onError?: StorageErrorHandler, ): { source: string } => { const raw = readStorage (SOURCE_DRAFT_KEY, onError) if (raw == null) return { source: '' } try { const value = JSON.parse (raw) as { source?: string } return { source: typeof value.source === 'string' ? value.source : '' } } catch { return { source: '' } } } export const savePostImportSourceDraft = ( source: string, onError?: StorageErrorHandler, ): boolean => writeStorage (SOURCE_DRAFT_KEY, JSON.stringify ({ source }), onError) export const clearPostImportSourceDraft = ( onError?: StorageErrorHandler, ) => { removeStorage (SOURCE_DRAFT_KEY, onError) } export const savePostImportSession = ( sessionId: string, session: Omit, onError?: StorageErrorHandler, ): boolean => writeStorage ( sessionKey (sessionId), JSON.stringify ({ ...session, version: SESSION_VERSION, savedAt: new Date ().toISOString () }), onError) export const loadPostImportSession = ( sessionId: string, onError?: StorageErrorHandler, ): PostImportSession | null => { const raw = readStorage (sessionKey (sessionId), onError) if (raw == null) return null try { const value = JSON.parse (raw) as Partial if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows))) return null if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt)) { removeStorage (sessionKey (sessionId), onError) return null } const rows = value.rows.map (sanitiseRow) if (rows.some (_1 => _1 == null)) return null return { version: SESSION_VERSION, savedAt: value.savedAt, source: typeof value.source === 'string' ? value.source : '', rows: rows as PostImportRow[], repairMode: value.repairMode === 'failed' ? 'failed' : 'all' } } catch { return null } }