このコミットが含まれているのは:
2026-07-14 19:12:23 +09:00
コミット 6d037192c4
9個のファイルの変更108行の追加72行の削除
+2 -1
ファイルの表示
@@ -148,7 +148,8 @@ class PostsController < ApplicationController
original_created_from: params[:original_created_from], original_created_from: params[:original_created_from],
original_created_before: params[:original_created_before], original_created_before: params[:original_created_before],
parent_post_ids: parse_parent_post_ids, parent_post_ids: parse_parent_post_ids,
video_ms: params[:video_ms], duration: params[:duration] }).create! video_ms: params[:video_ms],
duration: params[:duration] }).create!
post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
+23 -21
ファイルの表示
@@ -54,20 +54,25 @@ class PostImportPreviewer
add_field_warning!(field_warnings, 'url', EXISTING_SKIP_WARNING) add_field_warning!(field_warnings, 'url', EXISTING_SKIP_WARNING)
attributes['tags'] = merged_tags(tag_sources, provenance['tags']) attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present? warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
return { return { source_row: row[:source_row],
source_row: row[:source_row], url: normal_url,
url: normal_url, attributes:,
attributes:, provenance:,
provenance:, tag_sources:,
tag_sources:, metadata_url: url_for_metadata,
metadata_url: url_for_metadata, skip_reason: 'existing',
skip_reason: 'existing', existing_post_id: existing_post.id,
existing_post_id: existing_post.id, field_warnings:,
field_warnings:, base_warnings:,
base_warnings:, validation_errors:,
validation_errors:, status:
status: validation_errors.present? ? 'error' : (warnings_present ? 'warning' : 'ready'), if validation_errors.present?
} 'error'
elsif warnings_present
'warning'
else
'ready'
end }
end end
should_fetch = should_fetch_metadata?(fetch_metadata, row[:source_row].to_i) should_fetch = should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
@@ -88,8 +93,7 @@ class PostImportPreviewer
attributes['tags'] = merged_tags(tag_sources, provenance['tags']) attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present? warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
{ { source_row: row[:source_row],
source_row: row[:source_row],
url: normal_url || url, url: normal_url || url,
attributes:, attributes:,
provenance:, provenance:,
@@ -100,8 +104,7 @@ class PostImportPreviewer
field_warnings:, field_warnings:,
base_warnings:, base_warnings:,
validation_errors:, validation_errors:,
status: validation_errors.present? ? 'error' : (warnings_present ? 'warning' : 'ready'), status: validation_errors.present? ? 'error' : (warnings_present ? 'warning' : 'ready') }
}
end end
def should_fetch_metadata? fetch_metadata, source_row def should_fetch_metadata? fetch_metadata, source_row
@@ -175,9 +178,8 @@ class PostImportPreviewer
Preview::HttpFetcher::FetchFailed, Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e Preview::HttpFetcher::ResponseTooLarge => e
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_failure "\ "post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }", "#{ { error: e.class.name, message: e.message }.to_json }")
)
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } }
end end
+2 -3
ファイルの表示
@@ -24,11 +24,10 @@ class PostImportRunner
def run_row row, preview def run_row row, preview
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore } attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
return row.slice('source_row').merge(status: 'skipped') if preview[:skip_reason] == 'existing'
return { source_row: row['source_row'], return { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: preview[:validation_errors] } if preview[:validation_errors].present? errors: preview[:validation_errors] } if preview[:validation_errors].present?
return row.slice('source_row').merge(status: 'skipped') if preview[:skip_reason] == 'existing'
attributes['tags'] = preview[:attributes]['tags'] attributes['tags'] = preview[:attributes]['tags']
attributes['url'] = row['url'] attributes['url'] = row['url']
@@ -44,7 +43,7 @@ class PostImportRunner
{ source_row: row['source_row'], { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: { tags: ['廃止済みタグは付与できません.'] } } errors: { tags: ['廃止済みタグは付与できません.'] } }
rescue ArgumentError, PostCreator::VideoMsParseError rescue ArgumentError
{ source_row: row['source_row'], { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: { base: ['入力値が不正です.'] } } errors: { base: ['入力値が不正です.'] } }
+5 -4
ファイルの表示
@@ -2,9 +2,8 @@ class PostMetadataFetcher
def self.fetch raw_url def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url) uri, = Preview::UrlSafety.validate(raw_url)
response = Preview::HttpFetcher.fetch( response = Preview::HttpFetcher.fetch(
uri.to_s, uri.to_s,
max_bytes: Preview::ThumbnailFetcher::HTML_MAX_BYTES, max_bytes: Preview::ThumbnailFetcher::HTML_MAX_BYTES)
)
metadata = Preview::HtmlMetadataExtractor.extract(response) metadata = Preview::HtmlMetadataExtractor.extract(response)
document = Nokogiri::HTML.parse(response.body) document = Nokogiri::HTML.parse(response.body)
content = lambda { |name| content = lambda { |name|
@@ -19,7 +18,8 @@ class PostMetadataFetcher
created_range = original_created_range(published) created_range = original_created_range(published)
platform_tags = platform_tags(uri) platform_tags = platform_tags(uri)
{ title: metadata[:title], { title: metadata[:title],
thumbnail_base: Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url], thumbnail_base:
Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
original_created_from: created_range&.first&.iso8601, original_created_from: created_range&.first&.iso8601,
original_created_before: created_range&.last&.iso8601, original_created_before: created_range&.last&.iso8601,
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil }, duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil },
@@ -56,5 +56,6 @@ class PostMetadataFetcher
rescue ArgumentError, TypeError rescue ArgumentError, TypeError
nil nil
end end
private_class_method :platform_tags, :original_created_range private_class_method :platform_tags, :original_created_range
end end
+1 -1
ファイルの表示
@@ -6,7 +6,7 @@ class PostUrlNormaliser
uri.host = uri.host.downcase uri.host = uri.host.downcase
uri.path = uri.path.sub(/\/\z/, '') if uri.path.present? uri.path = uri.path.sub(/\/\z/, '') if uri.path.present?
uri.to_s PostUrlSanitisationRule.sanitise(uri.to_s)
rescue URI::InvalidURIError rescue URI::InvalidURIError
nil nil
end end
+3 -1
ファイルの表示
@@ -12,7 +12,9 @@ module Preview
Response = Data.define(:body, :content_type, :url) Response = Data.define(:body, :content_type, :url)
def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS, def self.fetch(raw_url,
max_bytes: DEFAULT_MAX_BYTES,
redirects: MAX_REDIRECTS,
allowed_hosts: nil) allowed_hosts: nil)
uri, addresses = UrlSafety.validate(raw_url) uri, addresses = UrlSafety.validate(raw_url)
if allowed_hosts && !allowed_hosts.include?(uri.host.downcase) if allowed_hosts && !allowed_hosts.include?(uri.host.downcase)
+27 -11
ファイルの表示
@@ -193,6 +193,9 @@ const isValidSkipReason = (
): value is PostImportSkipReason => ): value is PostImportSkipReason =>
value === 'existing' value === 'existing'
const isPositiveInteger = (value: unknown): value is number =>
Number.isInteger (value) && Number (value) > 0
const sanitiseRow = (value: unknown): PostImportRow | null => { const sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(isPlainObject (value))) if (!(isPlainObject (value)))
@@ -211,6 +214,12 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
return null return null
if (value.skipReason != null && !(isValidSkipReason (value.skipReason))) if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
return null return null
if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId)))
return null
if (value.skipReason !== 'existing' && value.existingPostId != null)
return null
if (value.createdPostId != null && !(isPositiveInteger (value.createdPostId)))
return null
const validationErrors = ensureStringListRecord (value.validationErrors) const validationErrors = ensureStringListRecord (value.validationErrors)
const fieldWarnings = ensureStringListRecord (value.fieldWarnings) const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
@@ -250,13 +259,13 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
provenance: value.provenance as Record<string, PostImportOrigin>, provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined, tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
status: value.status, status: value.status,
skipReason: value.skipReason, skipReason: value.skipReason,
existingPostId: existingPostId:
Number.isInteger (value.existingPostId) ? Number (value.existingPostId) : undefined, isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined, metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
createdPostId: createdPostId:
Number.isInteger (value.createdPostId) ? Number (value.createdPostId) : undefined, isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
importStatus: value.importStatus } importStatus: value.importStatus }
} }
@@ -605,6 +614,9 @@ export const validateImportSource = (
const hasSkipReason = (row: PostImportRow): boolean => const hasSkipReason = (row: PostImportRow): boolean =>
row.skipReason === 'existing' row.skipReason === 'existing'
const hasValidationErrors = (row: PostImportRow): boolean =>
Object.keys (row.validationErrors ?? { }).length > 0
export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] => export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.filter (row => { rows.filter (row => {
@@ -612,10 +624,12 @@ export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
return false return false
if (row.importStatus === 'skipped') if (row.importStatus === 'skipped')
return false return false
if (hasSkipReason (row)) if (hasSkipReason (row) && !(hasValidationErrors (row)))
return false return false
if (row.importStatus === 'failed') if (row.importStatus === 'failed')
return false return false
if (hasValidationErrors (row))
return false
return row.importStatus == null || row.importStatus === 'pending' return row.importStatus == null || row.importStatus === 'pending'
}) })
@@ -624,9 +638,11 @@ export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
total: rows.length, total: rows.length,
submittable: rows.filter (row => submittable: rows.filter (row =>
submittableImportRows ([row]).length > 0 submittableImportRows ([row]).length > 0
&& Object.keys (row.validationErrors ?? { }).length === 0).length, && !(hasValidationErrors (row))).length,
invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length, invalid: rows.filter (row => hasValidationErrors (row)).length,
skipPlanned: rows.filter (row => skipPlanned: rows.filter (row =>
hasSkipReason (row) && row.importStatus !== 'created').length, hasSkipReason (row)
&& !(hasValidationErrors (row))
&& row.importStatus !== 'created').length,
created: rows.filter (row => row.importStatus === 'created').length, created: rows.filter (row => row.importStatus === 'created').length,
failed: rows.filter (row => row.importStatus === 'failed').length }) failed: rows.filter (row => row.importStatus === 'failed').length })
+32 -8
ファイルの表示
@@ -16,9 +16,11 @@ import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft, import { clearPostImportSourceDraft,
loadPostImportSession, loadPostImportSession,
mergeImportResults, mergeImportResults,
mergeValidatedImportRows,
retryImportRow, retryImportRow,
savePostImportSession, savePostImportSession,
type PostImportResultRow, type PostImportResultRow,
type PostImportRow,
type PostImportSession } from '@/lib/postImportSession' type PostImportSession } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
@@ -92,9 +94,31 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
{ {
const pendingRows = retryImportRow (session.rows, sourceRow) const pendingRows = retryImportRow (session.rows, sourceRow)
setSession ({ ...session, rows: pendingRows }) setSession ({ ...session, rows: pendingRows })
const target = pendingRows.find (_1 => _1.sourceRow === sourceRow) const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: pendingRows
.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: -1 })
const validatedRows = mergeValidatedImportRows (pendingRows, validated.rows).map (row =>
row.sourceRow === sourceRow
&& Object.keys (row.validationErrors ?? { }).length > 0
? { ...row, importStatus: 'failed' as const }
: row)
setSession (current =>
current
? { ...current, rows: validatedRows }
: current)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null) if (target == null)
return return
if (Object.keys (target.validationErrors ?? { }).length > 0)
return
const result = await apiPost<{ const result = await apiPost<{
created: number created: number
@@ -125,15 +149,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
} }
const openRepair = (sourceRow: number) => { const openRepair = (sourceRow: number) => {
if (session == null || sessionId == null)
return
const nextSession = { ...session, repairMode: 'failed' as const } const nextSession = { ...session, repairMode: 'failed' as const }
setSession (nextSession) setSession (nextSession)
if (sessionId) const saved = savePostImportSession (sessionId, nextSession, message =>
{ toast ({ title: '取込状態を保存できませんでした', description: message }))
const saved = savePostImportSession (sessionId, nextSession, message => if (!(saved))
toast ({ title: '取込状態を保存できませんでした', description: message })) return
if (!(saved))
return
}
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`) navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
} }
+13 -22
ファイルの表示
@@ -24,8 +24,7 @@ import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
type PostFormField = type PostFormField = 'url'
'url'
| 'title' | 'title'
| 'tags' | 'tags'
| 'parentPostIds' | 'parentPostIds'
@@ -42,10 +41,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> () useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
useState<string | null> (null) const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('') const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('') const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('') const [duration, setDuration] = useState ('')
@@ -57,11 +54,9 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const thumbnailPreviewRef = useRef ('') const thumbnailPreviewRef = useRef ('')
const videoFlg = const videoFlg = useMemo (() =>
useMemo (() => tags.split (/\s+/).some (
tags.split (/\s+/).some ( tag => tag.replace (/\[.*\]$/, '') === '動画'), [tags])
tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors () clearValidationErrors ()
@@ -82,8 +77,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
try try
{ {
await apiPost ('/posts', formData, await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
{ headers: { 'Content-Type': 'multipart/form-data' } })
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate ('/posts') navigate ('/posts')
} }
@@ -98,8 +92,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
setTitleLoading (true) setTitleLoading (true)
try try
{ {
const data = await apiGet<{ title: string }> ('/preview/title', const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
{ params: { url } })
setTitle (data.title || '') setTitle (data.title || '')
} }
finally finally
@@ -116,13 +109,11 @@ const PostNewPage: FC<Props> = ({ user }) => {
URL.revokeObjectURL (thumbnailPreviewRef.current) URL.revokeObjectURL (thumbnailPreviewRef.current)
try try
{ {
const data = await apiGet<Blob> ('/preview/thumbnail', const data = await apiGet<Blob> ('/preview/thumbnail', { params: { url },
{ params: { url }, responseType: 'blob' })
responseType: 'blob' })
const imageURL = URL.createObjectURL (data) const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL) setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data], setThumbnailFile (new File ([data], 'thumbnail.png',
'thumbnail.png',
{ type: data.type || 'image/png' })) { type: data.type || 'image/png' }))
} }
finally finally
@@ -176,7 +167,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => void fetchTitle()} onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}> disabled={!(url) || titleLoading}>
</Button> </Button>
@@ -193,7 +184,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => void fetchThumbnail()} onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}> disabled={!(url) || thumbnailLoading}>
</Button> </Button>