import type { PostImportOrigin, PostImportResetSnapshot, PostImportRow, PostImportSession, PostImportStatus, PostImportSkipReason, StorageErrorHandler } from '@/lib/postImportTypes' const SESSION_VERSION = 2 const SESSION_PREFIX = 'post-import-session:' const CURRENT_SESSION_KEY = `${ SESSION_PREFIX }current` 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 WARNING_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value != null && !(Array.isArray (value)) const sessionKey = (): string => CURRENT_SESSION_KEY 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 (item => typeof item === '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' || value === 'manual' 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 sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => { if (!(isPlainObject (value))) return null if (typeof value.url !== 'string') return null if (!(isPlainObject (value.attributes))) return null if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS))) return null if (!(Object.values (value.attributes).every (entry => typeof entry === 'string' || typeof entry === 'number'))) return null if (!(isPlainObject (value.provenance))) return null if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS))) return null if (!(Object.values (value.provenance).every (origin => isValidOrigin (origin)))) return null if (!(isPlainObject (value.tagSources))) return null if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) return null if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string'))) return null const fieldWarnings = ensureStringListRecord (value.fieldWarnings) if (fieldWarnings == null) return null if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS))) return null if (!(Array.isArray (value.baseWarnings)) || !(value.baseWarnings.every (warning => typeof warning === 'string'))) return null if (value.metadataUrl != null && typeof value.metadataUrl !== 'string') return null return { url: value.url, attributes: value.attributes as Record, provenance: value.provenance as Record, tagSources: value.tagSources as Record, fieldWarnings, baseWarnings: value.baseWarnings, metadataUrl: value.metadataUrl as string | undefined } } 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.recoverable != null && value.recoverable !== true) 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 if (value.recoverable === true && value.importStatus !== 'failed' && value.importStatus !== 'pending') return null if ((value.importStatus === 'created' || value.importStatus === 'skipped') && value.recoverable != null) return null const validationErrors = ensureStringListRecord (value.validationErrors) const fieldWarnings = ensureStringListRecord (value.fieldWarnings) const resetSnapshot = sanitiseResetSnapshot (value.resetSnapshot) if (validationErrors == null || fieldWarnings == null) return null if (resetSnapshot == null) return null const importErrors = value.importErrors == null ? undefined : ensureStringListRecord (value.importErrors) if (importErrors === null) return null if (!(Array.isArray (value.baseWarnings)) || !(value.baseWarnings.every (warning => typeof warning === '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 (entry => typeof entry === '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 ?? undefined, existingPostId: isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined, metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined, resetSnapshot, createdPostId: isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined, importStatus: value.importStatus ?? undefined, recoverable: value.recoverable === true ? true : undefined } } const isExpiredSession = (savedAt: string): boolean => { const value = Date.parse (savedAt) return Number.isNaN (value) || Date.now () - value > SESSION_MAX_AGE_MS } 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 if (key !== CURRENT_SESSION_KEY) { sessionStorage.removeItem (key) --i 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 = ( sessionOrLegacyId: string | Omit, sessionOrOnError?: Omit | StorageErrorHandler, onError?: StorageErrorHandler, ): boolean => { const session = typeof sessionOrLegacyId === 'string' ? sessionOrOnError as Omit : sessionOrLegacyId const errorHandler = typeof sessionOrLegacyId === 'string' ? onError : sessionOrOnError as StorageErrorHandler | undefined return writeStorage ( sessionKey (), JSON.stringify ({ ...session, version: SESSION_VERSION, savedAt: new Date ().toISOString () }), errorHandler) } export const loadPostImportSession = ( legacyIdOrOnError?: string | StorageErrorHandler, onError?: StorageErrorHandler, ): PostImportSession | null => { const errorHandler = typeof legacyIdOrOnError === 'string' ? onError : legacyIdOrOnError const raw = readStorage (sessionKey (), errorHandler) 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 (), errorHandler) return null } const rows = value.rows.map (sanitiseRow) if (rows.some (row => row == 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 } } export const clearPostImportSession = ( onError?: StorageErrorHandler, ) => { removeStorage (sessionKey (), onError) }