ファイル
btrc-hub/frontend/src/lib/postImportStorage.ts
T
2026-07-16 22:31:32 +09:00

442 行
13 KiB
TypeScript

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<string, unknown> =>
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<string, string[]> | null => {
if (!(isPlainObject (value)))
return null
const result: Record<string, string[]> = { }
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<string, unknown>,
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<string, string | number>,
provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string>,
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<string, string | number>,
fieldWarnings,
baseWarnings: value.baseWarnings,
validationErrors,
importErrors,
provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string> | 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<PostImportSession, 'version' | 'savedAt'>,
sessionOrOnError?: Omit<PostImportSession, 'version' | 'savedAt'> | StorageErrorHandler,
onError?: StorageErrorHandler,
): boolean => {
const session =
typeof sessionOrLegacyId === 'string'
? sessionOrOnError as Omit<PostImportSession, 'version' | 'savedAt'>
: 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<PostImportSession>
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)
}