358 行
9.2 KiB
TypeScript
358 行
9.2 KiB
TypeScript
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<string, unknown> =>
|
|
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<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 (_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<string, unknown>,
|
|
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<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,
|
|
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<PostImportSession, 'version' | 'savedAt'>,
|
|
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<PostImportSession>
|
|
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
|
|
}
|
|
}
|