このコミットが含まれているのは:
@@ -158,10 +158,8 @@ class Post < ApplicationRecord
|
|||||||
def attach_thumbnail_from_url! raw_url
|
def attach_thumbnail_from_url! raw_url
|
||||||
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
|
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
|
||||||
thumbnail.attach(
|
thumbnail.attach(
|
||||||
io: StringIO.new(response.body),
|
self.class.resized_thumbnail_attachment(
|
||||||
filename: remote_thumbnail_filename(response.url),
|
StringIO.new(response.body)))
|
||||||
content_type: response.content_type)
|
|
||||||
resized_thumbnail!
|
|
||||||
rescue Preview::UrlSafety::UnsafeUrl,
|
rescue Preview::UrlSafety::UnsafeUrl,
|
||||||
Preview::ThumbnailFetcher::GenerationFailed,
|
Preview::ThumbnailFetcher::GenerationFailed,
|
||||||
Preview::HttpFetcher::FetchFailed,
|
Preview::HttpFetcher::FetchFailed,
|
||||||
@@ -173,15 +171,6 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
private
|
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
|
def validate_original_created_range
|
||||||
f = parse_original_created_value(:original_created_from)
|
f = parse_original_created_value(:original_created_from)
|
||||||
b = parse_original_created_value(:original_created_before)
|
b = parse_original_created_value(:original_created_before)
|
||||||
|
|||||||
@@ -81,12 +81,22 @@ class PostMetadataFetcher
|
|||||||
day = match[3].to_i
|
day = match[3].to_i
|
||||||
hour = match[4].to_i
|
hour = match[4].to_i
|
||||||
minute = match[5]&.to_i || 0
|
minute = match[5]&.to_i || 0
|
||||||
|
second = match[6]&.to_i || 0
|
||||||
|
fraction = match[7]
|
||||||
offset = match[8]
|
offset = match[8]
|
||||||
|
nanoseconds = parse_nanoseconds(fraction)
|
||||||
timestamp =
|
timestamp =
|
||||||
if offset.present?
|
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
|
else
|
||||||
Time.zone.local(year, month, day, hour, minute)
|
Time.zone.local(year, month, day, hour, minute, second).change(nsec: nanoseconds)
|
||||||
end
|
end
|
||||||
|
|
||||||
from = timestamp.change(sec: 0, nsec: 0)
|
from = timestamp.change(sec: 0, nsec: 0)
|
||||||
@@ -94,6 +104,13 @@ class PostMetadataFetcher
|
|||||||
[from, before]
|
[from, before]
|
||||||
end
|
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
|
def self.parse_offset value
|
||||||
return '+00:00' if value == 'Z'
|
return '+00:00' if value == 'Z'
|
||||||
|
|
||||||
@@ -109,6 +126,7 @@ class PostMetadataFetcher
|
|||||||
private_class_method :platform_tags,
|
private_class_method :platform_tags,
|
||||||
:original_created_range,
|
:original_created_range,
|
||||||
:parse_timestamp_range,
|
:parse_timestamp_range,
|
||||||
|
:parse_nanoseconds,
|
||||||
:parse_offset,
|
:parse_offset,
|
||||||
:serialise_time
|
:serialise_time
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -187,12 +187,59 @@ RSpec.describe Post, type: :model do
|
|||||||
|
|
||||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
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')
|
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||||
|
|
||||||
expect(post.thumbnail).to be_attached
|
expect(post.thumbnail).to be_attached
|
||||||
image = read_image(post.thumbnail)
|
image = read_image(post.thumbnail)
|
||||||
expect(image.dimensions).to eq([180, 180])
|
expect(image.dimensions).to eq([180, 180])
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -61,4 +61,11 @@ RSpec.describe PostMetadataFetcher do
|
|||||||
expect(result.fetch(:original_created_from)).to be_nil
|
expect(result.fetch(:original_created_from)).to be_nil
|
||||||
expect(result.fetch(:original_created_before)).to be_nil
|
expect(result.fetch(:original_created_before)).to be_nil
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ RSpec.describe Youtube::Sync do
|
|||||||
post,
|
post,
|
||||||
'https://example.com/thumb.jpg')
|
'https://example.com/thumb.jpg')
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe '#sync!' do
|
describe '#sync!' do
|
||||||
@@ -91,7 +107,7 @@ RSpec.describe Youtube::Sync do
|
|||||||
sync.sync!
|
sync.sync!
|
||||||
end
|
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.tagme
|
||||||
Tag.bot
|
Tag.bot
|
||||||
Tag.youtube
|
Tag.youtube
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||||
</div>
|
</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 && (
|
{warning && (
|
||||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
{warning}
|
{warning}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { creatableImportRows,
|
|||||||
mergeImportResults,
|
mergeImportResults,
|
||||||
mergeValidatedImportRows,
|
mergeValidatedImportRows,
|
||||||
processableImportRows,
|
processableImportRows,
|
||||||
|
resultRepairMode,
|
||||||
|
resultRowMessages,
|
||||||
resultSummaryCounts,
|
resultSummaryCounts,
|
||||||
retryImportRow,
|
retryImportRow,
|
||||||
reviewSummaryCounts } from '@/lib/postImportSession'
|
reviewSummaryCounts } from '@/lib/postImportSession'
|
||||||
@@ -131,6 +133,22 @@ describe ('post import row state', () => {
|
|||||||
importErrors: undefined })
|
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', () => {
|
it ('copies reset snapshot values instead of sharing mutable records', () => {
|
||||||
const row = buildPostImportRow ({ fieldWarnings: { title: ['warning'] } })
|
const row = buildPostImportRow ({ fieldWarnings: { title: ['warning'] } })
|
||||||
const initialised = initialisePreviewRows ([row])[0]
|
const initialised = initialisePreviewRows ([row])[0]
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const hasSkipReason = (row: PostImportRow): boolean =>
|
|||||||
const hasValidationErrors = (row: PostImportRow): boolean =>
|
const hasValidationErrors = (row: PostImportRow): boolean =>
|
||||||
Object.keys (row.validationErrors ?? { }).length > 0
|
Object.keys (row.validationErrors ?? { }).length > 0
|
||||||
|
|
||||||
|
const isRepairableImportStatus = (row: PostImportRow): boolean =>
|
||||||
|
row.importStatus === 'failed' || row.importStatus === 'pending'
|
||||||
|
|
||||||
const buildResetSnapshot = (row: PostImportRow) => ({
|
const buildResetSnapshot = (row: PostImportRow) => ({
|
||||||
url: row.url,
|
url: row.url,
|
||||||
attributes: { ...row.attributes },
|
attributes: { ...row.attributes },
|
||||||
@@ -67,6 +70,20 @@ export const resultSummaryCounts = (rows: PostImportRow[]) =>
|
|||||||
{ created: 0, skipped: 0, failed: 0 })
|
{ 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 = (
|
export const mergeValidatedImportRows = (
|
||||||
current: PostImportRow[],
|
current: PostImportRow[],
|
||||||
validated: PostImportRow[],
|
validated: PostImportRow[],
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { clearPostImportSourceDraft,
|
|||||||
loadPostImportSession,
|
loadPostImportSession,
|
||||||
mergeImportResults,
|
mergeImportResults,
|
||||||
mergeValidatedImportRows,
|
mergeValidatedImportRows,
|
||||||
|
resultRepairMode,
|
||||||
|
resultRowMessages,
|
||||||
resultSummaryCounts,
|
resultSummaryCounts,
|
||||||
retryImportRow,
|
retryImportRow,
|
||||||
savePostImportSession } from '@/lib/postImportSession'
|
savePostImportSession } from '@/lib/postImportSession'
|
||||||
@@ -34,9 +36,6 @@ import type { User } from '@/types'
|
|||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
const rowMessages = (row: PostImportRow): string[] =>
|
|
||||||
Object.values (row.importErrors ?? { }).flat ()
|
|
||||||
|
|
||||||
const buildNextEditedRow = (
|
const buildNextEditedRow = (
|
||||||
editingRow: PostImportRow,
|
editingRow: PostImportRow,
|
||||||
draft: PostImportRowDraft,
|
draft: PostImportRowDraft,
|
||||||
@@ -293,7 +292,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
const resultSession = {
|
const resultSession = {
|
||||||
...nextSession,
|
...nextSession,
|
||||||
rows: nextRows,
|
rows: nextRows,
|
||||||
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const }
|
repairMode: resultRepairMode (nextRows) }
|
||||||
sessionRef.current = resultSession
|
sessionRef.current = resultSession
|
||||||
setSession (resultSession)
|
setSession (resultSession)
|
||||||
if (recoverableTarget != null
|
if (recoverableTarget != null
|
||||||
@@ -367,10 +366,13 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{session.rows.map (row => {
|
{session.rows.map (row => {
|
||||||
const displayStatus = displayPostImportStatus (row)
|
const displayStatus = displayPostImportStatus (row)
|
||||||
const canEdit = row.importStatus === 'failed'
|
const hasValidationErrors = Object.keys (row.validationErrors).length > 0
|
||||||
const canRetry =
|
const canEdit =
|
||||||
row.importStatus === 'failed'
|
row.importStatus === 'failed'
|
||||||
&& Object.keys (row.validationErrors).length === 0
|
|| (row.importStatus === 'pending' && hasValidationErrors)
|
||||||
|
const canRetry =
|
||||||
|
(row.importStatus === 'failed' || row.importStatus === 'pending')
|
||||||
|
&& !(hasValidationErrors)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.sourceRow}
|
key={row.sourceRow}
|
||||||
@@ -388,7 +390,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{row.url}
|
{row.url}
|
||||||
</div>
|
</div>
|
||||||
<FieldError messages={rowMessages (row)}/>
|
<FieldError messages={resultRowMessages (row)}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする