diff --git a/AGENTS.md b/AGENTS.md index ccded6f..acf1a18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,13 +119,8 @@ npm run preview parameter-list `)` ではない。delimiter の役割を見誤らないこと。 - In Ruby, when an `if` condition is split across multiple lines and combines clauses with `&&` or `||`, wrap the whole condition in parentheses. -- Ruby hashes are not blocks; keep `}` on the same line as the final pair. -- Ruby hashes keep the first pair on the same line as `{` unless line length - requires a break. -- Short Ruby hashes may stay visually compact across two lines with the first - pair kept on the opening line and aligned continuation pairs below it. -- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body - indentation. +- Ruby hash / block / call delimiter の詳細は、下の + `Ruby delimiter and wrapping rules` を正本として扱ふこと。 - For arrays, never put whitespace or a line break immediately before `]`. - Keep the first element on the same line as `[` by default. - If an array would exceed the line limit, break after `[` and indent diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb index 4955d0d..29ab18b 100644 --- a/backend/app/services/post_creator.rb +++ b/backend/app/services/post_creator.rb @@ -73,11 +73,15 @@ class PostCreator sections.each_value do |ranges| ranges.each do |begin_ms, end_ms| - next if begin_ms < video_ms && (!end_ms || end_ms <= video_ms) - post = Post.new - post.errors.add :video_ms, 'タグ区間が動画時間の範囲外です.' - raise ActiveRecord::RecordInvalid, post + if begin_ms >= video_ms + post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.' + raise ActiveRecord::RecordInvalid, post + end + if end_ms && end_ms > video_ms + post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.' + raise ActiveRecord::RecordInvalid, post + end end end end diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index 898485c..5ef7e5e 100644 --- a/backend/app/services/post_import_previewer.rb +++ b/backend/app/services/post_import_previewer.rb @@ -16,8 +16,14 @@ class PostImportPreviewer def preview_rows rows:, fetch_metadata: true, metadata_cache: { } urls = {} - rows.map { |row| - preview_row(row, urls:, fetch_metadata:, metadata_cache:) + prepared_rows = rows.map { prepare_row(_1) } + existing_posts = + Post.where(url: prepared_rows.map { _1[:normal_url] }.compact.uniq).index_by(&:url) + prepared_rows.map { |row| + preview_row(row, urls:, + fetch_metadata:, + metadata_cache:, + existing_posts:) } end @@ -27,22 +33,30 @@ class PostImportPreviewer private - def preview_row row, urls:, fetch_metadata:, metadata_cache: - row = row.symbolize_keys + def prepare_row row + source = row.symbolize_keys + url = source[:url].to_s.strip + normal_url = normalised_url(url) + source.merge(url_text: url, normal_url:) + end + + def preview_row row, urls:, fetch_metadata:, metadata_cache:, existing_posts: attributes = initial_attributes(row) provenance = initial_provenance(row) tag_sources = initial_tag_sources(row, attributes, provenance) field_warnings = initial_field_warnings(row) base_warnings = initial_base_warnings(row) - url = row[:url].to_s.strip + url = row[:url_text] provenance['url'] = 'manual' - normal_url = normalised_url(url) + normal_url = row[:normal_url] url_for_metadata = normal_url || url - existing_post = normal_url.present? ? Post.find_by(url: normal_url) : nil + existing_post = normal_url.present? ? existing_posts[normal_url] : nil validation_errors = {} validation_errors[:url] = ['URL が不正です.'] if normal_url.blank? - validation_errors[:url] = ['URL が重複しています.'] if normal_url.present? && urls.key?(normal_url) + if normal_url.present? && urls.key?(normal_url) + validation_errors[:url] = ['URL が重複しています.'] + end urls[normal_url] = true if normal_url.present? if row[:metadata_url].present? && row[:metadata_url] != url_for_metadata @@ -50,7 +64,7 @@ class PostImportPreviewer clear_fetch_warnings!(field_warnings) end - if normal_url.present? && existing_post + if validation_errors.blank? && normal_url.present? && existing_post add_field_warning!(field_warnings, 'url', EXISTING_SKIP_WARNING) attributes['tags'] = merged_tags(tag_sources, provenance['tags']) warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present? @@ -267,9 +281,14 @@ class PostImportPreviewer ids = raw.to_s.split.map { Integer(_1, exception: false) } return if ids.compact.empty? && raw.to_s.blank? - errors[:parent_post_ids] = ['親投稿 Id. が不正です.'] and return if ids.any? { _1.nil? || _1 <= 0 } + if ids.any? { _1.nil? || _1 <= 0 } + errors[:parent_post_ids] = ['親投稿 Id. が不正です.'] + return + end if Post.where(id: ids).count != ids.uniq.length errors[:parent_post_ids] = ['存在しない親投稿 Id. があります.'] end end + + private :prepare_row end diff --git a/backend/app/services/post_import_url_list_parser.rb b/backend/app/services/post_import_url_list_parser.rb index af3623a..794cc61 100644 --- a/backend/app/services/post_import_url_list_parser.rb +++ b/backend/app/services/post_import_url_list_parser.rb @@ -11,7 +11,9 @@ class PostImportUrlListParser url = line.strip next if url.blank? - raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます." if url.bytesize > MAX_URL_BYTES + if url.bytesize > MAX_URL_BYTES + raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます." + end { source_row: index + 1, url: } } diff --git a/backend/app/services/post_metadata_fetcher.rb b/backend/app/services/post_metadata_fetcher.rb index 83fd7d5..d7a3baa 100644 --- a/backend/app/services/post_metadata_fetcher.rb +++ b/backend/app/services/post_metadata_fetcher.rb @@ -1,4 +1,9 @@ class PostMetadataFetcher + TIMESTAMP_PATTERN = + /\A(\d{4})-(\d{2})-(\d{2})T(\d{2})/ \ + /(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?/ \ + /(Z|[+-]\d{2}:?\d{2})?\z/ + def self.fetch raw_url uri, = Preview::UrlSafety.validate(raw_url) response = Preview::HttpFetcher.fetch( @@ -37,25 +42,76 @@ class PostMetadataFetcher return nil if value.blank? raw = value.to_s.strip - from = Time.zone.parse(raw) - zone = '(?:Z|[+-]\d{2}:?\d{2})?' - before = - case raw - when /\A\d{4}\z/ then from + 1.year - when /\A\d{4}-\d{2}\z/ then from + 1.month - when /\A\d{4}-\d{2}-\d{2}\z/ then from + 1.day - when /\A\d{4}-\d{2}-\d{2}T\d{2}#{ zone }\z/ then from + 1.hour - when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}#{ zone }\z/ then from + 1.minute - when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}#{ zone }\z/ then from + 1.second - when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.(\d+)#{ zone }\z/ - from + (10**(-Regexp.last_match(1).length)) - else - return nil - end + from, before = + case raw + when /\A(\d{4})\z/ + year = Regexp.last_match(1).to_i + from = Time.zone.local(year, 1, 1) + [from, from + 1.year] + when /\A(\d{4})-(\d{2})\z/ + year = Regexp.last_match(1).to_i + month = Regexp.last_match(2).to_i + from = Time.zone.local(year, month, 1) + [from, from + 1.month] + when /\A(\d{4})-(\d{2})-(\d{2})\z/ + year = Regexp.last_match(1).to_i + month = Regexp.last_match(2).to_i + day = Regexp.last_match(3).to_i + from = Time.zone.local(year, month, day) + [from, from + 1.day] + else + parse_timestamp_range(raw) + end + return nil if from.nil? || before.nil? + [from, before] rescue ArgumentError, TypeError nil end - private_class_method :platform_tags, :original_created_range + def self.parse_timestamp_range raw + match = raw.match(TIMESTAMP_PATTERN) + return nil unless match + + year = match[1].to_i + month = match[2].to_i + 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 = parse_offset(match[8]) + from = Time.new(year, month, day, hour, minute, second + fractional_seconds(fraction), offset) + .in_time_zone + before = + if fraction.present? + from + (10**(-fraction.length)) + elsif match[6].present? + from + 1.second + elsif match[5].present? + from + 1.minute + else + from + 1.hour + end + [from, before] + end + + def self.parse_offset value + return Time.zone.formatted_offset if value.blank? + return '+00:00' if value == 'Z' + + value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }" + end + + def self.fractional_seconds value + return 0 if value.blank? + + Rational(value.to_i, 10**value.length) + end + + private_class_method :platform_tags, + :original_created_range, + :parse_timestamp_range, + :parse_offset, + :fractional_seconds end diff --git a/backend/app/services/preview/http_fetcher.rb b/backend/app/services/preview/http_fetcher.rb index 6ce1c8f..455cb25 100644 --- a/backend/app/services/preview/http_fetcher.rb +++ b/backend/app/services/preview/http_fetcher.rb @@ -14,12 +14,8 @@ module Preview def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, - redirects: MAX_REDIRECTS, - allowed_hosts: nil) + redirects: MAX_REDIRECTS) uri, addresses = UrlSafety.validate(raw_url) - if allowed_hosts && !allowed_hosts.include?(uri.host.downcase) - raise FetchFailed, '許可されてゐない redirect 先です.' - end response = request(uri, addresses.first, max_bytes) if response.is_a?(Net::HTTPRedirection) @@ -56,7 +52,7 @@ module Preview raise FetchFailed, 'redirect 先が不正です.' end - return fetch(redirect_url, max_bytes:, redirects: redirects - 1, allowed_hosts:) + return fetch(redirect_url, max_bytes:, redirects: redirects - 1) end unless response.is_a?(Net::HTTPSuccess) diff --git a/frontend/src/components/posts/import/postImportRowStatus.ts b/frontend/src/components/posts/import/postImportRowStatus.ts index dae7bfa..7006584 100644 --- a/frontend/src/components/posts/import/postImportRowStatus.ts +++ b/frontend/src/components/posts/import/postImportRowStatus.ts @@ -18,6 +18,9 @@ export type PostImportBadgeValue = export const effectivePostImportStatus = ( row: PostImportRow, ): PostImportEffectiveStatus => { + if (Object.keys (row.validationErrors ?? { }).length > 0) + return 'error' + switch (row.importStatus) { case 'created': diff --git a/frontend/src/lib/postImportSession.ts b/frontend/src/lib/postImportSession.ts index a3a3433..e855e89 100644 --- a/frontend/src/lib/postImportSession.ts +++ b/frontend/src/lib/postImportSession.ts @@ -218,6 +218,10 @@ const sanitiseRow = (value: unknown): PostImportRow | null => { return null if (value.skipReason !== 'existing' && value.existingPostId != null) return null + if (value.importStatus === 'created' && !(isPositiveInteger (value.createdPostId))) + return null + if (value.importStatus !== 'created' && value.createdPostId != null) + return null if (value.createdPostId != null && !(isPositiveInteger (value.createdPostId))) return null @@ -618,14 +622,12 @@ const hasValidationErrors = (row: PostImportRow): boolean => Object.keys (row.validationErrors ?? { }).length > 0 -export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] => +export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] => rows.filter (row => { if (row.importStatus === 'created') return false if (row.importStatus === 'skipped') return false - if (hasSkipReason (row) && !(hasValidationErrors (row))) - return false if (row.importStatus === 'failed') return false if (hasValidationErrors (row)) @@ -633,11 +635,14 @@ export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] => return row.importStatus == null || row.importStatus === 'pending' }) +export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] => + processableImportRows (rows).filter (row => !(hasSkipReason (row))) + export const reviewSummaryCounts = (rows: PostImportRow[]) => ({ total: rows.length, submittable: rows.filter (row => - submittableImportRows ([row]).length > 0 + creatableImportRows ([row]).length > 0 && !(hasValidationErrors (row))).length, invalid: rows.filter (row => hasValidationErrors (row)).length, skipPlanned: rows.filter (row => diff --git a/frontend/src/pages/posts/PostImportResultPage.tsx b/frontend/src/pages/posts/PostImportResultPage.tsx index 4407e3d..adf844f 100644 --- a/frontend/src/pages/posts/PostImportResultPage.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.tsx @@ -8,6 +8,7 @@ import PageTitle from '@/components/common/PageTitle' import PrefetchLink from '@/components/PrefetchLink' import MainArea from '@/components/layout/MainArea' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' +import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' @@ -30,7 +31,9 @@ import type { User } from '@/types' type Props = { user: User | null } -const resultToneClass = (status: string | undefined): string[] => { +const resultToneClass = ( + status: ReturnType, +): string[] => { switch (status) { case 'created': @@ -42,9 +45,14 @@ const resultToneClass = (status: string | undefined): string[] => { 'border-stone-200 bg-stone-50', 'dark:border-stone-800 dark:bg-stone-900/60'] case 'failed': + case 'error': return [ 'border-rose-200 bg-rose-50', 'dark:border-rose-900 dark:bg-rose-950/30'] + case 'warning': + return [ + 'border-amber-200 bg-amber-50', + 'dark:border-amber-900 dark:bg-amber-950/30'] default: return [ 'border-border bg-white', @@ -52,6 +60,10 @@ const resultToneClass = (status: string | undefined): string[] => { } } +const rowMessages = (row: PostImportRow): string[] => [ + ...Object.values (row.validationErrors ?? { }).flat (), + ...Object.values (row.importErrors ?? { }).flat ()] + const PostImportResultPage: FC = ({ user }) => { const editable = canEditContent (user) @@ -63,7 +75,7 @@ const PostImportResultPage: FC = ({ user }) => { const [loadingRow, setLoadingRow] = useState (null) useEffect (() => { - if (!(sessionId)) + if (sessionId == null) return const loaded = loadPostImportSession (sessionId, message => @@ -73,7 +85,7 @@ const PostImportResultPage: FC = ({ user }) => { }, [sessionId]) useEffect (() => { - if (!(sessionId) || !(session)) + if (sessionId == null || session == null) return savePostImportSession (sessionId, session, message => @@ -83,10 +95,13 @@ const PostImportResultPage: FC = ({ user }) => { const counts = useMemo (() => ({ created: session?.rows.filter (_1 => _1.importStatus === 'created').length ?? 0, skipped: session?.rows.filter (_1 => _1.importStatus === 'skipped').length ?? 0, - failed: session?.rows.filter (_1 => _1.importStatus === 'failed').length ?? 0 }), [session]) + failed: session?.rows.filter (_1 => _1.importStatus === 'failed').length ?? 0, + invalid: + session?.rows.filter (_1 => effectivePostImportStatus (_1) === 'error').length ?? 0, + }), [session]) const retry = async (sourceRow: number) => { - if (!(session)) + if (session == null) return setLoadingRow (sourceRow) @@ -164,7 +179,7 @@ const PostImportResultPage: FC = ({ user }) => { if (!(editable)) return - if (missing || !(sessionId) || !(session)) + if (missing || sessionId == null || session == null) { return ( @@ -193,22 +208,25 @@ const PostImportResultPage: FC = ({ user }) => { +
- {session.rows.map (row => ( + {session.rows.map (row => { + const status = effectivePostImportStatus (row) + return (
+ ...resultToneClass (status)].join (' ')}>
行 {row.sourceRow} - +
{String (row.attributes.title ?? '') || row.url} @@ -216,7 +234,7 @@ const PostImportResultPage: FC = ({ user }) => {
{row.url}
- +
@@ -226,14 +244,15 @@ const PostImportResultPage: FC = ({ user }) => { 投稿を開く )} + {status === 'error' && ( + )} {row.importStatus === 'failed' && ( <> -
-
))} +
)})}
@@ -280,7 +299,7 @@ const SummaryChip = ( { label, value, badge }: { label: string value: number - badge: 'created' | 'skipped' | 'failed' }, + badge: 'created' | 'skipped' | 'failed' | 'error' }, ) => (
diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 46e4c04..649f551 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -15,11 +15,12 @@ import { SITE_TITLE } from '@/config' import { apiPost } from '@/lib/api' import { canEditContent } from '@/lib/users' import { loadPostImportSession, + creatableImportRows, mergeImportResults, mergeValidatedImportRows, + processableImportRows, reviewSummaryCounts, savePostImportSession, - submittableImportRows, type PostImportResultRow, type PostImportRow, type PostImportSession } from '@/lib/postImportSession' @@ -54,7 +55,7 @@ const PostImportReviewPage: FC = ({ user }) => { const [savingRow, setSavingRow] = useState (null) useEffect (() => { - if (!(sessionId)) + if (sessionId == null) return const loaded = loadPostImportSession (sessionId, message => @@ -64,7 +65,7 @@ const PostImportReviewPage: FC = ({ user }) => { }, [sessionId]) useEffect (() => { - if (!(sessionId) || !(session)) + if (sessionId == null || session == null) return savePostImportSession (sessionId, session, message => @@ -78,9 +79,12 @@ const PostImportReviewPage: FC = ({ user }) => { ? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null : null const counts = useMemo (() => reviewSummaryCounts (rows), [rows]) - const submittable = useMemo ( - () => submittableImportRows (rows).filter (row => - Object.keys (row.validationErrors ?? { }).length === 0), + const processable = useMemo ( + () => processableImportRows (rows), + [rows], + ) + const creatable = useMemo ( + () => creatableImportRows (rows), [rows], ) const reviewRows = @@ -105,7 +109,7 @@ const PostImportReviewPage: FC = ({ user }) => { current ? { ...current, rows: nextRows } : current) const saveDraft = async (draft: Draft): Promise => { - if (!(session)) + if (session == null) return false if (editingRow == null) return false @@ -186,7 +190,7 @@ const PostImportReviewPage: FC = ({ user }) => { } const submit = async () => { - if (!(sessionId) || !(session) || submittable.length === 0) + if (sessionId == null || session == null || processable.length === 0) return setLoading (true) @@ -197,7 +201,7 @@ const PostImportReviewPage: FC = ({ user }) => { skipped: number failed: number rows: PostImportResultRow[] }> ('/posts/import', { - rows: submittable.map (row => ({ + rows: processable.map (row => ({ sourceRow: row.sourceRow, url: row.url, attributes: row.attributes, @@ -226,7 +230,7 @@ const PostImportReviewPage: FC = ({ user }) => { if (!(editable)) return - if (missing || !(sessionId) || !(session)) + if (missing || sessionId == null || session == null) { return ( @@ -278,7 +282,8 @@ const PostImportReviewPage: FC = ({ user }) => { navigate ('/posts/import')} onSubmit={submit}/> @@ -295,12 +300,13 @@ const PostImportReviewPage: FC = ({ user }) => { } const PostImportFooter = ( - { loading, invalidCount, submittableCount, onBack, onSubmit }: { - loading: boolean - invalidCount: number - submittableCount: number - onBack: () => void - onSubmit: () => void }, + { loading, invalidCount, processableCount, creatableCount, onBack, onSubmit }: { + loading: boolean + invalidCount: number + processableCount: number + creatableCount: number + onBack: () => void + onSubmit: () => void }, ) => (
- 登録対象 {submittableCount} 件 + 登録対象 {creatableCount} 件 要修正 {invalidCount} 件
@@ -320,7 +326,7 @@ const PostImportFooter = (
diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx index 47303e5..7c30d29 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -24,7 +24,8 @@ import type { User } from '@/types' type Props = { user: User | null } -type PostFormField = 'url' +type PostFormField = + 'url' | 'title' | 'tags' | 'parentPostIds' @@ -54,9 +55,11 @@ const PostNewPage: FC = ({ user }) => { const [url, setURL] = useState ('') const thumbnailPreviewRef = useRef ('') - const videoFlg = useMemo (() => - tags.split (/\s+/).some ( - tag => tag.replace (/\[.*\]$/, '') === '動画'), [tags]) + const videoFlg = + useMemo (() => + tags.split (/\s+/).some ( + tag => tag.replace (/\[.*\]$/, '') === '動画'), + [tags]) const handleSubmit = async () => { clearValidationErrors () @@ -109,11 +112,13 @@ const PostNewPage: FC = ({ user }) => { URL.revokeObjectURL (thumbnailPreviewRef.current) try { - const data = await apiGet ('/preview/thumbnail', { params: { url }, - responseType: 'blob' }) + const data = await apiGet ('/preview/thumbnail', + { params: { url }, + responseType: 'blob' }) const imageURL = URL.createObjectURL (data) setThumbnailPreview (imageURL) - setThumbnailFile (new File ([data], 'thumbnail.png', + setThumbnailFile (new File ([data], + 'thumbnail.png', { type: data.type || 'image/png' })) } finally