import type { PostImportOrigin, PostImportExistingPost, PostImportResetSnapshot, PostImportRow, PostImportSession, PostImportStatus, PostImportSkipReason, StorageErrorHandler } from '@/lib/postImportTypes' const SESSION_VERSION = 3 const SESSION_PREFIX = 'post-import-session:' const SOURCE_DRAFT_KEY = 'post-import-source-draft' const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000 const MAX_SESSION_JSON_BYTES = 262_144 const MAX_SESSION_ROWS = 100 const MAX_SOURCE_BYTES = 262_144 const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i 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 ROW_KEYS = [ 'sourceRow', 'url', 'attributes', 'fieldWarnings', 'baseWarnings', 'validationErrors', 'importErrors', 'provenance', 'tagSources', 'status', 'skipReason', 'existingPostId', 'existingPost', 'metadataUrl', 'resetSnapshot', 'createdPostId', 'importStatus', 'recoverable'] as const const SESSION_KEYS = [ 'version', 'expiresAt', 'source', 'rows', 'repairMode'] as const const textEncoder = new TextEncoder () const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value != null && !(Array.isArray (value)) const byteLength = (value: string): number => textEncoder.encode (value).byteLength export const validatePostImportSessionId = (value: unknown): string | null => { if (typeof value !== 'string') return null if (value.length > 36 || !(SESSION_ID_PATTERN.test (value))) return null return value.toLowerCase () } export const generatePostImportSessionId = (): string => crypto.randomUUID () 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 (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 (!(hasOnlyKeys (value, ['url', 'attributes', 'provenance', 'tagSources', 'fieldWarnings', 'baseWarnings', 'metadataUrl']))) 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 sanitiseExistingPost = (value: unknown): PostImportExistingPost | undefined => { if (value == null) return undefined if (!(isPlainObject (value))) return undefined if (!(isPositiveInteger (value.id))) return undefined if (typeof value.title !== 'string' || typeof value.url !== 'string') return undefined if (value.thumbnailUrl != null && typeof value.thumbnailUrl !== 'string') return undefined return { id: Number (value.id), title: value.title, url: value.url, thumbnailUrl: value.thumbnailUrl as string | undefined } } const recalculateThumbnailWarning = (row: PostImportRow): PostImportRow => { const others = (row.fieldWarnings.thumbnailBase ?? []).filter ( key => key !== 'サムネールなし') const thumbnailBasePresent = typeof row.attributes.thumbnailBase === 'string' && row.attributes.thumbnailBase.trim () !== '' return { ...row, fieldWarnings: { ...row.fieldWarnings, thumbnailBase: thumbnailBasePresent ? others : [...new Set ([...others, 'サムネールなし'])] } } } const serialiseExistingPost = (value: PostImportExistingPost | undefined) => value == null ? undefined : { id: value.id, title: value.title, url: value.url, thumbnailUrl: value.thumbnailUrl } const serialiseResetSnapshot = (value: PostImportResetSnapshot) => ({ url: value.url, attributes: Object.fromEntries ( Object.entries (value.attributes).map (([key, entry]) => [key, entry])), provenance: Object.fromEntries ( Object.entries (value.provenance).map (([key, entry]) => [key, entry])), tagSources: { automatic: value.tagSources.automatic, manual: value.tagSources.manual }, fieldWarnings: Object.fromEntries ( Object.entries (value.fieldWarnings).map (([key, entry]) => [key, [...entry]])), baseWarnings: [...value.baseWarnings], metadataUrl: value.metadataUrl }) export const serialisePostImportRow = (row: PostImportRow) => { const thumbnailBaseWarnings = (() => { const others = (row.fieldWarnings.thumbnailBase ?? []).filter ( message => message !== 'サムネールなし') const thumbnailBasePresent = typeof row.attributes.thumbnailBase === 'string' && row.attributes.thumbnailBase.trim () !== '' return thumbnailBasePresent ? others : [...new Set ([...others, 'サムネールなし'])] }) () return { sourceRow: row.sourceRow, url: row.url, attributes: Object.fromEntries ( Object.entries (row.attributes).map (([key, entry]) => [key, entry])), fieldWarnings: Object.fromEntries ( Object.entries ({ ...row.fieldWarnings, thumbnailBase: thumbnailBaseWarnings }).map (([key, entry]) => [key, [...entry]])), baseWarnings: [...row.baseWarnings], validationErrors: Object.fromEntries ( Object.entries (row.validationErrors).map (([key, entry]) => [key, [...entry]])), importErrors: row.importErrors == null ? undefined : Object.fromEntries ( Object.entries (row.importErrors).map (([key, entry]) => [key, [...entry]])), provenance: Object.fromEntries ( Object.entries (row.provenance).map (([key, entry]) => [key, entry])), tagSources: row.tagSources == null ? undefined : { automatic: row.tagSources.automatic, manual: row.tagSources.manual }, status: row.status, skipReason: row.skipReason, existingPostId: row.existingPostId, existingPost: serialiseExistingPost (row.existingPost), metadataUrl: row.metadataUrl, resetSnapshot: serialiseResetSnapshot (row.resetSnapshot), createdPostId: row.createdPostId, importStatus: row.importStatus, recoverable: row.recoverable } } export const serialisePostImportRows = (rows: PostImportRow[]) => rows.map (row => serialisePostImportRow (row)) export const serialisedPostImportRowsEqual = ( left: PostImportRow[], right: PostImportRow[], ): boolean => JSON.stringify (serialisePostImportRows (left)) === JSON.stringify (serialisePostImportRows (right)) const sanitiseRow = (value: unknown): PostImportRow | null => { if (!(isPlainObject (value))) return null if (!(hasOnlyKeys (value, ROW_KEYS))) return null if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0) return null if (typeof value.url !== 'string') return null if (byteLength (value.url) > 20_480) 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, existingPost: sanitiseExistingPost (value.existingPost), 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 sanitiseSession = (value: unknown): PostImportSession | null => { if (!(isPlainObject (value))) return null if (!(hasOnlyKeys (value, SESSION_KEYS))) return null if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows))) return null if (value.rows.length === 0 || value.rows.length > MAX_SESSION_ROWS) return null if (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt)) return null if (typeof value.source !== 'string' || byteLength (value.source) > MAX_SOURCE_BYTES) return null const rows = value.rows.map (sanitiseRow) if (rows.some (row => row == null)) return null if ((new Set (rows.map (row => row?.sourceRow))).size !== rows.length) return null return { version: SESSION_VERSION, expiresAt: value.expiresAt, source: value.source, rows: (rows as PostImportRow[]).map (row => recalculateThumbnailWarning (row)), repairMode: value.repairMode === 'failed' ? 'failed' : 'all' } } const isExpiredSession = (expiresAt: string): boolean => { const value = Date.parse (expiresAt) return Number.isNaN (value) || value <= Date.now () } 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 sessionId = validatePostImportSessionId (key.slice (SESSION_PREFIX.length)) if (sessionId == null) { sessionStorage.removeItem (key) --i continue } const raw = sessionStorage.getItem (key) if (raw == null) continue if (byteLength (raw) > MAX_SESSION_JSON_BYTES) { sessionStorage.removeItem (key) --i continue } try { if (sanitiseSession (JSON.parse (raw)) == null) { 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 => { const validatedId = validatePostImportSessionId (sessionId) if (validatedId == null) return false return writeStorage ( sessionKey (validatedId), JSON.stringify ({ source: session.source, rows: serialisePostImportRows (session.rows), repairMode: session.repairMode, version: SESSION_VERSION, expiresAt: new Date (Date.now () + SESSION_MAX_AGE_MS).toISOString () }), onError) } export const loadPostImportSession = ( sessionId: string, onError?: StorageErrorHandler, ): PostImportSession | null => { const validatedId = validatePostImportSessionId (sessionId) if (validatedId == null) return null const raw = readStorage (sessionKey (validatedId), onError) if (raw == null) return null if (byteLength (raw) > MAX_SESSION_JSON_BYTES) { removeStorage (sessionKey (validatedId), onError) return null } try { const session = sanitiseSession (JSON.parse (raw)) if (session == null) { removeStorage (sessionKey (validatedId), onError) return null } return session } catch { removeStorage (sessionKey (validatedId), onError) return null } } export const clearPostImportSession = ( sessionId: string, onError?: StorageErrorHandler, ) => { const validatedId = validatePostImportSessionId (sessionId) if (validatedId == null) return removeStorage (sessionKey (validatedId), onError) }