442 行
13 KiB
TypeScript
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)
|
|
}
|