このコミットが含まれているのは:
2026-07-17 21:53:24 +09:00
コミット e6b7e33b83
10個のファイルの変更494行の追加149行の削除
+18 -11
ファイルの表示
@@ -1,5 +1,7 @@
class PostsController < ApplicationController class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
MAX_BULK_THUMBNAIL_BYTES = 20 * 1024 * 1024
MAX_BULK_REQUEST_BYTES = 40 * 1024 * 1024
class VideoMsParseError < ArgumentError class VideoMsParseError < ArgumentError
; ;
@@ -210,11 +212,10 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form] return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form]
return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
result = PostBulkCreator.new( posts = parse_bulk_posts_manifest
actor: current_user, thumbnails = parse_bulk_thumbnails(posts.length)
posts: parse_bulk_posts_manifest, result = PostBulkCreator.new(actor: current_user, posts:, thumbnails:).run
thumbnails: parse_bulk_thumbnails).run
render json: result render json: result
rescue JSON::ParserError rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.' render_bad_request 'posts manifest の JSON が不正です.'
@@ -541,16 +542,22 @@ class PostsController < ApplicationController
posts posts
end end
def parse_bulk_thumbnails def parse_bulk_thumbnails post_count
thumbnails = { } thumbnails = { }
post_count = Array(JSON.parse(params[:posts].presence || '[]')).length raw = params[:thumbnails]
return thumbnails if raw.blank?
raise ArgumentError, 'thumbnail key が不正です.' unless raw.respond_to?(:to_unsafe_h)
params.to_unsafe_h.each do |key, value| raw.to_unsafe_h.each do |key, value|
match = key.match(/\Athumbnails\[(\d+)\]\z/) raise ArgumentError, 'thumbnail key が不正です.' unless key.to_s.match?(/\A\d+\z/)
next if match.nil?
index = Integer(match[1], 10) index = Integer(key, 10)
raise ArgumentError, 'thumbnail index が範囲外です.' if index.negative? || index >= post_count raise ArgumentError, 'thumbnail index が範囲外です.' if index.negative? || index >= post_count
raise ArgumentError, 'thumbnail index が重複しています.' if thumbnails.key?(index)
unless value.is_a?(ActionDispatch::Http::UploadedFile)
raise ArgumentError, 'thumbnail upload が不正です.'
end
raise ArgumentError, 'thumbnail file size が大きすぎます.' if value.size > MAX_BULK_THUMBNAIL_BYTES
thumbnails[index] = value thumbnails[index] = value
end end
+1 -3
ファイルの表示
@@ -15,9 +15,7 @@ class Post < ApplicationRecord
def self.resized_thumbnail_attachment(upload) def self.resized_thumbnail_attachment(upload)
upload.rewind upload.rewind
image = MiniMagick::Image.read(upload.read) image = MiniMagick::Image.read(upload.read)
image.resize '180x180^' image.resize '180x180'
image.gravity 'Center'
image.extent '180x180'
image.format 'jpg' image.format 'jpg'
{ io: StringIO.new(image.to_blob), { io: StringIO.new(image.to_blob),
+3
ファイルの表示
@@ -33,6 +33,9 @@ class PostCreator
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
end end
post post
rescue StandardError
post&.thumbnail&.purge if post&.thumbnail&.attached?
raise
end end
private private
+54 -6
ファイルの表示
@@ -28,7 +28,8 @@ const buildDraft = (row: PostImportRow): Draft => ({
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''), originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''), originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
tags: String (row.attributes.tags ?? ''), tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? '') }) parentPostIds: String (row.attributes.parentPostIds ?? ''),
thumbnailFile: row.thumbnailFile })
const buildResetDraft = (row: PostImportRow): Draft => ({ const buildResetDraft = (row: PostImportRow): Draft => ({
url: row.resetSnapshot.url, url: row.resetSnapshot.url,
@@ -37,7 +38,8 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''), originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''), originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
tags: String (row.resetSnapshot.attributes.tags ?? ''), tags: String (row.resetSnapshot.attributes.tags ?? ''),
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') }) parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? ''),
thumbnailFile: undefined })
const groupedMessages = (...values: (string[] | undefined)[]): string[] => const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))] [...new Set (values.flatMap (value => value ?? []))]
@@ -64,6 +66,13 @@ const sameTagSources = (
(current?.automatic ?? '') === reset.automatic (current?.automatic ?? '') === reset.automatic
&& (current?.manual ?? '') === reset.manual && (current?.manual ?? '') === reset.manual
const sameWarnings = (
current: PostImportRow,
reset: PostImportRow['resetSnapshot'],
): boolean =>
JSON.stringify (current.fieldWarnings) === JSON.stringify (reset.fieldWarnings)
&& JSON.stringify (current.baseWarnings) === JSON.stringify (reset.baseWarnings)
const PostImportRowForm: FC<Props> = ( const PostImportRowForm: FC<Props> = (
{ row, { row,
@@ -76,6 +85,8 @@ const PostImportRowForm: FC<Props> = (
const [resetRequested, setResetRequested] = useState (false) const [resetRequested, setResetRequested] = useState (false)
const [committedThumbnailBase, setCommittedThumbnailBase] = useState ( const [committedThumbnailBase, setCommittedThumbnailBase] = useState (
() => String (row.attributes.thumbnailBase ?? '')) () => String (row.attributes.thumbnailBase ?? ''))
const [thumbnailObjectUrl, setThumbnailObjectUrl] = useState<string | undefined> (
() => row.thumbnailObjectUrl)
useEffect (() => { useEffect (() => {
const nextDraft = buildDraft (row) const nextDraft = buildDraft (row)
@@ -83,8 +94,14 @@ const PostImportRowForm: FC<Props> = (
setMessageRow (null) setMessageRow (null)
setResetRequested (false) setResetRequested (false)
setCommittedThumbnailBase (String (row.attributes.thumbnailBase ?? '')) setCommittedThumbnailBase (String (row.attributes.thumbnailBase ?? ''))
setThumbnailObjectUrl (row.thumbnailObjectUrl)
}, [row]) }, [row])
useEffect (() => () => {
if (thumbnailObjectUrl != null)
URL.revokeObjectURL (thumbnailObjectUrl)
}, [thumbnailObjectUrl])
const displayRow = messageRow ?? row const displayRow = messageRow ?? row
const resetDraft = useMemo ( const resetDraft = useMemo (
() => buildResetDraft (row), () => buildResetDraft (row),
@@ -94,7 +111,8 @@ const PostImportRowForm: FC<Props> = (
|| (sameDraft (draft, resetDraft) || (sameDraft (draft, resetDraft)
&& sameProvenance (row.provenance, row.resetSnapshot.provenance) && sameProvenance (row.provenance, row.resetSnapshot.provenance)
&& sameTagSources (row.tagSources, row.resetSnapshot.tagSources) && sameTagSources (row.tagSources, row.resetSnapshot.tagSources)
&& row.metadataUrl === row.resetSnapshot.metadataUrl) && row.metadataUrl === row.resetSnapshot.metadataUrl
&& sameWarnings (displayRow, row.resetSnapshot))
const update = <Key extends keyof Draft,> ( const update = <Key extends keyof Draft,> (
key: Key, key: Key,
@@ -122,8 +140,11 @@ const PostImportRowForm: FC<Props> = (
setResetRequested (true) setResetRequested (true)
setMessageRow (null) setMessageRow (null)
setCommittedThumbnailBase (resetDraft.thumbnailBase) setCommittedThumbnailBase (resetDraft.thumbnailBase)
if (thumbnailObjectUrl != null)
URL.revokeObjectURL (thumbnailObjectUrl)
setThumbnailObjectUrl (undefined)
return false return false
}, [controls, resetDisabled, resetDraft]) }, [controls, resetDisabled, resetDraft, thumbnailObjectUrl])
const save = useCallback (async (): Promise<boolean> => { const save = useCallback (async (): Promise<boolean> => {
setSaving (true) setSaving (true)
@@ -163,7 +184,7 @@ const PostImportRowForm: FC<Props> = (
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]"> <div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<div className="space-y-3 md:sticky md:top-0 md:self-start"> <div className="space-y-3 md:sticky md:top-0 md:self-start">
<PostImportThumbnailPreview <PostImportThumbnailPreview
url={committedThumbnailBase} url={committedThumbnailBase || thumbnailObjectUrl || ''}
className="h-28 w-28"/> className="h-28 w-28"/>
</div> </div>
@@ -191,7 +212,34 @@ const PostImportRowForm: FC<Props> = (
if (draft.thumbnailBase !== committedThumbnailBase) if (draft.thumbnailBase !== committedThumbnailBase)
setCommittedThumbnailBase (draft.thumbnailBase) setCommittedThumbnailBase (draft.thumbnailBase)
}} }}
onChange={value => update ('thumbnailBase', value)}/> onChange={value => {
if (value && thumbnailObjectUrl != null)
{
URL.revokeObjectURL (thumbnailObjectUrl)
setThumbnailObjectUrl (undefined)
update ('thumbnailFile', undefined)
}
update ('thumbnailBase', value)
}}/>
{!(draft.thumbnailBase) && (
<input
type="file"
accept="image/*"
disabled={saving}
onChange={event => {
const file = event.target.files?.[0]
if (thumbnailObjectUrl != null)
URL.revokeObjectURL (thumbnailObjectUrl)
if (file == null)
{
setThumbnailObjectUrl (undefined)
update ('thumbnailFile', undefined)
return
}
const objectUrl = URL.createObjectURL (file)
setThumbnailObjectUrl (objectUrl)
update ('thumbnailFile', file)
}}/>)}
</>} </>}
core={{ core={{
title: { title: {
+34 -3
ファイルの表示
@@ -2,6 +2,8 @@ import type { PostImportEditableDraft,
PostImportResultRow, PostImportResultRow,
PostImportRow } from '@/lib/postImportTypes' PostImportRow } from '@/lib/postImportTypes'
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
export const isExistingSkipRow = (row: PostImportRow): boolean => export const isExistingSkipRow = (row: PostImportRow): boolean =>
row.skipReason === 'existing' row.skipReason === 'existing'
@@ -55,6 +57,29 @@ const buildResetSnapshot = (row: PostImportRow) => ({
baseWarnings: [...row.baseWarnings], baseWarnings: [...row.baseWarnings],
metadataUrl: row.metadataUrl }) metadataUrl: row.metadataUrl })
const deduped = (values: string[]): string[] =>
[...new Set (values)]
const thumbnailWarnings = (row: PostImportRow): string[] => {
const current = row.fieldWarnings.thumbnailBase ?? []
const others = current.filter (message => message !== THUMBNAIL_MISSING_WARNING)
return row.attributes.thumbnailBase || row.thumbnailFile != null
? others
: deduped ([...others, THUMBNAIL_MISSING_WARNING])
}
export const applyThumbnailWarning = (row: PostImportRow): PostImportRow => ({
...row,
fieldWarnings: {
...row.fieldWarnings,
thumbnailBase: thumbnailWarnings (row) } })
export const applyThumbnailWarnings = (rows: PostImportRow[]): PostImportRow[] =>
rows.map (row => applyThumbnailWarning (row))
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] => export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
validatableImportRows (rows).filter (row => { validatableImportRows (rows).filter (row => {
@@ -182,6 +207,7 @@ export const buildNextEditedRow = (
...editingRow, ...editingRow,
url: draft.url, url: draft.url,
attributes: nextAttributes, attributes: nextAttributes,
thumbnailFile: draft.thumbnailFile,
provenance: { provenance: {
...nextProvenance, ...nextProvenance,
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') }, url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
@@ -258,6 +284,7 @@ export const mergeValidatedImportRows = (
tagSources: row.tagSources, tagSources: row.tagSources,
skipReason: row.skipReason, skipReason: row.skipReason,
existingPostId: row.existingPostId, existingPostId: row.existingPostId,
existingPost: row.existingPost,
fieldWarnings, fieldWarnings,
baseWarnings: baseWarnings:
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
@@ -294,6 +321,7 @@ export const mergeImportResults = (
skipReason: undefined, skipReason: undefined,
createdPostId: result.post.id, createdPostId: result.post.id,
existingPostId: undefined, existingPostId: undefined,
existingPost: undefined,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings, fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
baseWarnings: result.baseWarnings ?? row.baseWarnings, baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors } importErrors: result.errors }
@@ -305,6 +333,7 @@ export const mergeImportResults = (
skipReason: 'existing', skipReason: 'existing',
createdPostId: undefined, createdPostId: undefined,
existingPostId: result.existingPostId, existingPostId: result.existingPostId,
existingPost: result.existingPost ?? row.existingPost,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings, fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
baseWarnings: result.baseWarnings ?? row.baseWarnings, baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors } importErrors: result.errors }
@@ -316,6 +345,7 @@ export const mergeImportResults = (
skipReason: undefined, skipReason: undefined,
createdPostId: undefined, createdPostId: undefined,
existingPostId: undefined, existingPostId: undefined,
existingPost: undefined,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings, fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
baseWarnings: result.baseWarnings ?? row.baseWarnings, baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors } importErrors: result.errors }
@@ -336,6 +366,7 @@ export const retryImportRow = (
: row) : row)
export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] => export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.map (row => ({ applyThumbnailWarnings (
...row, rows.map (row => ({
resetSnapshot: buildResetSnapshot (row) })) ...row,
resetSnapshot: buildResetSnapshot (row) })))
+45 -20
ファイルの表示
@@ -1,4 +1,5 @@
import type { PostImportOrigin, import type { PostImportOrigin,
PostImportExistingPost,
PostImportResetSnapshot, PostImportResetSnapshot,
PostImportRow, PostImportRow,
PostImportSession, PostImportSession,
@@ -200,6 +201,25 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
metadataUrl: value.metadataUrl as string | undefined } metadataUrl: value.metadataUrl as string | undefined }
} }
const sanitiseExistingPost = (value: unknown): PostImportExistingPost | undefined => {
if (value == null)
return undefined
if (!(isPlainObject (value)))
return undefined
if (!(isPositiveInteger (value.id)))
return undefined
if (typeof value.title !== 'string' || typeof value.url !== 'string')
return undefined
if (value.thumbnailUrl != null && typeof value.thumbnailUrl !== 'string')
return undefined
return {
id: Number (value.id),
title: value.title,
url: value.url,
thumbnailUrl: value.thumbnailUrl as string | undefined }
}
const sanitiseRow = (value: unknown): PostImportRow | null => { const sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(isPlainObject (value))) if (!(isPlainObject (value)))
@@ -289,6 +309,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
skipReason: value.skipReason ?? undefined, skipReason: value.skipReason ?? undefined,
existingPostId: existingPostId:
isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined, isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
existingPost: sanitiseExistingPost (value.existingPost),
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined, metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
resetSnapshot, resetSnapshot,
createdPostId: createdPostId:
@@ -297,6 +318,26 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
recoverable: value.recoverable === true ? true : undefined } recoverable: value.recoverable === true ? true : undefined }
} }
const sanitiseSession = (value: unknown): PostImportSession | null => {
if (!(isPlainObject (value)))
return null
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
return null
if (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt))
return null
const rows = value.rows.map (sanitiseRow)
if (rows.some (row => row == null))
return null
return {
version: SESSION_VERSION,
expiresAt: value.expiresAt,
source: typeof value.source === 'string' ? value.source : '',
rows: rows as PostImportRow[],
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
}
const isExpiredSession = (expiresAt: string): boolean => { const isExpiredSession = (expiresAt: string): boolean => {
const value = Date.parse (expiresAt) const value = Date.parse (expiresAt)
@@ -332,11 +373,7 @@ export const cleanupExpiredPostImportSessions = (
try try
{ {
const value = JSON.parse (raw) as { expiresAt?: string if (sanitiseSession (JSON.parse (raw)) == null)
rows?: unknown[] }
if (typeof value.expiresAt !== 'string'
|| isExpiredSession (value.expiresAt)
|| !(Array.isArray (value.rows)))
{ {
sessionStorage.removeItem (key) sessionStorage.removeItem (key)
--i --i
@@ -422,25 +459,13 @@ export const loadPostImportSession = (
try try
{ {
const value = JSON.parse (raw) as Partial<PostImportSession> const session = sanitiseSession (JSON.parse (raw))
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows))) if (session == null)
return null
if (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt))
{ {
removeStorage (sessionKey (validatedId), onError) removeStorage (sessionKey (validatedId), onError)
return null return null
} }
return session
const rows = value.rows.map (sanitiseRow)
if (rows.some (row => row == null))
return null
return {
version: SESSION_VERSION,
expiresAt: value.expiresAt,
source: typeof value.source === 'string' ? value.source : '',
rows: rows as PostImportRow[],
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
} }
catch catch
{ {
+10 -1
ファイルの表示
@@ -18,6 +18,12 @@ export type PostImportResetSnapshot = {
baseWarnings: string[] baseWarnings: string[]
metadataUrl?: string } metadataUrl?: string }
export type PostImportExistingPost = {
id: number
title: string
url: string
thumbnailUrl?: string }
export type PostImportRow = { export type PostImportRow = {
sourceRow: number sourceRow: number
url: string url: string
@@ -31,6 +37,7 @@ export type PostImportRow = {
status: 'ready' | 'warning' | 'error' status: 'ready' | 'warning' | 'error'
skipReason?: PostImportSkipReason skipReason?: PostImportSkipReason
existingPostId?: number existingPostId?: number
existingPost?: PostImportExistingPost
metadataUrl?: string metadataUrl?: string
resetSnapshot: PostImportResetSnapshot resetSnapshot: PostImportResetSnapshot
createdPostId?: number createdPostId?: number
@@ -51,6 +58,7 @@ export type PostImportResultRow =
sourceRow: number sourceRow: number
status: 'skipped' status: 'skipped'
existingPostId: number existingPostId: number
existingPost?: PostImportExistingPost
fieldWarnings?: Record<string, string[]> fieldWarnings?: Record<string, string[]>
baseWarnings?: string[] baseWarnings?: string[]
errors?: Record<string, string[]> } errors?: Record<string, string[]> }
@@ -81,6 +89,7 @@ export type PostImportEditableDraft = {
originalCreatedFrom: string originalCreatedFrom: string
originalCreatedBefore: string originalCreatedBefore: string
tags: string tags: string
parentPostIds: string } parentPostIds: string
thumbnailFile?: File }
export type StorageErrorHandler = (message: string) => void export type StorageErrorHandler = (message: string) => void
+35 -22
ファイルの表示
@@ -9,6 +9,7 @@ import type {
} from '@/lib/postImportTypes' } from '@/lib/postImportTypes'
type QueryRowState = { type QueryRowState = {
source_row: number
title: string title: string
thumbnail_base: string thumbnail_base: string
original_created_from: string original_created_from: string
@@ -34,6 +35,7 @@ type EncodedSkip = 'e' | 'm'
type EncodedImportStatus = 'p' | 'c' | 's' | 'f' type EncodedImportStatus = 'p' | 'c' | 's' | 'f'
type EncodedRowState = { type EncodedRowState = {
n?: number
u?: string u?: string
t?: string t?: string
h?: string h?: string
@@ -241,6 +243,7 @@ const manualFields = (row: PostImportRow): string[] =>
const baseRowState = (row: PostImportRow): QueryRowState => ({ const baseRowState = (row: PostImportRow): QueryRowState => ({
source_row: row.sourceRow,
title: String (row.attributes.title ?? ''), title: String (row.attributes.title ?? ''),
thumbnail_base: String (row.attributes.thumbnailBase ?? ''), thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
original_created_from: String (row.attributes.originalCreatedFrom ?? ''), original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
@@ -257,10 +260,7 @@ const baseRowState = (row: PostImportRow): QueryRowState => ({
recoverable: row.recoverable ?? null }) recoverable: row.recoverable ?? null })
const buildRow = ( const buildRow = (state: FullRowState): PostImportRow => {
sourceRow: number,
state: FullRowState,
): PostImportRow => {
const manual = new Set (state.manual) const manual = new Set (state.manual)
const provenance: Record<string, PostImportOrigin> = { const provenance: Record<string, PostImportOrigin> = {
url: 'manual', url: 'manual',
@@ -286,7 +286,7 @@ const buildRow = (
parentPostIds: state.parent_post_ids } parentPostIds: state.parent_post_ids }
return { return {
sourceRow, sourceRow: state.source_row,
url: state.url, url: state.url,
attributes, attributes,
fieldWarnings: { }, fieldWarnings: { },
@@ -336,6 +336,7 @@ const parseEncodedRow = (
const existingPostId = row['e'] const existingPostId = row['e']
const createdPostId = row['c'] const createdPostId = row['c']
const recoverable = row['r'] const recoverable = row['r']
const sourceRow = row['n']
if (requireUrl && typeof url !== 'string') if (requireUrl && typeof url !== 'string')
return null return null
@@ -357,6 +358,11 @@ const parseEncodedRow = (
return null return null
if (recoverable != null && typeof recoverable !== 'boolean') if (recoverable != null && typeof recoverable !== 'boolean')
return null return null
if (sourceRow != null
&& !(typeof sourceRow === 'number'
&& Number.isInteger (sourceRow)
&& sourceRow > 0))
return null
const stringFields = [ const stringFields = [
['t', 'title'], ['t', 'title'],
@@ -385,6 +391,10 @@ const parseEncodedRow = (
} }
return { return {
source_row:
typeof sourceRow === 'number'
? sourceRow
: 1,
url: typeof url === 'string' ? url : '', url: typeof url === 'string' ? url : '',
title: parsedStrings.title, title: parsedStrings.title,
thumbnail_base: parsedStrings.thumbnail_base, thumbnail_base: parsedStrings.thumbnail_base,
@@ -412,7 +422,8 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
key => key === 'url' || key === 'session_id') key => key === 'url' || key === 'session_id')
if (hasOnlyUrl) if (hasOnlyUrl)
{ {
const row = buildRow (1, { const row = buildRow ({
source_row: 1,
url, url,
title: '', title: '',
thumbnail_base: '', thumbnail_base: '',
@@ -449,7 +460,8 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
if (importStatus != null && !(isValidImportStatus (importStatus))) if (importStatus != null && !(isValidImportStatus (importStatus)))
return null return null
const row = buildRow (1, { const row = buildRow ({
source_row: 1,
url, url,
title: params.get ('title') ?? '', title: params.get ('title') ?? '',
thumbnail_base: params.get ('thumbnail_base') ?? '', thumbnail_base: params.get ('thumbnail_base') ?? '',
@@ -508,7 +520,7 @@ const parseMetaRows = (
if (fullRows.some (row => row == null)) if (fullRows.some (row => row == null))
return null return null
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState)) const rows = fullRows.map (row => buildRow (row as FullRowState))
return { return {
rows, rows,
source: urls.join ('\n'), source: urls.join ('\n'),
@@ -539,7 +551,7 @@ const parseFullState = async (stateValue: string): Promise<ParsedPostNewState |
if (fullRows.some (row => row == null)) if (fullRows.some (row => row == null))
return null return null
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState)) const rows = fullRows.map (row => buildRow (row as FullRowState))
return { return {
rows, rows,
source: rows.map (row => row.url).join ('\n'), source: rows.map (row => row.url).join ('\n'),
@@ -594,6 +606,7 @@ const encodeRowState = (
const base = baseRowState (row) const base = baseRowState (row)
const encoded: EncodedRowState = includeUrl ? { u: row.url } : { } const encoded: EncodedRowState = includeUrl ? { u: row.url } : { }
encoded.n = base.source_row
if (base.title !== '') if (base.title !== '')
encoded.t = base.title encoded.t = base.title
if (base.thumbnail_base !== '') if (base.thumbnail_base !== '')
@@ -684,19 +697,19 @@ const serialiseCompressedRows = async (
rows: PostImportRow[], rows: PostImportRow[],
): Promise<SerialisedPostNewState | null> => { ): Promise<SerialisedPostNewState | null> => {
try try
{ {
for (let count = rows.length; count > 0; --count) for (let count = rows.length; count > 0; --count)
{ {
const nextRows = rows.slice (0, count) const nextRows = rows.slice (0, count)
const encoded = encodeStateRows (nextRows) const encoded = encodeStateRows (nextRows)
const state = await encodeCompressedState (JSON.stringify (encoded)) const state = await encodeCompressedState (JSON.stringify (encoded))
const payload = state.slice (COMPRESSED_STATE_PREFIX.length) const path = `/posts/new?state=${ state }`
if (payload.length <= MAX_COMPRESSED_STATE_LENGTH) if (path.length <= MAX_COMPRESSED_STATE_LENGTH)
return { return {
path: `/posts/new?state=${ state }`, path,
complete: count === rows.length } complete: count === rows.length }
} }
} }
catch catch
{ {
} }
+228 -72
ファイルの表示
@@ -1,7 +1,7 @@
import { useQueries } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { ChevronRight } from 'lucide-react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
@@ -15,7 +15,6 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPost, isApiError } from '@/lib/api' import { apiGet, apiPost, isApiError } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { fetchPost } from '@/lib/posts'
import { import {
appendPostNewSessionId, appendPostNewSessionId,
parsePostNewState, parsePostNewState,
@@ -23,6 +22,7 @@ import {
serialisePostNewState, serialisePostNewState,
} from '@/lib/postNewQueryState' } from '@/lib/postNewQueryState'
import { import {
applyThumbnailWarning,
buildNextEditedRow, buildNextEditedRow,
canEditReviewRow, canEditReviewRow,
canRetryResultRow, canRetryResultRow,
@@ -40,11 +40,11 @@ import {
resultRowMessages, resultRowMessages,
reviewSummaryCounts, reviewSummaryCounts,
retryImportRow, retryImportRow,
savePostImportSession,
loadPostImportSession, loadPostImportSession,
savePostImportSession,
} from '@/lib/postImportSession' } from '@/lib/postImportSession'
import { postsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
@@ -56,7 +56,7 @@ import type {
PostImportRow, PostImportRow,
PostImportSession, PostImportSession,
} from '@/lib/postImportSession' } from '@/lib/postImportSession'
import type { Post, User } from '@/types' import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
@@ -71,12 +71,20 @@ type PreviewResponse = {
tags?: string tags?: string
fieldWarnings?: Record<string, string[]> fieldWarnings?: Record<string, string[]>
baseWarnings?: string[] baseWarnings?: string[]
existingPost?: { id: number } | null } existingPost?: {
id: number
title: string
url: string
thumbnailUrl?: string } | null }
type BulkApiRow = { type BulkApiRow = {
status: 'created' | 'skipped' | 'failed' status: 'created' | 'skipped' | 'failed'
post?: { id: number } post?: { id: number }
existingPost?: { id: number } | null existingPost?: {
id: number
title: string
url: string
thumbnailUrl?: string } | null
fieldWarnings?: Record<string, string[]> fieldWarnings?: Record<string, string[]>
baseWarnings?: string[] baseWarnings?: string[]
errors?: Record<string, string[]> errors?: Record<string, string[]>
@@ -84,7 +92,7 @@ type BulkApiRow = {
recoverable?: boolean } recoverable?: boolean }
type ExistingSkippedRowsProps = { type ExistingSkippedRowsProps = {
open: boolean id: string
rows: PostImportRow[] } rows: PostImportRow[] }
const isRepairRow = (row: PostImportRow): boolean => const isRepairRow = (row: PostImportRow): boolean =>
@@ -117,6 +125,8 @@ const buildDryRunFormData = (row: PostImportRow): FormData => {
String (row.attributes.originalCreatedBefore ?? '')) String (row.attributes.originalCreatedBefore ?? ''))
formData.append ('video_ms', String (row.attributes.videoMs ?? '')) formData.append ('video_ms', String (row.attributes.videoMs ?? ''))
formData.append ('duration', String (row.attributes.duration ?? '')) formData.append ('duration', String (row.attributes.duration ?? ''))
if (!(row.attributes.thumbnailBase) && row.thumbnailFile != null)
formData.append ('thumbnail', row.thumbnailFile)
return formData return formData
} }
@@ -125,7 +135,8 @@ const mergeDryRunRow = (
currentRow: PostImportRow, currentRow: PostImportRow,
result: PreviewResponse, result: PreviewResponse,
): PostImportRow => ): PostImportRow =>
initialisePreviewRows ([{ applyThumbnailWarning (
initialisePreviewRows ([{
...currentRow, ...currentRow,
url: result.url, url: result.url,
attributes: { attributes: {
@@ -149,11 +160,12 @@ const mergeDryRunRow = (
: 'ready', : 'ready',
skipReason: result.existingPost?.id != null ? 'existing' : undefined, skipReason: result.existingPost?.id != null ? 'existing' : undefined,
existingPostId: result.existingPost?.id, existingPostId: result.existingPost?.id,
existingPost: result.existingPost ?? undefined,
importStatus: importStatus:
currentRow.importStatus === 'created' currentRow.importStatus === 'created'
? 'created' ? 'created'
: 'pending', : 'pending',
recoverable: undefined }])[0] recoverable: undefined }])[0])
const indexedBulkResults = ( const indexedBulkResults = (
@@ -178,6 +190,7 @@ const indexedBulkResults = (
sourceRow: row.sourceRow, sourceRow: row.sourceRow,
status: 'skipped', status: 'skipped',
existingPostId: result.existingPost.id, existingPostId: result.existingPost.id,
existingPost: result.existingPost,
fieldWarnings: result.fieldWarnings, fieldWarnings: result.fieldWarnings,
baseWarnings: result.baseWarnings, baseWarnings: result.baseWarnings,
errors: result.errors } errors: result.errors }
@@ -191,6 +204,81 @@ const indexedBulkResults = (
recoverable: result?.recoverable } recoverable: result?.recoverable }
}) })
const serialisableRowJson = (row: PostImportRow): string =>
JSON.stringify ({
url: row.url,
sourceRow: row.sourceRow,
attributes: row.attributes,
fieldWarnings: row.fieldWarnings,
baseWarnings: row.baseWarnings,
validationErrors: row.validationErrors,
importErrors: row.importErrors,
provenance: row.provenance,
tagSources: row.tagSources,
status: row.status,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
existingPost: row.existingPost,
metadataUrl: row.metadataUrl,
resetSnapshot: row.resetSnapshot,
createdPostId: row.createdPostId,
importStatus: row.importStatus,
recoverable: row.recoverable })
const serialisableRowsEqual = (
left: PostImportRow[],
right: PostImportRow[],
): boolean =>
left.length === right.length
&& left.every ((row, index) =>
serialisableRowJson (row) === serialisableRowJson (right[index]))
const mergePreviewRow = (
currentRow: PostImportRow,
preview: PreviewResponse,
): PostImportRow => {
const nextRow: PostImportRow = {
...currentRow,
url: preview.url,
fieldWarnings: preview.fieldWarnings ?? { },
baseWarnings: preview.baseWarnings ?? [],
existingPostId: preview.existingPost?.id,
existingPost: preview.existingPost ?? undefined,
skipReason:
preview.existingPost?.id != null
? 'existing'
: currentRow.skipReason === 'manual'
? 'manual'
: undefined,
status:
Object.keys (preview.fieldWarnings ?? { }).length > 0
|| (preview.baseWarnings?.length ?? 0) > 0
? 'warning'
: 'ready' }
if (currentRow.provenance.title !== 'manual')
nextRow.attributes.title = preview.title ?? ''
if (currentRow.provenance.thumbnailBase !== 'manual')
nextRow.attributes.thumbnailBase = preview.thumbnailBase ?? ''
if (currentRow.provenance.originalCreatedFrom !== 'manual')
nextRow.attributes.originalCreatedFrom = preview.originalCreatedFrom ?? ''
if (currentRow.provenance.originalCreatedBefore !== 'manual')
nextRow.attributes.originalCreatedBefore = preview.originalCreatedBefore ?? ''
if (currentRow.provenance.tags !== 'manual')
{
nextRow.attributes.tags = preview.tags ?? ''
nextRow.tagSources = {
automatic: preview.tags ?? '',
manual: currentRow.tagSources?.manual ?? '' }
}
if (currentRow.provenance.videoMs !== 'manual')
nextRow.attributes.videoMs = preview.videoMs ?? ''
if (currentRow.provenance.duration !== 'manual')
nextRow.attributes.duration = preview.duration ?? ''
return applyThumbnailWarning (nextRow)
}
const buildBulkFormData = (rows: PostImportRow[]): FormData => { const buildBulkFormData = (rows: PostImportRow[]): FormData => {
const formData = new FormData () const formData = new FormData ()
@@ -207,58 +295,36 @@ const buildBulkFormData = (rows: PostImportRow[]): FormData => {
original_created_before: row.attributes.originalCreatedBefore ?? '', original_created_before: row.attributes.originalCreatedBefore ?? '',
video_ms: row.attributes.videoMs ?? '', video_ms: row.attributes.videoMs ?? '',
duration: row.attributes.duration ?? '' })))) duration: row.attributes.duration ?? '' }))))
rows.forEach ((row, index) => {
if (!(row.attributes.thumbnailBase) && row.thumbnailFile != null)
formData.append (`thumbnails[${ index }]`, row.thumbnailFile)
})
return formData return formData
} }
const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ open, rows }) => { const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ id, rows }) => {
const existingPostIds = useMemo ( const posts = rows
() => .map (row => row.existingPost)
open .filter ((post): post is NonNullable<PostImportRow['existingPost']> => post != null)
? Array.from (
new Set (
rows
.map (row => row.existingPostId)
.filter ((postId): postId is number => postId != null),
),
)
: [],
[open, rows],
)
const posts = useQueries ({
queries: existingPostIds.map (postId => ({
enabled: open,
queryKey: postsKeys.show (String (postId)),
queryFn: () => fetchPost (String (postId)) })),
})
const postsById = new Map<number, Post> (
posts
.map (query => query.data)
.filter ((post): post is Post => post != null)
.map (post => [post.id, post]),
)
return ( return (
<div className="mt-3 max-h-72 overflow-y-auto rounded border"> <div id={id} className="mt-3 max-h-72 overflow-y-auto rounded border">
<div className="divide-y"> <div className="divide-y">
{existingPostIds.map (postId => { {posts.map (post => {
const post = postsById.get (postId)
if (post == null)
return <div key={postId} className="h-14"/>
return ( return (
<PrefetchLink <PrefetchLink
key={postId} key={post.id}
to={`/posts/${ post.id }`} to={`/posts/${ post.id }`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-3 p-3"> className="flex items-center gap-3 p-3">
<PostThumbnailPreview <PostThumbnailPreview
url={post.thumbnail ?? ''} url={post.thumbnailUrl ?? ''}
className="h-10 w-10 shrink-0"/> className="h-10 w-10 shrink-0"/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium"> <div className="truncate text-sm font-medium">
{post.title ?? ''} {post.title}
</div> </div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300"> <div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{post.url} {post.url}
@@ -294,6 +360,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const sessionIdRef = useRef<string | null> (null) const sessionIdRef = useRef<string | null> (null)
const persistedSearchRef = useRef<string | null> (null) const persistedSearchRef = useRef<string | null> (null)
const persistSequenceRef = useRef (0) const persistSequenceRef = useRef (0)
const previewSequenceRef = useRef (0)
const persistSession = async ( const persistSession = async (
nextSession: PostImportSession, nextSession: PostImportSession,
@@ -308,10 +375,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (persistSequenceRef.current !== sequence) if (persistSequenceRef.current !== sequence)
return sessionRef.current return sessionRef.current
savePostImportSession (sessionId, nextSession) const saved = savePostImportSession (sessionId, nextSession)
const loadedSession = loadPostImportSession (sessionId) const loadedSession = loadPostImportSession (sessionId)
if (!(serialised.complete) const canRecoverFromUrlOnly = serialised.complete
&& loadedSession?.rows.length !== nextSession.rows.length) if (!(canRecoverFromUrlOnly)
&& (!(saved))
&& !(serialisableRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
return null
if (saved
&& !(serialisableRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
return null return null
const persistedSession = buildSession (nextSession.rows) const persistedSession = buildSession (nextSession.rows)
@@ -334,11 +406,80 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
useEffect (() => { useEffect (() => {
let active = true let active = true
const previewSequence = ++previewSequenceRef.current
const controllers = new Set<AbortController> ()
if (sessionRef.current != null && persistedSearchRef.current === location.search) if (sessionRef.current != null && persistedSearchRef.current === location.search)
return () => { return () => {
previewSequenceRef.current = previewSequence
} }
const refreshRows = async (baseSession: PostImportSession) => {
let nextIndex = 0
const worker = async () => {
while (active)
{
const index = nextIndex
++nextIndex
if (index >= baseSession.rows.length)
return
const baseRow = baseSession.rows[index]
const controller = new AbortController ()
controllers.add (controller)
try
{
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
params: { url: baseRow.url },
signal: controller.signal })
if (!(active) || previewSequenceRef.current !== previewSequence)
return
const latestSession = sessionRef.current ?? baseSession
const currentRow = latestSession.rows.find (
row => row.sourceRow === baseRow.sourceRow)
if (currentRow == null)
continue
const mergedRow = mergePreviewRow (currentRow, preview)
const nextRows = replaceImportRow (latestSession.rows, mergedRow)
const nextSession = buildSession (nextRows)
sessionRef.current = nextSession
setSession (nextSession)
}
catch (requestError)
{
if (controller.signal.aborted)
return
if (!(active) || previewSequenceRef.current !== previewSequence)
return
const latestSession = sessionRef.current ?? baseSession
const currentRow = latestSession.rows.find (
row => row.sourceRow === baseRow.sourceRow)
if (currentRow == null)
continue
const nextRows = replaceImportRow (
latestSession.rows,
applyThumbnailWarning ({
...currentRow,
existingPostId: undefined,
existingPost: undefined }))
const nextSession = buildSession (nextRows)
sessionRef.current = nextSession
setSession (nextSession)
if (!(isApiError (requestError)))
continue
}
finally
{
controllers.delete (controller)
}
}
}
await Promise.all (
Array.from ({ length: Math.min (4, baseSession.rows.length) }, () => worker ()))
}
const hydrate = async () => { const hydrate = async () => {
const parsed = await parsePostNewState (location.search) const parsed = await parsePostNewState (location.search)
if (!(active)) if (!(active))
@@ -365,7 +506,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
params: { url: parsed.source } }) params: { url: parsed.source } })
if (!(active)) if (!(active))
return return
const rows = initialisePreviewRows ([{ const rows = [applyThumbnailWarning (initialisePreviewRows ([{
sourceRow: 1, sourceRow: 1,
url: preview.url, url: preview.url,
attributes: { attributes: {
@@ -400,6 +541,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
: 'ready', : 'ready',
skipReason: preview.existingPost?.id != null ? 'existing' : undefined, skipReason: preview.existingPost?.id != null ? 'existing' : undefined,
existingPostId: preview.existingPost?.id, existingPostId: preview.existingPost?.id,
existingPost: preview.existingPost ?? undefined,
resetSnapshot: { resetSnapshot: {
url: preview.url, url: preview.url,
attributes: { attributes: {
@@ -425,7 +567,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
automatic: preview.tags ?? '', automatic: preview.tags ?? '',
manual: '' }, manual: '' },
fieldWarnings: preview.fieldWarnings ?? { }, fieldWarnings: preview.fieldWarnings ?? { },
baseWarnings: preview.baseWarnings ?? [] } }]) baseWarnings: preview.baseWarnings ?? [] } }])[0])]
const persisted = await persistSession (buildSession (rows)) const persisted = await persistSession (buildSession (rows))
if (persisted == null) if (persisted == null)
return return
@@ -442,12 +584,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
{ {
await persistSession (loaded) await persistSession (loaded)
} }
void refreshRows (loaded)
} }
void hydrate () void hydrate ()
return () => { return () => {
active = false active = false
controllers.forEach (controller => controller.abort ())
} }
}, [location.search, navigate]) }, [location.search, navigate])
@@ -456,13 +600,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
sessionRef.current = session sessionRef.current = session
}, [session]) }, [session])
useEffect (() => {
if (session == null || session.rows.length === 0)
return
if (session.rows.every (row => isCompletedReviewRow (row)))
finishImport ()
}, [session])
useEffect (() => { useEffect (() => {
if (editingRow == null || session?.repairMode !== 'failed') if (editingRow == null || session?.repairMode !== 'failed')
return return
@@ -504,7 +641,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return { return {
...row, ...row,
skipReason: checked ? 'manual' : undefined, skipReason: checked ? 'manual' : undefined,
existingPostId: checked ? undefined : row.existingPostId } existingPostId: checked ? undefined : row.existingPostId,
existingPost: checked ? undefined : row.existingPost }
}) })
await persistSession (buildSession (nextRows)) await persistSession (buildSession (nextRows))
} }
@@ -533,7 +671,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
Object.entries (row.resetSnapshot.fieldWarnings) Object.entries (row.resetSnapshot.fieldWarnings)
.map (([key, values]) => [key, [...values]])), .map (([key, values]) => [key, [...values]])),
baseWarnings: [...row.resetSnapshot.baseWarnings], baseWarnings: [...row.resetSnapshot.baseWarnings],
metadataUrl: row.resetSnapshot.metadataUrl } metadataUrl: row.resetSnapshot.metadataUrl,
thumbnailFile: undefined,
thumbnailObjectUrl: undefined }
: row : row
const urlChanged = draft.url !== baseRow.url const urlChanged = draft.url !== baseRow.url
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged) const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
@@ -573,8 +713,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
baseWarnings: [], baseWarnings: [],
fieldWarnings: { }, fieldWarnings: { },
status: 'error' as const } status: 'error' as const }
setMessageRow (errorRow) return { saved: false, row: applyThumbnailWarning (errorRow) }
return { saved: false, row: errorRow }
} }
toast ({ title: '行の再検証に失敗しました' }) toast ({ title: '行の再検証に失敗しました' })
@@ -666,15 +805,17 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const recoverable = recoverableRows.find ( const recoverable = recoverableRows.find (
failedRow => failedRow.sourceRow === row.sourceRow) failedRow => failedRow.sourceRow === row.sourceRow)
if (recoverable == null) if (recoverable == null)
return row return applyThumbnailWarning (row)
return { return applyThumbnailWarning ({
...row, ...row,
importStatus: 'pending', importStatus: 'pending',
recoverable: true, recoverable: true,
validationErrors: recoverable.errors ?? { }, validationErrors: recoverable.errors ?? { },
importErrors: undefined } importErrors: undefined })
}) })
await persistSession (buildSession (nextRows)) const persisted = await persistSession (buildSession (nextRows))
if (persisted != null && processableImportRows (persisted.rows).length === 0)
finishImport ()
} }
catch catch
{ {
@@ -729,15 +870,17 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const recoverable = recoverableRows.find ( const recoverable = recoverableRows.find (
failedRow => failedRow.sourceRow === row.sourceRow) failedRow => failedRow.sourceRow === row.sourceRow)
if (recoverable == null) if (recoverable == null)
return row return applyThumbnailWarning (row)
return { return applyThumbnailWarning ({
...row, ...row,
importStatus: 'pending', importStatus: 'pending',
recoverable: true, recoverable: true,
validationErrors: recoverable.errors ?? { }, validationErrors: recoverable.errors ?? { },
importErrors: undefined } importErrors: undefined })
}) })
await persistSession (buildSession (nextRows)) const persisted = await persistSession (buildSession (nextRows))
if (persisted != null && processableImportRows (persisted.rows).length === 0)
finishImport ()
} }
catch catch
{ {
@@ -769,8 +912,19 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<button <button
type="button" type="button"
className="text-left text-sm font-medium" aria-expanded={showExistingRows}
aria-controls="post-import-existing-skips"
className="flex items-center gap-2 text-left text-sm font-medium"
onClick={() => setShowExistingRows (current => !(current))}> onClick={() => setShowExistingRows (current => !(current))}>
<span className="inline-flex">
<ChevronRight
className={cn (
'h-4 w-4',
showExistingRows && 'rotate-90',
animationMode === 'off'
? ''
: 'transition-transform')}/>
</span>
稿 {counts.existingSkipped} 稿 {counts.existingSkipped}
</button> </button>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
@@ -785,7 +939,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
transition={existingRowsTransition} transition={existingRowsTransition}
className="overflow-hidden"> className="overflow-hidden">
<ExistingSkippedRows open={showExistingRows} rows={existingRows}/> <ExistingSkippedRows
id="post-import-existing-skips"
rows={existingRows}/>
</motion.div>)} </motion.div>)}
</AnimatePresence> </AnimatePresence>
</div>)} </div>)}
@@ -848,7 +1004,7 @@ const PostImportFooter = (
<span> {creatableCount}</span> <span> {creatableCount}</span>
<span> {manualSkippedCount}</span> <span> {manualSkippedCount}</span>
<span>稿 {existingSkippedCount}</span> <span>稿 {existingSkippedCount}</span>
<span>error {pendingOrErrorCount}</span> <span> {pendingOrErrorCount}</span>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={onBack} disabled={loading}> <Button type="button" variant="outline" onClick={onBack} disabled={loading}>
+66 -11
ファイルの表示
@@ -47,7 +47,11 @@ type PreviewResponse = {
tags?: string tags?: string
fieldWarnings?: Record<string, string[]> fieldWarnings?: Record<string, string[]>
baseWarnings?: string[] baseWarnings?: string[]
existingPost?: { id: number } | null } existingPost?: {
id: number
title: string
url: string
thumbnailUrl?: string } | null }
const MAX_ROWS = 100 const MAX_ROWS = 100
const SOURCE_ERROR_ID = 'post-import-source-error' const SOURCE_ERROR_ID = 'post-import-source-error'
@@ -104,6 +108,7 @@ const previewRowFromResult = (
: 'ready', : 'ready',
skipReason: result.existingPost?.id != null ? 'existing' : undefined, skipReason: result.existingPost?.id != null ? 'existing' : undefined,
existingPostId: result.existingPost?.id, existingPostId: result.existingPost?.id,
existingPost: result.existingPost ?? undefined,
resetSnapshot: { resetSnapshot: {
url: result.url, url: result.url,
attributes: { attributes: {
@@ -134,25 +139,57 @@ const previewRowFromResult = (
} }
const fetchPreviewRows = async (urls: string[]): Promise<PostImportRow[]> => { const fetchPreviewRows = async (
const results: PostImportRow[] = new Array (urls.length) rows: Array<{ sourceRow: number
url: string }>,
signal: AbortSignal,
): Promise<PostImportRow[]> => {
const results: PostImportRow[] = new Array (rows.length)
let nextIndex = 0 let nextIndex = 0
const worker = async () => { const worker = async () => {
while (nextIndex < urls.length) while (nextIndex < rows.length)
{ {
const index = nextIndex const index = nextIndex
++nextIndex ++nextIndex
const row = rows[index]
const result = await apiGet<PreviewResponse> ('/posts/preview', { const result = await apiGet<PreviewResponse> ('/posts/preview', {
params: { url: urls[index] } }) params: { url: row.url },
results[index] = previewRowFromResult (index + 1, result) signal })
results[index] = previewRowFromResult (row.sourceRow, result)
} }
} }
await Promise.all ( await Promise.all (
Array.from ({ length: Math.min (4, urls.length) }, () => worker ())) Array.from ({ length: Math.min (4, rows.length) }, () => worker ()))
return results return results
} }
const serialisableRowJson = (row: PostImportRow): string =>
JSON.stringify ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
fieldWarnings: row.fieldWarnings,
baseWarnings: row.baseWarnings,
validationErrors: row.validationErrors,
provenance: row.provenance,
tagSources: row.tagSources,
status: row.status,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
existingPost: row.existingPost,
resetSnapshot: row.resetSnapshot,
importStatus: row.importStatus,
recoverable: row.recoverable })
const sameRows = (
left: PostImportRow[],
right: PostImportRow[],
): boolean =>
left.length === right.length
&& left.every ((row, index) =>
serialisableRowJson (row) === serialisableRowJson (right[index]))
const PostImportSourcePage: FC<Props> = ({ user }) => { const PostImportSourcePage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
@@ -163,6 +200,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
const [sourceError, setSourceError] = useState<string | null> (null) const [sourceError, setSourceError] = useState<string | null> (null)
const saveTimer = useRef<number | null> (null) const saveTimer = useRef<number | null> (null)
const editedRef = useRef (false) const editedRef = useRef (false)
const previewController = useRef<AbortController | null> (null)
const lineCount = countImportSourceLines (source) const lineCount = countImportSourceLines (source)
const messages = sourceError != null ? [sourceError] : [] const messages = sourceError != null ? [sourceError] : []
@@ -203,6 +241,10 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
} }
}, [source]) }, [source])
useEffect (() => () => {
previewController.current?.abort ()
}, [])
const preview = async () => { const preview = async () => {
if (lineCount === 0) if (lineCount === 0)
{ {
@@ -224,8 +266,17 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
setSourceError (null) setSourceError (null)
try try
{ {
const urls = source.split (/\r\n|\n|\r/).map (line => line.trim ()).filter (line => line !== '') previewController.current?.abort ()
const rows = await fetchPreviewRows (urls) const controller = new AbortController ()
previewController.current = controller
const rows = await fetchPreviewRows (
source
.split (/\r\n|\n|\r/)
.map ((line, index) => ({
sourceRow: index + 1,
url: line.trim () }))
.filter (row => row.url !== ''),
controller.signal)
const urlIssues = urlIssuesFromRows (rows, source) const urlIssues = urlIssuesFromRows (rows, source)
if (urlIssues.length > 0) if (urlIssues.length > 0)
{ {
@@ -242,9 +293,13 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
source, source,
rows: nextRows, rows: nextRows,
repairMode: resultRepairMode (nextRows) } repairMode: resultRepairMode (nextRows) }
savePostImportSession (sessionId, session) const saved = savePostImportSession (sessionId, session)
const sessionLoaded = loadPostImportSession (sessionId) const sessionLoaded = loadPostImportSession (sessionId)
if (!(serialised.complete) && sessionLoaded?.rows.length !== nextRows.length) if (!(serialised.complete)
&& (!(saved))
&& !(sameRows (sessionLoaded?.rows ?? [], nextRows)))
return
if (saved && !(sameRows (sessionLoaded?.rows ?? [], nextRows)))
return return
navigate (appendPostNewSessionId (serialised.path, sessionId)) navigate (appendPostNewSessionId (serialised.path, sessionId))