このコミットが含まれているのは:
@@ -0,0 +1,410 @@
|
||||
import { resultRepairMode } from '@/lib/postImportRows'
|
||||
|
||||
import type {
|
||||
PostImportOrigin,
|
||||
PostImportRepairMode,
|
||||
PostImportRow,
|
||||
PostImportSkipReason,
|
||||
} from '@/lib/postImportTypes'
|
||||
|
||||
type QueryRowState = {
|
||||
url: string
|
||||
title: string
|
||||
thumbnail_base: string
|
||||
original_created_from: string
|
||||
original_created_before: string
|
||||
duration: string
|
||||
tags: string
|
||||
parent_post_ids: string
|
||||
manual: string[]
|
||||
skip: PostImportSkipReason | null
|
||||
existing_post_id: number | null
|
||||
import_status: PostImportRow['importStatus'] | null
|
||||
created_post_id: number | null
|
||||
recoverable: boolean | null }
|
||||
|
||||
type MultiRowMetaState = {
|
||||
v: 1
|
||||
rows: QueryRowState[] }
|
||||
|
||||
type FullState = {
|
||||
v: 1
|
||||
rows: Array<QueryRowState & { url: string }> }
|
||||
|
||||
type ParsedPostNewState = {
|
||||
rows: PostImportRow[]
|
||||
source: string
|
||||
repairMode: PostImportRepairMode
|
||||
shortcut: boolean }
|
||||
|
||||
const BASIC_KEYS = [
|
||||
'url',
|
||||
'title',
|
||||
'thumbnail_base',
|
||||
'original_created_from',
|
||||
'original_created_before',
|
||||
'duration',
|
||||
'tags',
|
||||
'parent_post_ids'] as const
|
||||
const STATE_KEYS = [
|
||||
'manual',
|
||||
'skip',
|
||||
'existing_post_id',
|
||||
'import_status',
|
||||
'created_post_id',
|
||||
'recoverable'] as const
|
||||
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS] as const
|
||||
const MAX_REGULAR_URL_LENGTH = 768
|
||||
|
||||
const textEncoder = new TextEncoder ()
|
||||
const textDecoder = new TextDecoder ()
|
||||
|
||||
const isValidSkipReason = (value: unknown): value is PostImportSkipReason =>
|
||||
value === 'existing' || value === 'manual'
|
||||
|
||||
|
||||
const isValidImportStatus = (
|
||||
value: unknown,
|
||||
): value is PostImportRow['importStatus'] =>
|
||||
value === 'pending'
|
||||
|| value === 'created'
|
||||
|| value === 'skipped'
|
||||
|| value === 'failed'
|
||||
|
||||
|
||||
const parsePositiveInt = (value: string | null): number | null => {
|
||||
if (value == null || value === '')
|
||||
return null
|
||||
|
||||
const parsed = Number.parseInt (value, 10)
|
||||
return Number.isInteger (parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
|
||||
const decodeBase64Url = (value: string): string | null => {
|
||||
try
|
||||
{
|
||||
const padded = value.replaceAll ('-', '+').replaceAll ('_', '/')
|
||||
const remainder = padded.length % 4
|
||||
const base64 =
|
||||
remainder === 0
|
||||
? padded
|
||||
: `${ padded }${ '='.repeat (4 - remainder) }`
|
||||
const binary = window.atob (base64)
|
||||
const bytes = Uint8Array.from (binary, char => char.charCodeAt (0))
|
||||
return textDecoder.decode (bytes)
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const encodeBase64Url = (value: string): string => {
|
||||
const bytes = textEncoder.encode (value)
|
||||
const binary = Array.from (bytes, byte => String.fromCharCode (byte)).join ('')
|
||||
return window.btoa (binary).replaceAll ('+', '-').replaceAll ('/', '_').replaceAll ('=', '')
|
||||
}
|
||||
|
||||
|
||||
const manualFields = (row: PostImportRow): string[] =>
|
||||
['title',
|
||||
'thumbnailBase',
|
||||
'originalCreatedFrom',
|
||||
'originalCreatedBefore',
|
||||
'tags',
|
||||
'parentPostIds']
|
||||
.filter (field => row.provenance[field] === 'manual')
|
||||
|
||||
|
||||
const baseRowState = (row: PostImportRow): QueryRowState => ({
|
||||
url: row.url,
|
||||
title: String (row.attributes.title ?? ''),
|
||||
thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
|
||||
original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
|
||||
original_created_before: String (row.attributes.originalCreatedBefore ?? ''),
|
||||
duration: String (row.attributes.duration ?? ''),
|
||||
tags: String (row.attributes.tags ?? ''),
|
||||
parent_post_ids: String (row.attributes.parentPostIds ?? ''),
|
||||
manual: manualFields (row),
|
||||
skip: row.skipReason ?? null,
|
||||
existing_post_id: row.existingPostId ?? null,
|
||||
import_status: row.importStatus ?? null,
|
||||
created_post_id: row.createdPostId ?? null,
|
||||
recoverable: row.recoverable ?? null })
|
||||
|
||||
|
||||
const buildRow = (
|
||||
sourceRow: number,
|
||||
state: QueryRowState & { url: string },
|
||||
): PostImportRow => {
|
||||
const manual = new Set (state.manual)
|
||||
const provenance: Record<string, PostImportOrigin> = {
|
||||
url: 'manual',
|
||||
title: manual.has ('title') ? 'manual' : 'automatic',
|
||||
thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic',
|
||||
originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic',
|
||||
originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic',
|
||||
duration: 'automatic',
|
||||
tags: manual.has ('tags') ? 'manual' : 'automatic',
|
||||
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
|
||||
const tagSources = {
|
||||
automatic: '',
|
||||
manual: provenance.tags === 'manual' ? state.tags : '' }
|
||||
const attributes = {
|
||||
title: state.title,
|
||||
thumbnailBase: state.thumbnail_base,
|
||||
originalCreatedFrom: state.original_created_from,
|
||||
originalCreatedBefore: state.original_created_before,
|
||||
duration: state.duration,
|
||||
tags: state.tags,
|
||||
parentPostIds: state.parent_post_ids }
|
||||
|
||||
return {
|
||||
sourceRow,
|
||||
url: state.url,
|
||||
attributes,
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [],
|
||||
validationErrors: { },
|
||||
provenance,
|
||||
tagSources,
|
||||
status: 'ready',
|
||||
skipReason: state.skip ?? undefined,
|
||||
existingPostId: state.existing_post_id ?? undefined,
|
||||
resetSnapshot: {
|
||||
url: state.url,
|
||||
attributes: { ...attributes },
|
||||
provenance: { ...provenance },
|
||||
tagSources: { ...tagSources },
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [] },
|
||||
createdPostId: state.created_post_id ?? undefined,
|
||||
importStatus: state.import_status ?? undefined,
|
||||
recoverable: state.recoverable ?? undefined }
|
||||
}
|
||||
|
||||
|
||||
const parseManual = (value: string | null): string[] =>
|
||||
(value ?? '')
|
||||
.split (',')
|
||||
.map (entry => entry.trim ())
|
||||
.filter (entry => entry !== '')
|
||||
|
||||
|
||||
const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
|
||||
const url = params.get ('url')
|
||||
if (url == null || url === '')
|
||||
return null
|
||||
|
||||
const hasOnlyUrl = Array.from (params.keys ()).every (key => key === 'url')
|
||||
if (hasOnlyUrl)
|
||||
{
|
||||
const row = buildRow (1, {
|
||||
url,
|
||||
title: '',
|
||||
thumbnail_base: '',
|
||||
original_created_from: '',
|
||||
original_created_before: '',
|
||||
duration: '',
|
||||
tags: '',
|
||||
parent_post_ids: '',
|
||||
manual: [],
|
||||
skip: null,
|
||||
existing_post_id: null,
|
||||
import_status: null,
|
||||
created_post_id: null,
|
||||
recoverable: null })
|
||||
return {
|
||||
rows: [row],
|
||||
source: url,
|
||||
repairMode: 'all',
|
||||
shortcut: true }
|
||||
}
|
||||
|
||||
if (!(Array.from (params.keys ()).every (key => SINGLE_ROW_KEYS.includes (key as never))))
|
||||
return null
|
||||
|
||||
const skipValue = params.get ('skip')
|
||||
const importStatus = params.get ('import_status')
|
||||
const recoverable =
|
||||
params.get ('recoverable') == null
|
||||
? null
|
||||
: params.get ('recoverable') === 'true'
|
||||
if (skipValue != null && !(isValidSkipReason (skipValue)))
|
||||
return null
|
||||
if (importStatus != null && !(isValidImportStatus (importStatus)))
|
||||
return null
|
||||
|
||||
const row = buildRow (1, {
|
||||
url,
|
||||
title: params.get ('title') ?? '',
|
||||
thumbnail_base: params.get ('thumbnail_base') ?? '',
|
||||
original_created_from: params.get ('original_created_from') ?? '',
|
||||
original_created_before: params.get ('original_created_before') ?? '',
|
||||
duration: params.get ('duration') ?? '',
|
||||
tags: params.get ('tags') ?? '',
|
||||
parent_post_ids: params.get ('parent_post_ids') ?? '',
|
||||
manual: parseManual (params.get ('manual')),
|
||||
skip: skipValue,
|
||||
existing_post_id: parsePositiveInt (params.get ('existing_post_id')),
|
||||
import_status: importStatus,
|
||||
created_post_id: parsePositiveInt (params.get ('created_post_id')),
|
||||
recoverable })
|
||||
|
||||
return {
|
||||
rows: [row],
|
||||
source: url,
|
||||
repairMode: resultRepairMode ([row]),
|
||||
shortcut: false }
|
||||
}
|
||||
|
||||
|
||||
const parseMetaRows = (
|
||||
urlsValue: string,
|
||||
metaValue: string,
|
||||
): ParsedPostNewState | null => {
|
||||
const decoded = decodeBase64Url (metaValue)
|
||||
if (decoded == null)
|
||||
return null
|
||||
|
||||
let parsed: MultiRowMetaState
|
||||
try
|
||||
{
|
||||
parsed = JSON.parse (decoded) as MultiRowMetaState
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)))
|
||||
return null
|
||||
|
||||
const urls = urlsValue.split (' ').filter (url => url !== '')
|
||||
if (urls.length < 2 || urls.length !== parsed.rows.length)
|
||||
return null
|
||||
|
||||
const rows = parsed.rows.map ((row, index) => buildRow (index + 1, {
|
||||
...row,
|
||||
url: urls[index] ?? '' }))
|
||||
|
||||
return {
|
||||
rows,
|
||||
source: urls.join ('\n'),
|
||||
repairMode: resultRepairMode (rows),
|
||||
shortcut: false }
|
||||
}
|
||||
|
||||
|
||||
const parseFullState = (stateValue: string): ParsedPostNewState | null => {
|
||||
const decoded = decodeBase64Url (stateValue)
|
||||
if (decoded == null)
|
||||
return null
|
||||
|
||||
let parsed: FullState
|
||||
try
|
||||
{
|
||||
parsed = JSON.parse (decoded) as FullState
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)))
|
||||
return null
|
||||
|
||||
const rows = parsed.rows.map ((row, index) => buildRow (index + 1, row))
|
||||
return {
|
||||
rows,
|
||||
source: rows.map (row => row.url).join ('\n'),
|
||||
repairMode: resultRepairMode (rows),
|
||||
shortcut: false }
|
||||
}
|
||||
|
||||
|
||||
const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
|
||||
if (rows.length === 1)
|
||||
{
|
||||
const row = rows[0]
|
||||
const params = new URLSearchParams ()
|
||||
params.set ('url', row?.url ?? '')
|
||||
params.set ('title', String (row?.attributes.title ?? ''))
|
||||
params.set ('thumbnail_base', String (row?.attributes.thumbnailBase ?? ''))
|
||||
params.set ('original_created_from', String (row?.attributes.originalCreatedFrom ?? ''))
|
||||
params.set ('original_created_before', String (row?.attributes.originalCreatedBefore ?? ''))
|
||||
params.set ('duration', String (row?.attributes.duration ?? ''))
|
||||
params.set ('tags', String (row?.attributes.tags ?? ''))
|
||||
params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? ''))
|
||||
const manual = manualFields (row).join (',')
|
||||
if (manual !== '')
|
||||
params.set ('manual', manual)
|
||||
if (row.skipReason != null)
|
||||
params.set ('skip', row.skipReason)
|
||||
if (row.existingPostId != null)
|
||||
params.set ('existing_post_id', String (row.existingPostId))
|
||||
if (row.importStatus != null)
|
||||
params.set ('import_status', row.importStatus)
|
||||
if (row.createdPostId != null)
|
||||
params.set ('created_post_id', String (row.createdPostId))
|
||||
if (row.recoverable != null)
|
||||
params.set ('recoverable', row.recoverable ? 'true' : 'false')
|
||||
return params
|
||||
}
|
||||
|
||||
const urls = rows.map (row => row.url).join (' ')
|
||||
const metaRows: QueryRowState[] = rows.map (row => baseRowState (row))
|
||||
const meta = encodeBase64Url (JSON.stringify ({
|
||||
v: 1,
|
||||
rows: metaRows }))
|
||||
const params = new URLSearchParams ()
|
||||
params.set ('urls', urls)
|
||||
params.set ('meta', meta)
|
||||
return params
|
||||
}
|
||||
|
||||
|
||||
export const hasPostNewReviewState = (search: string): boolean => {
|
||||
const params = new URLSearchParams (search)
|
||||
return params.has ('state')
|
||||
|| params.has ('urls')
|
||||
|| params.has ('meta')
|
||||
|| params.has ('url')
|
||||
}
|
||||
|
||||
|
||||
export const parsePostNewState = (search: string): ParsedPostNewState | null => {
|
||||
const params = new URLSearchParams (search)
|
||||
const stateValue = params.get ('state')
|
||||
if (stateValue != null)
|
||||
return parseFullState (stateValue)
|
||||
|
||||
const urlsValue = params.get ('urls')
|
||||
const metaValue = params.get ('meta')
|
||||
if (urlsValue != null || metaValue != null)
|
||||
{
|
||||
if (urlsValue == null || metaValue == null)
|
||||
return null
|
||||
return parseMetaRows (urlsValue, metaValue)
|
||||
}
|
||||
|
||||
return parseSingleRow (params)
|
||||
}
|
||||
|
||||
|
||||
export const serialisePostNewState = (rows: PostImportRow[]): string => {
|
||||
const regular = regularQuery (rows).toString ()
|
||||
const regularPath = `/posts/new?${ regular }`
|
||||
if (regularPath.length < MAX_REGULAR_URL_LENGTH)
|
||||
return `/posts/new?${ regular }`
|
||||
|
||||
const stateRows: FullState['rows'] = rows.map (row => ({
|
||||
...baseRowState (row),
|
||||
url: row.url }))
|
||||
const state = encodeBase64Url (JSON.stringify ({
|
||||
v: 1,
|
||||
rows: stateRows }))
|
||||
return `/posts/new?state=${ state }`
|
||||
}
|
||||
新しい課題から参照
ユーザをブロックする