このコミットが含まれているのは:
2026-07-13 00:58:02 +09:00
コミット ac82adc6b3
12個のファイルの変更789行の追加256行の削除
+59 -4
ファイルの表示
@@ -49,6 +49,10 @@ const buildDraft = (row: PostImportRow): Draft => ({
tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? '') })
const warningClass =
'mt-1 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm '
+ 'text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'
const PostImportRowDialog: FC<Props> = (
{ open, row, saving, onOpenChange, onSave },
@@ -102,46 +106,81 @@ const PostImportRowDialog: FC<Props> = (
label="URL"
value={draft.url}
origin={originOf (row, 'url')}
warnings={row.fieldWarnings.url}
errors={[
...(row.validationErrors.url ?? []),
...(row.importErrors?.url ?? [])]}
onChange={value => update ('url', value)}/>
<DialogField
label="タイトル"
value={draft.title}
origin={originOf (row, 'title')}
warnings={row.fieldWarnings.title}
errors={[
...(row.validationErrors.title ?? []),
...(row.importErrors?.title ?? [])]}
onChange={value => update ('title', value)}/>
<DialogField
label="サムネール基底 URL"
value={draft.thumbnailBase}
origin={originOf (row, 'thumbnailBase')}
warnings={row.fieldWarnings.thumbnailBase}
errors={[
...(row.validationErrors.thumbnailBase ?? []),
...(row.importErrors?.thumbnailBase ?? [])]}
onChange={value => update ('thumbnailBase', value)}/>
<div className="grid gap-4 md:grid-cols-2">
<DialogField
label="作成日時 From"
value={draft.originalCreatedFrom}
origin={originOf (row, 'originalCreatedFrom')}
errors={[
...(row.validationErrors.originalCreatedFrom ?? []),
...(row.importErrors?.originalCreatedFrom ?? [])]}
onChange={value => update ('originalCreatedFrom', value)}/>
<DialogField
label="作成日時 Before"
value={draft.originalCreatedBefore}
origin={originOf (row, 'originalCreatedBefore')}
errors={[
...(row.validationErrors.originalCreatedBefore ?? []),
...(row.importErrors?.originalCreatedBefore ?? [])]}
onChange={value => update ('originalCreatedBefore', value)}/>
</div>
<DialogField
label="動画時間"
value={draft.duration}
origin={originOf (row, 'duration')}
errors={[
...(row.validationErrors.duration ?? []),
...(row.validationErrors.videoMs ?? []),
...(row.importErrors?.duration ?? []),
...(row.importErrors?.videoMs ?? [])]}
onChange={value => update ('duration', value)}/>
<DialogArea
label="タグ"
value={draft.tags}
origin={originOf (row, 'tags')}
warnings={row.fieldWarnings.tags}
errors={[
...(row.validationErrors.tags ?? []),
...(row.importErrors?.tags ?? [])]}
onChange={value => update ('tags', value)}/>
<DialogField
label="親投稿"
value={draft.parentPostIds}
origin={originOf (row, 'parentPostIds')}
errors={[
...(row.validationErrors.parentPostIds ?? []),
...(row.importErrors?.parentPostIds ?? [])]}
onChange={value => update ('parentPostIds', value)}/>
<FieldError messages={Object.values (row.validationErrors ?? { }).flat ()}/>
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
{row.baseWarnings.length > 0 && (
<div className={warningClass}>
{row.baseWarnings.map ((message, index) => (
<div key={`${ message }-${ index }`}>{message}</div>))}
</div>)}
<FieldError messages={row.validationErrors.base}/>
<FieldError messages={row.importErrors?.base}/>
</div>
</div>
@@ -164,10 +203,12 @@ const PostImportRowDialog: FC<Props> = (
}
const DialogField = (
{ label, value, origin, onChange }: {
{ label, value, origin, warnings, errors, onChange }: {
label: string
value: string
origin: PostImportOrigin
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<label className="block space-y-1">
@@ -179,13 +220,21 @@ const DialogField = (
value={value}
onChange={ev => onChange (ev.target.value)}
className={inputClass (false)}/>
{warnings && warnings.length > 0 && (
<div className={warningClass}>
{warnings.map ((message, index) => (
<div key={`${ message }-${ index }`}>{message}</div>))}
</div>)}
<FieldError messages={errors}/>
</label>)
const DialogArea = (
{ label, value, origin, onChange }: {
{ label, value, origin, warnings, errors, onChange }: {
label: string
value: string
origin: PostImportOrigin
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<label className="block space-y-1">
@@ -198,6 +247,12 @@ const DialogArea = (
rows={4}
onChange={ev => onChange (ev.target.value)}
className={inputClass (false)}/>
{warnings && warnings.length > 0 && (
<div className={warningClass}>
{warnings.map ((message, index) => (
<div key={`${ message }-${ index }`}>{message}</div>))}
</div>)}
<FieldError messages={errors}/>
</label>)
export default PostImportRowDialog
+35 -1
ファイルの表示
@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import PrefetchLink from '@/components/PrefetchLink'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
@@ -48,6 +49,11 @@ const summaryError = (row: PostImportRow): string | null => {
return Object.values (row.importErrors ?? { }).flat ()[0] ?? null
}
const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0]
?? row.baseWarnings?.[0]
?? null
const summaryDate = (row: PostImportRow): string =>
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
.filter (_1 => typeof _1 === 'string' && _1 !== '')
@@ -56,6 +62,7 @@ const summaryDate = (row: PostImportRow): string =>
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
const error = summaryError (row)
const warning = error == null ? summaryWarning (row) : null
const importStatus = row.importStatus ?? 'pending'
return (
@@ -89,6 +96,21 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
{summaryDate (row) || '日時未取得'}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
{row.createdPostId && (
<PrefetchLink
to={`/posts/${ row.createdPostId }`}
className="inline-block text-xs text-sky-700 underline
dark:text-sky-300">
稿 #{row.createdPostId}
</PrefetchLink>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
{error && (
<div className="text-xs text-red-700 dark:text-red-200">
{error}
</div>)}
</div>
<div className="space-y-1">
<PostImportStatusBadge value={importStatus}/>
@@ -96,7 +118,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div className="space-y-1">
{error
? <PostImportStatusBadge value="error"/>
: <PostImportStatusBadge value="ready"/>}
: (warning ? <PostImportStatusBadge value="warning"/> : <PostImportStatusBadge value="ready"/>)}
</div>
<div className="flex justify-end">
<Button
@@ -128,11 +150,23 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div className="flex flex-wrap gap-2">
<PostImportStatusBadge value={row.status}/>
<PostImportStatusBadge value={importStatus}/>
{warning && <PostImportStatusBadge value="warning"/>}
</div>
{row.createdPostId && (
<PrefetchLink
to={`/posts/${ row.createdPostId }`}
className="inline-block text-xs text-sky-700 underline
dark:text-sky-300">
稿 #{row.createdPostId}
</PrefetchLink>)}
{error && (
<div className="text-xs text-red-700 dark:text-red-200">
{error}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
</div>
</div>
<Button
+4 -3
ファイルの表示
@@ -31,9 +31,10 @@ const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className =
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-red-300 bg-red-50 p-2 text-center text-xs text-red-700
dark:border-red-900 dark:bg-red-950 dark:text-red-200`}>
border-amber-300 bg-amber-50 p-2 text-center text-xs
text-amber-700 dark:border-amber-900 dark:bg-amber-950
dark:text-amber-200`}>
</div>)
}
+474 -81
ファイルの表示
@@ -12,13 +12,13 @@ export type PostImportRow = {
sourceRow: number
url: string
attributes: Record<string, PostImportAttributeValue>
warnings: string[]
fieldWarnings: Record<string, string[]>
baseWarnings: string[]
validationErrors: Record<string, string[]>
importErrors?: Record<string, string[]>
provenance: Record<string, PostImportOrigin>
tagSources?: Record<PostImportOrigin, string>
status: 'ready' | 'warning' | 'error'
existing: boolean
metadataUrl?: string
createdPostId?: number
importStatus?: PostImportStatus }
@@ -33,13 +33,223 @@ export type PostImportSession = {
version: number
savedAt: string
source: string
existing: 'skip' | 'error'
rows: PostImportRow[]
repairMode: PostImportRepairMode }
const SESSION_VERSION = 1
export type PostImportSourceIssue = {
sourceRow: number
message: string
url: string }
type StorageErrorHandler = (message: string) => void
const SESSION_VERSION = 2
const SESSION_PREFIX = 'post-import-session:'
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const MAX_ROWS = 100
const MAX_URL_BYTES = 20 * 1024
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
const readStorage = (
key: string,
onError?: StorageErrorHandler,
): string | null => {
if (typeof window === 'undefined')
return null
try
{
return sessionStorage.getItem (key)
}
catch
{
onError?.('保存済みデータを読み込めませんでした.')
return null
}
}
const writeStorage = (
key: string,
value: string,
onError?: StorageErrorHandler,
): boolean => {
if (typeof window === 'undefined')
return false
try
{
sessionStorage.setItem (key, value)
return true
}
catch
{
onError?.('ブラウザへ保存できませんでした.')
return false
}
}
const removeStorage = (
key: string,
onError?: StorageErrorHandler,
) => {
if (typeof window === 'undefined')
return
try
{
sessionStorage.removeItem (key)
}
catch
{
onError?.('保存済みデータを削除できませんでした.')
}
}
const truncateUrl = (value: string): string =>
value.length > 120 ? `${ value.slice (0, 117) }` : value
const bytesize = (value: string): number =>
new TextEncoder ().encode (value).length
const normaliseImportUrl = (value: string): string | null => {
const trimmed = value.trim ()
if (!(trimmed))
return null
try
{
const url = new URL (trimmed)
if (!(url.protocol === 'http:' || url.protocol === 'https:'))
return null
if (!(url.host))
return null
url.hostname = url.hostname.toLowerCase ()
if (url.pathname.endsWith ('/'))
url.pathname = url.pathname.replace (/\/+$/, '')
return url.toString ()
}
catch
{
return null
}
}
const ensureStringListRecord = (value: unknown): Record<string, string[]> | null => {
if (!(isPlainObject (value)))
return null
const result: Record<string, string[]> = { }
for (const [key, entry] of Object.entries (value))
{
if (!(Array.isArray (entry)) || !(entry.every (_1 => typeof _1 === 'string')))
return null
result[key] = entry
}
return result
}
const isValidStatus = (
value: unknown,
): value is PostImportRow['status'] =>
value === 'ready' || value === 'warning' || value === 'error'
const isValidImportStatus = (
value: unknown,
): value is PostImportStatus =>
value === 'pending'
|| value === 'created'
|| value === 'skipped'
|| value === 'failed'
const isValidOrigin = (
value: unknown,
): value is PostImportOrigin =>
value === 'automatic' || value === 'manual'
const sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(isPlainObject (value)))
return null
if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
return null
if (typeof value.url !== 'string')
return null
if (!(isPlainObject (value.attributes)))
return null
if (!(isPlainObject (value.provenance)))
return null
if (!(isValidStatus (value.status)))
return null
if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
return null
const validationErrors = ensureStringListRecord (value.validationErrors)
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
if (validationErrors == null || fieldWarnings == null)
return null
const importErrors =
value.importErrors == null
? undefined
: ensureStringListRecord (value.importErrors)
if (value.importErrors != null && importErrors == null)
return null
if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
return null
const provenanceEntries = Object.entries (value.provenance)
if (!(provenanceEntries.every (([, origin]) => isValidOrigin (origin))))
return null
if (value.tagSources != null)
{
if (!(isPlainObject (value.tagSources)))
return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
return null
}
return {
sourceRow: Number (value.sourceRow),
url: value.url,
attributes: value.attributes as Record<string, PostImportAttributeValue>,
fieldWarnings,
baseWarnings: value.baseWarnings,
validationErrors,
importErrors,
provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
status: value.status,
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
createdPostId:
Number.isInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
importStatus: value.importStatus }
}
const isExpiredSession = (savedAt: string): boolean => {
const value = Date.parse (savedAt)
return Number.isNaN (value) || Date.now () - value > SESSION_MAX_AGE_MS
}
export const createPostImportSessionId = (): string =>
@@ -48,82 +258,122 @@ export const createPostImportSessionId = (): string =>
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
export const loadPostImportSourceDraft = (): {
source: string
existing: 'skip' | 'error' } => {
export const cleanupExpiredPostImportSessions = (
onError?: StorageErrorHandler,
) => {
if (typeof window === 'undefined')
return { source: '', existing: 'skip' }
return
try
{
const raw = sessionStorage.getItem (SOURCE_DRAFT_KEY)
if (raw == null)
return { source: '', existing: 'skip' }
for (let i = 0; i < sessionStorage.length; ++i)
{
const key = sessionStorage.key (i)
if (key == null || !(key.startsWith (SESSION_PREFIX)))
continue
const value = JSON.parse (raw) as {
source?: string
existing?: 'skip' | 'error' }
return {
source: typeof value.source === 'string' ? value.source : '',
existing: value.existing === 'error' ? 'error' : 'skip' }
const raw = sessionStorage.getItem (key)
if (raw == null)
continue
try
{
const value = JSON.parse (raw) as { savedAt?: string }
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{
sessionStorage.removeItem (key)
--i
}
}
catch
{
sessionStorage.removeItem (key)
--i
}
}
}
catch
{
return { source: '', existing: 'skip' }
onError?.('保存済みデータを整理できませんでした.')
}
}
export const loadPostImportSourceDraft = (
onError?: StorageErrorHandler,
): { source: string } => {
const raw = readStorage (SOURCE_DRAFT_KEY, onError)
if (raw == null)
return { source: '' }
try
{
const value = JSON.parse (raw) as { source?: string }
return { source: typeof value.source === 'string' ? value.source : '' }
}
catch
{
return { source: '' }
}
}
export const savePostImportSourceDraft = (
source: string,
existing: 'skip' | 'error',
source: string,
onError?: StorageErrorHandler,
): boolean =>
writeStorage (SOURCE_DRAFT_KEY, JSON.stringify ({ source }), onError)
export const clearPostImportSourceDraft = (
onError?: StorageErrorHandler,
) => {
if (typeof window === 'undefined')
return
sessionStorage.setItem (SOURCE_DRAFT_KEY, JSON.stringify ({ source, existing }))
removeStorage (SOURCE_DRAFT_KEY, onError)
}
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
export const savePostImportSession = (
sessionId: string,
session: Omit<PostImportSession, 'version' | 'savedAt'>,
) => {
if (typeof window === 'undefined')
return
sessionStorage.setItem (sessionKey (sessionId), JSON.stringify ({
...session,
version: SESSION_VERSION,
savedAt: new Date ().toISOString () }))
}
onError?: StorageErrorHandler,
): boolean =>
writeStorage (
sessionKey (sessionId),
JSON.stringify ({
...session,
version: SESSION_VERSION,
savedAt: new Date ().toISOString () }),
onError,
)
export const loadPostImportSession = (
sessionId: string,
onError?: StorageErrorHandler,
): PostImportSession | null => {
if (typeof window === 'undefined')
const raw = readStorage (sessionKey (sessionId), onError)
if (raw == null)
return null
try
{
const raw = sessionStorage.getItem (sessionKey (sessionId))
if (raw == null)
return null
const value = JSON.parse (raw) as Partial<PostImportSession>
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
return null
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{
removeStorage (sessionKey (sessionId), onError)
return null
}
const rows = value.rows.map (sanitiseRow)
if (rows.some (_1 => _1 == null))
return null
return {
version: SESSION_VERSION,
savedAt: typeof value.savedAt === 'string' ? value.savedAt : '',
savedAt: value.savedAt,
source: typeof value.source === 'string' ? value.source : '',
existing: value.existing === 'error' ? 'error' : 'skip',
rows: value.rows as PostImportRow[],
rows: rows as PostImportRow[],
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
}
catch
@@ -133,44 +383,122 @@ export const loadPostImportSession = (
}
const rowChanged = (
previous: PostImportRow,
next: PostImportRow,
): boolean =>
previous.url !== next.url
|| JSON.stringify (previous.attributes) !== JSON.stringify (next.attributes)
|| JSON.stringify (previous.provenance) !== JSON.stringify (next.provenance)
|| JSON.stringify (previous.tagSources ?? { }) !== JSON.stringify (next.tagSources ?? { })
export const mergeValidatedImportRows = (
current: PostImportRow[],
validated: PostImportRow[],
): PostImportRow[] =>
current
.map (previous => {
if (previous.importStatus === 'created')
return previous
): PostImportRow[] => {
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
return current.map (previous => {
if (previous.importStatus === 'created')
return previous
const row = validated.find (_1 => _1.sourceRow === previous.sourceRow)
return row
? {
...row,
importStatus: previous.importStatus,
createdPostId: previous.createdPostId,
importErrors: previous.importErrors,
validationErrors: row.validationErrors ?? { } }
: previous
})
.concat (validated.filter (row =>
!(current.some (_1 => _1.sourceRow === row.sourceRow))))
const row = validatedMap.get (previous.sourceRow)
if (row == null)
return previous
const fieldWarnings =
Object.keys (row.fieldWarnings).length > 0 || row.metadataUrl !== previous.metadataUrl
? { ...row.fieldWarnings }
: { ...previous.fieldWarnings }
for (const [field, origin] of Object.entries (row.provenance))
{
if (origin === 'manual' && String (row.attributes[field] ?? '') !== '')
delete fieldWarnings[field]
}
return {
...previous,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
fieldWarnings,
baseWarnings:
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
? row.baseWarnings
: previous.baseWarnings,
validationErrors: row.validationErrors,
status: row.status,
metadataUrl: row.metadataUrl }
})
}
export const mergePreviewImportRows = (
current: PostImportRow[],
preview: PostImportRow[],
): PostImportRow[] => {
const currentMap = new Map (current.map (row => [row.sourceRow, row]))
return preview.map (row => {
const previous = currentMap.get (row.sourceRow)
if (previous == null)
return row
if (previous.importStatus === 'created')
return previous
const mergedAttributes = { ...row.attributes }
const mergedProvenance = { ...row.provenance }
for (const [field, origin] of Object.entries (previous.provenance))
{
if (origin !== 'manual' || field === 'url')
continue
if (field in previous.attributes)
mergedAttributes[field] = previous.attributes[field]
mergedProvenance[field] = 'manual'
}
const mergedTagSources =
previous.provenance.tags === 'manual'
? {
automatic: row.tagSources?.automatic ?? '',
manual: previous.tagSources?.manual ?? String (previous.attributes.tags ?? '') }
: row.tagSources
const nextRow = {
...row,
attributes: mergedAttributes,
provenance: mergedProvenance,
tagSources: mergedTagSources,
createdPostId: previous.createdPostId,
importStatus: previous.importStatus,
importErrors: previous.importErrors }
if (rowChanged (previous, nextRow))
{
nextRow.importStatus = 'pending'
nextRow.importErrors = undefined
}
return nextRow
})
}
export const mergeImportResults = (
rows: PostImportRow[],
results: PostImportResultRow[],
): PostImportRow[] =>
rows.map (row => {
const result = results.find (_1 => _1.sourceRow === row.sourceRow)
): PostImportRow[] => {
const resultMap = new Map (results.map (row => [row.sourceRow, row]))
return rows.map (row => {
const result = resultMap.get (row.sourceRow)
return result
? {
...row,
importStatus: result.status,
createdPostId: result.post?.id,
importErrors: result.errors,
validationErrors: row.validationErrors }
importErrors: result.errors }
: row
})
}
export const retryImportRow = (
@@ -183,12 +511,6 @@ export const retryImportRow = (
: row)
export const rowMessages = (row: PostImportRow): string[] => [
...row.warnings,
...Object.values (row.validationErrors ?? { }).flat (),
...Object.values (row.importErrors ?? { }).flat ()]
export const countImportSourceLines = (source: string): number =>
source
.split (/\r\n|\n|\r/)
@@ -197,25 +519,96 @@ export const countImportSourceLines = (source: string): number =>
.length
export const countImportSourceValidLines = (source: string): number =>
source
.split (/\r\n|\n|\r/)
.map (_1 => _1.trim ())
.filter (_1 => /^https?:\/\//.test (_1))
.length
export const validateImportSource = (
source: string,
): PostImportSourceIssue[] => {
const lines = source.split (/\r\n|\n|\r/)
const issues: PostImportSourceIssue[] = []
const seen = new Map<string, number> ()
let count = 0
lines.forEach ((rawLine, index) => {
const value = rawLine.trim ()
if (!(value))
return
++count
const sourceRow = index + 1
const displayUrl = truncateUrl (value)
const normalised = normaliseImportUrl (value)
if (count > MAX_ROWS)
{
issues.push ({
sourceRow,
message: `取込件数は ${ MAX_ROWS } 件までです.`,
url: displayUrl })
return
}
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
{
issues.push ({
sourceRow,
message: 'HTTP または HTTPS の URL ではありません.',
url: displayUrl })
return
}
if (bytesize (value) > MAX_URL_BYTES)
{
issues.push ({
sourceRow,
message: 'URL が長すぎます.',
url: displayUrl })
return
}
if (normalised == null)
{
issues.push ({
sourceRow,
message: 'URL の形式が不正です.',
url: displayUrl })
return
}
const duplicateRow = seen.get (normalised)
if (duplicateRow != null)
{
issues.push ({
sourceRow,
message: `${ duplicateRow } 行目と同じ URL です.`,
url: displayUrl })
return
}
seen.set (normalised, sourceRow)
})
return issues
}
const hasSkipWarning = (row: PostImportRow): boolean =>
(row.fieldWarnings.url ?? []).includes ('既存投稿のためスキップします.')
export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.filter (row => row.importStatus == null || row.importStatus === 'pending')
rows.filter (row => {
if (row.importStatus === 'created')
return false
if (row.importStatus === 'skipped')
return false
if (hasSkipWarning (row))
return false
if (row.importStatus === 'failed')
return false
return row.importStatus == null || row.importStatus === 'pending'
})
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
total: rows.length,
submittable: rows.filter (row =>
(row.importStatus == null || row.importStatus === 'pending')
submittableImportRows ([row]).length > 0
&& Object.keys (row.validationErrors ?? { }).length === 0).length,
invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length,
skipPlanned: rows.filter (row =>
row.existing && row.importStatus !== 'created' && row.status !== 'error').length,
hasSkipWarning (row) && row.importStatus !== 'created').length,
created: rows.filter (row => row.importStatus === 'created').length,
failed: rows.filter (row => row.importStatus === 'failed').length })
+29 -8
ファイルの表示
@@ -13,7 +13,8 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { loadPostImportSession,
import { clearPostImportSourceDraft,
loadPostImportSession,
mergeImportResults,
retryImportRow,
savePostImportSession,
@@ -63,7 +64,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
if (!(sessionId))
return
const loaded = loadPostImportSession (sessionId)
const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
@@ -72,7 +74,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
if (!(sessionId) || !(session))
return
savePostImportSession (sessionId, session)
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
const counts = useMemo (() => ({
@@ -104,8 +107,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
attributes: target.attributes,
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }],
existing: session.existing })
metadataUrl: target.metadataUrl }] })
setSession (current =>
current
? { ...current, rows: mergeImportResults (current.rows, result.rows) }
@@ -126,7 +128,12 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const nextSession = { ...session, repairMode: 'failed' as const }
setSession (nextSession)
if (sessionId)
savePostImportSession (sessionId, nextSession)
{
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
}
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
}
@@ -185,6 +192,13 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url}
</div>
{row.createdPostId && (
<PrefetchLink
to={`/posts/${ row.createdPostId }`}
className="inline-block text-xs text-sky-700 underline
dark:text-sky-300">
稿 #{row.createdPostId}
</PrefetchLink>)}
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
</div>
@@ -222,7 +236,10 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
onClick={() => {
const nextSession = { ...session, repairMode: 'all' as const }
setSession (nextSession)
savePostImportSession (sessionId, nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
}}>
@@ -230,7 +247,11 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<Button
type="button"
variant="outline"
onClick={() => navigate ('/posts/import')}>
onClick={() => {
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
navigate ('/posts/import')
}}>
URL
</Button>
</div>
+21 -61
ファイルの表示
@@ -19,7 +19,6 @@ import { loadPostImportSession,
mergeImportResults,
mergeValidatedImportRows,
reviewSummaryCounts,
rowMessages,
savePostImportSession,
submittableImportRows,
type PostImportResultRow,
@@ -59,7 +58,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (!(sessionId))
return
const loaded = loadPostImportSession (sessionId)
const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
@@ -68,7 +68,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (!(sessionId) || !(session))
return
savePostImportSession (sessionId, session)
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
const rows = session?.rows ?? []
@@ -160,8 +161,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
try
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: nextRows.filter (row => row.importStatus !== 'created'),
existing: session.existing,
rows: nextRows
.filter (row => row.importStatus !== 'created')
.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? editingRow.sourceRow : -1 })
updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows))
setSearchParams ({ })
@@ -178,33 +186,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
}
}
const updateExisting = async (value: 'skip' | 'error') => {
if (!(session))
return
const pendingRows = session.rows.map (row =>
row.importStatus === 'created'
? row
: {
...row,
importStatus: 'pending' as const,
importErrors: undefined })
setSession ({ ...session, existing: value, rows: pendingRows })
setLoading (true)
try
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: pendingRows.filter (row => row.importStatus !== 'created'),
existing: value,
changed_row: -1 })
updateSessionRows (mergeValidatedImportRows (pendingRows, validated.rows))
}
finally
{
setLoading (false)
}
}
const submit = async () => {
if (!(sessionId) || !(session) || submittable.length === 0)
return
@@ -223,12 +204,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
existing: session.existing })
metadataUrl: row.metadataUrl })) })
const nextRows = mergeImportResults (session.rows, result.rows)
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
setSession (nextSession)
savePostImportSession (sessionId, nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/result`)
}
catch
@@ -264,7 +247,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="mx-auto max-w-6xl space-y-4 p-4 pb-28">
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4 pb-8">
<PageTitle>稿</PageTitle>
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
@@ -279,26 +262,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
</div>
</div>
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
dark:bg-neutral-900">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-neutral-600 dark:text-neutral-300">
URL {rows.length}
</div>
<label className="flex items-center gap-2 text-sm">
<span>稿</span>
<select
value={session.existing}
onChange={ev => updateExisting (ev.target.value as 'skip' | 'error')}
className="rounded border border-border bg-background px-3 py-2">
<option value="skip"></option>
<option value="error"></option>
</select>
</label>
</div>
</div>
<div className="space-y-3">
<div className="flex-1 space-y-3 pb-24">
{reviewRows.map (row => (
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary
@@ -306,16 +270,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
onEdit={() => {
setSearchParams ({ edit: String (row.sourceRow) })
}}/>
{rowMessages (row).length > 0 && (
<div className="mt-2 md:max-w-3xl">
<FieldError messages={rowMessages (row)}/>
</div>)}
</div>))}
</div>
</div>
<div
className="fixed inset-x-0 bottom-0 border-t bg-white/95 p-4 backdrop-blur
className="sticky bottom-0 border-t bg-white/95 p-4 backdrop-blur
dark:border-neutral-700 dark:bg-neutral-950/95">
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
+60 -37
ファイルの表示
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
@@ -13,11 +13,12 @@ import { apiPost, isApiError } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { countImportSourceLines,
countImportSourceValidLines,
cleanupExpiredPostImportSessions,
createPostImportSessionId,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft,
validateImportSource,
type PostImportRow } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden'
@@ -33,38 +34,58 @@ const MAX_ROWS = 100
const PostImportSourcePage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const navigate = useNavigate ()
const draft = useMemo (() => loadPostImportSourceDraft (), [])
const draft = useMemo (() => loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message })), [])
const [source, setSource] = useState (draft.source)
const [existing, setExisting] = useState<'skip' | 'error'> (draft.existing)
const [loading, setLoading] = useState (false)
const [error, setError] = useState<string | null> (null)
const [touched, setTouched] = useState (false)
const saveTimer = useRef<number | null> (null)
const lineCount = countImportSourceLines (source)
const validLineCount = countImportSourceValidLines (source)
const issues = useMemo (() => validateImportSource (source), [source])
const messages = [
...(lineCount === 0 ? ['URL を入力してください.'] : []),
...(lineCount > MAX_ROWS ? [`最大 ${ MAX_ROWS } 件までです.`] : []),
...(((touched) && lineCount === 0) ? ['URL を入力してください.'] : []),
...(error ? [error] : [])]
useEffect (() => {
savePostImportSourceDraft (source, existing)
}, [existing, source])
cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
}, [])
useEffect (() => {
if (saveTimer.current != null)
window.clearTimeout (saveTimer.current)
saveTimer.current = window.setTimeout (() => {
savePostImportSourceDraft (source, message =>
toast ({ title: '入力内容を保存できませんでした', description: message }))
}, 300)
return () => {
if (saveTimer.current != null)
window.clearTimeout (saveTimer.current)
}
}, [source])
const preview = async () => {
setTouched (true)
if (lineCount === 0 || issues.length > 0)
return
setLoading (true)
setError (null)
try
{
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
source,
existing })
source })
const sessionId = createPostImportSessionId ()
savePostImportSession (sessionId, {
const saved = savePostImportSession (sessionId, {
source,
existing,
rows: data.rows,
repairMode: 'all' })
repairMode: 'all' }, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
}
catch (requestError)
@@ -99,48 +120,50 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
dark:bg-neutral-900 md:p-6">
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-semibold">URL </h2>
<p className="text-sm text-neutral-600 dark:text-neutral-300">
URL
</p>
</div>
<FormField label="URL リスト">
{() => (
<textarea
value={source}
rows={14}
className={inputClass (
messages.length > 0,
messages.length > 0 || issues.length > 0,
'font-mono text-sm',
)}
onChange={ev => setSource (ev.target.value)}/>)}
onBlur={() => {
setTouched (true)
savePostImportSourceDraft (source, message =>
toast ({
title: '入力内容を保存できませんでした',
description: message }))
}}
onChange={ev => {
setSource (ev.target.value)
setError (null)
}}/>)}
</FormField>
<FieldError messages={messages}/>
<div className="space-y-2">
{issues.map (issue => (
<div
key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}
className="rounded-md border border-red-300 bg-red-50 p-3 text-sm
text-red-700 dark:border-red-900 dark:bg-red-950
dark:text-red-200">
<div>{issue.sourceRow} : {issue.message}</div>
<div className="mt-1 break-all font-mono text-xs">{issue.url}</div>
</div>))}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm
text-neutral-600 dark:text-neutral-300">
<div>: {validLineCount}</div>
<div> {MAX_ROWS} </div>
<div> {lineCount} / {MAX_ROWS}</div>
</div>
<label className="block space-y-1">
<span className="text-sm font-medium">稿</span>
<select
value={existing}
onChange={ev => setExisting (ev.target.value as typeof existing)}
className={inputClass (false, 'w-full md:w-auto')}>
<option value="skip"></option>
<option value="error"></option>
</select>
</label>
<div className="flex justify-end">
<Button
type="button"
onClick={preview}
disabled={loading || lineCount === 0 || lineCount > MAX_ROWS}>
disabled={loading || lineCount === 0 || issues.length > 0}>
稿
</Button>
</div>