From cb33d9ca2557780c19217433034599e5f919a29a Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 11 Jul 2026 23:08:03 +0900 Subject: [PATCH] #399 --- backend/app/services/post_import_runner.rb | 10 ++++- frontend/src/pages/posts/PostImportPage.tsx | 41 ++++++++++++++++----- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/backend/app/services/post_import_runner.rb b/backend/app/services/post_import_runner.rb index 6b59b90..49e03a8 100644 --- a/backend/app/services/post_import_runner.rb +++ b/backend/app/services/post_import_runner.rb @@ -17,9 +17,17 @@ class PostImportRunner private def run_row row - return row.slice('source_row').merge(status: 'skipped') if row['status'] == 'skipped' || row['existing'] attributes = row.fetch('attributes', { }).to_h.transform_keys { _1.to_s.underscore } raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_KEYS).empty? + raise ArgumentError, '取込項目が大きすぎます.' if attributes.values.any? { _1.to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES } + raise ArgumentError, 'URL が長すぎます.' if row['url'].to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES + preview = PostImportPreviewer.new(parsed: { columns: [], rows: [] }, url_column: 0, + mappings: {}, existing: 'error').preview_rows( + rows: [{ source_row: row['source_row'], url: row['url'], + attributes:, provenance: row['provenance'] }], + fetch_metadata: false).first + return { source_row: row['source_row'], status: 'failed', errors: preview[:errors] } if preview[:errors].present? + return row.slice('source_row').merge(status: 'skipped') if row['status'] == 'skipped' || preview[:existing] attributes['url'] = row['url'] post = PostCreator.new(actor: @actor, attributes:).create! { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) } diff --git a/frontend/src/pages/posts/PostImportPage.tsx b/frontend/src/pages/posts/PostImportPage.tsx index 9af57d9..c747a73 100644 --- a/frontend/src/pages/posts/PostImportPage.tsx +++ b/frontend/src/pages/posts/PostImportPage.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import FieldError from '@/components/common/FieldError' +import PrefetchLink from '@/components/PrefetchLink' import Form from '@/components/common/Form' import FormField from '@/components/common/FormField' import PageTitle from '@/components/common/PageTitle' @@ -51,6 +52,7 @@ const PostImportPage: FC = ({ user }) => { const [parsed, setParsed] = useState (null) const [columns, setColumns] = useState ([]) const [rows, setRows] = useState ([]) + const [results, setResults] = useState<{ sourceRow: number, status: string, post?: { id: number }, errors?: Record }[]> ([]) const [mappings, setMappings] = useState> ({ }) const [existing, setExisting] = useState<'skip' | 'error'> ('skip') const [loading, setLoading] = useState (false) @@ -176,11 +178,12 @@ const PostImportPage: FC = ({ user }) => { setLoading (true) try { - const result = await apiPost<{ created: number, skipped: number, failed: number }> ('/posts/import', - { rows: targets }) + const result = await apiPost<{ created: number, skipped: number, failed: number, + rows: { sourceRow: number, status: string, post?: { id: number }, errors?: Record }[] }> ('/posts/import', { rows: targets }) toast ({ title: `登録完了: ${result.created} 件`, description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` }) setPhase ('result') + setResults (result.rows) } catch { @@ -270,13 +273,12 @@ const PostImportPage: FC = ({ user }) => { )} -
{rows.map ((row, index) => )}
-
- {rows.map ((row, index) => )}
URLタイトルサムネール日時動画時間タグ親投稿状態
{row.sourceRow} updateURL (index, ev.target.value)}/> updateAttribute (index, 'title', ev.target.value)}/> updateAttribute (index, 'thumbnailBase', ev.target.value)}/> updateAttribute (index, 'originalCreatedFrom', ev.target.value)}/> updateAttribute (index, 'originalCreatedBefore', ev.target.value)}/> updateAttribute (index, 'duration', ev.target.value)}/> updateAttribute (index, 'tags', ev.target.value)}/> updateAttribute (index, 'parentPostIds', ev.target.value)}/>{row.status}
+
{rows.map ((row, index) => )}
) : (

インポート処理が完了しました。

+ {results.map (result =>
行 {result.sourceRow}:{result.status} {result.post && 投稿を開く}
)}
)} @@ -284,12 +286,33 @@ const PostImportPage: FC = ({ user }) => { } -const RowCard = ({ row, index, update }: { row: ImportRow, index: number, update: (index: number, field: string, value: string) => void }) => ( +const RowCard = ({ row, index, update, updateURL }: { row: ImportRow, index: number, update: (index: number, field: string, value: string) => void, updateURL: (index: number, value: string) => void }) => (
-

行 {row.sourceRow}・{row.status}

- - +

行 {row.sourceRow}・{row.status}

+ updateURL (index, value)}/> + update (index, 'title', value)}/> + update (index, 'thumbnailBase', value)}/> + {row.attributes.thumbnailBase && サムネール { ev.currentTarget.hidden = true }}/>} + update (index, 'originalCreatedFrom', value)}/> + update (index, 'originalCreatedBefore', value)}/> + update (index, 'duration', value)}/> + update (index, 'tags', value)}/> + update (index, 'parentPostIds', value)}/>
) +const EditableValue = ({ label, value, origin, onCommit }: { label: string, value: string, origin: string | undefined, onCommit: (value: string) => void }) => { + const [editing, setEditing] = useState (false) + const [draft, setDraft] = useState (value) + const originLabel = { automatic: '自動取得', mapped: '入力データ', fixed: '固定値', manual: '手修正' }[origin ?? 'automatic'] + const commit = () => { setEditing (false); if (draft !== value) onCommit (draft) } + const cancel = () => { setDraft (value); setEditing (false) } + return
+ {label}・{originLabel} + {editing ? setDraft (ev.target.value)} onBlur={commit} + onKeyDown={ev => { if (ev.key === 'Escape') cancel (); if (ev.key === 'Enter') commit () } } className={inputClass (false)}/> + : } +
+} + export default PostImportPage