このコミットが含まれているのは:
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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) })))
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする