このコミットが含まれているのは:
2026-07-16 18:33:45 +09:00
コミット ef95b20a7e
9個のファイルの変更145行の追加24行の削除
+2 -13
ファイルの表示
@@ -158,10 +158,8 @@ class Post < ApplicationRecord
def attach_thumbnail_from_url! raw_url
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
thumbnail.attach(
io: StringIO.new(response.body),
filename: remote_thumbnail_filename(response.url),
content_type: response.content_type)
resized_thumbnail!
self.class.resized_thumbnail_attachment(
StringIO.new(response.body)))
rescue Preview::UrlSafety::UnsafeUrl,
Preview::ThumbnailFetcher::GenerationFailed,
Preview::HttpFetcher::FetchFailed,
@@ -173,15 +171,6 @@ class Post < ApplicationRecord
private
def remote_thumbnail_filename url
filename = File.basename(URI.parse(url).path.to_s)
return filename if filename.present? && filename != '/'
'thumbnail'
rescue URI::InvalidURIError
'thumbnail'
end
def validate_original_created_range
f = parse_original_created_value(:original_created_from)
b = parse_original_created_value(:original_created_before)
+20 -2
ファイルの表示
@@ -81,12 +81,22 @@ class PostMetadataFetcher
day = match[3].to_i
hour = match[4].to_i
minute = match[5]&.to_i || 0
second = match[6]&.to_i || 0
fraction = match[7]
offset = match[8]
nanoseconds = parse_nanoseconds(fraction)
timestamp =
if offset.present?
Time.new(year, month, day, hour, minute, 0, parse_offset(offset)).in_time_zone
Time.new(
year,
month,
day,
hour,
minute,
second + Rational(nanoseconds, 1_000_000_000),
parse_offset(offset)).in_time_zone
else
Time.zone.local(year, month, day, hour, minute)
Time.zone.local(year, month, day, hour, minute, second).change(nsec: nanoseconds)
end
from = timestamp.change(sec: 0, nsec: 0)
@@ -94,6 +104,13 @@ class PostMetadataFetcher
[from, before]
end
def self.parse_nanoseconds value
return 0 if value.blank?
digits = value[0, 9].ljust(9, '0')
Integer(digits, 10)
end
def self.parse_offset value
return '+00:00' if value == 'Z'
@@ -109,6 +126,7 @@ class PostMetadataFetcher
private_class_method :platform_tags,
:original_created_range,
:parse_timestamp_range,
:parse_nanoseconds,
:parse_offset,
:serialise_time
end
+47
ファイルの表示
@@ -187,12 +187,59 @@ RSpec.describe Post, type: :model do
post = described_class.create!(title: 'title', url: 'https://example.com/post')
expect(post.thumbnail).to receive(:attach).once.and_call_original
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
expect(post.thumbnail).to be_attached
image = read_image(post.thumbnail)
expect(image.dimensions).to eq([180, 180])
end
it 'does not attach anything when thumbnail conversion fails' do
response = Preview::HttpFetcher::Response.new(
'not-an-image',
'image/png',
'https://example.com/thumb.png')
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
.with('https://example.com/thumb.png')
.and_return(response)
allow(described_class).to receive(:resized_thumbnail_attachment)
.and_raise(MiniMagick::Error, 'convert failed')
post = described_class.create!(title: 'title', url: 'https://example.com/post')
expect {
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
expect(post.thumbnail).not_to be_attached
end
it 'keeps an existing thumbnail when remote conversion fails' do
existing = described_class.create!(title: 'title', url: 'https://example.com/post')
existing.thumbnail.attach(
io: StringIO.new('existing'),
filename: 'existing.jpg',
content_type: 'image/jpeg')
blob_id = existing.thumbnail.blob.id
response = Preview::HttpFetcher::Response.new(
'not-an-image',
'image/png',
'https://example.com/thumb.png')
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
.with('https://example.com/thumb.png')
.and_return(response)
allow(described_class).to receive(:resized_thumbnail_attachment)
.and_raise(MiniMagick::Error, 'convert failed')
expect {
existing.attach_thumbnail_from_url!('https://example.com/thumb.png')
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
existing.reload
expect(existing.thumbnail).to be_attached
expect(existing.thumbnail.blob.id).to eq(blob_id)
end
end
end
end
+7
ファイルの表示
@@ -61,4 +61,11 @@ RSpec.describe PostMetadataFetcher do
expect(result.fetch(:original_created_from)).to be_nil
expect(result.fetch(:original_created_before)).to be_nil
end
it 'returns nil dates for an invalid timestamp second value' do
result = fetch_with_published_time('2024-02-03T12:34:99Z')
expect(result.fetch(:original_created_from)).to be_nil
expect(result.fetch(:original_created_before)).to be_nil
end
end
+17 -1
ファイルの表示
@@ -23,6 +23,22 @@ RSpec.describe Youtube::Sync do
post,
'https://example.com/thumb.jpg')
end
it 'retries on a later sync when the previous remote attach failed' do
post = create(:post, thumbnail_base: nil)
allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original
expect(post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg')
.twice
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
2.times do
sync.send(
:attach_thumbnail_if_needed!,
post,
'https://example.com/thumb.jpg')
end
end
end
describe '#sync!' do
@@ -91,7 +107,7 @@ RSpec.describe Youtube::Sync do
sync.sync!
end
it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do
it 'creates a YouTube post with default tags when no deerjikist mapping exists' do
Tag.tagme
Tag.bot
Tag.youtube
+7
ファイルの表示
@@ -92,6 +92,13 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div className="flex flex-wrap gap-2">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '') || 'タグなし'}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
+18
ファイルの表示
@@ -5,6 +5,8 @@ import { creatableImportRows,
mergeImportResults,
mergeValidatedImportRows,
processableImportRows,
resultRepairMode,
resultRowMessages,
resultSummaryCounts,
retryImportRow,
reviewSummaryCounts } from '@/lib/postImportSession'
@@ -131,6 +133,22 @@ describe ('post import row state', () => {
importErrors: undefined })
})
it ('deduplicates messages and keeps repair mode only for repairable rows', () => {
const repairable = buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
validationErrors: { title: ['invalid'], base: ['duplicate'] },
importErrors: { base: ['duplicate'], url: ['network'] } })
const complete = buildPostImportRow ({
sourceRow: 2,
importStatus: 'created',
createdPostId: 2 })
expect (resultRowMessages (repairable)).toEqual (['invalid', 'duplicate', 'network'])
expect (resultRepairMode ([repairable, complete])).toBe ('failed')
expect (resultRepairMode ([complete])).toBe ('all')
})
it ('copies reset snapshot values instead of sharing mutable records', () => {
const row = buildPostImportRow ({ fieldWarnings: { title: ['warning'] } })
const initialised = initialisePreviewRows ([row])[0]
+17
ファイルの表示
@@ -7,6 +7,9 @@ const hasSkipReason = (row: PostImportRow): boolean =>
const hasValidationErrors = (row: PostImportRow): boolean =>
Object.keys (row.validationErrors ?? { }).length > 0
const isRepairableImportStatus = (row: PostImportRow): boolean =>
row.importStatus === 'failed' || row.importStatus === 'pending'
const buildResetSnapshot = (row: PostImportRow) => ({
url: row.url,
attributes: { ...row.attributes },
@@ -67,6 +70,20 @@ export const resultSummaryCounts = (rows: PostImportRow[]) =>
{ created: 0, skipped: 0, failed: 0 })
export const resultRepairMode = (
rows: PostImportRow[],
): 'all' | 'failed' =>
rows.some (row => hasValidationErrors (row) || isRepairableImportStatus (row))
? 'failed'
: 'all'
export const resultRowMessages = (row: PostImportRow): string[] =>
[...new Set ([
...Object.values (row.validationErrors ?? { }).flat (),
...Object.values (row.importErrors ?? { }).flat ()])]
export const mergeValidatedImportRows = (
current: PostImportRow[],
validated: PostImportRow[],
+10 -8
ファイルの表示
@@ -20,6 +20,8 @@ import { clearPostImportSourceDraft,
loadPostImportSession,
mergeImportResults,
mergeValidatedImportRows,
resultRepairMode,
resultRowMessages,
resultSummaryCounts,
retryImportRow,
savePostImportSession } from '@/lib/postImportSession'
@@ -34,9 +36,6 @@ import type { User } from '@/types'
type Props = { user: User | null }
const rowMessages = (row: PostImportRow): string[] =>
Object.values (row.importErrors ?? { }).flat ()
const buildNextEditedRow = (
editingRow: PostImportRow,
draft: PostImportRowDraft,
@@ -293,7 +292,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const resultSession = {
...nextSession,
rows: nextRows,
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const }
repairMode: resultRepairMode (nextRows) }
sessionRef.current = resultSession
setSession (resultSession)
if (recoverableTarget != null
@@ -367,10 +366,13 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="space-y-3">
{session.rows.map (row => {
const displayStatus = displayPostImportStatus (row)
const canEdit = row.importStatus === 'failed'
const canRetry =
const hasValidationErrors = Object.keys (row.validationErrors).length > 0
const canEdit =
row.importStatus === 'failed'
&& Object.keys (row.validationErrors).length === 0
|| (row.importStatus === 'pending' && hasValidationErrors)
const canRetry =
(row.importStatus === 'failed' || row.importStatus === 'pending')
&& !(hasValidationErrors)
return (
<div
key={row.sourceRow}
@@ -388,7 +390,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url}
</div>
<FieldError messages={rowMessages (row)}/>
<FieldError messages={resultRowMessages (row)}/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">