このコミットが含まれているのは:
@@ -8,7 +8,6 @@ import type {
|
||||
} from '@/lib/postImportTypes'
|
||||
|
||||
type QueryRowState = {
|
||||
url: string
|
||||
title: string
|
||||
thumbnail_base: string
|
||||
original_created_from: string
|
||||
@@ -23,20 +22,45 @@ type QueryRowState = {
|
||||
created_post_id: number | null
|
||||
recoverable: boolean | null }
|
||||
|
||||
type FullRowState = QueryRowState & { url: string }
|
||||
|
||||
type MultiRowMetaState = {
|
||||
v: 1
|
||||
rows: QueryRowState[] }
|
||||
rows: EncodedRowState[] }
|
||||
|
||||
type FullState = {
|
||||
type EncodedSkip = 'e' | 'm'
|
||||
type EncodedImportStatus = 'p' | 'c' | 's' | 'f'
|
||||
|
||||
type EncodedRowState = {
|
||||
u?: string
|
||||
t?: string
|
||||
h?: string
|
||||
f?: string
|
||||
b?: string
|
||||
d?: string
|
||||
g?: string
|
||||
p?: string
|
||||
m?: string[]
|
||||
s?: EncodedSkip
|
||||
e?: number
|
||||
i?: EncodedImportStatus
|
||||
c?: number
|
||||
r?: boolean }
|
||||
|
||||
type EncodedState = {
|
||||
v: 1
|
||||
rows: Array<QueryRowState & { url: string }> }
|
||||
rows: EncodedRowState[] }
|
||||
|
||||
type ParsedPostNewState = {
|
||||
export type ParsedPostNewState = {
|
||||
rows: PostImportRow[]
|
||||
source: string
|
||||
repairMode: PostImportRepairMode
|
||||
shortcut: boolean }
|
||||
|
||||
export type SerialisedPostNewState = {
|
||||
path: string
|
||||
rows: PostImportRow[] }
|
||||
|
||||
const BASIC_KEYS = [
|
||||
'url',
|
||||
'title',
|
||||
@@ -55,6 +79,8 @@ const STATE_KEYS = [
|
||||
'recoverable'] as const
|
||||
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS] as const
|
||||
const MAX_REGULAR_URL_LENGTH = 768
|
||||
const MAX_COMPRESSED_STATE_LENGTH = 767
|
||||
const COMPRESSED_STATE_PREFIX = 'z1.'
|
||||
|
||||
const textEncoder = new TextEncoder ()
|
||||
const textDecoder = new TextDecoder ()
|
||||
@@ -72,6 +98,60 @@ const isValidImportStatus = (
|
||||
|| value === 'failed'
|
||||
|
||||
|
||||
const encodeSkip = (
|
||||
value: PostImportSkipReason | null,
|
||||
): EncodedSkip | undefined =>
|
||||
value === 'existing'
|
||||
? 'e'
|
||||
: value === 'manual'
|
||||
? 'm'
|
||||
: undefined
|
||||
|
||||
|
||||
const decodeSkip = (
|
||||
value: unknown,
|
||||
): PostImportSkipReason | null => {
|
||||
if (value == null)
|
||||
return null
|
||||
if (value === 'e')
|
||||
return 'existing'
|
||||
if (value === 'm')
|
||||
return 'manual'
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const encodeImportStatus = (
|
||||
value: PostImportRow['importStatus'] | null,
|
||||
): EncodedImportStatus | undefined =>
|
||||
value === 'pending'
|
||||
? 'p'
|
||||
: value === 'created'
|
||||
? 'c'
|
||||
: value === 'skipped'
|
||||
? 's'
|
||||
: value === 'failed'
|
||||
? 'f'
|
||||
: undefined
|
||||
|
||||
|
||||
const decodeImportStatus = (
|
||||
value: unknown,
|
||||
): PostImportRow['importStatus'] | null => {
|
||||
if (value == null)
|
||||
return null
|
||||
if (value === 'p')
|
||||
return 'pending'
|
||||
if (value === 'c')
|
||||
return 'created'
|
||||
if (value === 's')
|
||||
return 'skipped'
|
||||
if (value === 'f')
|
||||
return 'failed'
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const parsePositiveInt = (value: string | null): number | null => {
|
||||
if (value == null || value === '')
|
||||
return null
|
||||
@@ -81,7 +161,7 @@ const parsePositiveInt = (value: string | null): number | null => {
|
||||
}
|
||||
|
||||
|
||||
const decodeBase64Url = (value: string): string | null => {
|
||||
const decodeBase64UrlBytes = (value: string): Uint8Array | null => {
|
||||
try
|
||||
{
|
||||
const padded = value.replaceAll ('-', '+').replaceAll ('_', '/')
|
||||
@@ -91,8 +171,7 @@ const decodeBase64Url = (value: string): string | null => {
|
||||
? padded
|
||||
: `${ padded }${ '='.repeat (4 - remainder) }`
|
||||
const binary = window.atob (base64)
|
||||
const bytes = Uint8Array.from (binary, char => char.charCodeAt (0))
|
||||
return textDecoder.decode (bytes)
|
||||
return Uint8Array.from (binary, char => char.charCodeAt (0))
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -101,13 +180,52 @@ const decodeBase64Url = (value: string): string | null => {
|
||||
}
|
||||
|
||||
|
||||
const encodeBase64Url = (value: string): string => {
|
||||
const bytes = textEncoder.encode (value)
|
||||
const binary = Array.from (bytes, byte => String.fromCharCode (byte)).join ('')
|
||||
const encodeBase64UrlBytes = (value: Uint8Array): string => {
|
||||
const binary = Array.from (value, byte => String.fromCharCode (byte)).join ('')
|
||||
return window.btoa (binary).replaceAll ('+', '-').replaceAll ('/', '_').replaceAll ('=', '')
|
||||
}
|
||||
|
||||
|
||||
const compressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
|
||||
const stream =
|
||||
new Blob ([value]).stream ().pipeThrough (new CompressionStream ('gzip'))
|
||||
return new Uint8Array (await new Response (stream).arrayBuffer ())
|
||||
}
|
||||
|
||||
|
||||
const decompressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
|
||||
const stream =
|
||||
new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip'))
|
||||
return new Uint8Array (await new Response (stream).arrayBuffer ())
|
||||
}
|
||||
|
||||
|
||||
const decodeCompressedState = async (value: string): Promise<string | null> => {
|
||||
if (!(value.startsWith (COMPRESSED_STATE_PREFIX)))
|
||||
return null
|
||||
|
||||
const encoded = value.slice (COMPRESSED_STATE_PREFIX.length)
|
||||
const compressed = decodeBase64UrlBytes (encoded)
|
||||
if (compressed == null)
|
||||
return null
|
||||
|
||||
try
|
||||
{
|
||||
return textDecoder.decode (await decompressBytes (compressed))
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const encodeCompressedState = async (value: string): Promise<string> => {
|
||||
const compressed = await compressBytes (textEncoder.encode (value))
|
||||
return `${ COMPRESSED_STATE_PREFIX }${ encodeBase64UrlBytes (compressed) }`
|
||||
}
|
||||
|
||||
|
||||
const manualFields = (row: PostImportRow): string[] =>
|
||||
['title',
|
||||
'thumbnailBase',
|
||||
@@ -119,7 +237,6 @@ const manualFields = (row: PostImportRow): string[] =>
|
||||
|
||||
|
||||
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 ?? ''),
|
||||
@@ -137,7 +254,7 @@ const baseRowState = (row: PostImportRow): QueryRowState => ({
|
||||
|
||||
const buildRow = (
|
||||
sourceRow: number,
|
||||
state: QueryRowState & { url: string },
|
||||
state: FullRowState,
|
||||
): PostImportRow => {
|
||||
const manual = new Set (state.manual)
|
||||
const provenance: Record<string, PostImportOrigin> = {
|
||||
@@ -149,9 +266,9 @@ const buildRow = (
|
||||
duration: 'automatic',
|
||||
tags: manual.has ('tags') ? 'manual' : 'automatic',
|
||||
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
|
||||
const tagSources = {
|
||||
automatic: '',
|
||||
manual: provenance.tags === 'manual' ? state.tags : '' }
|
||||
const tagSources = provenance.tags === 'manual'
|
||||
? { automatic: '', manual: state.tags }
|
||||
: { automatic: state.tags, manual: '' }
|
||||
const attributes = {
|
||||
title: state.title,
|
||||
thumbnailBase: state.thumbnail_base,
|
||||
@@ -193,6 +310,90 @@ const parseManual = (value: string | null): string[] =>
|
||||
.filter (entry => entry !== '')
|
||||
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray (value) && value.every (entry => typeof entry === 'string')
|
||||
|
||||
|
||||
const parseEncodedRow = (
|
||||
value: unknown,
|
||||
requireUrl: boolean,
|
||||
): FullRowState | null => {
|
||||
if (typeof value !== 'object' || value == null || Array.isArray (value))
|
||||
return null
|
||||
|
||||
const row = value as Record<string, unknown>
|
||||
const url = row['u']
|
||||
const manual = row['m']
|
||||
const skip = decodeSkip (row['s'])
|
||||
const importStatus = decodeImportStatus (row['i'])
|
||||
const existingPostId = row['e']
|
||||
const createdPostId = row['c']
|
||||
const recoverable = row['r']
|
||||
|
||||
if (requireUrl && typeof url !== 'string')
|
||||
return null
|
||||
if (manual != null && !(isStringArray (manual)))
|
||||
return null
|
||||
if (row['s'] != null && skip == null)
|
||||
return null
|
||||
if (row['i'] != null && importStatus == null)
|
||||
return null
|
||||
if (existingPostId != null
|
||||
&& !(typeof existingPostId === 'number'
|
||||
&& Number.isInteger (existingPostId)
|
||||
&& existingPostId > 0))
|
||||
return null
|
||||
if (createdPostId != null
|
||||
&& !(typeof createdPostId === 'number'
|
||||
&& Number.isInteger (createdPostId)
|
||||
&& createdPostId > 0))
|
||||
return null
|
||||
if (recoverable != null && typeof recoverable !== 'boolean')
|
||||
return null
|
||||
|
||||
const stringFields = [
|
||||
['t', 'title'],
|
||||
['h', 'thumbnail_base'],
|
||||
['f', 'original_created_from'],
|
||||
['b', 'original_created_before'],
|
||||
['d', 'duration'],
|
||||
['g', 'tags'],
|
||||
['p', 'parent_post_ids']] as const
|
||||
let parsedStrings: Record<(typeof stringFields)[number][1], string>
|
||||
try
|
||||
{
|
||||
parsedStrings = Object.fromEntries (
|
||||
stringFields.map (([key, target]) => {
|
||||
const fieldValue = row[key]
|
||||
if (fieldValue != null && typeof fieldValue !== 'string')
|
||||
throw new Error ('invalid row')
|
||||
return [target, fieldValue ?? '']
|
||||
}),
|
||||
) as Record<(typeof stringFields)[number][1], string>
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
url: typeof url === 'string' ? url : '',
|
||||
title: parsedStrings.title,
|
||||
thumbnail_base: parsedStrings.thumbnail_base,
|
||||
original_created_from: parsedStrings.original_created_from,
|
||||
original_created_before: parsedStrings.original_created_before,
|
||||
duration: parsedStrings.duration,
|
||||
tags: parsedStrings.tags,
|
||||
parent_post_ids: parsedStrings.parent_post_ids,
|
||||
manual: manual ?? [],
|
||||
skip,
|
||||
existing_post_id: existingPostId ?? null,
|
||||
import_status: importStatus,
|
||||
created_post_id: createdPostId ?? null,
|
||||
recoverable: recoverable ?? null }
|
||||
}
|
||||
|
||||
|
||||
const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
|
||||
const url = params.get ('url')
|
||||
if (url == null || url === '')
|
||||
@@ -265,14 +466,14 @@ const parseMetaRows = (
|
||||
urlsValue: string,
|
||||
metaValue: string,
|
||||
): ParsedPostNewState | null => {
|
||||
const decoded = decodeBase64Url (metaValue)
|
||||
if (decoded == null)
|
||||
const decodedBytes = decodeBase64UrlBytes (metaValue)
|
||||
if (decodedBytes == null)
|
||||
return null
|
||||
|
||||
let parsed: MultiRowMetaState
|
||||
try
|
||||
{
|
||||
parsed = JSON.parse (decoded) as MultiRowMetaState
|
||||
parsed = JSON.parse (textDecoder.decode (decodedBytes)) as MultiRowMetaState
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -286,10 +487,16 @@ const parseMetaRows = (
|
||||
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] ?? '' }))
|
||||
const fullRows = parsed.rows.map ((row, index) => {
|
||||
const parsedRow = parseEncodedRow (row, false)
|
||||
return parsedRow == null
|
||||
? null
|
||||
: { ...parsedRow, url: urls[index] ?? '' }
|
||||
})
|
||||
if (fullRows.some (row => row == null))
|
||||
return null
|
||||
|
||||
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState))
|
||||
return {
|
||||
rows,
|
||||
source: urls.join ('\n'),
|
||||
@@ -298,25 +505,29 @@ const parseMetaRows = (
|
||||
}
|
||||
|
||||
|
||||
const parseFullState = (stateValue: string): ParsedPostNewState | null => {
|
||||
const decoded = decodeBase64Url (stateValue)
|
||||
const parseFullState = async (stateValue: string): Promise<ParsedPostNewState | null> => {
|
||||
const decoded = await decodeCompressedState (stateValue)
|
||||
if (decoded == null)
|
||||
return null
|
||||
|
||||
let parsed: FullState
|
||||
let parsed: EncodedState
|
||||
try
|
||||
{
|
||||
parsed = JSON.parse (decoded) as FullState
|
||||
parsed = JSON.parse (decoded) as EncodedState
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)))
|
||||
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)) || parsed.rows.length === 0)
|
||||
return null
|
||||
|
||||
const rows = parsed.rows.map ((row, index) => buildRow (index + 1, row))
|
||||
const fullRows = parsed.rows.map (row => parseEncodedRow (row, true))
|
||||
if (fullRows.some (row => row == null))
|
||||
return null
|
||||
|
||||
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState))
|
||||
return {
|
||||
rows,
|
||||
source: rows.map (row => row.url).join ('\n'),
|
||||
@@ -354,18 +565,58 @@ const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
|
||||
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)
|
||||
params.set ('urls', rows.map (row => row.url).join (' '))
|
||||
params.set ('meta', encodeBase64UrlBytes (textEncoder.encode (JSON.stringify ({
|
||||
v: 1,
|
||||
rows: rows.map (row => encodeRowState (row, false)) }))))
|
||||
return params
|
||||
}
|
||||
|
||||
|
||||
const encodeRowState = (
|
||||
row: PostImportRow,
|
||||
includeUrl: boolean,
|
||||
): EncodedRowState => {
|
||||
const base = baseRowState (row)
|
||||
const encoded: EncodedRowState = includeUrl ? { u: row.url } : { }
|
||||
|
||||
if (base.title !== '')
|
||||
encoded.t = base.title
|
||||
if (base.thumbnail_base !== '')
|
||||
encoded.h = base.thumbnail_base
|
||||
if (base.original_created_from !== '')
|
||||
encoded.f = base.original_created_from
|
||||
if (base.original_created_before !== '')
|
||||
encoded.b = base.original_created_before
|
||||
if (base.duration !== '')
|
||||
encoded.d = base.duration
|
||||
if (base.tags !== '')
|
||||
encoded.g = base.tags
|
||||
if (base.parent_post_ids !== '')
|
||||
encoded.p = base.parent_post_ids
|
||||
if (base.manual.length > 0)
|
||||
encoded.m = [...base.manual]
|
||||
if (base.skip != null)
|
||||
encoded.s = encodeSkip (base.skip)
|
||||
if (base.existing_post_id != null)
|
||||
encoded.e = base.existing_post_id
|
||||
if (base.import_status != null)
|
||||
encoded.i = encodeImportStatus (base.import_status)
|
||||
if (base.created_post_id != null)
|
||||
encoded.c = base.created_post_id
|
||||
if (base.recoverable != null)
|
||||
encoded.r = base.recoverable
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
|
||||
const encodeStateRows = (rows: PostImportRow[]): EncodedState => ({
|
||||
v: 1,
|
||||
rows: rows.map (row => encodeRowState (row, true)) })
|
||||
|
||||
|
||||
export const hasPostNewReviewState = (search: string): boolean => {
|
||||
const params = new URLSearchParams (search)
|
||||
return params.has ('state')
|
||||
@@ -375,11 +626,13 @@ export const hasPostNewReviewState = (search: string): boolean => {
|
||||
}
|
||||
|
||||
|
||||
export const parsePostNewState = (search: string): ParsedPostNewState | null => {
|
||||
export const parsePostNewState = async (
|
||||
search: string,
|
||||
): Promise<ParsedPostNewState | null> => {
|
||||
const params = new URLSearchParams (search)
|
||||
const stateValue = params.get ('state')
|
||||
if (stateValue != null)
|
||||
return parseFullState (stateValue)
|
||||
return await parseFullState (stateValue)
|
||||
|
||||
const urlsValue = params.get ('urls')
|
||||
const metaValue = params.get ('meta')
|
||||
@@ -394,17 +647,40 @@ export const parsePostNewState = (search: string): ParsedPostNewState | null =>
|
||||
}
|
||||
|
||||
|
||||
export const serialisePostNewState = (rows: PostImportRow[]): string => {
|
||||
const serialiseCompressedRows = async (
|
||||
rows: PostImportRow[],
|
||||
): Promise<SerialisedPostNewState | null> => {
|
||||
try
|
||||
{
|
||||
for (let count = rows.length; count > 0; --count)
|
||||
{
|
||||
const nextRows = rows.slice (0, count)
|
||||
const encoded = encodeStateRows (nextRows)
|
||||
const state = await encodeCompressedState (JSON.stringify (encoded))
|
||||
const payload = state.slice (COMPRESSED_STATE_PREFIX.length)
|
||||
if (payload.length <= MAX_COMPRESSED_STATE_LENGTH)
|
||||
return {
|
||||
path: `/posts/new?state=${ state }`,
|
||||
rows: nextRows }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
export const serialisePostNewState = async (
|
||||
rows: PostImportRow[],
|
||||
): Promise<SerialisedPostNewState | null> => {
|
||||
const regular = regularQuery (rows).toString ()
|
||||
const regularPath = `/posts/new?${ regular }`
|
||||
if (regularPath.length < MAX_REGULAR_URL_LENGTH)
|
||||
return `/posts/new?${ regular }`
|
||||
return {
|
||||
path: regularPath,
|
||||
rows }
|
||||
|
||||
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 }`
|
||||
return await serialiseCompressedRows (rows)
|
||||
}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする