コミットを比較

..

1 コミット

作成者 SHA1 メッセージ 日付
みてるぞ f1181e8510 広場投稿追加画面の刷新 (#399) (#413)
Reviewed-on: #413
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-19 00:03:10 +09:00
103個のファイルの変更6825行の追加3600行の削除
+209 -24
ファイルの表示
@@ -124,7 +124,7 @@ npm run preview
- 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
elements by 4 spaces.
elements 4 spaces deeper than the statement's base indentation.
### Ruby delimiter and wrapping rules
@@ -140,6 +140,32 @@ npm run preview
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
- Ruby の method chain や call argument を折り返す際、call-site の `)`
block close のやうに独立させない。
- Ruby では、行末の `\` を用途を問はず一切使用しない。
- Ruby では、文字列連結、logger message、method call、条件式、SQL 断片、
正規表現その他すべての式で、行末バックスラッシュによる継続を禁止する。
- Ruby の block body は、その基準位置から 2 空白深くする。
- Ruby の wrapped expression、method argument、array element、hash pair などの
continuation indentation は、その statement の基準位置から 4 空白深くする。
- Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。
- Ruby では、暗黙的に継続可能な構文を優先し、method call、array、Hash 及び
括弧内ではバックスラッシュなしで改行する。
- Ruby では、行長制限を守るために行末バックスラッシュを導入してはならない。
- Ruby で行末バックスラッシュが必要に見える場合は、括弧内で自然に改行する、
一つの文字列補間へまとめる、中間変数へ分ける、`format` を使ふ、heredoc を
使ふ、array 又は Hash を組み立ててから処理する、条件式全体を括弧で囲む、
method へ抽出する、のいづれかへ書き換へる。
- Ruby では、一つの文字列を、改行をまたいだ隣接文字列 literal として記述しない。
- Ruby では、method argument 内でも、複数の文字列 literal を区切りなしで縦に
並べない。
- RSpec の `describe``context``it` 等の description が長い場合は、意味を
保ったまま一行へ収まる文言へ短縮する。
- 文字列を短縮できない場合は、用途に応じて `format`、heredoc 又は中間変数を
検討する。
- ただし RSpec description では、原則として簡潔な一行の文字列を使ふ。
- formatter 又は自動修正にも、Ruby の行末バックスラッシュを生成させない。
- 新規 code だけでなく、今回触れる Ruby code にも行末バックスラッシュを残さない。
- 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白
深くするため、結果として行頭から 6 空白になる。
Bad:
@@ -191,6 +217,63 @@ end
Bad:
```rb
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ payload.to_json }")
```
Good:
```rb
payload = {
error: e.class.name,
message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_failure #{ payload.to_json }")
```
Bad:
```rb
message = "first "\
"second"
```
Good:
```rb
message = format(
'%<first>s %<second>s',
first: 'first',
second: 'second')
```
Bad:
```rb
result = first_value + \
second_value
```
Bad:
```rb
it(
'returns 409 when stale changes '
'do not conflict'
) do
```
Good:
```rb
it 'returns mergeable 409 for stale non-conflicting changes' do
```
Bad:
```rb
records.each {
do_work(_1) }
@@ -211,6 +294,13 @@ records.each {
- In TypeScript and TSX, block bodies for components, functions, callbacks,
`if`, `try`, `catch`, `finally`, loops, and JSX nesting use 2 spaces per
level.
- In TypeScript and TSX, put the opening brace of `try`, `catch`, and
`finally` blocks on the next line at the same indentation as the keyword.
- Do not indent the opening `{` one level deeper than `try`, `catch`, or
`finally`.
- Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`.
- In TypeScript and TSX, use 4-space continuation indentation for wrapped
expressions, arguments, conditions, arrays, object literals, JSX
attributes, and similar continuations.
@@ -218,20 +308,37 @@ records.each {
formatting as the local reference shape: component and callback bodies use
2-space block indentation, single-line bodies do not gain unnecessary
braces, wrapped expressions use 4-space continuation indentation, multi-
stage ternaries use explicit parentheses, and only complete leading runs
of 8 spaces are compressed to tabs.
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
stage ternaries use explicit parentheses, and TypeScript / TSX indentation
and alignment whitespace compress every complete run of 8 spaces to tabs.
- A tab does not represent one indentation level.
- Do not replace 2-space or 4-space indentation with tabs.
- First determine visible indentation using 2-space block indentation and
4-space continuation indentation, then compress only complete leading runs
of 8 spaces into tabs.
- Tabs are only for leading indentation, never for spaces after non-space
text.
- Keep residual leading 2, 4, or 6 spaces after any tab compression.
- In TypeScript and TSX, first determine visible indentation using 2-space
block indentation and 4-space continuation indentation, then compress every
complete run of 8 spaces used for indentation or column alignment to tabs.
- In TypeScript and TSX, 8-space compression is mandatory, not optional.
- In TypeScript and TSX, this applies both to leading indentation and to
alignment whitespace after non-space text, such as aligned inline type
columns.
- In TypeScript and TSX, keep residual 2, 4, or 6 spaces after each tab
compression.
- In TypeScript and TSX, do not treat a tab as one logical indentation level.
- In TypeScript and TSX, do not alter string literals, template-literal
contents, regular expressions, or user-facing text merely to apply tab
compression.
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
tab + 4 spaces, 16 columns = 2 tabs.
tab + 4 spaces, 14 columns = 1 tab + 6 spaces, 16 columns = 2 tabs.
- When TypeScript or TSX code already uses column alignment, apply the same
8-space compression rule to that alignment whitespace.
- Example:
```ts
type Props = {
row: PostImportRow
displayNumber?: number
onEdit?: () => void
onRetry?: () => void
onToggleSkip?: (checked: boolean) => void }
```
- TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically.
- Keep runtime value imports and type imports in separate declarations.
@@ -274,6 +381,17 @@ case 'no':
- In TypeScript and TSX, use `value == null` and `value != null` as the
default nullish checks. Do not use `=== null`, `=== undefined`,
`!== null`, or `!== undefined`.
- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
Use a meaningful callback parameter name such as `row`, `item`, `value`,
`entry`, or `result`.
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
whenever `className` combines multiple values, conditional classes, or a
caller-provided `className` prop.
- Do not construct `className` with template literals, `${ ... }`, string
concatenation, arrays joined with spaces, or feature-local class-merging
helpers.
- A static `className="..."` containing only fixed classes does not need `cn`.
- If code appears to need a distinction between `null` and `undefined`, treat
that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the
@@ -449,9 +567,39 @@ and layout reuse, follow `frontend/AGENTS.md`.
structure, control flow, or variable mutability unless the requested style
explicitly requires it.
- Do not add production dependencies without explicit approval.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
placeholders, empty-state messages, loading messages, or explanatory text
unless the user explicitly specified the wording.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until
they pass or the remaining failure is clearly blocked.
test work. When the user asks for tests, keep working within the permitted
test-file scope and rerun them until they pass or the remaining failure is
clearly blocked.
- Test-only work includes adding, updating, deleting, reorganising, or fixing
SyntaxError in tests. During test-only work, do not modify production code.
- During test-only work, do not change production constants, behaviour, API
contracts, validation, routes, authentication, permissions, UI, copy,
dependencies, limits, thresholds, defaults, migrations, schema, or
environment settings to satisfy tests.
- Do not make production code match failing tests, mock assumptions, fixtures,
snapshots, old expectations, or stale setup. This includes changing
production constants merely because a test expects a different value.
- If test work reveals a production bug, spec mismatch, or missing behaviour,
stop without modifying production code and report: the failing test or
discovered issue, the related production file, the actual behaviour, the
expected behaviour, and why a production change appears necessary.
- Modify production code for test failures only when the user explicitly asks
for that production change. Do not expand a test task into a production task
on your own authority.
- If the user explicitly asks for both production implementation and test
updates, implement production code to the confirmed specification first,
then add or update tests to verify that specification. Never roll production
behaviour back to satisfy old tests.
- If it is unclear whether the test or the production implementation is stale,
or a test cannot be corrected without changing production code, ask the user
instead of guessing.
## Backend rules
@@ -505,6 +653,11 @@ and layout reuse, follow `frontend/AGENTS.md`.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
make dense controls wrap or scroll intentionally, and keep tag/filter
controls usable without horizontal page overflow.
- Frontend のスマホ/PC表示境界は原則 `md` とする。
- button stack、footer action、dialogue action は `md` 未満で縦並び、
`md` 以上で横並びとする。
- 同じ画面内で `sm``md` を混在させて中間 layout を作らない。
- 明確に別の responsive 要件がある component だけを例外とする。
- For mobile horizontal scrollers, make the scroll direction and item sizing
explicit, and ensure chip text remains readable in both light and dark modes.
- In TypeScript and TSX, prefer direct comparison operators such as `===` and
@@ -569,18 +722,34 @@ and layout reuse, follow `frontend/AGENTS.md`.
beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading block indentation uses
2 spaces per level, wrapped continuations use the repository's 4-space
continuation alignment, and complete leading runs of 8 spaces may be
compressed to tabs.
- The TypeScript/TSX self-review must confirm block indentation uses 2 spaces
per level, wrapped continuations use the repository's 4-space continuation
alignment, and every complete run of 8 spaces used for indentation or
alignment has been compressed to tabs.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, use 2-space block indentation and 4-space continuation
indentation.
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a
tab character.
- A leading tab is exactly equivalent to 8 leading spaces.
- In TypeScript and TSX, put the opening brace of `try`, `catch`, and
`finally` blocks on the next line at the same indentation as the keyword.
- Do not indent the opening `{` one level deeper than `try`, `catch`, or
`finally`.
- Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`.
- In TypeScript and TSX, convert every complete run of 8 spaces used for
indentation or alignment to a tab character.
- In TypeScript and TSX, a tab is exactly equivalent to 8 columns, whether it
appears at the beginning of a line or in alignment whitespace after
non-space text.
- In TSX, JSX nesting uses 2-space block indentation and wrapped JSX
attributes use 4-space continuation indentation; after visible columns are
determined, compress every complete run of 8 spaces in the resulting
indentation or alignment to tabs.
- In TSX, do not leave JSX subtree indentation at 8, 10, 12, 14, or 16
columns as spaces alone; convert each complete run of 8 spaces to tabs and
keep only the residual 2, 4, or 6 spaces.
- In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing
`)`.
@@ -628,6 +797,22 @@ and layout reuse, follow `frontend/AGENTS.md`.
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- `try` / `catch` / `finally` brace placement example:
```ts
try
{
doWork ()
}
catch
{
recover ()
}
finally
{
cleanUp ()
}
```
- Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`.
@@ -1207,9 +1392,9 @@ to `.ts` and `.tsx`:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading block indentation uses 2 spaces per level, wrapped continuations
use the repository's 4-space continuation alignment, and complete leading
runs of 8 spaces may be compressed to tabs.
9. Block indentation uses 2 spaces per level, wrapped continuations use the
repository's 4-space continuation alignment, and every complete run of 8
spaces used for indentation or alignment has been compressed to tabs.
10. No line has trailing whitespace.
Preferred:
-44
ファイルの表示
@@ -1,44 +0,0 @@
class PostImportsController < ApplicationController
before_action :require_member!
def preview
rows = PostImportPreviewer.new.preview_rows(
rows: PostImportUrlListParser.parse(params[:source]))
render json: { rows: }
rescue ArgumentError => e
render_bad_request e.message
end
def validate
rows = normalised_import_rows allow_warning_fields: true
changed_row = Integer(params[:changed_row], exception: false)
result =
PostImportPreviewer.new.preview_rows(rows:,
fetch_metadata: changed_row,
metadata_cache: { })
render json: { rows: result }
rescue ArgumentError => e
render_bad_request e.message
end
def create
result = PostImportRunner.new(actor: current_user,
rows: normalised_import_rows).run
render json: result, status: result[:created].positive? ? :created : :ok
rescue ArgumentError => e
render_bad_request e.message
end
private
def require_member!
return head :unauthorized unless current_user
return if current_user.gte_member?
head :forbidden
end
def normalised_import_rows allow_warning_fields: false
PostImportRowNormaliser.normalise!(params[:rows], allow_warning_fields:)
end
end
+215 -7
ファイルの表示
@@ -1,5 +1,6 @@
class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
MAX_BULK_REQUEST_BYTES = 40 * 1024 * 1024
class VideoMsParseError < ArgumentError
;
@@ -114,6 +115,77 @@ class PostsController < ApplicationController
render json: PostRepr.base(post, current_user)
end
def metadata
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
return render_bad_request('URL は必須です.') if params[:url].blank?
normal_url = PostUrlNormaliser.normalise(params[:url].to_s)
return render_validation_error(fields: { url: ['URL が不正です.'] }) if normal_url.blank?
Preview::UrlSafety.validate(normal_url)
existing_post = Post.with_attached_thumbnail.find_by(url: normal_url)
if existing_post.present?
return render json: {
url: normal_url,
title: nil,
thumbnail_base: nil,
tags: nil,
display_tags: [],
original_created_from: nil,
original_created_before: nil,
duration: nil,
video_ms: nil,
field_warnings: { },
base_warnings: [],
existing_post_id: existing_post.id,
existing_post: compact_post(existing_post.id) }
end
metadata = PostMetadataFetcher.fetch(normal_url)
field_warnings = { }
field_warnings[:title] = ['タイトルを取得できませんでした.'] if metadata[:title].blank?
if metadata[:thumbnail_base].blank?
field_warnings[:thumbnail_base] = ['サムネールを取得できませんでした.']
end
render json: {
url: normal_url,
title: metadata[:title],
thumbnail_base: metadata[:thumbnail_base],
tags: metadata[:tags],
display_tags: metadata[:display_tags],
original_created_from: metadata[:original_created_from],
original_created_before: metadata[:original_created_before],
duration: metadata[:duration],
video_ms: metadata[:video_ms],
field_warnings: field_warnings,
base_warnings: [],
existing_post_id: nil,
existing_post: nil }
rescue ArgumentError => e
render_bad_request e.message
rescue Preview::UrlSafety::UnsafeUrl => e
render_validation_error fields: { url: [e.message] }
rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge
render json: {
url: normal_url,
title: nil,
thumbnail_base: nil,
tags: nil,
display_tags: [],
original_created_from: nil,
original_created_before: nil,
duration: nil,
video_ms: nil,
field_warnings: { url: ['自動取得に失敗しました.'] },
base_warnings: [],
existing_post_id: nil,
existing_post: nil }
end
def show
post =
Post
@@ -142,17 +214,41 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail],
host: request.base_url).run
return render json: dry_run_json(preflight) if bool?(:dry)
if preflight[:existing_post_id].present?
post = Post.new(url: preflight[:url])
post.errors.add :url, :taken
return render_post_form_record_invalid post
end
post = PostCreator.new(actor: current_user,
attributes: { title: params[:title], url: params[:url],
thumbnail: params[:thumbnail], tags: params[:tags],
original_created_from: params[:original_created_from],
original_created_before: params[:original_created_before],
parent_post_ids: parse_parent_post_ids,
video_ms: params[:video_ms],
duration: params[:duration] }).create!
attributes: post_create_attributes.merge(
preflight.slice(
:url,
:title,
:thumbnail_base,
:tags,
:parent_post_ids,
:original_created_from,
:original_created_before,
:duration,
:video_ms,
:direct_tag_specs,
:default_tag_specs,
:snapshot_tag_specs,
:post_tag_specs,
:tag_sections,
:normalised_parent_post_ids).symbolize_keys).merge(
thumbnail: params[:thumbnail])).create!
post.reload
render json: PostRepr.base(post), status: :created
rescue PostCreatePreflight::ValidationFailed => e
render_validation_error fields: e.fields, base: e.base_errors
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError
@@ -161,6 +257,8 @@ class PostsController < ApplicationController
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue PostCreator::VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue Post::RemoteThumbnailFetchFailed
render_validation_error fields: { thumbnail_base: ['サムネイル画像の取得に失敗しました.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e
@@ -169,6 +267,25 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record
end
def bulk
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form]
return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
posts = parse_bulk_posts_manifest
thumbnails = parse_bulk_thumbnails(posts.length)
result = PostBulkCreator.new(
actor: current_user,
posts:,
thumbnails:,
host: request.base_url).run
render json: result
rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.'
rescue ArgumentError => e
render_validation_error base: [e.message]
end
def viewed
return head :unauthorized unless current_user
@@ -463,6 +580,79 @@ class PostsController < ApplicationController
}.uniq
end
def post_create_attributes
{ title: params[:title],
url: params[:url],
thumbnail_base: params[:thumbnail_base],
tags: params[:tags],
original_created_from: params[:original_created_from],
original_created_before: params[:original_created_before],
parent_post_ids: parse_parent_post_ids,
video_ms: params[:video_ms],
duration: params[:duration] }
end
def parse_bulk_posts_manifest
manifest = params[:posts]
raise ArgumentError, 'posts は必須です.' if manifest.blank?
raise ArgumentError, 'posts は JSON 文字列で指定してください.' unless manifest.is_a?(String)
posts = JSON.parse(manifest)
raise ArgumentError, 'posts は配列で指定してください.' unless posts.is_a?(Array)
raise ArgumentError, '投稿件数は 1 件以上必要です.' if posts.empty?
raise ArgumentError, '投稿件数が多すぎます.' if posts.length > 100
raise ArgumentError, 'posts 要素の形式が不正です.' unless posts.all? { _1.is_a?(Hash) }
posts
end
def parse_bulk_thumbnails post_count
thumbnails = { }
raw = params[:thumbnails]
return thumbnails if raw.blank?
raise ArgumentError, 'thumbnail key が不正です.' unless raw.respond_to?(:to_unsafe_h)
raw.to_unsafe_h.each do |key, value|
raise ArgumentError, 'thumbnail key が不正です.' unless key.to_s.match?(/\A\d+\z/)
index = Integer(key, 10)
raise ArgumentError, 'thumbnail index が範囲外です.' if index.negative? || index >= post_count
raise ArgumentError, 'thumbnail index が重複しています.' if thumbnails.key?(index)
unless value.is_a?(ActionDispatch::Http::UploadedFile)
raise ArgumentError, 'thumbnail upload が不正です.'
end
thumbnails[index] = value
end
thumbnails
end
def compact_post post_id
return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post, host: request.base_url)
end
def dry_run_json preflight
preflight.slice(
:url,
:title,
:thumbnail_base,
:tags,
:display_tags,
:parent_post_ids,
:original_created_from,
:original_created_before,
:duration,
:video_ms,
:field_warnings,
:base_warnings,
:existing_post_id,
:existing_post)
end
def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
@@ -542,6 +732,7 @@ class PostsController < ApplicationController
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
@@ -579,6 +770,23 @@ class PostsController < ApplicationController
value.to_s
end
def validate_original_created_values! original_created_from, original_created_before
candidate = Post.new(
url: 'https://example.invalid/original-created-validation',
original_created_from:,
original_created_before:)
candidate.valid?
fields = [:original_created_from, :original_created_before, :original_created_at]
relevant_errors = candidate.errors.select { fields.include?(_1.attribute) }
return if relevant_errors.empty?
invalid_post = Post.new
relevant_errors.each { |error|
invalid_post.errors.add(error.attribute, error.message)
}
raise ActiveRecord::RecordInvalid, invalid_post
end
def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end
+6 -8
ファイルの表示
@@ -18,14 +18,12 @@ class PreviewController < ApplicationController
def thumbnail
return render_bad_request('URL は必須です.') if params[:url].blank?
image = MiniMagick::Image.read(Preview::ThumbnailFetcher.fetch(params[:url]))
image.auto_orient
image.resize '180x180>'
image.format 'png'
width, height = image.dimensions
raise Preview::ThumbnailFetcher::GenerationFailed, 'サムネール画像の変換に失敗しました.' if width > 180 || height > 180
send_data image.to_blob, type: 'image/png', disposition: 'inline'
attachment =
Post.resized_thumbnail_attachment(
StringIO.new(Preview::ThumbnailFetcher.fetch(params[:url])))
send_data attachment[:io].read,
type: attachment[:content_type],
disposition: 'inline'
rescue Preview::UrlSafety::UnsafeUrl => e
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
+367 -15
ファイルの表示
@@ -1,20 +1,57 @@
class Post < ApplicationRecord
require 'date'
require 'mini_magick'
require 'nokogiri'
require 'stringio'
require 'timeout'
def self.resized_thumbnail_attachment(upload)
class RemoteThumbnailFetchFailed < StandardError; end
ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze
ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE =
'オリジナルの作成日時は分単位で入力してください.'.freeze
ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze
ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
'オリジナルの作成日時の範囲は1分以上必要です.'.freeze
REMOTE_SVG_CONTENT_TYPE = 'image/svg+xml'.freeze
MAX_SVG_DIMENSION = 4_096
MAX_SVG_PIXELS = 16_777_216
THUMBNAIL_PROCESS_TIMEOUT = 5.seconds
def self.resized_thumbnail_attachment(upload, content_type: nil)
upload.rewind
image = MiniMagick::Image.read(upload.read)
image.resize '180x180'
image.format 'jpg'
bytes = upload.read
blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
image = image_for_thumbnail_upload(bytes, content_type:)
image.auto_orient
image.resize '180x180'
image.format 'jpg'
image.to_blob
end
{ io: StringIO.new(image.to_blob),
{ io: StringIO.new(blob),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
rescue Timeout::Error
raise MiniMagick::Error, 'サムネイル画像の変換に失敗しました.'
ensure
upload.rewind
end
def self.remote_thumbnail_attachment(raw_url)
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
resized_thumbnail_attachment(
StringIO.new(response.body),
content_type: response.content_type)
rescue Preview::UrlSafety::UnsafeUrl,
Preview::ThumbnailFetcher::GenerationFailed,
Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge,
Timeout::Error,
MiniMagick::Error => e
raise RemoteThumbnailFetchFailed, e.message
end
belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post
@@ -101,7 +138,10 @@ class Post < ApplicationRecord
end
def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
end_ms =
section.end_ms ? Post.ms_to_time(section.end_ms) : ''
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
end
def self.ms_to_time ms
@@ -118,7 +158,11 @@ class Post < ApplicationRecord
'%d:%02d' % [min, s]
end
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
if remainder_ms.positive?
"#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }"
else
base
end
end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
@@ -140,19 +184,26 @@ class Post < ApplicationRecord
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
end
def attach_thumbnail_from_url! raw_url
thumbnail.attach(self.class.remote_thumbnail_attachment(raw_url))
end
private
def validate_original_created_range
f = original_created_from
b = original_created_before
return if f.blank? || b.blank?
return if skip_original_created_validation?
f = Time.zone.parse(f) if String === f
b = Time.zone.parse(b) if String === b
return if !(f) || !(b)
f = parse_original_created_value(:original_created_from)
b = parse_original_created_value(:original_created_before)
return if f.nil? || b.nil?
if f >= b
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.'
if b <= f
errors.add :original_created_at, ORIGINAL_CREATED_ORDER_MESSAGE
return
end
if b - f < 1.minute
errors.add :original_created_at, ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE
end
end
@@ -175,4 +226,305 @@ class Post < ApplicationRecord
self.url = PostUrlNormaliser.normalise(url) || url.strip
end
def self.image_for_thumbnail_upload(bytes, content_type: nil)
if svg_content_type?(content_type) || svg_document_bytes?(bytes)
return decode_svg_thumbnail(bytes)
end
raise MiniMagick::Error, 'サムネイル画像の形式が不正です.' unless raster_thumbnail_bytes?(bytes)
decode_raster_thumbnail(bytes)
end
def self.raster_thumbnail_bytes?(bytes)
raster_thumbnail_format(bytes).present?
end
def self.remote_thumbnail_image_bytes?(bytes, content_type: nil)
return true if svg_content_type?(content_type) || svg_document_bytes?(bytes)
raster_thumbnail_bytes?(bytes)
end
def self.svg_content_type?(content_type)
content_type.to_s.split(';', 2).first.to_s.downcase.strip == REMOTE_SVG_CONTENT_TYPE
end
def self.svg_document_bytes?(bytes)
document = Nokogiri::XML(
bytes,
nil,
nil,
Nokogiri::XML::ParseOptions::STRICT |
Nokogiri::XML::ParseOptions::NONET)
document.root&.name == 'svg'
rescue Nokogiri::XML::SyntaxError
false
end
def self.decode_raster_thumbnail(bytes)
MiniMagick::Image.read(bytes)
end
def self.decode_svg_thumbnail(bytes)
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
end
def self.raster_thumbnail_format(bytes)
binary = bytes.to_s.b
return 'jpeg' if binary.start_with?("\xFF\xD8\xFF".b)
return 'png' if binary.start_with?("\x89PNG\r\n\x1A\n".b)
return 'gif' if binary.start_with?('GIF87a'.b) || binary.start_with?('GIF89a'.b)
return 'webp' if binary.bytesize >= 12 &&
binary.start_with?('RIFF'.b) &&
binary.byteslice(8, 4) == 'WEBP'
nil
end
def self.sanitised_svg_bytes(bytes)
parse_options =
Nokogiri::XML::ParseOptions::STRICT |
Nokogiri::XML::ParseOptions::NONET
document = Nokogiri::XML(
bytes,
nil,
nil,
parse_options)
root = document.root
raise MiniMagick::Error, 'SVG が不正です.' if root == nil || root.name != 'svg'
raise MiniMagick::Error, 'SVG が不正です.' if document.internal_subset != nil
raise MiniMagick::Error, 'SVG が不正です.' if svg_uses_disallowed_features?(document)
width, height = svg_dimensions(root)
raise MiniMagick::Error, 'SVG が大きすぎます.' if width == nil || height == nil
if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION || width * height > MAX_SVG_PIXELS
raise MiniMagick::Error, 'SVG が大きすぎます.'
end
document.to_xml
rescue Nokogiri::XML::SyntaxError
raise MiniMagick::Error, 'SVG が不正です.'
end
def self.svg_uses_disallowed_features?(document)
document.traverse.any? do |node|
next false unless node.element?
name = node.name.to_s.downcase
next true if name == 'script' || name == 'foreignobject'
next style_contains_disallowed_urls?(node.text.to_s) if name == 'style'
node.attribute_nodes.any? do |attribute|
attribute_name = attribute.name.to_s.downcase
attribute_value = attribute.value.to_s
attribute_name.start_with?('on') ||
external_svg_reference?(attribute_name, attribute_value)
end
end
end
def self.external_svg_reference?(attribute_name, attribute_value)
if ['href', 'xlink:href', 'src'].include?(attribute_name)
return external_svg_url?(attribute_value)
end
return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
false
end
def self.external_svg_url?(value)
stripped = value.to_s.strip
return false if stripped.blank? || stripped.start_with?('#')
true
end
def self.style_contains_disallowed_urls?(value)
text = value.to_s
text.match?(/@import/i) || svg_url_function_disallowed?(text)
end
def self.svg_url_function_disallowed?(value)
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
reference =
entry.to_s.strip
.delete_prefix("'")
.delete_prefix('"')
.delete_suffix("'")
.delete_suffix('"')
reference.present? && !(reference.start_with?('#'))
end
end
def self.svg_dimensions(root)
width = svg_length_to_pixels(root['width'])
height = svg_length_to_pixels(root['height'])
return [width, height] if width && height
view_box = root['viewBox'].to_s.strip.split(/\s+/).map { Float(_1) rescue nil }
return [nil, nil] if view_box.length != 4 || view_box.any?(&:nil?)
return [nil, nil] unless view_box[2].finite? && view_box[2].positive?
return [nil, nil] unless view_box[3].finite? && view_box[3].positive?
[view_box[2], view_box[3]]
end
def self.svg_length_to_pixels(value)
return nil if value.blank?
matched = /\A([0-9]+(?:\.[0-9]+)?)(px)?\z/i.match(value.to_s.strip)
return nil if matched == nil
pixels = Float(matched[1])
return nil unless pixels.finite? && pixels.positive?
pixels
rescue ArgumentError
nil
end
private_class_method :image_for_thumbnail_upload,
:svg_content_type?,
:decode_raster_thumbnail,
:decode_svg_thumbnail,
:raster_thumbnail_format,
:sanitised_svg_bytes,
:svg_uses_disallowed_features?,
:external_svg_reference?,
:external_svg_url?,
:style_contains_disallowed_urls?,
:svg_url_function_disallowed?,
:svg_dimensions,
:svg_length_to_pixels
def parse_original_created_value field
raw_value = public_send("#{ field }_before_type_cast")
value = public_send(field)
return nil if raw_value.blank? && value.blank?
time =
case raw_value
when String
parse_original_created_string(raw_value)
when Time, ActiveSupport::TimeWithZone
raw_value.in_time_zone
else
value&.in_time_zone
end
if time.nil?
errors.add field, ORIGINAL_CREATED_INVALID_MESSAGE
return nil
end
unless minute_precision_time?(time)
errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE
return nil
end
time
end
def parse_original_created_string raw_value
value = raw_value.to_s.strip
return nil if value.blank?
match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/)
if 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
return nil unless valid_original_created_components?(year, month, day, hour, minute, 0)
return Time.zone.local(year, month, day, hour, minute)
end
match =
value.match(
/
\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})
(?::(\d{2})(?:\.(\d+))?)?
(Z|[+-]\d{2}:?\d{2})?
\z
/x)
return nil if match.nil?
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
second = match[6]&.to_i || 0
fraction = match[7]
offset = match[8]
return nil unless valid_original_created_components?(year, month, day, hour, minute, second)
return nil if offset.present? && !(valid_original_created_offset?(offset))
if offset.present?
return Time.new(
year,
month,
day,
hour,
minute,
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
normalise_original_created_offset(offset)).in_time_zone
end
Time.zone.local(
year,
month,
day,
hour,
minute,
second).change(nsec: parse_original_created_nanoseconds(fraction))
rescue ArgumentError, TypeError
nil
end
def minute_precision_time? value
value.sec.zero? && value.nsec.zero?
end
def valid_original_created_components? year, month, day, hour, minute, second
return false unless Date.valid_date?(year, month, day)
return false unless hour.between?(0, 23)
return false unless minute.between?(0, 59)
return false unless second.between?(0, 59)
true
end
def valid_original_created_offset? value
match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
return true if value == 'Z'
return false if match.nil?
hours = match[2].to_i
minutes = match[3].to_i
hours.between?(0, 23) && minutes.between?(0, 59)
end
def parse_original_created_nanoseconds value
return 0 if value.blank?
digits = value[0, 9].ljust(9, '0')
Integer(digits, 10)
end
def normalise_original_created_offset value
return '+00:00' if value == 'Z'
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
end
def skip_original_created_validation?
return false if new_record?
return false if will_save_change_to_original_created_from?
return false if will_save_change_to_original_created_before?
true
end
end
+19
ファイルの表示
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module PostCompactRepr
module_function
def base post, host: nil
return nil if post.nil?
PostRepr
.common(post, host:)
.slice(
'id',
'title',
'url',
'thumbnail',
'thumbnail_base')
end
end
+65 -21
ファイルの表示
@@ -17,8 +17,13 @@ module PostRepr
module_function
def base post, current_user = nil
json = common(post)
def base post, current_user = nil, host: nil
json =
if host.present?
common(post, host:)
else
common(post)
end
json['tags'] = tag_json(post)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
@@ -26,31 +31,59 @@ module PostRepr
end
def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: []
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
sibling_posts: { }, related: [], host: nil
if host.present?
base(post, current_user, host:).merge(
'parent_posts' => cards(parent_posts, host:),
'child_posts' => cards(child_posts, host:),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts, host:)
},
'related' => cards(related, host:))
else
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
end
end
def card post
common(post).merge('parent_posts' => [], 'child_posts' => [])
def card post, host: nil
if host.present?
common(post, host:).merge('parent_posts' => [], 'child_posts' => [])
else
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
end
def cards posts
posts.map { |post| card(post) }
def cards posts, host: nil
if host.present?
posts.map { |post| card(post, host:) }
else
posts.map { |post| card(post) }
end
end
def many posts, current_user = nil
posts.map { |p| base(p, current_user) }
def many posts, current_user = nil, host: nil
if host.present?
posts.map { |p| base(p, current_user, host:) }
else
posts.map { |p| base(p, current_user) }
end
end
def common post
def common post, host: nil
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge('thumbnail' => thumbnail_url(post))
.merge(
'thumbnail' =>
if host.present?
thumbnail_url(post, host:)
else
thumbnail_url(post)
end)
end
def tag_json post
@@ -65,11 +98,22 @@ module PostRepr
}
end
def thumbnail_url post
def thumbnail_url post, host: nil
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
rescue
options = { only_path: false }
options[:host] = host if host.present?
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
payload = {
post_id: post.id,
attachment_id: post.thumbnail.attachment&.id,
blob_id: post.thumbnail.blob&.id,
error_class: e.class,
message: e.message }
Rails.logger.warn("PostRepr.thumbnail_url failed #{ payload.to_json }")
nil
end
end
+202
ファイルの表示
@@ -0,0 +1,202 @@
class PostBulkCreator
def initialize actor:, posts:, thumbnails:, host: nil
@actor_id = actor.id
@posts = posts
@thumbnails = thumbnails
@host = host
end
def run
results = Array.new(@posts.length)
mutex = Mutex.new
next_index = 0
workers = Array.new(2) do
Thread.new do
Rails.application.executor.wrap do
ActiveRecord::Base.connection_pool.with_connection do
actor = User.find(@actor_id)
loop do
index = nil
begin
index = mutex.synchronize do
current = next_index
next_index += 1
current
end
break if index >= @posts.length
attributes = @posts[index]
results[index] = create_row(actor, attributes, index)
rescue StandardError => e
Rails.logger.error(
"post_bulk_creator_worker_failure #{ { error: e.class.name,
message: e.message,
index: }.to_json }")
results[index] = {
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
end
end
end
end
end
end
workers.each(&:join)
results.each_index do |index|
next if results[index].present?
results[index] = {
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
end
{ results: }
end
private
def create_row actor, attributes, index
preflight =
PostCreatePreflight.new(
attributes: attributes,
thumbnail: thumbnail_for(index, attributes),
host: @host).run
if preflight[:existing_post_id].present?
return {
status: 'skipped',
existing_post_id: preflight[:existing_post_id],
existing_post: preflight[:existing_post] }
end
post = PostCreator.new(
actor: actor,
attributes: normalised_attributes(attributes, preflight, index)).create!
result = {
status: 'created',
post: { id: post.id } }
result[:field_warnings] = preflight[:field_warnings] if preflight[:field_warnings].present?
result[:base_warnings] = preflight[:base_warnings] if preflight[:base_warnings].present?
result
rescue PostCreatePreflight::ValidationFailed => e
{
status: 'failed',
recoverable: true,
errors: e.fields,
base_errors: e.base_errors }
rescue ActiveRecord::RecordInvalid => e
existing_post = existing_post_for_race(attributes, e.record)
if existing_post.present?
return {
status: 'skipped',
existing_post_id: existing_post[:id],
existing_post: existing_post }
end
{
status: 'failed',
recoverable: true,
errors: e.record.errors.to_hash,
base_errors: e.record.errors[:base] }
rescue ActiveRecord::RecordNotUnique => e
if e.message.include?('index_posts_on_url')
existing_post = existing_post_for_race(attributes)
return {
status: 'skipped',
existing_post_id: existing_post[:id],
existing_post: existing_post } if existing_post.present?
end
Rails.logger.error(
"post_bulk_creator_record_not_unique #{ { error: e.class.name,
message: e.message }.to_json }")
{
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
rescue Tag::NicoTagNormalisationError
{
status: 'failed',
recoverable: true,
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
base_errors: [] }
rescue Tag::DeprecatedTagNormalisationError
{
status: 'failed',
recoverable: true,
errors: { tags: ['廃止済みタグは付与できません.'] },
base_errors: [] }
rescue PostCreator::VideoMsParseError
{
status: 'failed',
recoverable: true,
errors: { video_ms: ['動画時間の記法が不正です.'] },
base_errors: [] }
rescue Post::RemoteThumbnailFetchFailed
{
status: 'failed',
recoverable: true,
errors: { thumbnail_base: ['サムネイル画像の取得に失敗しました.'] },
base_errors: [] }
rescue ArgumentError => e
{
status: 'failed',
recoverable: true,
errors: { base: [e.message] },
base_errors: [] }
rescue StandardError => e
Rails.logger.error(
"post_bulk_creator_failure #{ { error: e.class.name,
message: e.message }.to_json }")
{
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
end
def normalised_attributes attributes, preflight, index
{
url: preflight[:url],
title: preflight[:title],
thumbnail_base: preflight[:thumbnail_base],
thumbnail: thumbnail_for(index, attributes),
tags: preflight[:tags],
parent_post_ids: preflight[:parent_post_ids],
original_created_from: preflight[:original_created_from],
original_created_before: preflight[:original_created_before],
duration: preflight[:duration],
video_ms: preflight[:video_ms],
direct_tag_specs: preflight[:direct_tag_specs],
default_tag_specs: preflight[:default_tag_specs],
snapshot_tag_specs: preflight[:snapshot_tag_specs],
post_tag_specs: preflight[:post_tag_specs],
tag_sections: preflight[:tag_sections],
normalised_parent_post_ids: preflight[:normalised_parent_post_ids] }
end
def thumbnail_for index, attributes
return nil if attributes['thumbnail_base'].present? || attributes[:thumbnail_base].present?
@thumbnails[index]
end
def existing_post_for_race attributes, record = nil
return nil if record.present? && !(record.errors.of_kind?(:url, :taken))
normal_url = PostUrlNormaliser.normalise(attributes['url'] || attributes[:url])
return nil if normal_url.blank?
compact_existing_post(Post.with_attached_thumbnail.find_by(url: normal_url))
end
def compact_existing_post post
PostCompactRepr.base(post, host: @host)
end
end
+271
ファイルの表示
@@ -0,0 +1,271 @@
class PostCreatePlan
VIDEO_TAG_NAME = '動画'.freeze
TAGME_TAG_NAME = 'タグ希望'.freeze
NO_DEERJIKIST_TAG_NAME = 'ニジラー情報不詳'.freeze
def initialize attributes:
@attributes = attributes.symbolize_keys
@existing_tags_by_name = nil
end
def build!
direct_tag_specs, tag_sections = parse_direct_tag_specs
default_tag_specs = build_default_tag_specs(direct_tag_specs)
snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs)
preload_existing_tags_by_name!(snapshot_tag_specs.map { _1[:name] })
validate_new_tag_specs!(snapshot_tag_specs)
post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs)
video_ms = normalise_video_ms(snapshot_tag_specs)
validate_video_sections!(video_ms, tag_sections)
parent_post_ids = normalise_parent_post_ids
validate_parent_post_ids!(parent_post_ids)
{
url: @attributes[:url],
title: @attributes[:title].to_s,
thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence,
tags: serialised_tags(direct_tag_specs, tag_sections),
display_tags: display_tags(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s,
video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '),
direct_tag_specs: direct_tag_specs,
default_tag_specs: default_tag_specs,
snapshot_tag_specs: snapshot_tag_specs,
post_tag_specs: post_tag_specs,
tag_sections: tag_sections,
normalised_parent_post_ids: parent_post_ids }
end
private
def tag_names = @attributes[:tags].to_s.split
def parse_direct_tag_specs
tag_sections = { }
direct_tag_specs = []
tag_names.each do |raw_name|
tag_name, category, sections = parse_raw_tag_name(raw_name)
existing_tag = existing_tags_by_name[tag_name]
raise Tag::NicoTagNormalisationError if existing_tag&.nico?
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated?
direct_tag_specs << {
name: tag_name,
category: (category || existing_tag&.category || 'general').to_sym }
if sections.present?
tag_sections[tag_name] ||= []
tag_sections[tag_name].concat(sections)
tag_sections[tag_name] = Tag.merge_section_ranges(tag_sections[tag_name])
tag_sections.delete(tag_name) if tag_sections[tag_name] == [[0, nil]]
end
end
[merge_tag_specs(direct_tag_specs), tag_sections]
end
def parse_raw_tag_name raw_name
name = raw_name.to_s
prefix, category =
Tag::CATEGORY_PREFIXES.find {
name.downcase.start_with?(_1[0])
} || ['', nil]
name = name.sub(/\A#{ prefix }/i, '')
sections = []
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
sections << Tag.normalise_section_range!(
begin_raw: match[2],
end_raw: match[3],
tag_name: name)
end
if name.include?('[') || name.include?(']')
raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
end
[resolved_tag_name(name), category&.to_sym, sections]
end
def build_default_tag_specs direct_tag_specs
default_tag_specs = []
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
default_tag_specs << {
name: TAGME_TAG_NAME,
category: :meta }
end
if direct_tag_specs.none? { deerjikist_tag_spec?(_1) }
default_tag_specs << {
name: NO_DEERJIKIST_TAG_NAME,
category: :meta }
end
default_tag_specs
end
def validate_new_tag_specs! specs
Array(specs).each do |spec|
next if existing_tags_by_name.key?(spec[:name])
validate_new_tag_spec!(spec)
end
end
def validate_new_tag_spec! spec
tag_name = TagName.new(name: spec[:name])
tag = Tag.new(category: spec[:category], tag_name:)
return if tag_name.valid? && tag.valid?
post = Post.new
tag_name.errors[:name].each do |message|
post.errors.add :tags, "#{ spec[:name] }: #{ message }"
end
tag.errors.each do |error|
next if error.attribute == :tag_name
post.errors.add :tags, "#{ spec[:name] }: #{ error.message }"
end
raise ActiveRecord::RecordInvalid, post
end
def expand_parent_tag_specs snapshot_tag_specs
existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] }
expanded_parent_specs =
Tag.expand_parent_tags(existing_snapshot_tags)
.reject(&:deprecated?)
.map { |tag|
{
name: tag.name,
category: tag.category.to_sym } }
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
end
def merge_tag_specs specs
specs.each_with_object({ }) do |spec, merged|
merged[spec[:name]] =
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
merged[spec[:name]]
else
{
name: spec[:name],
category: spec[:category] }
end
end.values.sort_by { _1[:name] }
end
def existing_tags_by_name
@existing_tags_by_name ||= begin
names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq
Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name)
end
end
def preload_existing_tags_by_name! names
wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq
missing_names = wanted_names - existing_tags_by_name.keys
return if missing_names.empty?
existing_tags_by_name.merge!(
Tag.joins(:tag_name)
.where(tag_names: { name: missing_names })
.index_by(&:name))
end
def canonical_tag_name_without_sections raw_name
name, = parse_raw_tag_name(raw_name)
name
end
def deerjikist_tag_spec? spec
return true if spec[:category] == :deerjikist
existing_tags_by_name[spec[:name]]&.deerjikist?
end
def normalise_parent_post_ids
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id
}.uniq.sort
end
def validate_parent_post_ids! ids
missing = ids - Post.where(id: ids).pluck(:id)
raise ArgumentError, "存在しない親投稿 Id. があります: #{ missing.join(' ') }" if missing.present?
end
def serialised_tags direct_tag_specs, tag_sections
direct_tag_specs.map { |spec|
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { section_literal(_1) }.join }"
}.sort.join(' ')
end
def display_tags direct_tag_specs, tag_sections
direct_tag_specs.map { |spec|
{
name: spec[:name],
category: spec[:category].to_s,
section_literals: tag_sections[spec[:name]].to_a.map { section_literal(_1) } }
}.sort_by { _1[:name] }
end
def section_literal range
begin_ms, end_ms = range
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
end
def normalise_video_ms snapshot_tag_specs
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
video_ms = @attributes[:video_ms]
if video_ms.present?
value = Integer(video_ms, exception: false)
raise PostCreator::VideoMsParseError unless value&.positive?
return value
end
duration = @attributes[:duration]
return nil if duration.blank?
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
raise PostCreator::VideoMsParseError unless value.positive?
value
rescue Tag::SectionLiteralParseError
raise PostCreator::VideoMsParseError
end
def validate_video_sections! video_ms, tag_sections
return unless video_ms
tag_sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
if begin_ms >= video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
end
end
end
def resolved_tag_name name
tag_name = TagName.includes(:canonical).find_by(name:)
return name if tag_name.nil?
(tag_name.canonical || tag_name).name
end
end
+141
ファイルの表示
@@ -0,0 +1,141 @@
class PostCreatePreflight
class ValidationFailed < StandardError
attr_reader :fields, :base_errors
def initialize fields: { }, base_errors: []
super('入力内容を確認してください.')
@fields = fields
@base_errors = base_errors
end
end
def initialize attributes:, thumbnail: nil, host: nil
@attributes = attributes.symbolize_keys
@thumbnail = thumbnail
@host = host
end
def run
preview = PostImportPreviewer.new.preview_rows(
rows: [preview_row],
fetch_metadata: false).first
if preview[:existing_post_id].present?
return {
url: preview[:url],
title: preview[:attributes]['title'],
thumbnail_base: preview[:attributes]['thumbnail_base'],
tags: preview[:attributes]['tags'],
parent_post_ids: preview[:attributes]['parent_post_ids'],
original_created_from: preview[:attributes]['original_created_from'],
original_created_before: preview[:attributes]['original_created_before'],
duration: preview[:attributes]['duration'],
video_ms: preview[:attributes]['video_ms'],
display_tags: preview[:display_tags] || [],
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings],
existing_post_id: preview[:existing_post_id],
existing_post: existing_post_compact(preview[:existing_post_id]) }
end
if preview[:validation_errors].present?
raise ValidationFailed.new(fields: preview[:validation_errors])
end
validate_thumbnail_upload!
plan = PostCreatePlan.new(
attributes: {
url: preview[:url],
title: preview[:attributes]['title'],
thumbnail_base: preview[:attributes]['thumbnail_base'],
tags: preview[:attributes]['tags'],
parent_post_ids: preview[:attributes]['parent_post_ids'],
original_created_from: preview[:attributes]['original_created_from'],
original_created_before: preview[:attributes]['original_created_before'],
duration: preview[:attributes]['duration'],
video_ms: preview[:attributes]['video_ms'] }).build!
{
url: plan[:url],
title: plan[:title],
thumbnail_base: plan[:thumbnail_base],
tags: plan[:tags],
parent_post_ids: plan[:parent_post_ids],
original_created_from: plan[:original_created_from],
original_created_before: plan[:original_created_before],
duration: plan[:duration],
video_ms: plan[:video_ms],
display_tags: plan[:display_tags],
direct_tag_specs: plan[:direct_tag_specs],
default_tag_specs: plan[:default_tag_specs],
snapshot_tag_specs: plan[:snapshot_tag_specs],
post_tag_specs: plan[:post_tag_specs],
tag_sections: plan[:tag_sections],
normalised_parent_post_ids: plan[:normalised_parent_post_ids],
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings],
existing_post_id: preview[:existing_post_id],
existing_post: existing_post_compact(preview[:existing_post_id]) }
end
private
def preview_row
{
source_row: 1,
url: @attributes[:url].to_s,
attributes: {
'title' => @attributes[:title].to_s,
'thumbnail_base' => @attributes[:thumbnail_base].to_s,
'original_created_from' => @attributes[:original_created_from].to_s,
'original_created_before' => @attributes[:original_created_before].to_s,
'duration' => @attributes[:duration].to_s,
'video_ms' => @attributes[:video_ms],
'tags' => @attributes[:tags].to_s,
'parent_post_ids' => parent_post_ids_text },
provenance: {
'url' => 'manual',
'title' => 'manual',
'thumbnail_base' => 'manual',
'original_created_from' => 'manual',
'original_created_before' => 'manual',
'duration' => 'manual',
'video_ms' => 'manual',
'tags' => 'manual',
'parent_post_ids' => 'manual' },
tag_sources: {
'automatic' => '',
'manual' => @attributes[:tags].to_s } }
end
def parent_post_ids_text
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.join(' ')
end
def validate_thumbnail_upload!
return if @attributes[:thumbnail_base].present?
return if @thumbnail.blank?
PostThumbnailUploadValidator.validate!(@thumbnail)
rescue PostThumbnailUploadValidator::InvalidUpload => e
raise ValidationFailed.new(fields: { thumbnail: [e.message] })
end
def existing_post_compact post_id
return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post, host: @host)
end
def final_field_warnings field_warnings
thumbnail_warnings = (field_warnings['thumbnail_base'] || []).reject { _1 == 'サムネールなし' }
if @attributes[:thumbnail_base].blank? && @thumbnail.blank?
thumbnail_warnings = (thumbnail_warnings + ['サムネールなし']).uniq
end
next_warnings = field_warnings.except('thumbnail_base')
next_warnings['thumbnail_base'] = thumbnail_warnings if thumbnail_warnings.present?
next_warnings
end
end
+82 -50
ファイルの表示
@@ -1,91 +1,123 @@
class PostCreator
class VideoMsParseError < ArgumentError; end
attr_reader :field_warnings
def initialize actor:, attributes:
@actor = actor
@attributes = attributes.symbolize_keys
@field_warnings = { }
end
def create!
thumbnail_attachment = prepare_thumbnail_attachment
post = Post.new(title: @attributes[:title].presence,
url: @attributes[:url],
thumbnail_base: @attributes[:thumbnail_base].presence,
uploaded_user: @actor,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence)
thumbnail = @attributes[:thumbnail]
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) if thumbnail.present?
ApplicationRecord.transaction do
post.save!
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = normalise_video_ms(tags)
validate_video_sections!(post.video_ms, sections)
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
snapshot_tags = planned_snapshot_tags
post_tags = planned_post_tags
sections = planned_sections
TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
post.video_ms = planned_video_ms
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
sync_post_tags!(post, post_tags, sections)
sync_parent_posts!(post, planned_parent_post_ids)
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
end
post
rescue StandardError
post&.thumbnail&.purge if post&.thumbnail&.attached?
raise
end
private
def tag_names = @attributes[:tags].to_s.split
def parent_post_ids
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id
}.uniq
def prepare_thumbnail_attachment
PostThumbnailAttachmentBuilder.build(
thumbnail: @attributes[:thumbnail],
thumbnail_base: @attributes[:thumbnail_base].presence)
end
def normalise_video_ms tags
return nil unless tags.any? { _1.id == Tag.video.id }
def planned_snapshot_tags = planned_create_attributes[:snapshot_tags]
video_ms = @attributes[:video_ms]
if video_ms.present?
value = Integer(video_ms, exception: false)
raise VideoMsParseError unless value&.positive?
def planned_post_tags = planned_create_attributes[:post_tags]
return value
end
duration = @attributes[:duration]
return nil if duration.blank?
def planned_sections = planned_create_attributes[:tag_sections]
return duration.to_i if duration.is_a?(Numeric) && duration.to_i.positive?
def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids]
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
raise VideoMsParseError unless value.positive?
value
rescue Tag::SectionLiteralParseError
raise VideoMsParseError
def planned_video_ms
planned_create_attributes[:video_ms]
end
def validate_video_sections! video_ms, sections
return unless video_ms
sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
post = Post.new
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
def planned_create_attributes
@planned_create_attributes ||= begin
if @attributes.key?(:snapshot_tag_specs)
snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || [])
post_tags = materialise_tags(@attributes[:post_tag_specs] || [])
{
snapshot_tags: snapshot_tags,
post_tags: post_tags,
tag_sections: materialise_sections(
@attributes[:tag_sections] || { },
snapshot_tags,
post_tags),
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
video_ms: @attributes[:video_ms] }
else
build_materialised_plan
end
end
end
def build_materialised_plan
plan = PostCreatePlan.new(attributes: @attributes).build!
snapshot_tags = materialise_tags(plan[:snapshot_tag_specs] || [])
post_tags = materialise_tags(plan[:post_tag_specs] || [])
{
snapshot_tags: snapshot_tags,
post_tags: post_tags,
tag_sections: materialise_sections(
plan[:tag_sections] || { },
snapshot_tags,
post_tags),
normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [],
video_ms: plan[:video_ms] }
end
def materialise_tags specs
Array(specs).each_with_object({ }) do |spec, tags|
name = spec[:name] || spec['name']
category = spec[:category] || spec['category']
next if name.blank? || category.blank?
tag = Tag.find_or_create_by_tag_name!(name, category:)
tag.update!(category:) if tag.category.to_sym != category.to_sym
tags[name] ||= tag
end.values
end
def materialise_sections sections_by_name, snapshot_tags, post_tags
tags_by_name = post_tags.index_by(&:name)
snapshot_tags.each do |tag|
tags_by_name[tag.name] ||= tag
end
sections_by_name.each_with_object({ }) do |(tag_name, ranges), sections|
tag = tags_by_name[tag_name.to_s]
next if tag.nil?
sections[tag.id] = Array(ranges).map { |range| [range[0], range[1]] }
end
end
def sync_post_tags! post, desired_tags, sections
desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set
+71 -34
ファイルの表示
@@ -3,17 +3,18 @@ require 'timeout'
class PostImportPreviewer
FIELDS = [
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'duration',
'tags',
'parent_post_ids'].freeze
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'video_ms',
'duration',
'tags',
'parent_post_ids'].freeze
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze
METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
prepared_rows = rows.map { prepare_row(_1) }
@@ -125,7 +126,6 @@ class PostImportPreviewer
validate_basic_data(attributes, validation_errors)
validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
validation_errors,
field_warnings,
known_tags)
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
attributes.delete('url')
@@ -151,13 +151,23 @@ class PostImportPreviewer
when true then true
when Integer then fetch_metadata == source_row
when false, nil then false
else raise ArgumentError, 'メタデータ取得対象が不正です.'
else raise ArgumentError, '取得対象が不正です.'
end
end
def initial_attributes row
attributes = row[:attributes]&.stringify_keys || { }
FIELDS.to_h { |field| [field, attributes[field].to_s] }
FIELDS.to_h { |field|
value = attributes[field]
normalised =
if field == 'duration'
normalise_duration_attribute(value)
elsif field == 'video_ms'
value.nil? ? '' : value.to_s
else
value.to_s
end
[field, normalised] }
end
def initial_provenance row
@@ -186,7 +196,7 @@ class PostImportPreviewer
def clear_automatic_values! attributes, provenance, tag_sources
['title', 'thumbnail_base', 'original_created_from',
'original_created_before', 'duration'].each do |field|
'original_created_before', 'video_ms', 'duration'].each do |field|
attributes[field] = '' if provenance[field] == 'automatic'
end
tag_sources['automatic'] = ''
@@ -215,15 +225,15 @@ class PostImportPreviewer
end
{ data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_failure #{ payload.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
@@ -244,22 +254,40 @@ class PostImportPreviewer
def sanitise_metadata_url value
return nil unless value.is_a?(String)
PostUrlNormaliser.normalise(value)
stripped = value.strip
return nil if stripped.blank?
uri = URI.parse(stripped)
return nil unless uri.is_a?(URI::HTTP) && uri.host.present?
stripped
rescue URI::InvalidURIError
nil
end
def sanitise_metadata_time value
return nil unless value.is_a?(String)
time = Time.iso8601(value)
time.nsec.zero? ? time.iso8601 : time.iso8601(9)
Time.iso8601(value).in_time_zone.change(sec: 0, nsec: 0).iso8601
rescue ArgumentError, TypeError
nil
end
def sanitise_metadata_duration value
return nil unless value.is_a?(Numeric)
return nil unless value.is_a?(String)
value.positive? ? value.to_i : nil
value.presence
end
def normalise_duration_attribute value
return '' if value.nil?
return value if value.is_a?(String)
milliseconds = Integer(value, exception: false)
return value.to_s if milliseconds.nil? || milliseconds <= 0
seconds_string = (milliseconds / 1_000.0).to_s
seconds_string.end_with?('.0') ? seconds_string.delete_suffix('.0') : seconds_string
end
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
@@ -307,14 +335,14 @@ class PostImportPreviewer
def safe_fetch_metadata url
fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue StandardError => e
payload = { error: e.class.name, message: e.message }
Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_unexpected_failure #{ payload.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
@@ -421,7 +449,7 @@ class PostImportPreviewer
[]
end
def validate_preview_tags raw, errors, field_warnings, known_tags
def validate_preview_tags raw, errors, known_tags
names = raw.to_s.split
return if names.empty?
if names.any? { _1.downcase.start_with?('nico:') }
@@ -433,11 +461,6 @@ class PostImportPreviewer
existing = parsed.filter_map { known_tags[_1] }
deprecated = existing.select(&:deprecated?).map(&:name)
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
known = existing.reject(&:deprecated?).map(&:name)
new_tags = parsed.uniq - known
if new_tags.present?
add_field_warning!(field_warnings, 'tags', "新規タグを作成します: #{ new_tags.join(' ') }")
end
rescue Tag::SectionLiteralParseError
errors[:tags] = ['タグ区間の記法が不正です.']
end
@@ -448,7 +471,7 @@ class PostImportPreviewer
thumbnail_base: attributes['thumbnail_base'].presence,
original_created_from: attributes['original_created_from'].presence,
original_created_before: attributes['original_created_before'].presence,
video_ms: parse_duration(attributes['duration'], errors))
video_ms: parse_video_ms(attributes, errors))
post.valid?
post.errors.each do |error|
next if error.attribute == :url && error.type == :taken
@@ -457,10 +480,24 @@ class PostImportPreviewer
end
end
def parse_duration value, errors
def parse_video_ms attributes, errors
return nil unless attributes['tags'].to_s.split.include?('動画')
video_ms = attributes['video_ms']
if video_ms.present?
value = Integer(video_ms, exception: false)
if value&.positive?
return value
end
errors[:video_ms] = ['動画時間の記法が不正です.']
return nil
end
value = attributes['duration']
return nil if value.blank?
value.is_a?(Numeric) ? value.to_i : Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
rescue Tag::SectionLiteralParseError
errors[:video_ms] = ['動画時間の記法が不正です.']
nil
-189
ファイルの表示
@@ -1,189 +0,0 @@
class PostImportRowNormaliser
ORIGINS = ['automatic', 'manual'].freeze
STRING_FIELDS = [
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'tags',
'parent_post_ids'].freeze
FLEXIBLE_FIELDS = ['duration', 'video_ms'].freeze
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
def self.normalise! rows, allow_warning_fields: false
raise ArgumentError, '取込行の形式が不正です.' unless rows.is_a?(Array)
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportUrlListParser::MAX_ROWS
normalised_rows = rows.map { normalise_row!(_1, allow_warning_fields:) }
source_rows = normalised_rows.map { _1['source_row'] }
raise ArgumentError, '元行番号が重複しています.' if source_rows.uniq.length != source_rows.length
if normalised_rows.sum { row_bytesize(_1) } > PostImportUrlListParser::MAX_BYTES
raise ArgumentError, '取込データが大きすぎます.'
end
normalised_rows
end
def self.normalise_row! row, allow_warning_fields:
unless row.is_a?(Hash) || row.is_a?(ActionController::Parameters)
raise ArgumentError, '取込行の形式が不正です.'
end
parameters =
row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row)
permitted = parameters.permit(*permitted_keys(allow_warning_fields))
normalised = permitted.to_h.deep_transform_keys { _1.to_s.underscore }
normalised['source_row'] = normalise_source_row!(normalised['source_row'])
normalise_url!(normalised['url'])
normalise_metadata_url!(normalised['metadata_url'])
normalise_attributes!(normalised.fetch('attributes', { }))
normalise_provenance!(normalised.fetch('provenance', { }))
normalise_tag_sources!(normalised['tag_sources'])
normalise_warning_values!(normalised, allow_warning_fields:)
if row_bytesize(normalised) > PostImportUrlListParser::MAX_BYTES
raise ArgumentError, '取込行が大きすぎます.'
end
normalised
end
def self.permitted_keys allow_warning_fields
keys = [
:source_row,
:sourceRow,
:url,
:metadata_url,
:metadataUrl,
{ attributes: {} },
{ provenance: {} },
{ tag_sources: {} },
{ tagSources: {} }]
return keys unless allow_warning_fields
keys + [
{ field_warnings: {} },
{ fieldWarnings: {} },
{ base_warnings: [] },
{ baseWarnings: [] }]
end
private_class_method :permitted_keys
def self.normalise_source_row! value
source_row = Integer(value, exception: false)
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
source_row
end
private_class_method :normalise_source_row!
def self.normalise_url! value
unless value.is_a?(String)
raise ArgumentError, 'URL の形式が不正です.'
end
if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'URL が長すぎます.'
end
end
private_class_method :normalise_url!
def self.normalise_metadata_url! value
raise ArgumentError, 'metadata_url の形式が不正です.' unless value.nil? || value.is_a?(String)
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'metadata_url が長すぎます.'
end
end
private_class_method :normalise_metadata_url!
def self.normalise_attributes! attributes
raise ArgumentError, 'attributes の形式が不正です.' unless attributes.is_a?(Hash)
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_FIELDS).empty?
attributes.each do |key, value|
case key
when *STRING_FIELDS
raise ArgumentError, '取込項目の型が不正です.' unless value.nil? || value.is_a?(String)
when *FLEXIBLE_FIELDS
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric)
raise ArgumentError, '取込項目の型が不正です.'
end
end
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, '取込項目が大きすぎます.'
end
end
end
private_class_method :normalise_attributes!
def self.normalise_provenance! provenance
unless provenance.is_a?(Hash)
raise ArgumentError, 'provenance の形式が不正です.'
end
allowed = ATTRIBUTE_FIELDS + ['url']
unless (provenance.keys - allowed).empty?
raise ArgumentError, '値の由来が不正です.'
end
unless provenance.values.all? { ORIGINS.include?(_1) }
raise ArgumentError, '値の由来が不正です.'
end
end
private_class_method :normalise_provenance!
def self.normalise_tag_sources! tag_sources
return if tag_sources.nil?
unless tag_sources.is_a?(Hash)
raise ArgumentError, 'タグ由来の形式が不正です.'
end
unless (tag_sources.keys - ORIGINS).empty?
raise ArgumentError, 'タグ由来の形式が不正です.'
end
unless tag_sources.values.all? { _1.is_a?(String) }
raise ArgumentError, 'タグ由来の形式が不正です.'
end
if tag_sources.values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, 'タグ由来が大きすぎます.'
end
if tag_sources.values.sum(&:bytesize) > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'タグ由来が大きすぎます.'
end
end
private_class_method :normalise_tag_sources!
def self.normalise_warning_values! normalised, allow_warning_fields:
return unless allow_warning_fields
field_warnings = normalised['field_warnings']
unless field_warnings.nil? || field_warnings.is_a?(Hash)
raise ArgumentError, '警告の形式が不正です.'
end
field_warnings&.each do |key, values|
unless ATTRIBUTE_FIELDS.include?(key) || key == 'url'
raise ArgumentError, '警告の形式が不正です.'
end
unless values.is_a?(Array) && values.all? { _1.is_a?(String) }
raise ArgumentError, '警告の形式が不正です.'
end
if values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, '警告が大きすぎます.'
end
end
base_warnings = normalised['base_warnings']
return if base_warnings.nil?
unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) }
raise ArgumentError, '警告の形式が不正です.'
end
if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, '警告が大きすぎます.'
end
end
private_class_method :normalise_warning_values!
def self.row_bytesize row
row.to_json.bytesize
end
private_class_method :row_bytesize
end
-104
ファイルの表示
@@ -1,104 +0,0 @@
class PostImportRunner
def initialize actor:, rows:
@actor = actor
@rows = rows
end
def run
normalised_rows = PostImportRowNormaliser.normalise!(@rows)
previews = PostImportPreviewer.new.preview_rows(rows: normalised_rows,
fetch_metadata: false)
preview_map = previews.index_by { _1[:source_row] }
results = normalised_rows.map do |row|
run_row(row, preview_map.fetch(row['source_row']))
end
{ created: results.count { _1[:status] == 'created' },
skipped: results.count { _1[:status] == 'skipped' },
failed: results.count { _1[:status] == 'failed' },
rows: results }
end
private
def run_row row, preview
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
return { source_row: row['source_row'],
status: 'failed',
errors: preview[:validation_errors],
recoverable: true } if preview[:validation_errors].present?
if preview[:skip_reason] == 'existing'
return { source_row: row['source_row'],
status: 'skipped',
existing_post_id: preview[:existing_post_id] }
end
attributes['tags'] = preview[:attributes]['tags']
attributes['url'] = row['url']
post = PostCreator.new(actor: @actor, attributes:).create!
{ source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
rescue ActiveRecord::RecordInvalid => e
existing_post = existing_post_for_race(row, e.record)
if existing_post
return { source_row: row['source_row'],
status: 'skipped',
existing_post_id: existing_post.id }
end
{ source_row: row['source_row'],
status: 'failed',
errors: e.record.errors.to_hash,
recoverable: true }
rescue ActiveRecord::RecordNotUnique => e
raise unless url_record_not_unique?(e)
existing_post = existing_post_for_race(row)
raise unless existing_post
{ source_row: row['source_row'],
status: 'skipped',
existing_post_id: existing_post.id }
rescue Tag::NicoTagNormalisationError
{ source_row: row['source_row'],
status: 'failed',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
recoverable: true }
rescue Tag::DeprecatedTagNormalisationError
{ source_row: row['source_row'],
status: 'failed',
errors: { tags: ['廃止済みタグは付与できません.'] },
recoverable: true }
rescue PostCreator::VideoMsParseError
{ source_row: row['source_row'],
status: 'failed',
errors: { duration: ['動画時間の記法が不正です.'] },
recoverable: true }
rescue ArgumentError
{ source_row: row['source_row'],
status: 'failed',
errors: { base: ['入力値が不正です.'] },
recoverable: true }
rescue StandardError => e
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name,
message: e.message }.to_json }")
{ source_row: row['source_row'],
status: 'failed',
errors: { base: ['登録中にエラーが発生しました.'] } }
end
def existing_post_for_race row, record = nil
if record && !(record.errors.of_kind?(:url, :taken))
return nil
end
normal_url = PostUrlNormaliser.normalise(row['url'])
return nil if normal_url.blank?
Post.find_by(url: normal_url)
end
def url_record_not_unique? error
error.message.include?('index_posts_on_url')
end
end
-25
ファイルの表示
@@ -1,25 +0,0 @@
class PostImportUrlListParser
MAX_ROWS = 100
MAX_BYTES = 1.megabyte
MAX_URL_BYTES = 20.kilobytes
def self.parse source
raw = source.to_s
raise ArgumentError, '入力が大きすぎます.' if raw.bytesize > MAX_BYTES
rows = raw.split(/\r\n|\n|\r/).each_with_index.filter_map { |line, index|
url = line.strip
next if url.blank?
if url.bytesize > MAX_URL_BYTES
raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます."
end
{ source_row: index + 1, url: }
}
raise ArgumentError, 'URL を入力してください.' if rows.empty?
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
rows
end
end
+87 -20
ファイルの表示
@@ -1,11 +1,11 @@
require 'time'
require 'date'
class PostMetadataFetcher
TIMESTAMP_PATTERN =
Regexp.new(
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
'(Z|[+-]\d{2}:?\d{2})?\z')
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2})
(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?
(Z|[+-]\d{2}:?\d{2})?\z/x
def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url)
@@ -30,7 +30,8 @@ class PostMetadataFetcher
Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
original_created_from: serialise_time(created_range&.first),
original_created_before: serialise_time(created_range&.last),
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil },
duration: serialise_duration(duration),
display_tags: display_tags(platform_tags),
tags: platform_tags.join(' ') }
end
@@ -41,6 +42,24 @@ class PostMetadataFetcher
[]
end
def self.display_tags names
return [] if names.blank?
existing_tags =
Tag
.joins(:tag_name)
.where(tag_names: { name: names })
.index_by(&:name)
names.map { |name|
tag = existing_tags[name]
{
name: name,
category: (tag&.category || 'meta'),
section_literals: [] }
}
end
def self.original_created_range value
return nil if value.blank?
@@ -54,12 +73,16 @@ class PostMetadataFetcher
when /\A(\d{4})-(\d{2})\z/
year = Regexp.last_match(1).to_i
month = Regexp.last_match(2).to_i
return nil unless month.between?(1, 12)
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
return nil unless Date.valid_date?(year, month, day)
from = Time.zone.local(year, month, day)
[from, from + 1.day]
else
@@ -84,36 +107,76 @@ class PostMetadataFetcher
second = match[6]&.to_i || 0
fraction = match[7]
offset = match[8]
whole_second = second + fractional_seconds(fraction)
from =
return nil unless valid_timestamp_components?(year, month, day, hour, minute, second)
return nil unless valid_offset?(offset)
nanoseconds = parse_nanoseconds(fraction)
timestamp =
if offset.present?
Time.new(year, month, day, hour, minute, whole_second, 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, whole_second)
Time.zone.local(year, month, day, hour, minute, second).change(nsec: nanoseconds)
end
from = timestamp.change(sec: 0, nsec: 0)
before =
if fraction.present?
from + (10**(-fraction.length))
elsif match[6].present?
from + 1.second
elsif match[5].present?
from + 1.minute
else
if match[5].nil?
from + 1.hour
else
from + 1.minute
end
[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'
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?
def self.serialise_duration value
seconds = Float(value)
return nil unless seconds.positive?
Rational(value.to_i, 10**value.length)
milliseconds = (seconds * 1_000).round
seconds_string = (milliseconds / 1_000.0).to_s
seconds_string.end_with?('.0') ? seconds_string.delete_suffix('.0') : seconds_string
rescue ArgumentError, TypeError
nil
end
def self.valid_timestamp_components? year, month, day, hour, minute, second
return false unless Date.valid_date?(year, month, day)
return false unless hour.between?(0, 23)
return false unless minute.between?(0, 59)
return false unless second.between?(0, 59)
true
end
def self.valid_offset? value
return true if value.nil? || value == 'Z'
match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
return false if match.nil?
hours = match[2].to_i
minutes = match[3].to_i
hours.between?(0, 23) && minutes.between?(0, 59)
end
def self.serialise_time value
@@ -123,9 +186,13 @@ class PostMetadataFetcher
end
private_class_method :platform_tags,
:display_tags,
:original_created_range,
:parse_timestamp_range,
:parse_nanoseconds,
:parse_offset,
:fractional_seconds,
:serialise_duration,
:valid_timestamp_components?,
:valid_offset?,
:serialise_time
end
+11
ファイルの表示
@@ -0,0 +1,11 @@
class PostThumbnailAttachmentBuilder
def self.build thumbnail:, thumbnail_base:
if thumbnail_base.present?
return Post.remote_thumbnail_attachment(thumbnail_base)
end
return nil if thumbnail.blank?
Post.resized_thumbnail_attachment(thumbnail)
end
end
+34
ファイルの表示
@@ -0,0 +1,34 @@
class PostThumbnailUploadValidator
MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024
ALLOWED_CONTENT_TYPES = Preview::ThumbnailFetcher::RASTER_IMAGE_CONTENT_TYPES.freeze
class InvalidUpload < StandardError; end
def self.validate! thumbnail
return if thumbnail.blank?
unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
raise InvalidUpload, 'thumbnail upload が不正です.'
end
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type)
bytes = thumbnail.read
raise InvalidUpload, 'サムネイル画像の形式が不正です.' if Post.svg_document_bytes?(bytes)
unless Post.raster_thumbnail_bytes?(bytes)
raise InvalidUpload, 'サムネイル画像の形式が不正です.'
end
attachment = Post.resized_thumbnail_attachment(
StringIO.new(bytes),
content_type: thumbnail.content_type)
attachment[:io].close if attachment[:io].respond_to?(:close)
rescue MiniMagick::Error, Timeout::Error
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
ensure
thumbnail&.rewind if thumbnail.respond_to?(:rewind)
end
def self.allowed_content_type? content_type
mime_type = content_type.to_s.split(';', 2).first.to_s.downcase.strip
ALLOWED_CONTENT_TYPES.include?(mime_type)
end
end
+2 -6
ファイルの表示
@@ -12,9 +12,7 @@ module Preview
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)
uri, addresses = UrlSafety.validate(raw_url)
response = request(uri, addresses.first, max_bytes)
@@ -69,9 +67,7 @@ module Preview
log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
raise FetchTimeout, e.message
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
log_failure(:network_error,
url: uri&.to_s || raw_url,
error: e.class.name,
log_failure(:network_error, url: uri&.to_s || raw_url, error: e.class.name,
message: e.message)
raise FetchFailed, e.message
end
+36 -29
ファイルの表示
@@ -1,7 +1,7 @@
module Preview
class ThumbnailFetcher
class GenerationFailed < StandardError; end
ALLOWED_IMAGE_CONTENT_TYPES = [
RASTER_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze
HTML_MAX_BYTES = 1.megabyte
@@ -25,6 +25,24 @@ module Preview
fetch_image!(metadata[:image_url])
end
def self.fetch_image_response(raw_url)
uri, = UrlSafety.validate(raw_url)
response = HttpFetcher.fetch(uri.to_s)
unless Post.remote_thumbnail_image_bytes?(
response.body,
content_type: response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
response
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::ResponseTooLarge
raise
rescue HttpFetcher::FetchFailed
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
end
def self.title(raw_url)
uri, = UrlSafety.validate(raw_url)
HtmlMetadataExtractor.extract(
@@ -35,7 +53,9 @@ module Preview
return nil if url.blank?
response = HttpFetcher.fetch(url)
return nil unless allowed_image_content_type?(response.content_type)
return nil unless Post.remote_thumbnail_image_bytes?(
response.body,
content_type: response.content_type)
response.body
rescue HttpFetcher::FetchTimeout
@@ -45,18 +65,7 @@ module Preview
end
def self.fetch_image!(url)
response = HttpFetcher.fetch(url)
unless allowed_image_content_type?(response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
response.body
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::ResponseTooLarge
raise
rescue HttpFetcher::FetchFailed
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
fetch_image_response(url).body
end
def self.niconico_thumbnail_url(uri)
@@ -70,26 +79,24 @@ module Preview
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
payload = {
url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
nil
rescue Nokogiri::XML::SyntaxError => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
payload = {
url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
nil
end
def self.allowed_image_content_type?(content_type)
mime_type = content_type.to_s.split(';', 2).first.downcase.strip
ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type)
end
private_class_method :fetch_image_or_nil, :fetch_image!,
:niconico_thumbnail_url,
:allowed_image_content_type?
:niconico_thumbnail_url
end
end
+6 -7
ファイルの表示
@@ -1,4 +1,3 @@
require 'open-uri'
require 'set'
require 'time'
@@ -127,12 +126,12 @@ module Youtube
return if post.thumbnail.attached?
return if thumbnail_url.blank?
post.thumbnail.attach(
io: URI.open(thumbnail_url),
filename: File.basename(URI.parse(thumbnail_url).path),
content_type: 'image/jpeg')
post.resized_thumbnail!
post.attach_thumbnail_from_url!(thumbnail_url)
rescue Post::RemoteThumbnailFetchFailed => e
Rails.logger.info("youtube_sync_thumbnail_fetch_failed #{ { post_id: post.id,
thumbnail_url:,
error: e.class.name,
message: e.message }.to_json }")
end
def youtube_url_regexp id
+8 -4
ファイルの表示
@@ -1,4 +1,4 @@
require "active_support/core_ext/integer/time"
require 'active_support/core_ext/integer/time'
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
@@ -17,8 +17,8 @@ Rails.application.configure do
# Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching.
if Rails.root.join("tmp/caching-dev.txt").exist?
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
if Rails.root.join('tmp/caching-dev.txt').exist?
config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" }
else
config.action_controller.perform_caching = false
end
@@ -36,7 +36,11 @@ Rails.application.configure do
config.action_mailer.perform_caching = false
# Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
Rails.application.routes.default_url_options.merge!(
host: 'localhost',
port: 3002,
protocol: 'http')
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
+2 -6
ファイルの表示
@@ -33,12 +33,6 @@ Rails.application.routes.draw do
get :thumbnail
end
scope 'posts/import', controller: :post_imports do
post :preview
post :validate
post '', action: :create
end
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
collection do
get :search
@@ -58,6 +52,8 @@ Rails.application.routes.draw do
resources :posts, only: [:index, :show, :create, :update] do
collection do
get :metadata
post :bulk
get :random
get :changes
get :versions, to: 'post_versions#index'
+20 -10
ファイルの表示
@@ -70,11 +70,17 @@ namespace :nico do
unless post.thumbnail.attached?
thumbnail_base = fetch_thumbnail.(post.url) rescue nil
if thumbnail_base.present?
post.thumbnail.attach(
io: URI.open(thumbnail_base),
filename: File.basename(URI.parse(thumbnail_base).path),
content_type: 'image/jpeg')
attrs[:thumbnail_base] = thumbnail_base
begin
post.attach_thumbnail_from_url!(thumbnail_base)
rescue Post::RemoteThumbnailFetchFailed => e
payload = {
post_id: post.id,
thumbnail_base:,
error: e.class.name,
message: e.message }
Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
end
end
end
@@ -82,7 +88,6 @@ namespace :nico do
post_changed = post.changed?
if post_changed
post.save!
post.resized_thumbnail! if post.thumbnail.attached?
end
else
post_created = true
@@ -91,13 +96,18 @@ namespace :nico do
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
original_created_from:, original_created_before:)
if thumbnail_base.present?
post.thumbnail.attach(
io: URI.open(thumbnail_base),
filename: File.basename(URI.parse(thumbnail_base).path),
content_type: 'image/jpeg')
begin
post.attach_thumbnail_from_url!(thumbnail_base)
rescue Post::RemoteThumbnailFetchFailed => e
payload = {
post_id: nil,
thumbnail_base:,
error: e.class.name,
message: e.message }
Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
end
end
post.save!
post.resized_thumbnail!
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end
+293
ファイルの表示
@@ -1,4 +1,5 @@
require 'rails_helper'
require 'tempfile'
RSpec.describe Post, type: :model do
before do
@@ -65,4 +66,296 @@ RSpec.describe Post, type: :model do
expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
end
end
describe 'thumbnail processing' do
def image_blob(width:, height:, background:, draw: nil)
Tempfile.create(['post-thumbnail', '.png']) do |file|
MiniMagick::Tool::Convert.new do |convert|
convert.size "#{ width }x#{ height }"
convert.xc background
draw&.call(convert)
convert << file.path
end
File.binread(file.path)
end
end
def upload_for(blob)
StringIO.new(blob).tap(&:rewind)
end
def read_image(attachment)
blob =
attachment.is_a?(Hash) ? attachment.fetch(:io).read : attachment.download
MiniMagick::Image.read(blob)
end
def colour_at(image, x, y)
image.get_pixels.fetch(y).fetch(x)
end
def expect_green(pixel)
expect(pixel[1]).to be > pixel[0] + 40
expect(pixel[1]).to be > pixel[2] + 40
end
def expect_red(pixel)
expect(pixel[0]).to be > pixel[1] + 40
expect(pixel[0]).to be > pixel[2] + 40
end
def expect_blue(pixel)
expect(pixel[2]).to be > pixel[0] + 40
expect(pixel[2]).to be > pixel[1] + 40
end
describe '.resized_thumbnail_attachment' do
it 'fits a wide image within 180x180 without distorting it' do
blob = image_blob(
width: 360,
height: 180,
background: 'red',
draw: -> convert {
convert.fill 'green'
convert.draw 'rectangle 90,0 269,179'
})
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
expect(image.dimensions).to eq([180, 90])
expect_red(colour_at(image, 0, 45))
expect_green(colour_at(image, 90, 45))
expect_red(colour_at(image, 179, 45))
end
it 'fits a tall image within 180x180 without distorting it' do
blob = image_blob(
width: 180,
height: 360,
background: 'red',
draw: -> convert {
convert.fill 'green'
convert.draw 'rectangle 0,90 179,269'
})
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
expect(image.dimensions).to eq([90, 180])
expect_red(colour_at(image, 45, 0))
expect_green(colour_at(image, 45, 90))
expect_red(colour_at(image, 45, 179))
end
it 'keeps a square image square without distortion' do
blob = image_blob(
width: 180,
height: 180,
background: 'red',
draw: -> convert {
convert.fill 'blue'
convert.draw 'rectangle 90,0 179,179'
})
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
expect(image.dimensions).to eq([180, 180])
expect_red(colour_at(image, 20, 90))
expect_blue(colour_at(image, 160, 90))
end
end
describe '#attach_thumbnail_from_url!' do
it 'attaches a fetched remote image through the common resize path' do
blob = image_blob(
width: 240,
height: 180,
background: 'red',
draw: -> convert {
convert.fill 'green'
convert.draw 'rectangle 30,0 209,179'
})
response = Preview::HttpFetcher::Response.new(
blob,
'image/png',
'https://example.com/thumb.png')
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
.with('https://example.com/thumb.png')
.and_return(response)
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, 135])
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
describe 'original created datetime validation' do
it 'allows unrelated updates on persisted posts with second-bearing datetimes' do
post = described_class.create!(title: 'title', url: 'https://example.com/post')
post.update_columns(
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
)
post.title = 'updated title'
expect(post).to be_valid
expect { post.save! }.not_to raise_error
end
it 'rejects second-bearing updates when the datetime field changes' do
post = described_class.create!(title: 'title', url: 'https://example.com/post')
post.original_created_from = '2024-01-01T12:34:30Z'
expect(post).to be_invalid
expect(post.errors[:original_created_from]).to eq(
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
)
end
it 'accepts fixing persisted datetimes to minute precision' do
post = described_class.create!(title: 'title', url: 'https://example.com/post')
post.update_columns(
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
)
post.original_created_from = '2024-01-01T12:34Z'
post.original_created_before = '2024-01-01T12:35Z'
expect(post).to be_valid
expect { post.save! }.not_to raise_error
end
it 'adds only the minute-precision error for second precision values' do
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: '2024-01-01T12:34:30',
original_created_before: '2024-01-01T12:35'
)
expect(post).to be_invalid
expect(post.errors[:original_created_from]).to eq(
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
)
expect(post.errors[:original_created_at]).to be_empty
end
it 'adds only the minute-precision error for fractional-second values' do
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: '2024-01-01T12:34:00.123',
original_created_before: '2024-01-01T12:35'
)
expect(post).to be_invalid
expect(post.errors[:original_created_from]).to eq(
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
)
expect(post.errors[:original_created_at]).to be_empty
end
it 'checks range rules only for valid minute-precision endpoints' do
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: '2024-01-01T12:34',
original_created_before: '2024-01-01T12:34'
)
expect(post).to be_invalid
expect(post.errors[:original_created_at]).to eq(
[described_class::ORIGINAL_CREATED_ORDER_MESSAGE]
)
end
it 'accepts a one-minute range at minute precision' do
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: '2024-01-01T12:34',
original_created_before: '2024-01-01T12:35'
)
expect(post).to be_valid
end
it 'rejects invalid calendar dates and invalid hours' do
invalid_dates = [
'2024-02-31T12:00',
'2023-02-29T12:00',
'2024-02-29T24:00'
]
invalid_dates.each do |value|
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: value
)
expect(post).to be_invalid
expect(post.errors[:original_created_from]).to eq(
[described_class::ORIGINAL_CREATED_INVALID_MESSAGE]
)
end
end
end
end
-112
ファイルの表示
@@ -1,112 +0,0 @@
require 'rails_helper'
RSpec.describe 'Post imports API', type: :request do
let(:member) { create(:user, :member) }
before do
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['8.8.8.8']]
end
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: nil,
tags: ''
)
end
describe 'POST /posts/import/preview' do
it 'requires a member' do
sign_out
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:unauthorized)
sign_in_as(create(:user, :guest))
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:forbidden)
end
it 'parses a URL list and returns preview rows' do
sign_in_as(member)
post '/posts/import/preview', params: {
source: " https://example.com/one \r\n\r\nhttps://example.com/two"
}
expect(response).to have_http_status(:ok)
expect(json.fetch('rows').map { _1.fetch('source_row') }).to eq([1, 3])
expect(json.fetch('rows').map { _1.fetch('url') }).to eq([
'https://example.com/one',
'https://example.com/two'
])
end
it 'returns a safe 400 response for an invalid source' do
sign_in_as(member)
post '/posts/import/preview', params: { source: '' }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('URL を入力してください.')
end
end
describe 'POST /posts/import/validate' do
it 'accepts camel-case row properties and returns their warnings' do
sign_in_as(member)
post '/posts/import/validate', params: {
rows: [{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'manual title' },
provenance: { url: 'manual', title: 'manual' },
tagSources: { automatic: '', manual: '' },
fieldWarnings: { title: ['old warning'] },
baseWarnings: ['base warning']
}],
changed_row: -1
}
expect(response).to have_http_status(:ok)
result = json.fetch('rows').first
expect(result.fetch('source_row')).to eq(1)
expect(result.fetch('tag_sources')).to eq('automatic' => '', 'manual' => '')
expect(result.fetch('field_warnings')).to eq('title' => ['old warning'])
expect(result.fetch('base_warnings')).to eq(['base warning'])
end
it 'rejects a non-array rows value with 400' do
sign_in_as(member)
post '/posts/import/validate', params: { rows: { sourceRow: 1 }, changed_row: -1 }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('取込行の形式が不正です.')
end
end
describe 'POST /posts/import' do
it 'returns a formal skipped result for an existing post' do
existing = create(:post, url: 'https://example.com/existing')
sign_in_as(member)
post '/posts/import', params: {
rows: [{
sourceRow: 1,
url: existing.url,
attributes: { title: 'ignored' },
provenance: { url: 'manual', title: 'manual' },
tagSources: { automatic: '', manual: '' }
}]
}
expect(response).to have_http_status(:ok)
expect(json).to include('created' => 0, 'skipped' => 1, 'failed' => 0)
expect(json.fetch('rows').first).to include(
'status' => 'skipped',
'existing_post_id' => existing.id
)
end
end
end
+262 -29
ファイルの表示
@@ -9,11 +9,11 @@ RSpec.describe 'Posts API', type: :request do
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
before do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg'
)
allow(Post).to receive(:resized_thumbnail_attachment) do
{ io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
end
end
def create_nico_tag!(name)
@@ -21,8 +21,7 @@ RSpec.describe 'Posts API', type: :request do
end
def dummy_upload
# 中身は何でもいい(加工処理はスタブしてる)
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
real_thumbnail_upload
end
def real_thumbnail_upload
@@ -102,34 +101,34 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe "GET /posts" do
describe 'GET /posts' do
let!(:user) { create_member_user! }
let!(:tag_name) { TagName.create!(name: "spec_tag") }
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name:, category: :general) }
let!(:tag_name2) { TagName.create!(name: 'unko') }
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
let!(:hit_post) do
Post.create!(uploaded_user: user, title: "hello spec world",
Post.create!(uploaded_user: user, title: 'hello spec world',
url: 'https://example.com/spec2').tap do |p|
PostTag.create!(post: p, tag:)
end
end
let!(:miss_post) do
Post.create!(uploaded_user: user, title: "unrelated title",
Post.create!(uploaded_user: user, title: 'unrelated title',
url: 'https://example.com/spec3').tap do |p|
PostTag.create!(post: p, tag: tag2)
end
end
it "returns posts with tag name in JSON" do
get "/posts"
it 'returns posts with tag name in JSON' do
get '/posts'
expect(response).to have_http_status(:ok)
posts = json.fetch("posts")
posts = json.fetch('posts')
# 全postの全tagが name を含むこと
expect(posts).not_to be_empty
@@ -142,8 +141,8 @@ RSpec.describe 'Posts API', type: :request do
expect(json['count']).to be_an(Integer)
# spec_tag を含む投稿が存在すること
all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } }
expect(all_tag_names).to include("spec_tag")
all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } }
expect(all_tag_names).to include('spec_tag')
end
it 'keeps children and sections keys in non-detail tag responses' do
@@ -162,9 +161,9 @@ RSpec.describe 'Posts API', type: :request do
])
end
context "when q is provided" do
it "filters posts by q (hit case)" do
get "/posts", params: { tags: "spec_tag" }
context 'when q is provided' do
it 'filters posts by q (hit case)' do
get '/posts', params: { tags: 'spec_tag' }
expect(response).to have_http_status(:ok)
posts = json.fetch('posts')
@@ -182,8 +181,8 @@ RSpec.describe 'Posts API', type: :request do
end
end
it "filters posts by q (hit case by alias)" do
get "/posts", params: { tags: "manko" }
it 'filters posts by q (hit case by alias)' do
get '/posts', params: { tags: 'manko' }
expect(response).to have_http_status(:ok)
posts = json.fetch('posts')
@@ -201,11 +200,11 @@ RSpec.describe 'Posts API', type: :request do
end
end
it "returns empty posts when nothing matches" do
get "/posts", params: { tags: "no_such_keyword_12345" }
it 'returns empty posts when nothing matches' do
get '/posts', params: { tags: 'no_such_keyword_12345' }
expect(response).to have_http_status(:ok)
expect(json.fetch("posts")).to eq([])
expect(json.fetch('posts')).to eq([])
expect(json.fetch('count')).to eq(0)
end
end
@@ -693,6 +692,73 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe 'GET /posts/metadata' do
let(:member) { create(:user, :member) }
it 'returns compact existing post data without fetching external metadata' do
sign_in_as(member)
existing = create(
:post,
title: 'existing post',
url: 'https://example.com/existing')
existing.thumbnail.attach(
io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg',
content_type: 'image/jpeg')
expect(PostMetadataFetcher).not_to receive(:fetch)
get '/posts/metadata', params: { url: 'https://example.com/existing' }
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => existing.url,
'existing_post_id' => existing.id,
'field_warnings' => { })
expect(json.fetch('existing_post')).to include(
'id' => existing.id,
'title' => existing.title,
'url' => existing.url,
'thumbnail_base' => existing.thumbnail_base)
expect(json.dig('existing_post', 'thumbnail'))
.to include('/rails/active_storage/blobs/proxy/')
end
it 'returns fetched metadata and structured display tags' do
sign_in_as(member)
allow(Preview::UrlSafety).to receive(:validate)
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: 'https://example.com/thumbnail.jpg',
tags: 'character:虹夏',
display_tags: [{ name: '虹夏', category: 'character' }],
original_created_from: nil,
original_created_before: nil,
duration: '1:00',
video_ms: 60_000)
get '/posts/metadata', params: { url: 'https://example.com/new' }
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => 'https://example.com/new',
'title' => 'fetched title',
'duration' => '1:00',
'video_ms' => 60_000,
'field_warnings' => { })
expect(json.fetch('display_tags')).to eq(
[{ 'name' => '虹夏', 'category' => 'character' }])
end
it 'returns URL validation errors as 422' do
sign_in_as(member)
get '/posts/metadata', params: { url: 'file:///etc/passwd' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to have_key('url')
end
end
describe 'POST /posts' do
let(:member) { create(:user, :member) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
@@ -712,6 +778,46 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:forbidden)
end
it 'dry-runs without persisting posts or new tags' do
sign_in_as(member)
counts = [Post.count, Tag.count, TagName.count]
post '/posts?dry=1', params: post_write_params(
title: 'dry-run post',
url: 'https://example.com/dry-run',
tags: 'character:new_dry_run_tag')
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => 'https://example.com/dry-run',
'tags' => 'new_dry_run_tag',
'existing_post_id' => nil)
expect(json.fetch('display_tags')).to eq(
[{ 'name' => 'new_dry_run_tag',
'category' => 'character',
'section_literals' => [] }])
expect([Post.count, Tag.count, TagName.count]).to eq(counts)
end
it 'dry-runs an existing URL without validating its upload' do
sign_in_as(member)
existing = create(:post, url: 'https://example.com/dry-existing')
invalid_upload = Rack::Test::UploadedFile.new(
StringIO.new('<svg></svg>'),
'image/png',
original_filename: 'thumbnail.png')
post '/posts?dry=1', params: post_write_params(
title: '',
url: existing.url,
tags: '',
thumbnail: invalid_upload)
expect(response).to have_http_status(:ok)
expect(json).to include('existing_post_id' => existing.id)
expect(json.fetch('field_warnings')).not_to have_key('thumbnail_base')
end
it '201 and creates post + tags when member' do
sign_in_as(member)
@@ -769,7 +875,8 @@ RSpec.describe 'Posts API', type: :request do
)
expect(response).to have_http_status(:created)
expect(open_transactions).to eq([baseline_open_transactions])
expect(open_transactions).to eq(
[baseline_open_transactions, baseline_open_transactions])
end
it 'returns 422 and does not create a post when thumbnail resize fails' do
@@ -827,7 +934,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['廃止済みタグは付与できません.']
'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
)
end
@@ -1054,7 +1161,7 @@ RSpec.describe 'Posts API', type: :request do
)
end
context "when nico tag already exists in tags" do
context 'when nico tag already exists in tags' do
before do
Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
@@ -1251,6 +1358,47 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe 'POST /posts/bulk' do
let(:member) { create(:user, :member) }
it 'parses the manifest and indexed thumbnail parts' do
sign_in_as(member)
manifest = [{ 'url' => 'https://example.com/bulk',
'title' => 'bulk post',
'tags' => 'spec_tag',
'parent_post_ids' => '' }]
creator = instance_double(
PostBulkCreator,
run: { results: [{ status: 'created', post: { id: 123 } }] })
allow(PostBulkCreator).to receive(:new).and_return(creator)
post '/posts/bulk', params: {
posts: JSON.generate(manifest),
thumbnails: { '0' => real_thumbnail_upload } }
expect(response).to have_http_status(:ok)
expect(json.fetch('results')).to eq(
[{ 'status' => 'created', 'post' => { 'id' => 123 } }])
expect(PostBulkCreator).to have_received(:new) do |arguments|
expect(arguments[:actor]).to eq(member)
expect(arguments[:posts]).to eq(manifest)
expect(arguments[:thumbnails].keys).to eq([0])
expect(arguments[:host]).to eq('http://www.example.com')
end
end
it 'rejects malformed manifests as a request-level error' do
sign_in_as(member)
post '/posts/bulk', params: {
posts: '{',
thumbnails: { '0' => real_thumbnail_upload } }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('posts manifest の JSON が不正です.')
end
end
describe 'PUT /posts/:id' do
let(:member) { create(:user, :member) }
@@ -1307,7 +1455,7 @@ RSpec.describe 'Posts API', type: :request do
)
end
context "when nico tag already exists in tags" do
context 'when nico tag already exists in tags' do
before do
Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
@@ -1564,7 +1712,7 @@ RSpec.describe 'Posts API', type: :request do
expect(post_record.reload.title).to eq('updated by other user')
end
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
@@ -2206,6 +2354,91 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects second-bearing original created timestamps on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid original created from',
url: 'https://example.com/invalid-original-created-from',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00:01Z')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
)
expect(json.fetch('errors')).not_to have_key('original_created_at')
end
it 'rejects fractional-second original created timestamps on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid original created before',
url: 'https://example.com/invalid-original-created-before',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_before: '2020-01-01T00:00:00.123Z')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.']
)
expect(json.fetch('errors')).not_to have_key('original_created_at')
end
it 'rejects non-increasing original created ranges on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'non-increasing original created range',
url: 'https://example.com/non-increasing-original-created-range',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00Z',
original_created_before: '2020-01-01T00:00Z')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_at' => ['オリジナルの作成日時の順番がをかしぃです.']
)
end
it 'accepts original created ranges that are exactly one minute on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'valid original created range',
url: 'https://example.com/valid-original-created-range',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00Z',
original_created_before: '2020-01-01T00:01Z')
expect(response).to have_http_status(:created)
expect(Time.iso8601(json.fetch('original_created_from')))
.to eq(Time.utc(2020, 1, 1, 0, 0))
expect(Time.iso8601(json.fetch('original_created_before')))
.to eq(Time.utc(2020, 1, 1, 0, 1))
end
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
sign_in_as(member)
base_version = create_post_version_for!(post_record)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title',
tags: 'spec_tag',
original_created_from: 'not-a-time')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_from' => ['オリジナルの作成日時の形式が不正です.']
)
end
end
describe 'tag versioning from post write actions' do
-27
ファイルの表示
@@ -1,27 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki body search', type: :request do
let!(:user) { create_member_user! }
it 'searches wiki pages by body text' do
pending '#336 で対応予定'
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
body: 'unique body keyword for wiki search',
created_by_user: user,
message: 'init')
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
body: 'ordinary body',
created_by_user: user,
message: 'init')
get '/wiki/search', params: { body: 'unique body keyword' }
expect(response).to have_http_status(:ok)
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
end
end
-37
ファイルの表示
@@ -1,37 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki restore', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
it 'restores wiki page to previous version' do
pending '#337 で対応予定'
page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_restore_page'),
body: 'v1',
created_by_user: user,
message: 'init')
v1 = page.wiki_versions.order(:version_no).last
Wiki::Commit.content!(
page:,
body: 'v2',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)
post "/wiki/#{ page.id }/restore",
params: { version_no: v1.version_no },
headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('v1')
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
end
end
+59
ファイルの表示
@@ -0,0 +1,59 @@
require 'rails_helper'
RSpec.describe PostBulkCreator do
it 'limits workers to two and keeps failures in their request slots' do
actor = instance_double(User, id: 123)
allow(User).to receive(:find).with(123) {
instance_double(User, id: 123)
}
mutex = Mutex.new
active = 0
maximum_active = 0
allow(PostCreatePreflight).to receive(:new) do |attributes:, **|
preflight = instance_double(PostCreatePreflight)
allow(preflight).to receive(:run) do
mutex.synchronize do
active += 1
maximum_active = [maximum_active, active].max
end
sleep 0.02
mutex.synchronize { active -= 1 }
attributes.symbolize_keys.merge(
existing_post_id: nil,
field_warnings: { },
base_warnings: [])
end
preflight
end
allow(PostCreator).to receive(:new) do |attributes:, **|
creator = instance_double(PostCreator)
if attributes[:title] == 'broken'
allow(creator).to receive(:create!).and_raise(StandardError, 'broken')
else
post = instance_double(Post, id: attributes[:title].delete_prefix('post ').to_i)
allow(creator).to receive(:create!).and_return(post)
end
creator
end
posts = [
{ 'title' => 'post 1', 'url' => 'https://example.com/1' },
{ 'title' => 'broken', 'url' => 'https://example.com/2' },
{ 'title' => 'post 3', 'url' => 'https://example.com/3' },
{ 'title' => 'post 4', 'url' => 'https://example.com/4' }]
results = described_class.new(
actor:,
posts:,
thumbnails: { }).run.fetch(:results)
expect(maximum_active).to eq(2)
expect(results.length).to eq(posts.length)
expect(results.map { _1[:status] }).to eq(
['created', 'failed', 'created', 'created'])
expect(results[0].dig(:post, :id)).to eq(1)
expect(results[1]).to include(status: 'failed', recoverable: false)
expect(results[2].dig(:post, :id)).to eq(3)
expect(results[3].dig(:post, :id)).to eq(4)
end
end
+82
ファイルの表示
@@ -0,0 +1,82 @@
require 'rails_helper'
RSpec.describe PostCreatePlan do
def create_tag! name, category
Tag.create!(name:, category:)
end
before do
create_tag!('タグ希望', :meta)
create_tag!('ニジラー情報不詳', :meta)
end
it 'plans direct and existing default tags without persisting records' do
counts = [TagName.count, Tag.count]
plan = described_class.new(
attributes: {
url: 'https://example.com/post',
title: 'title',
tags: 'character:new_character',
parent_post_ids: '' }).build!
expect(plan[:tags]).to eq('new_character')
expect(plan[:direct_tag_specs]).to eq(
[{ name: 'new_character', category: :character }])
expect(plan[:default_tag_specs]).to include(
{ name: 'タグ希望', category: :meta },
{ name: 'ニジラー情報不詳', category: :meta })
expect([TagName.count, Tag.count]).to eq(counts)
end
it 'resolves aliases and keeps tag sections separate from canonical names' do
canonical = create_tag!('虹夏', :character)
TagName.create!(name: 'にじか', canonical: canonical.tag_name)
create_tag!('動画', :meta)
plan = described_class.new(
attributes: {
url: 'https://example.com/video',
title: 'video',
tags: '動画 にじか[0:10-0:20]',
duration: '1:00',
parent_post_ids: '' }).build!
expect(plan[:tags].split).to include('動画', '虹夏[0:10-0:20]')
expect(plan[:display_tags]).to include(
{ name: '虹夏',
category: 'character',
section_literals: ['[0:10-0:20]'] })
expect(plan[:video_ms]).to eq(60_000)
end
it 'validates a new tag name without persisting it' do
long_name = 'a' * 256
counts = [TagName.count, Tag.count]
expect {
described_class.new(
attributes: {
url: 'https://example.com/post',
title: 'title',
tags: long_name,
parent_post_ids: '' }).build!
}.to raise_error(ActiveRecord::RecordInvalid) { |error|
expect(error.record.errors[:tags]).not_to be_empty
}
expect([TagName.count, Tag.count]).to eq(counts)
end
it 'ignores duration when the planned tags do not include video' do
plan = described_class.new(
attributes: {
url: 'https://example.com/post',
title: 'title',
tags: 'ordinary_tag',
duration: 'invalid',
parent_post_ids: '' }).build!
expect(plan[:duration]).to eq('invalid')
expect(plan[:video_ms]).to be_nil
end
end
+78
ファイルの表示
@@ -0,0 +1,78 @@
require 'rails_helper'
require 'base64'
RSpec.describe PostCreator do
let(:actor) { create(:user, :member) }
def real_thumbnail_upload
gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
Rack::Test::UploadedFile.new(
StringIO.new(gif),
'image/gif',
original_filename: 'thumbnail.gif')
end
before do
allow(Tag).to receive(:normalise_tags!).and_return({ tags: [], sections: {} })
allow(TagVersioning).to receive(:record_tag_snapshots!)
allow(Tag).to receive(:expand_parent_tags).and_return([])
allow(PostVersionRecorder).to receive(:record!)
end
it 'prefers thumbnail_base over an explicit upload' do
expect(Post).not_to receive(:resized_thumbnail_attachment)
allow(Post).to receive(:remote_thumbnail_attachment).and_return(
io: StringIO.new('remote'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg')
post = described_class.new(
actor:,
attributes: {
title: 'title',
url: 'https://example.com/post',
thumbnail: real_thumbnail_upload,
thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' }).create!
expect(post.thumbnail).to be_attached
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
end
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
expect(Post).to receive(:remote_thumbnail_attachment)
.with('https://example.com/thumb.jpg')
.and_return(
io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg',
content_type: 'image/jpeg')
post = described_class.new(
actor:,
attributes: {
title: 'title',
url: 'https://example.com/post',
thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' }).create!
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
expect(post.thumbnail).to be_attached
end
it 'does not create a post when remote thumbnail fetch fails' do
allow(Post).to receive(:remote_thumbnail_attachment)
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
creator = described_class.new(
actor:,
attributes: {
title: 'title',
url: 'https://example.com/post',
thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' })
post_count = Post.count
expect { creator.create! }.to raise_error(Post::RemoteThumbnailFetchFailed)
expect(Post.count).to eq(post_count)
end
end
+2 -2
ファイルの表示
@@ -68,7 +68,7 @@ RSpec.describe PostImportPreviewer do
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'metadata title',
thumbnail_base: 'https://example.com/thumb.jpg',
duration: 2_000,
duration: '2',
tags: 'known-tag'
)
@@ -79,7 +79,7 @@ RSpec.describe PostImportPreviewer do
expect(result.fetch(:attributes)).to include(
'title' => 'metadata title',
'thumbnail_base' => 'https://example.com/thumb.jpg',
'duration' => 2_000,
'duration' => '2',
'tags' => 'known-tag'
)
expect(result.fetch(:field_warnings)).not_to have_key('tags')
-77
ファイルの表示
@@ -1,77 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportRowNormaliser do
def valid_row(overrides = { })
{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'title', duration: 1_000 },
provenance: { url: 'manual', title: 'automatic' },
tagSources: { automatic: 'tag', manual: '' }
}.deep_merge(overrides)
end
describe '.normalise!' do
it 'normalises aliases and source rows into a permitted plain hash' do
row = ActionController::Parameters.new(valid_row)
expect(described_class.normalise!([row])).to eq([
{
'source_row' => 1,
'url' => 'https://example.com/post',
'metadata_url' => 'https://example.com/post',
'attributes' => { 'title' => 'title', 'duration' => 1_000 },
'provenance' => { 'url' => 'manual', 'title' => 'automatic' },
'tag_sources' => { 'automatic' => 'tag', 'manual' => '' }
}
])
end
it 'normalises source rows before checking duplicates' do
rows = [valid_row, valid_row(sourceRow: 1, url: 'https://example.com/other')]
expect { described_class.normalise!(rows) }
.to raise_error(ArgumentError, '元行番号が重複しています.')
end
it 'rejects non-array batches and non-hash rows' do
expect { described_class.normalise!({}) }
.to raise_error(ArgumentError, '取込行の形式が不正です.')
expect { described_class.normalise!(['row']) }
.to raise_error(ArgumentError, '取込行の形式が不正です.')
end
it 'rejects unknown attributes and invalid field types' do
expect { described_class.normalise!([valid_row(attributes: { unknown: 'x' })]) }
.to raise_error(ArgumentError, '取込項目が不正です.')
expect { described_class.normalise!([valid_row(attributes: { title: [] })]) }
.to raise_error(ArgumentError, '取込項目の型が不正です.')
expect { described_class.normalise!([valid_row(attributes: { duration: false })]) }
.to raise_error(ArgumentError, '取込項目の型が不正です.')
end
it 'rejects unknown provenance and tag-source values' do
expect { described_class.normalise!([valid_row(provenance: { title: 'mapped' })]) }
.to raise_error(ArgumentError, '値の由来が不正です.')
expect { described_class.normalise!([valid_row(tagSources: { mapped: 'tag' })]) }
.to raise_error(ArgumentError, 'タグ由来の形式が不正です.')
end
it 'accepts warning fields only at the validation boundary' do
row = valid_row.merge(
fieldWarnings: { title: ['取得できませんでした.'] },
baseWarnings: ['確認してください.']
)
without_warnings = described_class.normalise!([row]).first
with_warnings = described_class.normalise!([row], allow_warning_fields: true).first
expect(without_warnings).not_to include('field_warnings', 'base_warnings')
expect(with_warnings).to include(
'field_warnings' => { 'title' => ['取得できませんでした.'] },
'base_warnings' => ['確認してください.']
)
end
end
end
-133
ファイルの表示
@@ -1,133 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportRunner do
let(:actor) { create(:user, :member) }
def row(source_row: 1, url: 'https://example.com/post')
{
sourceRow: source_row,
url:,
attributes: { title: 'title', tags: '' },
provenance: { url: 'manual', title: 'manual', tags: 'manual' },
tagSources: { automatic: '', manual: '' }
}
end
def preview(source_row: 1, errors: { }, skip_reason: nil, existing_post_id: nil)
{
source_row:,
attributes: { 'title' => 'title', 'tags' => '' },
validation_errors: errors,
skip_reason:,
existing_post_id:
}
end
it 'previews the whole batch once before processing individual rows' do
rows = [row, row(source_row: 2, url: 'https://example.com/two')]
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
expect(previewer).to receive(:preview_rows)
.with(
rows: satisfy { _1.map { |row_value| row_value['source_row'] } == [1, 2] },
fetch_metadata: false
)
.and_return([preview, preview(source_row: 2)])
allow(PostCreator).to receive(:new).and_return(
instance_double(PostCreator, create!: create(:post))
)
result = described_class.new(actor:, rows:).run
expect(result).to include(created: 2, skipped: 0, failed: 0)
end
it 'treats validation errors as failures before an existing skip' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(errors: { url: ['URL が重複しています.'] },
skip_reason: 'existing', existing_post_id: existing.id)
])
expect(PostCreator).not_to receive(:new)
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to include(
status: 'failed',
errors: { url: ['URL が重複しています.'] },
recoverable: true
)
end
it 'returns the existing post ID for skipped rows' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(skip_reason: 'existing', existing_post_id: existing.id)
])
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to eq(
source_row: 1,
status: 'skipped',
existing_post_id: existing.id
)
end
it 'converts a URL uniqueness validation race into a skip' do
existing = create(:post, url: 'https://example.com/race')
invalid = Post.new(url: existing.url)
invalid.errors.add(:url, :taken)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(invalid))
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
it 're-raises RecordNotUnique errors unrelated to the posts URL index' do
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!)
.and_raise(ActiveRecord::RecordNotUnique, 'other_unique_index')
expect {
described_class.new(actor:, rows: [row]).run
}.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'converts a posts URL index race into a skip' do
existing = create(:post, url: 'https://example.com/index-race')
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(
ActiveRecord::RecordNotUnique,
'duplicate key index_posts_on_url'
)
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/index-race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
end
-40
ファイルの表示
@@ -1,40 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportUrlListParser do
describe '.parse' do
it 'trims URLs, ignores blank lines, and preserves source line numbers' do
source = " https://example.com/one \r\n\r\nhttps://example.com/two\n"
expect(described_class.parse(source)).to eq([
{ source_row: 1, url: 'https://example.com/one' },
{ source_row: 3, url: 'https://example.com/two' }
])
end
it 'rejects an empty URL list' do
expect { described_class.parse(" \n\r\n") }
.to raise_error(ArgumentError, 'URL を入力してください.')
end
it 'rejects more than 100 non-empty rows' do
source = 101.times.map { |index| "https://example.com/#{ index }" }.join("\n")
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '取込件数は 100 件までです.')
end
it 'includes the original line number in an oversized URL error' do
source = "\n#{ 'a' * (described_class::MAX_URL_BYTES + 1) }"
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '2 行目: URL が長すぎます.')
end
it 'rejects an oversized request before parsing rows' do
source = 'a' * (described_class::MAX_BYTES + 1)
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '入力が大きすぎます.')
end
end
end
+63 -4
ファイルの表示
@@ -37,16 +37,30 @@ RSpec.describe PostMetadataFetcher do
)
end
it 'preserves an input offset and fractional-second precision' do
it 'rounds second and fractional-second timestamps down to one-minute ranges' do
result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30')
expect(Time.iso8601(result.fetch(:original_created_from)))
.to eq(Time.iso8601('2024-02-03T12:34:56.123+02:30'))
.to eq(Time.iso8601('2024-02-03T12:34:00+02:30'))
expect(
Time.iso8601(result.fetch(:original_created_before)) -
Time.iso8601(result.fetch(:original_created_from))
).to eq(0.001)
expect(result.fetch(:original_created_from)).to include('.123000000')
).to eq(60)
expect(result.fetch(:original_created_from)).not_to include('.')
end
it 'treats hour-precision timestamps as one-hour ranges with and without offsets' do
offset_result = fetch_with_published_time('2024-02-03T12+02:30')
local_result = fetch_with_published_time('2024-02-03T12')
expect(Time.iso8601(offset_result.fetch(:original_created_from)))
.to eq(Time.iso8601('2024-02-03T12:00:00+02:30'))
expect(
Time.iso8601(offset_result.fetch(:original_created_before)) -
Time.iso8601(offset_result.fetch(:original_created_from))
).to eq(3600)
expect(local_result.fetch(:original_created_from)).to eq('2024-02-03T12:00:00Z')
expect(local_result.fetch(:original_created_before)).to eq('2024-02-03T13:00:00Z')
end
it 'adds platform tags for known video URLs' do
@@ -61,4 +75,49 @@ 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
it 'returns nil dates for invalid calendar dates and invalid hour values' do
invalid_day = fetch_with_published_time('2024-02-31T12:00Z')
invalid_leap = fetch_with_published_time('2023-02-29T12:00Z')
invalid_hour = fetch_with_published_time('2024-02-29T24:00Z')
[invalid_day, invalid_leap, invalid_hour].each do |result|
expect(result.fetch(:original_created_from)).to be_nil
expect(result.fetch(:original_created_before)).to be_nil
end
end
it 'accepts a valid leap-day timestamp at minute precision' do
result = fetch_with_published_time('2024-02-29T12:34Z')
expect(result.fetch(:original_created_from)).to eq('2024-02-29T12:34:00Z')
expect(result.fetch(:original_created_before)).to eq('2024-02-29T12:35:00Z')
end
it 'serialises metadata duration as the same seconds string contract used by forms' do
html = <<~HTML
<html><head>
<meta property="article:published_time" content="2024-02-03T12:34:56.123+02:30">
<meta property="og:video:duration" content="2.5">
</head></html>
HTML
uri = URI.parse('https://example.com/video')
allow(Preview::UrlSafety).to receive(:validate)
.with('https://example.com/video')
.and_return([uri, ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch).and_return(Response.new(html))
allow(Preview::HtmlMetadataExtractor).to receive(:extract)
.and_return(title: 'title', image_url: nil)
result = described_class.fetch('https://example.com/video')
expect(result.fetch(:duration)).to eq('2.5')
end
end
+52
ファイルの表示
@@ -0,0 +1,52 @@
require 'rails_helper'
require 'base64'
require 'tempfile'
RSpec.describe PostThumbnailUploadValidator do
def with_upload bytes, content_type:, filename:
tempfile = Tempfile.new(['thumbnail-upload', File.extname(filename)])
tempfile.binmode
tempfile.write(bytes)
tempfile.rewind
upload = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: content_type)
yield upload
ensure
tempfile&.close!
end
it 'accepts a raster upload after decoding and rewinds it' do
gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
with_upload(gif, content_type: 'image/gif', filename: 'thumbnail.gif') do |upload|
expect { described_class.validate!(upload) }.not_to raise_error
expect(upload.read(6)).to eq('GIF87a')
end
end
it 'rejects SVG content disguised as a raster MIME type' do
with_upload(
'<svg width="10" height="10"></svg>',
content_type: 'image/png',
filename: 'thumbnail.png') do |upload|
expect { described_class.validate!(upload) }
.to raise_error(
described_class::InvalidUpload,
'サムネイル画像の形式が不正です.')
end
end
it 'rejects non-raster content disguised as an image' do
with_upload(
'%PDF-1.7',
content_type: 'image/png',
filename: 'thumbnail.png') do |upload|
expect { described_class.validate!(upload) }
.to raise_error(
described_class::InvalidUpload,
'サムネイル画像の形式が不正です.')
end
end
end
+63 -10
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Preview::ThumbnailFetcher do
describe '.fetch' do
it 'rejects svg thumbnails' do
it 'accepts svg thumbnails for the common safe rasterisation path' do
page = Preview::HttpFetcher::Response.new(
'<meta property="og:image" content="https://example.com/thumb.svg">',
'text/html',
@@ -12,8 +12,9 @@ RSpec.describe Preview::ThumbnailFetcher do
'image/svg+xml',
'https://example.com/thumb.svg')
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['203.0.113.10']]
end
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
@@ -21,9 +22,7 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.svg')
.and_return(svg)
expect {
described_class.fetch('https://example.com/page')
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
expect(described_class.fetch('https://example.com/page')).to eq('<svg></svg>')
end
it 'accepts allowed image content type with parameters' do
@@ -32,12 +31,13 @@ RSpec.describe Preview::ThumbnailFetcher do
'text/html',
'https://example.com/page')
image = Preview::HttpFetcher::Response.new(
'jpeg-bytes',
"\xFF\xD8\xFFjpeg-bytes".b,
'image/jpeg; charset=binary',
'https://example.com/thumb.jpg')
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['203.0.113.10']]
end
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
@@ -45,7 +45,60 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.jpg')
.and_return(image)
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
expect(described_class.fetch('https://example.com/page'))
.to eq("\xFF\xD8\xFFjpeg-bytes".b)
end
end
describe '.fetch_image_response' do
it 'rejects an unsafe input URL before HTTP fetch' do
allow(Preview::UrlSafety).to receive(:validate)
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
expect(Preview::HttpFetcher).not_to receive(:fetch)
expect {
described_class.fetch_image_response('https://unsafe.example.com/thumb.jpg')
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
end
it 'rejects an unsafe redirect target' do
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.jpg')
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
expect {
described_class.fetch_image_response('https://example.com/thumb.jpg')
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
end
it 'rejects an oversized image response' do
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.jpg')
.and_raise(Preview::HttpFetcher::ResponseTooLarge, 'too large')
expect {
described_class.fetch_image_response('https://example.com/thumb.jpg')
}.to raise_error(Preview::HttpFetcher::ResponseTooLarge)
end
it 'rejects a non-image content type' do
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.jpg')
.and_return(
Preview::HttpFetcher::Response.new(
'<html></html>',
'text/html',
'https://example.com/thumb.jpg'))
expect {
described_class.fetch_image_response('https://example.com/thumb.jpg')
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
end
end
end
+32 -1
ファイルの表示
@@ -10,6 +10,37 @@ RSpec.describe Youtube::Sync do
allow(sync).to receive(:attach_thumbnail_if_needed!)
end
describe '#attach_thumbnail_if_needed!' do
it 'uses the common remote thumbnail attach path' 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')
sync.send(
:attach_thumbnail_if_needed!,
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
it 'returns without fetching video details when no video ids are discovered' do
allow(sync).to receive(:query_terms).and_return([])
@@ -76,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
+77 -25
ファイルの表示
@@ -1,10 +1,10 @@
require "rails_helper"
require 'rails_helper'
RSpec.describe "nico:sync" do
RSpec.describe 'nico:sync' do
def stub_python(json_array)
status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture3).and_return([json_array.to_json, "", status])
allow(Open3).to receive(:capture3).and_return([json_array.to_json, '', status])
end
def create_tag!(name, category:)
@@ -16,17 +16,21 @@ RSpec.describe "nico:sync" do
NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id)
end
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do
# 既存 post(正規表現で拾われるURL)
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
# 既存の非nicoタグ(kept_non_nico_ids)
kept_general = create_tag!("spec_kept", category: "general")
kept_general = create_tag!('spec_kept', category: 'general')
PostTag.create!(post: post, tag: kept_general)
# 追加される linked tag を準備(nico tag に紐付く一般タグ)
linked = create_tag!("spec_linked", category: "general")
nico = create_tag!("nico:AAA", category: "nico")
linked = create_tag!('spec_linked', category: 'general')
nico = create_tag!('nico:AAA', category: 'nico')
link_nico_to_tag!(nico, linked)
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
@@ -42,43 +46,91 @@ RSpec.describe "nico:sync" do
'deleted_at' => '2026-01-31 00:00:00' }])
# 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task("nico:sync")
run_rake_task('nico:sync')
post.reload
active_tag_names = post.tags.joins(:tag_name).pluck("tag_names.name")
active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_tag_names).to include("spec_kept")
expect(active_tag_names).to include("nico:AAA")
expect(active_tag_names).to include("spec_linked")
expect(active_tag_names).to include('spec_kept')
expect(active_tag_names).to include('nico:AAA')
expect(active_tag_names).to include('spec_linked')
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z'))
# 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids)
expect(active_tag_names).to include("bot操作")
expect(active_tag_names).to include('bot操作')
end
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil)
Tag.bot
Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open) do
StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')
end
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg')
run_rake_task('nico:sync')
end
it 'サムネール取得失敗後も次回同期で再試行できる' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil)
Tag.bot
Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open) do
StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')
end
calls = 0
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) do
calls += 1
raise Post::RemoteThumbnailFetchFailed, 'failed'
end
2.times do
run_rake_task('nico:sync')
end
expect(calls).to eq(2)
end
it '既存 post にあった古い nico tag は active から外され、履歴として discard される' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
# 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_tag!("nico:OLD", category: "nico")
old_nico = create_tag!('nico:OLD', category: 'nico')
old_pt = PostTag.create!(post: post, tag: old_nico)
expect(old_pt.discarded_at).to be_nil
# 今回は NEW のみ欲しい
new_nico = create_tag!("nico:NEW", category: "nico")
new_nico = create_tag!('nico:NEW', category: 'nico')
# bot/tagme 念のため
Tag.bot
Tag.tagme
stub_python([{ "code" => "sm9", "title" => "t", "tags" => ["NEW"] }])
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => ['NEW'] }])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task("nico:sync")
run_rake_task('nico:sync')
# OLD は active から外れる(discarded_at が入る)
old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a
@@ -86,9 +138,9 @@ RSpec.describe "nico:sync" do
# NEW は active にいる
post.reload
active_names = post.tags.joins(:tag_name).pluck("tag_names.name")
expect(active_names).to include("nico:NEW")
expect(active_names).not_to include("nico:OLD")
active_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_names).to include('nico:NEW')
expect(active_names).not_to include('nico:OLD')
end
def snapshot_tags(post)
+51 -1
ファイルの表示
@@ -130,6 +130,13 @@ pass or the remaining failure is clearly blocked.
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
whenever `className` combines multiple values, conditional classes, or a
caller-provided `className` prop.
- Do not construct `className` with template literals, `${ ... }`, string
concatenation, arrays joined with spaces, or feature-local class-merging
helpers.
- A static `className="..."` containing only fixed classes does not need `cn`.
- Reuse components from `src/components/common`, `src/components/layout`, and
`src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components.
@@ -140,6 +147,15 @@ pass or the remaining failure is clearly blocked.
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
placeholders, empty-state messages, loading messages, or explanatory text
unless the user explicitly specified the wording.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- Do not use `タグなし` as user-facing copy for an empty tag state. When the
tag state is empty, show no copy. If actual data contains the tag name
`タグなし`, treat it as ordinary data and display it normally.
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist
if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
@@ -153,6 +169,13 @@ pass or the remaining failure is clearly blocked.
closing `</div>)` forms when nearby code uses them.
- Block bodies for components, functions, callbacks, `if`, `try`, `catch`,
`finally`, loops, and JSX nesting use 2 spaces per level.
- Put the opening brace of `try`, `catch`, and `finally` blocks on the next
line at the same indentation as the keyword.
- Do not indent the opening `{` one level deeper than `try`, `catch`, or
`finally`.
- Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`.
- Wrapped expressions, arguments, ternary branches, method chains, and object
pairs use 4-space continuation indentation relative to the owning
expression. Do not confuse this with 2-space block indentation.
@@ -166,6 +189,10 @@ pass or the remaining failure is clearly blocked.
`?` / `:` pairing.
- Keep short inline props types local when they remain readable and within the
line limit; do not mechanically extract a named type with no reuse benefit.
- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
Use a meaningful callback parameter name such as `row`, `item`, `value`,
`entry`, or `result`.
- In multi-line object literals, keep the opening `{` with the first pair when
the line length allows it; do not mechanically explode short objects into
Prettier-style vertical blocks.
@@ -229,12 +256,29 @@ const Component = () => {
}
```
- `try` / `catch` / `finally` brace placement example:
```ts
try
{
doWork ()
}
catch
{
recover ()
}
finally
{
cleanUp ()
}
```
- Continuation indentation example:
```ts
const editingRow =
Number.isFinite (editingSourceRow)
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
? rows.find (row => row.sourceRow === editingSourceRow) ?? null
: null
```
@@ -558,6 +602,12 @@ offsets, or footer offsets, inspect existing layout components such as
Do not create a second layout shell before checking whether the current layout
can be reused or minimally extended.
- Frontend のスマホ/PC表示境界は原則 `md` とする。
- button stack、footer action、dialogue action は `md` 未満で縦並び、
`md` 以上で横並びとする。
- 同じ画面内で `sm``md` を混在させて中間 layout を作らない。
- 明確に別の responsive 要件がある component だけを例外とする。
### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
+43 -47
ファイルの表示
@@ -1,8 +1,9 @@
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { BrowserRouter,
import { createBrowserRouter,
Navigate,
Route,
RouterProvider,
Routes,
useLocation } from 'react-router-dom'
@@ -41,11 +42,8 @@ import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import PostListPage from '@/pages/posts/PostListPage'
import PostNewPage from '@/pages/posts/PostNewPage'
import PostListPage from '@/pages/posts/PostListPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage'
@@ -78,9 +76,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
@@ -119,9 +114,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
@@ -163,7 +155,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
}
const App: FC = () => {
const RoutedApp: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
@@ -262,42 +254,46 @@ const App: FC = () => {
}
return (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<BrowserRouter>
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</BrowserRouter>
</>)
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>)
}
const router = createBrowserRouter ([{
path: '*',
element: <RoutedApp/> }])
const App: FC = () => (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<RouterProvider router={router}/>
</>)
export default App
+33 -5
ファイルの表示
@@ -19,8 +19,8 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => ({
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => ({
choice: vi.fn (),
}),
}))
@@ -78,15 +78,43 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
expect (screen.queryByText ('動画時間')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
})
it (
'shows deduplicated original-created endpoint errors on the shared datetime field',
async () => {
const post = buildPost ()
api.isApiError.mockReturnValue (true)
postsApi.updatePost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
errors: {
original_created_at: ['日時を確認してください.'],
original_created_from: ['日時を確認してください.'],
original_created_before: ['終了を確認してください.'] },
},
},
})
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
})
})
+31 -42
ファイルの表示
@@ -1,15 +1,16 @@
import { useEffect, useMemo, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts'
import { inputClass, msToTime } from '@/lib/utils'
import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
@@ -17,7 +18,11 @@ import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types'
type PostFormField =
'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt'
'parentPostIds' | 'tags' | 'videoMs'
| 'originalCreatedAt' | 'originalCreatedFrom' | 'originalCreatedBefore'
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const videoMsToDurationValue = (videoMs: number | null): string =>
videoMs == null ? '' : String (videoMs / 1_000)
@@ -157,32 +162,20 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/>
{/* タイトル */}
<FormField label="タイトル">
{({ invalid }) => (
<input
type="text"
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''}
onChange={e => setTitle (e.target.value)}/>)}
</FormField>
<PostTextField
label="タイトル"
value={title ?? ''}
disabled={disabled}
onChange={setTitle}/>
{/* 親投稿 */}
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<PostTextField
label="親投稿"
value={parentPostIds}
disabled={disabled}
errors={fieldErrors.parentPostIds}
onChange={setParentPostIds}/>
{/* タグ */}
<PostFormTagsArea
<PostTagsField
disabled={disabled}
tags={tags}
setTags={setTags}
@@ -195,21 +188,17 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
errors={groupedMessages (
fieldErrors.originalCreatedAt,
fieldErrors.originalCreatedFrom,
fieldErrors.originalCreatedBefore)}/>
{/* 動画時間 */}
{videoFlg && (
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
disabled={disabled}
className={inputClass (invalid)}
value={duration}
onChange={e => setDuration (e.target.value)}/>)}
</FormField>)}
<PostDurationField
value={duration}
disabled={disabled}
errors={fieldErrors.videoMs}
onChange={setDuration}/>)}
{/* 送信 */}
<Button type="submit" disabled={disabled}>
+41 -3
ファイルの表示
@@ -20,8 +20,10 @@ describe ('PostOriginalCreatedTimeField', () => {
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
expect (setFrom).toHaveBeenCalledWith (expect.any (String))
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
expect (setFrom).toHaveBeenCalledWith (expect.stringMatching (
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
expect (setBefore).toHaveBeenCalledWith (expect.stringMatching (
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
})
it ('infers an exclusive before value on blur', () => {
@@ -38,7 +40,43 @@ describe ('PostOriginalCreatedTimeField', () => {
const input = screen.getAllByDisplayValue ('')[0]
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
const value = setBefore.mock.calls.at (-1)?.[0]
expect (value).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
expect (new Date (value).getTime () - new Date ('2026-01-02T03:04').getTime ())
.toBe (60_000)
})
it ('does not rewrite mounted values that only differ by offset notation', () => {
const setFrom = vi.fn ()
const setBefore = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom="2024-01-01T12:34+09:00"
setOriginalCreatedFrom={setFrom}
originalCreatedBefore="2024-01-01T12:35+09:00"
setOriginalCreatedBefore={setBefore}/>,
)
expect (setFrom).not.toHaveBeenCalled ()
expect (setBefore).not.toHaveBeenCalled ()
})
it ('emits minute-precision UTC values only when the user edits the input', () => {
const setFrom = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom="2024-01-01T12:34+09:00"
setOriginalCreatedFrom={setFrom}
originalCreatedBefore={null}
setOriginalCreatedBefore={vi.fn ()}/>,
)
const input = screen.getByDisplayValue ('2024-01-01T12:34')
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
})
it ('resets both values', () => {
+68 -66
ファイルの表示
@@ -1,4 +1,4 @@
import DateTimeField from '@/components/common/DateTimeField'
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
@@ -19,72 +19,74 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props) => (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<DateTimeField
className="w-full"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
errors }: Props) => {
return (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<DateTimeField
className="w-full"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</>)}
</FormField>)
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</>)}
</FormField>)
}
export default PostOriginalCreatedTimeField
-13
ファイルの表示
@@ -6,7 +6,6 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -36,7 +35,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
@@ -45,10 +43,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString ()
}, [path])
const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => {
@@ -93,13 +87,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => {
setOverlay (true)
})
+43 -12
ファイルの表示
@@ -4,9 +4,13 @@ import { cn } from '@/lib/utils'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types'
import type { Category, Tag } from '@/types'
type CommonProps = {
type LightweightTag = {
name: string
category: Category }
type FullCommonProps = {
tag: Tag
nestLevel?: number
truncateOnMobile?: boolean
@@ -14,18 +18,43 @@ type CommonProps = {
withCount?: boolean }
type PropsWithLink =
& CommonProps
& FullCommonProps
& { linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
type PropsWithoutLink =
& CommonProps
& FullCommonProps
& { linkFlg: false }
& Partial<HTMLAttributes<HTMLSpanElement>>
type LightweightPropsWithLink =
& {
tag: LightweightTag
nestLevel?: number
truncateOnMobile?: boolean
withWiki: false
withCount: false
linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
type LightweightPropsWithoutLink =
& {
tag: LightweightTag
nestLevel?: number
truncateOnMobile?: boolean
withWiki: false
withCount: false
linkFlg: false }
& Partial<HTMLAttributes<HTMLSpanElement>>
type Props =
| PropsWithLink
| PropsWithoutLink
| LightweightPropsWithLink
| LightweightPropsWithoutLink
const isFullTag = (tag: Tag | LightweightTag): tag is Tag =>
'id' in tag
const TagLink: FC<Props> = ({ tag,
@@ -46,16 +75,18 @@ const TagLink: FC<Props> = ({ tag,
const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const rootClass =
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto'
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
const textTitle = title
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias }${ tag.name }`)
?? (matchedAlias == null ? tag.name : `${ matchedAlias }${ tag.name }`)
return (
<span className={rootClass}>
{(linkFlg && withWiki) && (
<span
className={cn (
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
'gap-x-1 md:items-baseline')}>
{(linkFlg && withWiki && isFullTag (tag)) && (
<span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? (
@@ -118,7 +149,7 @@ const TagLink: FC<Props> = ({ tag,
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)}
{tag.matchedAlias != null && (
{matchedAlias != null && (
<>
<span
title={textTitle}
@@ -126,7 +157,7 @@ const TagLink: FC<Props> = ({ tag,
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.matchedAlias}
text={matchedAlias}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>
@@ -156,7 +187,7 @@ const TagLink: FC<Props> = ({ tag,
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>)}
{withCount && (
{(withCount && isFullTag (tag)) && (
<span className={countClass}>{tag.postCount}</span>)}
</span>)
}
+6 -3
ファイルの表示
@@ -8,7 +8,8 @@ const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item:
user: buildUser ({ role }),
wikiId: section === 'Wiki' ? 10 : null,
pathName: section === 'Wiki' ? '/wiki/page' : '/posts' })
return menu.find (_1 => _1.name === section)?.subMenu.find (_1 => _1.name === item)
return menu.find (entry => entry.name === section)?.subMenu.find (
subMenuItem => subMenuItem.name === item)
}
describe ('menuOutline', () => {
@@ -16,19 +17,21 @@ describe ('menuOutline', () => {
for (const role of ['member', 'admin'] as const)
{
expect (submenuItem (role, '広場', '追加')?.visible).toBe (true)
expect (submenuItem (role, '広場', '取込')?.visible).toBe (true)
expect (submenuItem (role, '素材', '追加')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true)
}
expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', '広場', '取込')?.visible).toBe (false)
expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
})
it ('uses /posts/new as the post creation entrypoint', () => {
expect (submenuItem ('member', '広場', '追加')?.to).toBe ('/posts/new')
})
it ('keeps material suppression admin-only', () => {
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
+1 -2
ファイルの表示
@@ -19,7 +19,7 @@ import type { FC, MouseEvent } from 'react'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null, }
type Props = { user: User | null }
export const menuOutline = (
@@ -45,7 +45,6 @@ export const menuOutline = (
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new', visible: editable },
{ name: '取込', to: '/posts/import', visible: editable },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
+2 -1
ファイルの表示
@@ -21,7 +21,8 @@ describe ('DateTimeField', () => {
fireEvent.change (input, { target: { value: '' } })
const first = handleChange.mock.calls[0]?.[0]
expect (new Date (first).getFullYear ()).toBe (2026)
expect (first).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
expect (new Date (first).getUTCSeconds ()).toBe (0)
expect (handleChange).toHaveBeenLastCalledWith (null)
})
})
+24 -8
ファイルの表示
@@ -8,13 +8,27 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0')
const toDateTimeLocalValue = (d: Date) => {
const y = d.getFullYear ()
const m = pad (d.getMonth () + 1)
const day = pad (d.getDate ())
const h = pad (d.getHours ())
const min = pad (d.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }:00`
const toDateTimeLocalValue = (value: Date) => {
const y = value.getFullYear ()
const m = pad (value.getMonth () + 1)
const day = pad (value.getDate ())
const h = pad (value.getHours ())
const min = pad (value.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }`
}
const toMinutePrecisionIsoUtc = (value: string) => {
const date = new Date (value)
if (Number.isNaN (date.getTime ()))
return value
const y = date.getUTCFullYear ()
const m = pad (date.getUTCMonth () + 1)
const day = pad (date.getUTCDate ())
const h = pad (date.getUTCHours ())
const min = pad (date.getUTCMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }Z`
}
@@ -47,14 +61,16 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid,
'focus:ring-2 focus:ring-blue-200']),
className)}
type="datetime-local"
step={60}
value={local}
aria-invalid={invalid}
onChange={ev => {
const v = ev.target.value
setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null)
onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
}}
onBlur={onBlur}/>)
}
export default DateTimeField
export { toMinutePrecisionIsoUtc }
+3 -1
ファイルの表示
@@ -80,7 +80,9 @@ describe ('DialogueProvider', () => {
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
.toBeInTheDocument ())
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto')
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass (
'w-full',
'md:w-auto')
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
+15 -15
ファイルの表示
@@ -86,7 +86,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
return current.filter (request => request.id !== id)
})
setPendingIds (current => current.filter (_1 => _1 !== id))
setPendingIds (current => current.filter (pendingId => pendingId !== id))
setFormActions (current => {
const { [id]: _, ...rest } = current
return rest
@@ -140,15 +140,15 @@ const DialogueProvider: FC<Props> = ({ children }) => {
setPendingIds (current => [...current, id])
try
{
const shouldClose = await action.onSelect ()
if (shouldClose !== false)
closeRequest (id)
}
{
const shouldClose = await action.onSelect ()
if (shouldClose !== false)
closeRequest (id)
}
finally
{
setPendingIds (current => current.filter (_1 => _1 !== id))
}
{
setPendingIds (current => current.filter (pendingId => pendingId !== id))
}
},
[closeRequest, pendingIds])
@@ -215,12 +215,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<DialogFooter
className="shrink-0 flex-col gap-2 pt-4
sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
md:flex-row md:justify-between md:gap-0 md:space-x-0">
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
{startActions.map (action => (
<Button
key={action.label}
className="w-full sm:w-auto"
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
: 'default'}
@@ -232,9 +232,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
</Button>))}
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
<Button
className="w-full sm:w-auto"
className="w-full md:w-auto"
variant="outline"
onClick={() => closeRequest (active.id)}
disabled={pendingIds.includes (active.id)
@@ -245,7 +245,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
{endActions.map (action => (
<Button
key={action.label}
className="w-full sm:w-auto"
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
: 'default'}
+94
ファイルの表示
@@ -0,0 +1,94 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
type TextMessages = string[] | undefined
type CoreField = {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean }
type OriginalCreatedField = {
originalCreatedAt?: TextMessages
originalCreatedFrom?: TextMessages
originalCreatedBefore?: TextMessages }
type PostCoreDataFieldsProps = {
title: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
after?: ReactNode }
originalCreated: {
disabled?: boolean
originalCreatedFrom: string | null
setOriginalCreatedFrom: (value: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (value: string | null) => void
errors?: OriginalCreatedField }
tags: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
rows?: number }
parentPostIds: CoreField }
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
{ title,
originalCreated,
tags,
parentPostIds },
) => (
<>
<PostTextField
label="タイトル"
value={title.value}
disabled={title.disabled}
warnings={title.warnings}
errors={title.errors}
after={title.after}
onChange={title.onChange}/>
<PostOriginalCreatedTimeField
disabled={originalCreated.disabled}
originalCreatedFrom={originalCreated.originalCreatedFrom}
setOriginalCreatedFrom={originalCreated.setOriginalCreatedFrom}
originalCreatedBefore={originalCreated.originalCreatedBefore}
setOriginalCreatedBefore={originalCreated.setOriginalCreatedBefore}
errors={groupedMessages (
originalCreated.errors?.originalCreatedAt,
originalCreated.errors?.originalCreatedFrom,
originalCreated.errors?.originalCreatedBefore)}/>
<PostTagsField
tags={tags.value}
disabled={tags.disabled}
setTags={tags.onChange}
warnings={tags.warnings}
errors={tags.errors}
rows={tags.rows}/>
<PostTextField
label="親投稿"
value={parentPostIds.value}
disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings}
errors={parentPostIds.errors}
onChange={parentPostIds.onChange}/>
</>)
export default PostCoreDataFields
export type { PostCoreDataFieldsProps }
+48
ファイルの表示
@@ -0,0 +1,48 @@
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
type TextMessages = string[] | undefined
type Props = {
url: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
type?: string
placeholder?: string }
thumbnailField: ReactNode
core: PostCoreDataFieldsProps
extraFields?: ReactNode }
const PostCreationDataFields: FC<Props> = (
{ url,
thumbnailField,
core,
extraFields },
) => (
<>
<PostTextField
label="URL"
type={url.type}
value={url.value}
disabled={url.disabled}
warnings={url.warnings}
errors={url.errors}
placeholder={url.placeholder}
onChange={url.onChange}/>
{thumbnailField}
<PostCoreDataFields {...core}/>
{extraFields}
</>)
export default PostCreationDataFields
+31
ファイルの表示
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
const sharedFieldsSpy = vi.hoisted (() => vi.fn (() => <div data-testid="shared-fields"/>))
vi.mock ('@/components/posts/PostCreationDataFields', () => ({
default: sharedFieldsSpy,
}))
describe ('PostCreationDataFields usage', () => {
it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm')
render (
<PostImportRowForm
row={buildPostImportRow ()}
controls={{
close: vi.fn (),
confirm: vi.fn (),
setActions: vi.fn (),
} as DialogueFormControls}
onSave={vi.fn ()}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
})
+27
ファイルの表示
@@ -0,0 +1,27 @@
import PostTextField from '@/components/posts/PostTextField'
import type { FC } from 'react'
type Props = {
value: string
onChange: (value: string) => void
errors?: string[]
disabled?: boolean }
const PostDurationField: FC<Props> = (
{ value,
onChange,
errors,
disabled },
) => (
<PostTextField
label="動画時間"
value={value}
onChange={onChange}
errors={errors}
disabled={disabled}
type="text"/>
)
export default PostDurationField
+29
ファイルの表示
@@ -0,0 +1,29 @@
import PostFormTagsArea from '@/components/PostFormTagsArea'
import FieldWarning from '@/components/common/FieldWarning'
import type { ComponentPropsWithoutRef, FC } from 'react'
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
tags: string
setTags: (tags: string) => void
warnings?: string[]
errors?: string[] }
const PostTagsField: FC<Props> = (
{ tags,
setTags,
warnings,
errors,
...rest },
) => (
<div className="space-y-2">
<PostFormTagsArea
{...rest}
tags={tags}
setTags={setTags}
errors={errors}/>
<FieldWarning messages={warnings}/>
</div>)
export default PostTagsField
+52
ファイルの表示
@@ -0,0 +1,52 @@
import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField'
import { inputClass } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type Props = {
label: string
value: string
onChange: (value: string) => void
warnings?: string[]
errors?: string[]
disabled?: boolean
type?: string
placeholder?: string
className?: string
after?: ReactNode
onBlur?: () => void }
const PostTextField: FC<Props> = (
{ label,
value,
onChange,
warnings,
errors,
disabled,
type = 'text',
placeholder,
className,
after,
onBlur },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<input
type={type}
value={value}
disabled={disabled}
placeholder={placeholder}
onBlur={onBlur}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid, className)}/>
<FieldWarning messages={warnings}/>
{after}
</>)}
</FormField>)
export default PostTextField
+36
ファイルの表示
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
describe ('PostThumbnailPreview', () => {
it ('keeps an existing blob preview URL unchanged for normal post forms', () => {
render (<PostThumbnailPreview url="blob:preview" className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
})
it ('renders an empty thumbnail frame without text when the URL is empty', () => {
const { container } = render (
<PostThumbnailPreview url="" className="h-10 w-10"/>)
expect (screen.queryByRole ('img')).toBeNull ()
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
expect (screen.queryByText ('なし')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('')
})
it ('renders an empty thumbnail frame without text when image loading fails', () => {
const { container } = render (
<PostThumbnailPreview url="blob:preview" className="h-10 w-10"/>)
fireEvent.error (screen.getByRole ('img'))
expect (screen.queryByRole ('img')).toBeNull ()
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
expect (screen.queryByText ('なし')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('')
})
})
+64
ファイルの表示
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = {
url: string
file?: File
alt?: string
className?: string
referrerPolicy?: 'no-referrer' }
const PostThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
className = 'h-16 w-16',
referrerPolicy },
) => {
const [failed, setFailed] = useState (false)
const [fileUrl, setFileUrl] = useState<string | null> (null)
useEffect (() => {
setFailed (false)
}, [file, url])
useEffect (() => {
if (file == null)
{
setFileUrl (null)
return
}
const nextUrl = URL.createObjectURL (file)
setFileUrl (nextUrl)
return () => {
URL.revokeObjectURL (nextUrl)
}
}, [file])
const resolvedUrl = url.trim () !== '' ? url : (fileUrl ?? '')
if (resolvedUrl === '' || failed)
{
return (
<div
className={cn (
className,
'rounded border border-border bg-muted')}/>)
}
return (
<img
src={resolvedUrl}
alt={alt}
referrerPolicy={referrerPolicy}
className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>)
}
export default PostThumbnailPreview
+194 -7
ファイルの表示
@@ -1,12 +1,25 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import { buildPostImportRow } from '@/test/postImportFactories'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostImportRowForm', () => {
beforeEach (() => {
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
})
it ('resets only the draft, then saves with resetRequested', async () => {
const row = buildPostImportRow ()
row.attributes.title = 'manual title'
@@ -24,7 +37,7 @@ describe ('PostImportRowForm', () => {
const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
const reset = actions.at (-1)?.find (_1 => _1.label === '変更をリセット')
const reset = actions.at (-1)?.find (action => action.label === '変更をリセット')
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => {
await reset?.onSelect ()
@@ -34,7 +47,7 @@ describe ('PostImportRowForm', () => {
expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled ()
const save = actions.at (-1)?.find (_1 => _1.label === '編輯内容を保存')
const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存')
await act (async () => {
await save?.onSelect ()
})
@@ -63,7 +76,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
await actions.find (_1 => _1.label === '変更をリセット')?.onSelect ()
await actions.find (action => action.label === '変更をリセット')?.onSelect ()
})
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
@@ -72,7 +85,7 @@ describe ('PostImportRowForm', () => {
it ('marks edited fields and areas invalid from field errors', () => {
const row = buildPostImportRow ({
validationErrors: { url: ['URL error'], tags: ['tag error'] },
importErrors: { duration: ['duration error'] },
importErrors: { title: ['title error'] },
fieldWarnings: { title: ['title warning'] } })
render (
@@ -83,9 +96,183 @@ describe ('PostImportRowForm', () => {
expect (screen.getByText ('URL error')).toBeInTheDocument ()
expect (screen.getByText ('tag error')).toBeInTheDocument ()
expect (screen.getByText ('duration error')).toBeInTheDocument ()
expect (screen.getByText ('title error')).toBeInTheDocument ()
expect (screen.getByText ('title warning')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox').filter (
_1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
})
it ('keeps untouched original created values unchanged in the save payload', async () => {
let actions: DialogueFormAction[] = []
const row = buildPostImportRow ({
attributes: {
originalCreatedFrom: '2024-01-01T12:34+09:00',
originalCreatedBefore: '2024-01-01T12:35+09:00' } })
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null })
render (
<PostImportRowForm
row={row}
controls={controls}
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({
originalCreatedFrom: '2024-01-01T12:34+09:00',
originalCreatedBefore: '2024-01-01T12:35+09:00' }),
resetRequested: false })
})
it (
'shows upload input only when the thumbnail URL is blank',
async () => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null })
const { container } = render (
<PostImportRowForm
row={buildPostImportRow ({
attributes: { duration: '2', tags: 'tag1' } })}
controls={controls}
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
const labels = Array.from (container.querySelectorAll ('label'))
.map (node => node.textContent?.trim ())
expect (labels.slice (0, 6)).toEqual ([
'URL',
'サムネール',
'タイトル',
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (container.querySelector ('input[type="file"]')).toHaveAttribute (
'accept',
'image/*')
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({
tags: 'tag1' }),
resetRequested: false })
})
it ('keeps reset enabled when the value matches but provenance still differs', async () => {
const row = buildPostImportRow ({
attributes: { title: 'same title' },
provenance: { title: 'manual' },
resetSnapshot: {
url: 'https://example.com/post',
attributes: {
title: 'same title',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
tags: '',
parentPostIds: '' },
provenance: {
url: 'manual',
title: 'automatic',
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' },
tagSources: { automatic: '', manual: '' },
fieldWarnings: { },
baseWarnings: [] } })
let actions: DialogueFormAction[] = []
render (
<PostImportRowForm
row={row}
controls={{
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }}
onSave={vi.fn ()}/>)
await waitFor (() => expect (actions.length).toBe (2))
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
})
it (
'disables every field while save validation is pending and re-enables them afterwards',
async () => {
let actions: DialogueFormAction[] = []
let resolveSave:
((value: { saved: boolean
row: ReturnType<typeof buildPostImportRow> | null }) => void) | null
= null
const onSave = vi.fn (() =>
new Promise<{ saved: boolean
row: ReturnType<typeof buildPostImportRow> | null }> (resolve => {
resolveSave = resolve
}))
render (
<PostImportRowForm
row={buildPostImportRow ({ attributes: { title: 'draft title' } })}
controls={{
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }}
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
let savePromise: Promise<boolean | void> | undefined
await act (async () => {
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
await waitFor (() => {
screen.getAllByRole ('textbox').forEach (textbox => {
expect (textbox).toBeDisabled ()
})
})
resolveSave?.({
saved: false,
row: buildPostImportRow ({
attributes: { title: 'draft title' },
validationErrors: { title: ['タイトルを確認してください.'] } }) })
await act (async () => {
await savePromise
})
await waitFor (() => {
screen.getAllByRole ('textbox').forEach (textbox => {
expect (textbox).not.toBeDisabled ()
})
})
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
})
})
+170 -139
ファイルの表示
@@ -1,27 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { inputClass } from '@/lib/utils'
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
type Draft = {
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
type Draft = PostImportEditableDraft
type Props = {
row: PostImportRow
@@ -31,15 +23,18 @@ type Props = {
saved: boolean
row: PostImportRow | null }> }
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
const buildDraft = (row: PostImportRow): Draft => ({
url: row.url,
title: String (row.attributes.title ?? ''),
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
duration: String (row.attributes.duration ?? ''),
tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? '') })
parentPostIds: String (row.attributes.parentPostIds ?? ''),
duration: String (row.attributes.duration ?? ''),
thumbnailFile: row.thumbnailFile })
const buildResetDraft = (row: PostImportRow): Draft => ({
url: row.resetSnapshot.url,
@@ -47,12 +42,13 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
duration: String (row.resetSnapshot.attributes.duration ?? ''),
tags: String (row.resetSnapshot.attributes.tags ?? ''),
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? ''),
duration: String (row.resetSnapshot.attributes.duration ?? ''),
thumbnailFile: undefined })
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
values.flatMap (value => value ?? [])
[...new Set (values.flatMap (value => value ?? []))]
const sameDraft = (left: Draft, right: Draft): boolean =>
left.url === right.url
@@ -60,9 +56,41 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
&& left.thumbnailBase === right.thumbnailBase
&& left.originalCreatedFrom === right.originalCreatedFrom
&& left.originalCreatedBefore === right.originalCreatedBefore
&& left.duration === right.duration
&& left.tags === right.tags
&& left.parentPostIds === right.parentPostIds
&& left.duration === right.duration
&& left.thumbnailFile === right.thumbnailFile
const sameProvenance = (
current: PostImportRow['provenance'],
reset: PostImportRow['resetSnapshot']['provenance'],
): boolean =>
Object.keys (reset).every (field => current[field] === reset[field])
const sameTagSources = (
current: PostImportRow['tagSources'],
reset: PostImportRow['resetSnapshot']['tagSources'],
): boolean =>
(current?.automatic ?? '') === reset.automatic
&& (current?.manual ?? '') === reset.manual
const sameWarnings = (
current: PostImportRow,
reset: PostImportRow['resetSnapshot'],
): boolean =>
JSON.stringify (current.fieldWarnings) === JSON.stringify (reset.fieldWarnings)
&& JSON.stringify (current.baseWarnings) === JSON.stringify (reset.baseWarnings)
const thumbnailWarnings = (
messages: string[] | undefined,
thumbnailBase: string,
thumbnailFile: File | undefined,
): string[] => {
const others = (messages ?? []).filter (message => message !== THUMBNAIL_MISSING_WARNING)
return hasThumbnailBaseValue (thumbnailBase) || thumbnailFile != null
? others
: [...new Set ([...others, THUMBNAIL_MISSING_WARNING])]
}
const PostImportRowForm: FC<Props> = (
@@ -74,19 +102,33 @@ const PostImportRowForm: FC<Props> = (
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
const [saving, setSaving] = useState (false)
const [resetRequested, setResetRequested] = useState (false)
const [committedThumbnailBase, setCommittedThumbnailBase] = useState (
() => String (row.attributes.thumbnailBase ?? ''))
useEffect (() => {
const nextDraft = buildDraft (row)
setDraft (nextDraft)
setMessageRow (null)
setResetRequested (false)
setCommittedThumbnailBase (String (row.attributes.thumbnailBase ?? ''))
}, [row])
const displayRow = messageRow ?? row
const resetDraft = useMemo (
() => buildResetDraft (row),
[row])
const resetDisabled = saving || sameDraft (draft, resetDraft)
const durationVisible = hasVideoTag (draft.tags)
const currentThumbnailWarnings = thumbnailWarnings (
displayRow.fieldWarnings.thumbnailBase,
draft.thumbnailBase,
draft.thumbnailFile)
const resetDisabled =
saving
|| (sameDraft (draft, resetDraft)
&& sameProvenance (row.provenance, row.resetSnapshot.provenance)
&& sameTagSources (row.tagSources, row.resetSnapshot.tagSources)
&& row.metadataUrl === row.resetSnapshot.metadataUrl
&& sameWarnings (displayRow, row.resetSnapshot))
const update = <Key extends keyof Draft,> (
key: Key,
@@ -103,7 +145,6 @@ const PostImportRowForm: FC<Props> = (
const confirmed = await controls.confirm ({
title: '変更をリセットしますか?',
description: '現在の URL に対する自動取得直後の内容へ戻します.',
confirmText: 'リセット',
cancelText: '取消',
variant: 'danger' })
@@ -113,25 +154,26 @@ const PostImportRowForm: FC<Props> = (
setDraft (resetDraft)
setResetRequested (true)
setMessageRow (null)
setCommittedThumbnailBase (resetDraft.thumbnailBase)
return false
}, [controls, resetDisabled, resetDraft])
const save = useCallback (async (): Promise<boolean> => {
setSaving (true)
try
{
const result = await onSave ({ draft, resetRequested })
if (result.saved)
return true
{
const result = await onSave ({ draft, resetRequested })
if (result.saved)
return true
if (result.row != null)
setMessageRow (result.row)
return false
}
if (result.row != null)
setMessageRow (result.row)
return false
}
finally
{
setSaving (false)
}
{
setSaving (false)
}
}, [draft, onSave, resetRequested])
useEffect (() => {
@@ -153,72 +195,104 @@ const PostImportRowForm: FC<Props> = (
<div className="space-y-4">
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<div className="space-y-3 md:sticky md:top-0 md:self-start">
<ThumbnailPreview
url={draft.thumbnailBase}
<PostImportThumbnailPreview
url={committedThumbnailBase}
file={
hasThumbnailBaseValue (committedThumbnailBase)
? undefined
: draft.thumbnailFile}
className="h-28 w-28"/>
</div>
<div className="space-y-4">
<PostImportTextField
label="URL"
value={draft.url}
warnings={displayRow.fieldWarnings.url}
errors={groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url)}
onChange={value => update ('url', value)}/>
<PostImportTextField
label="タイトル"
value={draft.title}
warnings={displayRow.fieldWarnings.title}
errors={groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title)}
onChange={value => update ('title', value)}/>
<PostImportTextField
label="サムネール基底 URL"
value={draft.thumbnailBase}
warnings={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField
originalCreatedFrom={draft.originalCreatedFrom || null}
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
originalCreatedBefore={draft.originalCreatedBefore || null}
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
errors={groupedMessages (
displayRow.validationErrors.originalCreatedAt,
displayRow.validationErrors.originalCreatedFrom,
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedAt,
displayRow.importErrors?.originalCreatedFrom,
displayRow.importErrors?.originalCreatedBefore)}/>
<PostImportTextField
label="動画時間"
value={draft.duration}
errors={groupedMessages (
displayRow.validationErrors.duration,
displayRow.validationErrors.videoMs,
displayRow.importErrors?.duration,
displayRow.importErrors?.videoMs)}
onChange={value => update ('duration', value)}/>
<PostImportAreaField
label="タグ"
value={draft.tags}
warnings={displayRow.fieldWarnings.tags}
errors={groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags)}
onChange={value => update ('tags', value)}/>
<PostImportTextField
label="親投稿"
value={draft.parentPostIds}
errors={groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds)}
onChange={value => update ('parentPostIds', value)}/>
<PostCreationDataFields
url={{
value: draft.url,
onChange: value => update ('url', value),
disabled: saving,
warnings: displayRow.fieldWarnings.url,
errors: groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url) }}
thumbnailField={
<>
<PostTextField
label="サムネール"
value={draft.thumbnailBase}
disabled={saving}
warnings={currentThumbnailWarnings}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onBlur={() => {
if (draft.thumbnailBase.trim () !== committedThumbnailBase.trim ())
setCommittedThumbnailBase (draft.thumbnailBase)
}}
onChange={value => update ('thumbnailBase', value)}/>
{!(hasThumbnailBaseValue (draft.thumbnailBase)) && (
<input
type="file"
accept="image/*"
disabled={saving}
onChange={event => {
const file = event.target.files?.[0]
update ('thumbnailFile', file)
}}/>)}
</>}
core={{
title: {
value: draft.title,
onChange: value => update ('title', value),
disabled: saving,
warnings: displayRow.fieldWarnings.title,
errors: groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title) },
originalCreated: {
disabled: saving,
originalCreatedFrom: draft.originalCreatedFrom || null,
setOriginalCreatedFrom: value =>
update ('originalCreatedFrom', value ?? ''),
originalCreatedBefore: draft.originalCreatedBefore || null,
setOriginalCreatedBefore: value =>
update ('originalCreatedBefore', value ?? ''),
errors: {
originalCreatedAt: groupedMessages (
displayRow.validationErrors.originalCreatedAt,
displayRow.importErrors?.originalCreatedAt),
originalCreatedFrom: groupedMessages (
displayRow.validationErrors.originalCreatedFrom,
displayRow.importErrors?.originalCreatedFrom),
originalCreatedBefore: groupedMessages (
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedBefore) } },
tags: {
value: draft.tags,
onChange: value => update ('tags', value),
disabled: saving,
warnings: displayRow.fieldWarnings.tags,
errors: groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags),
rows: 4 },
parentPostIds: {
value: draft.parentPostIds,
onChange: value => update ('parentPostIds', value),
disabled: saving,
errors: groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds) } }}
extraFields={
durationVisible
? (
<PostDurationField
value={draft.duration}
onChange={value => update ('duration', value)}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.videoMs,
displayRow.importErrors?.videoMs)}/>)
: null}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
@@ -229,49 +303,6 @@ const PostImportRowForm: FC<Props> = (
</>)
}
const PostImportTextField = (
{ label, value, warnings, errors, onChange }: {
label: string
value: string
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<input
value={value}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
<FieldWarning messages={warnings}/>
</>)}
</FormField>)
const PostImportAreaField = (
{ label, value, warnings, errors, onChange }: {
label: string
value: string
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<TextArea
value={value}
rows={4}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
invalid={invalid}
className="h-auto"/>
<FieldWarning messages={warnings}/>
</>)}
</FormField>)
export default PostImportRowForm
export { buildDraft }
export type { Draft as PostImportRowDraft }
+161 -75
ファイルの表示
@@ -1,17 +1,32 @@
import FieldError from '@/components/common/FieldError'
import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import {
canEditReviewRow,
canRetryResultRow,
hasVideoTag,
} from '@/lib/postImportRows'
import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
import type { PostImportRow } from '@/lib/postImportTypes'
type Props = {
row: PostImportRow
onEdit: () => void }
row: PostImportRow
displayNumber?: number
onEdit?: () => void
onRetry?: () => void
onToggleSkip?: (checked: boolean) => void
rowMessages?: string[]
editDisabled?: boolean
retryDisabled?: boolean
skipDisabled?: boolean
showActions?: boolean
showSkipToggle?: boolean }
const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0]
@@ -19,94 +34,165 @@ const summaryWarning = (row: PostImportRow): string | null =>
?? null
const summaryDate = (row: PostImportRow): string =>
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
.filter (_1 => typeof _1 === 'string' && _1 !== '')
.join (' ~ ')
originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
const PostImportRowSummary: FC<Props> = (
{ row,
displayNumber,
onEdit,
onRetry,
onToggleSkip,
rowMessages,
editDisabled,
retryDisabled,
skipDisabled,
showActions = true,
showSkipToggle = false },
) => {
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
const editVisible = onEdit != null
const editAllowed = editVisible && canEditReviewRow (row)
const retryAllowed = onRetry != null && canRetryResultRow (row)
const skipChecked = row.skipReason === 'manual'
const rowNumber = displayNumber ?? row.sourceRow
const duration = String (row.attributes.duration ?? '')
const showDuration = hasVideoTag (row.attributes.tags) && duration !== ''
const skipControl = showSkipToggle
? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={skipChecked}
onChange={event => onToggleSkip?.(event.target.checked)}
disabled={skipDisabled === true}/>
<span></span>
</label>)
: null
return (
<>
<div
className={cn (
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
'transition-shadow hover:shadow-sm')}>
<div className="space-y-1">
<div className="text-sm font-medium">#{row.sourceRow}</div>
return (
<>
<div
className={cn (
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
'transition-shadow hover:shadow-sm')}>
<div className="space-y-1">
<div className="text-sm font-medium">#{rowNumber}</div>
</div>
<PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')}
file={row.thumbnailFile}
className="h-16 w-16"/>
<div className="min-w-0 space-y-1">
<div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '')}
</div>
<ThumbnailPreview
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url}
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{duration}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
<FieldError messages={rowMessages}/>
</div>
<div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="flex justify-end">
<div className="flex items-center gap-2">
{skipControl}
{showActions && editVisible && (
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
</Button>)}
{showActions && retryAllowed && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
</Button>)}
</div>
</div>
</div>
<div
className={cn (
'space-y-3 rounded-lg border p-4 md:hidden',
'transition-shadow hover:shadow-sm')}>
<div className="text-sm font-medium">#{rowNumber}</div>
<div className="flex items-start gap-3">
<PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')}
className="h-16 w-16"/>
<div className="min-w-0 space-y-1">
file={row.thumbnailFile}
className="h-20 w-20 shrink-0"/>
<div className="min-w-0 flex-1 space-y-2">
<div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
{String (row.attributes.title ?? '')}
</div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url}
</div>
<div className="truncate text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '') || 'タグなし'}
<div className="flex flex-wrap gap-2">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row) || '日時未取得'}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
{summaryDate (row)}
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{duration}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
</div>
<div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={row.importStatus === 'created'}>
</Button>
<FieldError messages={rowMessages}/>
{skipControl}
</div>
</div>
<div
className={cn (
'space-y-3 rounded-lg border p-4 md:hidden',
'transition-shadow hover:shadow-sm')}>
<div className="flex items-start gap-3">
<ThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')}
className="h-20 w-20 shrink-0"/>
<div className="min-w-0 flex-1 space-y-2">
<div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
</div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url}
</div>
<div className="flex flex-wrap gap-2">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
</div>
</div>
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={row.importStatus === 'created'}>
</Button>
</div>
</>)
{showActions && (editVisible || retryAllowed) && (
<div className="flex flex-col gap-2 md:flex-row">
{editVisible && (
<Button
type="button"
className="w-full md:w-auto"
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
</Button>)}
{retryAllowed && (
<Button
type="button"
className="w-full md:w-auto"
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
</Button>)}
</div>)}
</div>
</>)
}
export default PostImportRowSummary
+8 -2
ファイルの表示
@@ -10,13 +10,19 @@ type Props = {
const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能',
error: '登録不可',
warning: '警告',
skipped: 'スキップ' }
skipped: 'スキップ',
created: '登録済み',
failed: '登録失敗' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'success',
error: 'warning',
warning: 'warning',
skipped: 'neutral' }
skipped: 'neutral',
created: 'success',
failed: 'warning' }
const PostImportStatusBadge: FC<Props> = ({ value }) => (
+36
ファイルの表示
@@ -0,0 +1,36 @@
import TagLink from '@/components/TagLink'
import type { FC } from 'react'
import type { PostImportDisplayTag } from '@/lib/postImportTypes'
type Props = {
tags: PostImportDisplayTag[] | undefined }
const PostImportTagLinks: FC<Props> = ({ tags }) => {
if (tags == null || tags.length === 0)
return null
return (
<div className="flex flex-wrap text-xs gap-x-1">
{tags.map (tag => {
const key = `${ tag.category }:${ tag.name }:${ tag.sectionLiterals?.join ('|') ?? '' }`
return (
<span key={key} className="inline-flex flex-nowrap items-baseline gap-1">
<TagLink
tag={{
name: tag.name,
category: tag.category }}
linkFlg={false}
withWiki={false}
withCount={false}/>
{tag.sectionLiterals?.map (literal => (
<span key={literal} className="text-xs text-neutral-500 dark:text-neutral-400">
{literal}
</span>))}
</span>)
})}
</div>)
}
export default PostImportTagLinks
+59
ファイルの表示
@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
describe ('PostImportThumbnailPreview', () => {
beforeEach (() => {
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
})
it ('renders the remote URL directly without a backend proxy', () => {
render (
<PostImportThumbnailPreview
url="https://example.com/thumbnail.jpg"
className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute (
'src',
'https://example.com/thumbnail.jpg')
expect (screen.getByRole ('img')).toHaveAttribute (
'referrerpolicy',
'no-referrer')
})
it ('shows the empty frame after the remote image fails', () => {
const { container } = render (
<PostImportThumbnailPreview
url="https://example.com/missing.jpg"
className="h-10 w-10"/>)
fireEvent.error (screen.getByRole ('img'))
expect (screen.queryByRole ('img')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('')
})
it ('uses and revokes an object URL only when the remote URL is blank', () => {
const file = new File (['image'], 'thumbnail.png', { type: 'image/png' })
const { rerender, unmount } = render (
<PostImportThumbnailPreview url="" file={file} className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
rerender (
<PostImportThumbnailPreview
url="https://example.com/remote.jpg"
file={file}
className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute (
'src',
'https://example.com/remote.jpg')
unmount ()
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:preview')
})
})
+25
ファイルの表示
@@ -0,0 +1,25 @@
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import type { FC } from 'react'
type Props = {
url: string
file?: File
alt?: string
className?: string }
const PostImportThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
className = 'h-16 w-16' },
) => (
<PostThumbnailPreview
url={url}
file={file}
alt={alt}
className={className}
referrerPolicy="no-referrer"/>)
export default PostImportThumbnailPreview
-49
ファイルの表示
@@ -1,49 +0,0 @@
import { useEffect, useState } from 'react'
import type { FC } from 'react'
type Props = {
url: string
alt?: string
className?: string }
const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className = 'h-16 w-16' }) => {
const [failed, setFailed] = useState (false)
useEffect (() => {
setFailed (false)
}, [url])
if (!(url))
{
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-border bg-muted text-xs text-muted-foreground`}>
</div>)
}
if (failed)
{
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-amber-300 bg-amber-50 p-2 text-center text-xs
text-amber-700 dark:border-amber-900 dark:bg-amber-950
dark:text-amber-200`}>
</div>)
}
return (
<img
src={url}
alt={alt}
className={`${ className } rounded border border-border object-cover`}
onError={() => setFailed (true)}/>)
}
export default ThumbnailPreview
+6 -4
ファイルの表示
@@ -12,16 +12,18 @@ describe ('displayPostImportStatus', () => {
expect (displayPostImportStatus (buildPostImportRow ({
skipReason: 'existing',
existingPostId: 2 }))).toBe ('skipped')
expect (displayPostImportStatus (buildPostImportRow ({
skipReason: 'manual' }))).toBe ('skipped')
})
it ('does not expose validation, failure, or created states as badges', () => {
it ('distinguishes validation, failure, and created states', () => {
expect (displayPostImportStatus (buildPostImportRow ({
status: 'error',
validationErrors: { title: ['invalid'] } }))).toBeNull ()
validationErrors: { title: ['invalid'] } }))).toBe ('error')
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'failed' }))).toBeNull ()
importStatus: 'failed' }))).toBe ('failed')
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'created',
createdPostId: 3 }))).toBeNull ()
createdPostId: 3 }))).toBe ('created')
})
})
+23 -11
ファイルの表示
@@ -1,22 +1,34 @@
import type { PostImportRow } from '@/lib/postImportSession'
import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
export type PostImportDisplayStatus =
'ready'
| 'error'
| 'skipped'
| 'warning'
| 'created'
| 'failed'
export type PostImportBadgeValue = PostImportDisplayStatus
const hasWarnings = (row: PostImportRow): boolean =>
Object.values (row.fieldWarnings ?? { }).some (_1 => _1.length > 0)
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|| row.baseWarnings.length > 0
export const displayPostImportStatus = (
row: PostImportRow,
): PostImportDisplayStatus | null =>
(row.skipReason === 'existing' || row.importStatus === 'skipped')
row.status === 'pending'
? null
: (row.importStatus === 'failed')
? 'failed'
: (row.skipReason != null || row.importStatus === 'skipped')
? 'skipped'
: ((Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed'
|| row.importStatus === 'created')
? null
: ((hasWarnings (row) || row.status === 'warning')
? 'warning'
: 'ready'))
: (row.importStatus === 'created')
? 'created'
: (row.status === 'error')
? 'error'
: (Object.values (row.validationErrors ?? { }).some (messages => messages.length > 0))
? 'error'
: ((hasWarnings (row) || row.status === 'warning')
? 'warning'
: 'ready')
+95 -97
ファイルの表示
@@ -1,10 +1,10 @@
"use client"
'use client'
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from 'react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
@@ -15,111 +15,109 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className)}
{...props}
/>))
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn (
'fixed inset-0 z-50 bg-black/80',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className)}
{...props}/>))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className)}
{...props}
>
{children}
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(
({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className)}
{...props}>
{children}
<DialogPrimitive.Close
className={cn (
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>))
<DialogPrimitive.Close
className={cn (
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className)}
{...props}
/>)
DialogHeader.displayName = "DialogHeader"
const DialogHeader = (
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>,
) => (
<div
className={cn (
'flex flex-col space-y-1.5 text-center md:text-left',
className)}
{...props}/>)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className)}
{...props}
/>)
DialogFooter.displayName = "DialogFooter"
const DialogFooter = (
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>,
) => (
<div
className={cn (
'flex flex-col-reverse md:flex-row md:justify-end md:space-x-2',
className)}
{...props}/>)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className)}
{...props}
/>))
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(
({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn (
'text-lg font-semibold leading-none tracking-tight',
className)}
{...props}/>))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>))
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(
({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn ('text-sm text-muted-foreground', className)}
{...props}/>))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+1
ファイルの表示
@@ -8,6 +8,7 @@ import type { AxiosError, AxiosRequestConfig } from 'axios'
type Opt = {
params?: AxiosRequestConfig['params']
headers?: Record<string, string>
signal?: AbortSignal
responseType?: 'blob' }
const client = axios.create ({ baseURL: API_BASE_URL })
+157 -12
ファイルの表示
@@ -1,13 +1,22 @@
import { describe, expect, it } from 'vitest'
import { creatableImportRows,
buildNextEditedRow,
canEditResultRow,
canEditReviewRow,
canRetryResultRow,
hasExactSourceRows,
initialisePreviewRows,
mergeImportResults,
mergeValidatedImportRow,
mergeValidatedImportRows,
processableImportRows,
replaceImportRow,
resultRepairMode,
resultRowMessages,
resultSummaryCounts,
retryImportRow,
reviewSummaryCounts } from '@/lib/postImportSession'
reviewSummaryCounts } from '@/lib/postImportRows'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import row state', () => {
@@ -17,22 +26,26 @@ describe ('post import row state', () => {
sourceRow: 2,
skipReason: 'existing',
existingPostId: 20 })
const invalid = buildPostImportRow ({
const manual = buildPostImportRow ({
sourceRow: 3,
skipReason: 'manual' })
const invalid = buildPostImportRow ({
sourceRow: 4,
status: 'error',
validationErrors: { url: ['invalid'] } })
const created = buildPostImportRow ({
sourceRow: 4,
sourceRow: 5,
importStatus: 'created',
createdPostId: 40 })
const rows = [ready, existing, invalid, created]
const rows = [ready, existing, manual, invalid, created]
expect (processableImportRows (rows)).toEqual ([ready, existing])
expect (processableImportRows (rows)).toEqual ([ready])
expect (creatableImportRows (rows)).toEqual ([ready])
expect (reviewSummaryCounts (rows)).toEqual ({
total: 4,
submittable: 1,
skipPlanned: 1 })
creatable: 1,
manualSkipped: 1,
existingSkipped: 1,
pendingOrError: 1 })
})
it ('preserves terminal rows while merging validation results', () => {
@@ -82,13 +95,16 @@ describe ('post import row state', () => {
it ('merges result states and clears incompatible post identifiers', () => {
const created = mergeImportResults ([buildPostImportRow ({
skipReason: 'existing',
existingPostId: 2 })], [{
existingPostId: 2,
recoverable: true,
importStatus: 'pending' })], [{
sourceRow: 1,
status: 'created',
post: { id: 3 } }])[0]
const skipped = mergeImportResults ([buildPostImportRow ({
createdPostId: 3,
importStatus: 'created' })], [{
importStatus: 'created',
recoverable: true })], [{
sourceRow: 1,
status: 'skipped',
existingPostId: 4 }])[0]
@@ -97,20 +113,24 @@ describe ('post import row state', () => {
existingPostId: 4 })], [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { base: ['failure'] } }])[0]
expect (created).toMatchObject ({
importStatus: 'created',
createdPostId: 3,
existingPostId: undefined,
recoverable: undefined,
skipReason: undefined })
expect (skipped).toMatchObject ({
importStatus: 'skipped',
existingPostId: 4,
createdPostId: undefined,
recoverable: undefined,
skipReason: 'existing' })
expect (failed).toMatchObject ({
importStatus: 'failed',
recoverable: true,
createdPostId: undefined,
existingPostId: undefined,
skipReason: undefined,
@@ -123,12 +143,103 @@ describe ('post import row state', () => {
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
existingPostId: 2, skipReason: 'existing' }),
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
importErrors: { base: ['failed'] } })]
recoverable: true, importErrors: { base: ['failed'] } }),
buildPostImportRow ({ sourceRow: 4, importStatus: 'pending',
recoverable: true, validationErrors: { base: ['failed'] } })]
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 1 })
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 2 })
expect (retryImportRow (rows, 3)[2]).toMatchObject ({
importStatus: 'pending',
importErrors: undefined })
expect (retryImportRow (rows, 4)[3]).toBe (rows[3])
})
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 ('classifies editable and retryable rows by terminal and recoverable state', () => {
const ready = buildPostImportRow ()
const skipped = buildPostImportRow ({
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 2 })
const hardFailed = buildPostImportRow ({
importStatus: 'failed',
importErrors: { base: ['failed'] } })
const pendingInvalid = buildPostImportRow ({
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } })
const pendingValid = buildPostImportRow ({
importStatus: 'pending',
recoverable: true })
expect (canEditReviewRow (ready)).toBe (true)
expect (canEditReviewRow (skipped)).toBe (false)
expect (canEditReviewRow (hardFailed)).toBe (false)
expect (canEditResultRow (pendingInvalid)).toBe (true)
expect (canRetryResultRow (pendingInvalid)).toBe (false)
expect (canRetryResultRow (pendingValid)).toBe (true)
})
it ('detects missing, duplicate, and extra source rows exactly', () => {
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 2 }])).toBe (true)
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }])).toBe (false)
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 1 }])).toBe (false)
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 3 }])).toBe (false)
})
it ('merges only the validated source row and preserves other row edits', () => {
const edited = buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited row' },
provenance: { title: 'manual' } })
const other = buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'keep me' },
provenance: { title: 'manual' } })
const validated = buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'validated row' } })
const result = mergeValidatedImportRow ([edited, other], validated)
expect (result[0]?.attributes.title).toBe ('validated row')
expect (result[1]?.attributes.title).toBe ('keep me')
})
it ('replaces only the targeted source row and preserves the others', () => {
const original = buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })
const other = buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'keep edited row' } })
const restored = buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })
const result = replaceImportRow ([original, other], restored)
expect (result[0]).toEqual (restored)
expect (result[1]?.attributes.title).toBe ('keep edited row')
})
it ('copies reset snapshot values instead of sharing mutable records', () => {
@@ -144,4 +255,38 @@ describe ('post import row state', () => {
expect (initialised.resetSnapshot.attributes.title).toBe ('')
expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning'])
})
it ('builds the next edited row with shared repair semantics', () => {
const row = buildPostImportRow ({
url: 'https://example.com/original',
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] },
attributes: { title: 'old title', tags: 'old-tag', duration: '2' },
provenance: { title: 'automatic', tags: 'automatic', url: 'manual' },
tagSources: { automatic: 'old-tag', manual: '' } })
const nextRow = buildNextEditedRow (
row,
{
url: 'https://example.com/edited',
title: 'edited title',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '2',
tags: 'edited-tag',
parentPostIds: '' },
true)
expect (nextRow.importStatus).toBe ('pending')
expect (nextRow.importErrors).toBeUndefined ()
expect (nextRow.url).toBe ('https://example.com/edited')
expect (nextRow.attributes.title).toBe ('edited title')
expect (nextRow.attributes.duration).toBe ('2')
expect (nextRow.attributes.tags).toBe ('edited-tag')
expect (nextRow.provenance.title).toBe ('manual')
expect (nextRow.provenance.url).toBe ('manual')
expect (nextRow.tagSources?.manual).toBe ('edited-tag')
})
})
+357 -109
ファイルの表示
@@ -1,15 +1,77 @@
import type { PostImportResultRow,
import type { PostImportEditableDraft,
PostImportResultRow,
PostImportRow } from '@/lib/postImportTypes'
const hasSkipReason = (row: PostImportRow): boolean =>
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
export const hasThumbnailBaseValue = (value: unknown): boolean =>
typeof value === 'string' && value.trim () !== ''
export const hasVideoTag = (value: unknown): boolean =>
typeof value === 'string'
&& value.split (/\s+/).includes ('動画')
export const isExistingSkipRow = (row: PostImportRow): boolean =>
row.skipReason === 'existing'
export const isManualSkipRow = (row: PostImportRow): boolean =>
row.skipReason === 'manual'
const hasSkipReason = (row: PostImportRow): boolean =>
row.skipReason != null
export const compactMessageRecord = (
messages: Record<string, string[]>,
): Record<string, string[]> =>
Object.fromEntries (
Object.entries (messages).filter (([, values]) => values.length > 0))
export const hasErrorMessages = (
messages: Record<string, string[]>,
): boolean =>
Object.values (messages).some (values => values.length > 0)
const hasValidationErrors = (row: PostImportRow): boolean =>
Object.keys (row.validationErrors ?? { }).length > 0
hasErrorMessages (row.validationErrors ?? { })
const isRecoverableRow = (row: PostImportRow): boolean =>
row.recoverable === true
const isRepairableImportStatus = (row: PostImportRow): boolean =>
isRecoverableRow (row)
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
export const isNonRecoverableFailedRow = (row: PostImportRow): boolean =>
row.importStatus === 'failed' && row.recoverable !== true
export const isTerminalRow = (row: PostImportRow): boolean =>
row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| isNonRecoverableFailedRow (row)
export const isCompletedReviewRow = (row: PostImportRow): boolean =>
row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| hasSkipReason (row)
export const validatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.filter (row => !(hasSkipReason (row)) && !(isTerminalRow (row)))
const buildResetSnapshot = (row: PostImportRow) => ({
url: row.url,
attributes: { ...row.attributes },
displayTags: row.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals: tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })) ?? [],
provenance: { ...row.provenance },
tagSources: {
automatic: row.tagSources?.automatic ?? '',
@@ -19,18 +81,43 @@ const buildResetSnapshot = (row: PostImportRow) => ({
baseWarnings: [...row.baseWarnings],
metadataUrl: row.metadataUrl })
const deduped = (values: string[]): string[] =>
[...new Set (values)]
const thumbnailWarnings = (row: PostImportRow): string[] => {
const current = row.fieldWarnings.thumbnailBase ?? []
const others = current.filter (message => message !== THUMBNAIL_MISSING_WARNING)
return hasThumbnailBaseValue (row.attributes.thumbnailBase) || row.thumbnailFile != null
? others
: deduped ([...others, THUMBNAIL_MISSING_WARNING])
}
export const applyThumbnailWarning = (row: PostImportRow): PostImportRow => ({
...row,
fieldWarnings: compactMessageRecord ({
...row.fieldWarnings,
thumbnailBase: thumbnailWarnings (row) }) })
export const applyThumbnailWarnings = (rows: PostImportRow[]): PostImportRow[] =>
rows.map (row => applyThumbnailWarning (row))
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.filter (row => {
if (row.importStatus === 'created')
return false
if (row.importStatus === 'skipped')
return false
if (row.importStatus === 'failed')
return false
if (hasValidationErrors (row))
return false
return row.importStatus == null || row.importStatus === 'pending'
validatableImportRows (rows).filter (row => {
if (row.status === 'pending')
return false
if (row.importStatus === 'created')
return false
if (row.importStatus === 'skipped')
return false
if (row.importStatus === 'failed')
return false
if (hasValidationErrors (row))
return false
return row.importStatus == null || row.importStatus === 'pending'
})
export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
@@ -38,33 +125,169 @@ export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
total: rows.length,
submittable: rows.filter (row =>
creatableImportRows ([row]).length > 0
&& !(hasValidationErrors (row))).length,
skipPlanned: rows.filter (row =>
processableImportRows ([row]).length > 0
&& hasSkipReason (row)).length })
creatable: rows.filter (row => creatableImportRows ([row]).length > 0).length,
manualSkipped: rows.filter (row => isManualSkipRow (row)).length,
existingSkipped: rows.filter (row => isExistingSkipRow (row)).length,
pendingOrError: rows.filter (row =>
!(isCompletedReviewRow (row))
&& creatableImportRows ([row]).length === 0).length })
export const resultSummaryCounts = (rows: PostImportRow[]) =>
rows.reduce (
(counts, row) => {
if (row.importStatus === 'created')
{
++counts.created
return counts
}
if (row.importStatus === 'skipped')
{
++counts.skipped
return counts
}
if (row.importStatus === 'failed')
++counts.failed
return counts
},
{ created: 0, skipped: 0, failed: 0 })
(counts, row) => {
if (row.importStatus === 'created')
{
++counts.created
return counts
}
if (row.importStatus === 'skipped')
{
++counts.skipped
return counts
}
if (row.importStatus === 'failed'
|| (row.recoverable === true && row.importStatus === 'pending'))
++counts.failed
return counts
},
{ created: 0, skipped: 0, failed: 0 })
export const resultRepairMode = (
rows: PostImportRow[],
): 'all' | 'failed' =>
rows.some (row => hasValidationErrors (row) || isRepairableImportStatus (row))
? 'failed'
: 'all'
export const canEditReviewRow = (row: PostImportRow): boolean =>
!(hasSkipReason (row)
|| row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| (row.importStatus === 'failed' && row.recoverable !== true))
export const canEditResultRow = (row: PostImportRow): boolean =>
row.skipReason == null
&& row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && hasValidationErrors (row)))
export const canRetryResultRow = (row: PostImportRow): boolean =>
row.skipReason == null
&& row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && !(hasValidationErrors (row))))
export const resultRowMessages = (row: PostImportRow): string[] =>
[...new Set ([
...Object.values (row.validationErrors ?? { }).flat (),
...Object.values (row.importErrors ?? { }).flat ()])]
export const resultRowWarnings = (row: PostImportRow): string[] =>
[...new Set ([
...Object.values (row.fieldWarnings ?? { }).flat (),
...row.baseWarnings])]
const isManualChange = (
current: unknown,
next: string,
): boolean =>
next !== String (current ?? '')
export const buildNextEditedRow = (
editingRow: PostImportRow,
draft: PostImportEditableDraft,
urlChanged: boolean,
): PostImportRow => {
const nextProvenance = { ...editingRow.provenance }
const nextAttributes = { ...editingRow.attributes }
const nextTagSources = {
automatic: editingRow.tagSources?.automatic ?? '',
manual: editingRow.tagSources?.manual ?? '' }
const draftFields = [
['title', draft.title],
['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => {
nextAttributes[field] = value
nextProvenance[field] =
isManualChange (editingRow.attributes[field], value)
? 'manual'
: (editingRow.provenance[field] ?? 'automatic')
})
nextAttributes.tags = draft.tags
if (isManualChange (editingRow.attributes.tags, draft.tags))
{
nextProvenance.tags = 'manual'
nextTagSources.manual = draft.tags
}
else
{
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
}
return {
...editingRow,
url: draft.url,
attributes: nextAttributes,
displayTags:
editingRow.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals: tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
thumbnailFile: draft.thumbnailFile,
provenance: {
...nextProvenance,
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
tagSources: nextTagSources,
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
importErrors: undefined }
}
export const hasExactSourceRows = (
expected: number[],
actual: Array<{ sourceRow: number }>,
): boolean => {
if (expected.length !== actual.length)
return false
const expectedSorted = [...expected].sort ((a, b) => a - b)
const actualSorted = actual.map (row => row.sourceRow).sort ((a, b) => a - b)
return expectedSorted.every ((value, index) => value === actualSorted[index])
}
export const replaceImportRow = (
rows: PostImportRow[],
nextRow: PostImportRow,
): PostImportRow[] =>
rows.map (row => row.sourceRow === nextRow.sourceRow ? nextRow : row)
export const mergeValidatedImportRow = (
rows: PostImportRow[],
validated: PostImportRow,
): PostImportRow[] => {
const current = rows.find (row => row.sourceRow === validated.sourceRow)
if (current == null)
return rows
const [merged] = mergeValidatedImportRows ([current], [validated])
return merged == null ? rows : replaceImportRow (rows, merged)
}
export const mergeValidatedImportRows = (
@@ -73,46 +296,53 @@ export const mergeValidatedImportRows = (
): PostImportRow[] => {
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
return current.map (previous => {
if (
previous.importStatus === 'created'
|| previous.importStatus === 'skipped'
|| previous.importStatus === 'failed')
return previous
if (
previous.importStatus === 'created'
|| previous.importStatus === 'skipped'
|| previous.importStatus === 'failed')
return previous
const row = validatedMap.get (previous.sourceRow)
if (row == null)
return previous
const row = validatedMap.get (previous.sourceRow)
if (row == null)
return previous
const fieldWarnings =
Object.keys (row.fieldWarnings).length > 0 || row.metadataUrl !== previous.metadataUrl
? { ...row.fieldWarnings }
: { ...previous.fieldWarnings }
for (const [field, origin] of Object.entries (row.provenance))
{
if (origin === 'manual')
delete fieldWarnings[field]
}
const fieldWarnings =
hasErrorMessages (row.fieldWarnings) || row.metadataUrl !== previous.metadataUrl
? { ...row.fieldWarnings }
: { ...previous.fieldWarnings }
for (const [field, origin] of Object.entries (row.provenance))
{
if (origin === 'manual')
delete fieldWarnings[field]
}
return {
...previous,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
fieldWarnings,
baseWarnings:
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
? row.baseWarnings
: previous.baseWarnings,
validationErrors: row.validationErrors,
status: row.status,
metadataUrl: row.metadataUrl,
resetSnapshot:
row.metadataUrl !== previous.metadataUrl
? buildResetSnapshot (row)
: previous.resetSnapshot }
return {
...previous,
url: row.url,
attributes: row.attributes,
displayTags:
row.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
provenance: row.provenance,
tagSources: row.tagSources,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
existingPost: row.existingPost,
fieldWarnings,
baseWarnings:
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
? row.baseWarnings
: previous.baseWarnings,
validationErrors: row.validationErrors,
status: row.status,
metadataUrl: row.metadataUrl,
resetSnapshot:
row.metadataUrl !== previous.metadataUrl
? buildResetSnapshot (row)
: previous.resetSnapshot }
})
}
@@ -123,37 +353,52 @@ export const mergeImportResults = (
): PostImportRow[] => {
const resultMap = new Map (results.map (row => [row.sourceRow, row]))
return rows.map (row => {
const result = resultMap.get (row.sourceRow)
if (result == null)
return row
const result = resultMap.get (row.sourceRow)
if (result == null)
return row
switch (result.status)
{
case 'created':
return {
...row,
importStatus: 'created',
skipReason: undefined,
createdPostId: result.post.id,
existingPostId: undefined,
importErrors: result.errors }
case 'skipped':
return {
...row,
importStatus: 'skipped',
skipReason: 'existing',
createdPostId: undefined,
existingPostId: result.existingPostId,
importErrors: result.errors }
case 'failed':
return {
...row,
importStatus: 'failed',
skipReason: undefined,
createdPostId: undefined,
existingPostId: undefined,
importErrors: result.errors }
}
switch (result.status)
{
case 'created':
return {
...row,
importStatus: 'created',
recoverable: undefined,
skipReason: undefined,
createdPostId: result.post.id,
existingPostId: undefined,
existingPost: undefined,
fieldWarnings: compactMessageRecord (
result.fieldWarnings ?? row.fieldWarnings),
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: compactMessageRecord (result.errors ?? { }) }
case 'skipped':
return {
...row,
importStatus: 'skipped',
recoverable: undefined,
skipReason: 'existing',
createdPostId: undefined,
existingPostId: result.existingPostId,
existingPost: result.existingPost ?? row.existingPost,
fieldWarnings: compactMessageRecord (
result.fieldWarnings ?? row.fieldWarnings),
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: compactMessageRecord (result.errors ?? { }) }
case 'failed':
return {
...row,
importStatus: 'failed',
recoverable: result.recoverable === true ? true : undefined,
skipReason: undefined,
createdPostId: undefined,
existingPostId: undefined,
existingPost: undefined,
fieldWarnings: compactMessageRecord (
result.fieldWarnings ?? row.fieldWarnings),
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: compactMessageRecord (result.errors ?? { }) }
}
})
}
@@ -163,11 +408,14 @@ export const retryImportRow = (
sourceRow: number,
): PostImportRow[] =>
rows.map (row =>
row.sourceRow === sourceRow && row.importStatus === 'failed'
? { ...row, importStatus: 'pending', importErrors: undefined }
: row)
row.sourceRow === sourceRow
&& row.importStatus === 'failed'
&& row.recoverable === true
? { ...row, importStatus: 'pending', importErrors: undefined }
: row)
export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.map (row => ({
...row,
resetSnapshot: buildResetSnapshot (row) }))
applyThumbnailWarnings (
rows.map (row => ({
...row,
resetSnapshot: buildResetSnapshot (row) })))
-4
ファイルの表示
@@ -1,4 +0,0 @@
export * from '@/lib/postImportTypes'
export * from '@/lib/postImportStorage'
export * from '@/lib/postImportSourceValidation'
export * from '@/lib/postImportRows'
+4 -1
ファイルの表示
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession'
import {
countImportSourceLines,
validateImportSource,
} from '@/lib/postImportSourceValidation'
describe ('post import source validation', () => {
it ('counts trimmed non-empty CRLF and LF rows', () => {
+77 -66
ファイルの表示
@@ -12,37 +12,39 @@ const bytesize = (value: string): number =>
new TextEncoder ().encode (value).length
const normaliseImportUrl = (value: string): string | null => {
const parseImportUrl = (value: string): URL | null => {
const trimmed = value.trim ()
if (!(trimmed))
return null
try
{
const url = new URL (trimmed)
if (!(url.protocol === 'http:' || url.protocol === 'https:'))
return null
if (!(url.host))
return null
url.hostname = url.hostname.toLowerCase ()
if (url.pathname.endsWith ('/'))
url.pathname = url.pathname.replace (/\/+$/, '')
return url.toString ()
return new URL (trimmed)
}
catch
{
return null
return null
}
}
export const countImportSourceLines = (source: string): number =>
const normaliseImportUrl = (url: URL): string => {
url.hostname = url.hostname.toLowerCase ()
if (url.pathname.endsWith ('/'))
url.pathname = url.pathname.replace (/\/+$/, '')
return url.toString ()
}
export const extractImportSourceUrls = (source: string): string[] =>
source
.split (/\r\n|\n|\r/)
.map (_1 => _1.trim ())
.filter (_1 => _1 !== '')
.length
.split (/\r\n|\n|\r/)
.map (line => line.trim ())
.filter (line => line !== '')
export const countImportSourceLines = (source: string): number =>
extractImportSourceUrls (source).length
export const validateImportSource = (
@@ -54,56 +56,65 @@ export const validateImportSource = (
let count = 0
lines.forEach ((rawLine, index) => {
const value = rawLine.trim ()
if (!(value))
return
const value = rawLine.trim ()
if (!(value))
return
++count
const sourceRow = index + 1
const displayUrl = truncateUrl (value)
const normalised = normaliseImportUrl (value)
if (count > MAX_ROWS)
{
issues.push ({
sourceRow,
message: `取込件数は ${ MAX_ROWS } 件までです.`,
url: displayUrl })
return
}
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
{
issues.push ({
sourceRow,
message: 'HTTP または HTTPS の URL ではありません.',
url: displayUrl })
return
}
if (bytesize (value) > MAX_URL_BYTES)
{
issues.push ({
sourceRow,
message: 'URL が長すぎます.',
url: displayUrl })
return
}
if (normalised == null)
{
issues.push ({
sourceRow,
message: 'URL の形式が不正です',
url: displayUrl })
return
}
const duplicateRow = seen.get (normalised)
if (duplicateRow != null)
{
issues.push ({
sourceRow,
message: `${ duplicateRow } 行目と同じ URL です.`,
url: displayUrl })
return
}
seen.set (normalised, sourceRow)
++count
const sourceRow = index + 1
const displayUrl = truncateUrl (value)
if (count > MAX_ROWS)
{
issues.push ({
sourceRow,
message: `取込件数は ${ MAX_ROWS } 件までです.`,
url: displayUrl })
return
}
if (bytesize (value) > MAX_URL_BYTES)
{
issues.push ({
sourceRow,
message: 'URL が長すぎます.',
url: displayUrl })
return
}
const parsed = parseImportUrl (value)
if (parsed == null)
{
issues.push ({
sourceRow,
message: 'URL の形式が不正です.',
url: displayUrl })
return
}
if (!(parsed.protocol === 'http:' || parsed.protocol === 'https:'))
{
issues.push ({
sourceRow,
message: 'HTTP または HTTPS の URL ではありません',
url: displayUrl })
return
}
if (!(parsed.host))
{
issues.push ({
sourceRow,
message: 'URL の形式が不正です.',
url: displayUrl })
return
}
const normalised = normaliseImportUrl (parsed)
const duplicateRow = seen.get (normalised)
if (duplicateRow != null)
{
issues.push ({
sourceRow,
message: `${ duplicateRow } 行目と同じ URL です.`,
url: displayUrl })
return
}
seen.set (normalised, sourceRow)
})
return issues
+15 -89
ファイルの表示
@@ -1,105 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanupExpiredPostImportSessions,
clearPostImportSourceDraft,
loadPostImportSession,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft } from '@/lib/postImportSession'
import { buildPostImportRow } from '@/test/postImportFactories'
import {
clearPostImportSourceDraft,
loadPostImportSourceDraft,
savePostImportSourceDraft,
} from '@/lib/postImportStorage'
describe ('post import storage', () => {
describe ('post import source draft storage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.useRealTimers ()
vi.restoreAllMocks ()
})
it ('round-trips a valid session and source draft', () => {
const row = buildPostImportRow ({
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 })
expect (savePostImportSession ('session', {
source: row.url,
rows: [row],
repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ('session')).toMatchObject ({
version: 2,
source: row.url,
rows: [{
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 }] })
it ('round-trips and clears the URL list source draft', () => {
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
expect (loadPostImportSourceDraft ()).toEqual ({
source: 'https://example.com' })
clearPostImportSourceDraft ()
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
})
it ('rejects inconsistent post IDs and terminal statuses', () => {
const session = {
version: 2,
savedAt: new Date ().toISOString (),
source: '',
repairMode: 'all',
rows: [buildPostImportRow ()] }
const invalidRows = [
{ ...session.rows[0], skipReason: 'existing', existingPostId: undefined },
{ ...session.rows[0], existingPostId: 2, skipReason: undefined },
{ ...session.rows[0], importStatus: 'created', createdPostId: undefined },
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 }]
it ('ignores malformed stored drafts', () => {
sessionStorage.setItem ('post-import-source-draft', '{')
for (const [index, row] of invalidRows.entries ())
{
sessionStorage.setItem (`post-import-session:invalid-${ index }`, JSON.stringify ({
...session,
rows: [row] }))
expect (loadPostImportSession (`invalid-${ index }`)).toBeNull ()
}
})
it ('rejects invalid attribute, provenance, tag-source, and snapshot data', () => {
const row = buildPostImportRow ()
const invalidRows = [
{ ...row, attributes: { title: [] } },
{ ...row, attributes: { unknown: 'value' } },
{ ...row, provenance: { title: 'mapped' } },
{ ...row, tagSources: { mapped: 'tag' } },
{ ...row, resetSnapshot: { ...row.resetSnapshot,
fieldWarnings: { title: 'warning' } } }]
invalidRows.forEach ((invalidRow, index) => {
sessionStorage.setItem (`post-import-session:shape-${ index }`, JSON.stringify ({
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [invalidRow],
repairMode: 'all' }))
expect (loadPostImportSession (`shape-${ index }`)).toBeNull ()
})
})
it ('removes expired and malformed sessions without touching current sessions', () => {
const current = {
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [buildPostImportRow ()],
repairMode: 'all' }
const expired = {
...current,
savedAt: new Date (Date.now () - 25 * 60 * 60 * 1000).toISOString () }
sessionStorage.setItem ('post-import-session:current', JSON.stringify (current))
sessionStorage.setItem ('post-import-session:expired', JSON.stringify (expired))
sessionStorage.setItem ('post-import-session:malformed', '{')
cleanupExpiredPostImportSessions ()
expect (sessionStorage.getItem ('post-import-session:current')).not.toBeNull ()
expect (sessionStorage.getItem ('post-import-session:expired')).toBeNull ()
expect (sessionStorage.getItem ('post-import-session:malformed')).toBeNull ()
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
})
it ('reports storage access failures without throwing', () => {
+47 -361
ファイルの表示
@@ -1,52 +1,24 @@
import type { PostImportOrigin,
PostImportResetSnapshot,
PostImportRow,
PostImportSession,
PostImportStatus,
PostImportSkipReason,
StorageErrorHandler } from '@/lib/postImportTypes'
import type { StorageErrorHandler } from '@/lib/postImportTypes'
const SESSION_VERSION = 2
const SESSION_PREFIX = 'post-import-session:'
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const ATTRIBUTE_KEYS = [
'title',
'thumbnailBase',
'originalCreatedFrom',
'originalCreatedBefore',
'duration',
'videoMs',
'tags',
'parentPostIds'] as const
const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const TAG_SOURCE_KEYS = ['automatic', 'manual'] as const
const WARNING_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
const readStorage = (
key: string,
onError?: StorageErrorHandler,
): string | null => {
if (typeof window === 'undefined')
return null
if (typeof window === 'undefined')
return null
try
{
return sessionStorage.getItem (key)
}
catch
{
onError?.('保存済みデータを読み込めませんでした.')
return null
}
try
{
return sessionStorage.getItem (key)
}
catch
{
onError?.('保存済みデータを読み込めませんでした.')
return null
}
}
@@ -55,19 +27,19 @@ const writeStorage = (
value: string,
onError?: StorageErrorHandler,
): boolean => {
if (typeof window === 'undefined')
return false
if (typeof window === 'undefined')
return false
try
{
sessionStorage.setItem (key, value)
return true
}
catch
{
onError?.('ブラウザへ保存できませんでした.')
return false
}
try
{
sessionStorage.setItem (key, value)
return true
}
catch
{
onError?.('ブラウザへ保存できませんでした.')
return false
}
}
@@ -75,271 +47,36 @@ const removeStorage = (
key: string,
onError?: StorageErrorHandler,
) => {
if (typeof window === 'undefined')
return
if (typeof window === 'undefined')
return
try
{
sessionStorage.removeItem (key)
}
catch
{
onError?.('保存済みデータを削除できませんでした.')
}
}
const ensureStringListRecord = (value: unknown): Record<string, string[]> | null => {
if (!(isPlainObject (value)))
return null
const result: Record<string, string[]> = { }
for (const [key, entry] of Object.entries (value))
{
if (!(Array.isArray (entry)) || !(entry.every (_1 => typeof _1 === 'string')))
return null
result[key] = entry
}
return result
}
const isValidStatus = (
value: unknown,
): value is PostImportRow['status'] =>
value === 'ready' || value === 'warning' || value === 'error'
const isValidImportStatus = (
value: unknown,
): value is PostImportStatus =>
value === 'pending'
|| value === 'created'
|| value === 'skipped'
|| value === 'failed'
const isValidOrigin = (
value: unknown,
): value is PostImportOrigin =>
value === 'automatic' || value === 'manual'
const isValidSkipReason = (
value: unknown,
): value is PostImportSkipReason =>
value === 'existing'
const isPositiveInteger = (value: unknown): value is number =>
Number.isInteger (value) && Number (value) > 0
const hasOnlyKeys = (
value: Record<string, unknown>,
allowedKeys: readonly string[],
): boolean =>
Object.keys (value).every (key => allowedKeys.includes (key))
const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => {
if (!(isPlainObject (value)))
return null
if (typeof value.url !== 'string')
return null
if (!(isPlainObject (value.attributes)))
return null
if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
return null
if (!(Object.values (value.attributes).every (entry =>
typeof entry === 'string' || typeof entry === 'number')))
return null
if (!(isPlainObject (value.provenance)))
return null
if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
return null
if (!(Object.values (value.provenance).every (origin => isValidOrigin (origin))))
return null
if (!(isPlainObject (value.tagSources)))
return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
return null
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
if (fieldWarnings == null)
return null
if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS)))
return null
if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
return null
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
return null
return {
url: value.url,
attributes: value.attributes as Record<string, string | number>,
provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string>,
fieldWarnings,
baseWarnings: value.baseWarnings,
metadataUrl: value.metadataUrl as string | undefined }
}
const sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(isPlainObject (value)))
return null
if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
return null
if (typeof value.url !== 'string')
return null
if (!(isPlainObject (value.attributes)))
return null
if (!(isPlainObject (value.provenance)))
return null
if (!(isValidStatus (value.status)))
return null
if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
return null
if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
return null
if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId)))
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
const validationErrors = ensureStringListRecord (value.validationErrors)
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
const resetSnapshot = sanitiseResetSnapshot (value.resetSnapshot)
if (validationErrors == null || fieldWarnings == null)
return null
if (resetSnapshot == null)
return null
const importErrors =
value.importErrors == null
? undefined
: ensureStringListRecord (value.importErrors)
if (importErrors === null)
return null
if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
return null
const provenanceEntries = Object.entries (value.provenance)
if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
return null
if (!(Object.values (value.attributes).every (entry =>
typeof entry === 'string' || typeof entry === 'number')))
return null
if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
return null
if (!(provenanceEntries.every (([, origin]) => isValidOrigin (origin))))
return null
if (value.tagSources != null)
{
if (!(isPlainObject (value.tagSources)))
return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
return null
}
return {
sourceRow: Number (value.sourceRow),
url: value.url,
attributes: value.attributes as Record<string, string | number>,
fieldWarnings,
baseWarnings: value.baseWarnings,
validationErrors,
importErrors,
provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
status: value.status,
skipReason: value.skipReason ?? undefined,
existingPostId:
isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
resetSnapshot,
createdPostId:
isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
importStatus: value.importStatus ?? undefined }
}
const isExpiredSession = (savedAt: string): boolean => {
const value = Date.parse (savedAt)
return Number.isNaN (value) || Date.now () - value > SESSION_MAX_AGE_MS
}
export const createPostImportSessionId = (): string =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID ()
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
export const cleanupExpiredPostImportSessions = (
onError?: StorageErrorHandler,
) => {
if (typeof window === 'undefined')
return
try
{
for (let i = 0; i < sessionStorage.length; ++i)
{
const key = sessionStorage.key (i)
if (key == null || !(key.startsWith (SESSION_PREFIX)))
continue
const raw = sessionStorage.getItem (key)
if (raw == null)
continue
try
{
const value = JSON.parse (raw) as { savedAt?: string }
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{
sessionStorage.removeItem (key)
--i
}
}
catch
{
sessionStorage.removeItem (key)
--i
}
}
}
catch
{
onError?.('保存済みデータを整理できませんでした.')
}
try
{
sessionStorage.removeItem (key)
}
catch
{
onError?.('保存済みデータを削除できませんでした.')
}
}
export const loadPostImportSourceDraft = (
onError?: StorageErrorHandler,
): { source: string } => {
const raw = readStorage (SOURCE_DRAFT_KEY, onError)
if (raw == null)
return { source: '' }
const raw = readStorage (SOURCE_DRAFT_KEY, onError)
if (raw == null)
return { source: '' }
try
{
const value = JSON.parse (raw) as { source?: string }
return { source: typeof value.source === 'string' ? value.source : '' }
}
catch
{
return { source: '' }
}
try
{
const value = JSON.parse (raw) as { source?: string }
return { source: typeof value.source === 'string' ? value.source : '' }
}
catch
{
return { source: '' }
}
}
@@ -353,56 +90,5 @@ export const savePostImportSourceDraft = (
export const clearPostImportSourceDraft = (
onError?: StorageErrorHandler,
) => {
removeStorage (SOURCE_DRAFT_KEY, onError)
}
export const savePostImportSession = (
sessionId: string,
session: Omit<PostImportSession, 'version' | 'savedAt'>,
onError?: StorageErrorHandler,
): boolean =>
writeStorage (
sessionKey (sessionId),
JSON.stringify ({
...session,
version: SESSION_VERSION,
savedAt: new Date ().toISOString () }),
onError)
export const loadPostImportSession = (
sessionId: string,
onError?: StorageErrorHandler,
): PostImportSession | null => {
const raw = readStorage (sessionKey (sessionId), onError)
if (raw == null)
return null
try
{
const value = JSON.parse (raw) as Partial<PostImportSession>
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
return null
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{
removeStorage (sessionKey (sessionId), onError)
return null
}
const rows = value.rows.map (sanitiseRow)
if (rows.some (_1 => _1 == null))
return null
return {
version: SESSION_VERSION,
savedAt: value.savedAt,
source: typeof value.source === 'string' ? value.source : '',
rows: rows as PostImportRow[],
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
}
catch
{
return null
}
removeStorage (SOURCE_DRAFT_KEY, onError)
}
+40 -10
ファイルの表示
@@ -1,3 +1,5 @@
import type { Category } from '@/types'
export type PostImportOrigin = 'automatic' | 'manual'
export type PostImportRepairMode = 'all' | 'failed'
export type PostImportStatus =
@@ -5,19 +7,32 @@ export type PostImportStatus =
| 'created'
| 'skipped'
| 'failed'
export type PostImportSkipReason = 'existing'
export type PostImportSkipReason = 'existing' | 'manual'
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
export type PostImportAttributeValue = string | number
export type PostImportDisplayTag = {
name: string
category: Category
sectionLiterals?: string[] }
export type PostImportResetSnapshot = {
url: string
attributes: Record<string, PostImportAttributeValue>
displayTags: PostImportDisplayTag[]
provenance: Record<string, PostImportOrigin>
tagSources: Record<PostImportOrigin, string>
fieldWarnings: Record<string, string[]>
baseWarnings: string[]
metadataUrl?: string }
export type PostImportExistingPost = {
id: number
title: string
url: string
thumbnail?: string | null
thumbnailBase?: string | null }
export type PostImportRow = {
sourceRow: number
url: string
@@ -28,41 +43,56 @@ export type PostImportRow = {
importErrors?: Record<string, string[]>
provenance: Record<string, PostImportOrigin>
tagSources?: Record<PostImportOrigin, string>
status: 'ready' | 'warning' | 'error'
status: 'pending' | 'ready' | 'warning' | 'error'
skipReason?: PostImportSkipReason
existingPostId?: number
existingPost?: PostImportExistingPost
metadataUrl?: string
displayTags?: PostImportDisplayTag[]
resetSnapshot: PostImportResetSnapshot
createdPostId?: number
importStatus?: PostImportStatus }
importStatus?: PostImportStatus
recoverable?: boolean
thumbnailFile?: File }
export type PostImportResultRow =
| {
sourceRow: number
status: 'created'
post: { id: number }
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]> }
| {
sourceRow: number
status: 'skipped'
existingPostId: number
existingPost?: PostImportExistingPost
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]> }
| {
sourceRow: number
status: 'failed'
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]>
recoverable?: boolean }
export type PostImportSession = {
version: number
savedAt: string
source: string
rows: PostImportRow[]
repairMode: PostImportRepairMode }
export type PostImportSourceIssue = {
sourceRow: number
message: string
url: string }
export type PostImportEditableDraft = {
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string
thumbnailFile?: File }
export type StorageErrorHandler = (message: string) => void
+43
ファイルの表示
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
buildPostNewReviewPath,
hasPostNewReviewState,
isPostNewReviewPathWithinLimit,
parsePostNewReviewUrls,
postNewReviewPathByteLength,
} from '@/lib/postNewQueryState'
describe ('post new review URL state', () => {
it ('encodes each URL separately and preserves literal plus signs', () => {
const urls = [
'https://example.com/one+a',
'https://example.com/two?value=b+c']
const path = buildPostNewReviewPath (urls)
expect (path).toBe (
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
expect (parsePostNewReviewUrls (path.slice ('/posts/new'.length))).toEqual (urls)
})
it ('uses only the raw urls parameter as review state', () => {
expect (hasPostNewReviewState ('?session_id=old&meta=old')).toBe (false)
expect (hasPostNewReviewState ('?unknown=value&urls=')).toBe (true)
expect (parsePostNewReviewUrls ('?unknown=value&urls=one+two&meta=old'))
.toEqual (['one', 'two'])
})
it ('allows at most a 6 143 byte request target', () => {
const baseUrl = 'https://example.com/'
const baseLength = postNewReviewPathByteLength ([baseUrl])
const allowed = `${ baseUrl }${ 'a'.repeat (6_143 - baseLength) }`
const denied = `${ allowed }a`
expect (postNewReviewPathByteLength ([allowed])).toBe (6_143)
expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
expect (postNewReviewPathByteLength ([denied])).toBe (6_144)
expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
})
})
+58
ファイルの表示
@@ -0,0 +1,58 @@
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144
const textEncoder = new TextEncoder ()
const rawUrlsParam = (search: string): string | null => {
const query = search.startsWith ('?') ? search.slice (1) : search
if (query === '')
return null
for (const segment of query.split ('&'))
{
if (segment === 'urls')
return ''
if (segment.startsWith ('urls='))
return segment.slice ('urls='.length)
}
return null
}
export const buildPostNewReviewPath = (urls: string[]): string =>
`${ POST_NEW_REVIEW_PATH_PREFIX }${ urls.map (url => encodeURIComponent (url)).join ('+') }`
export const postNewReviewPathByteLength = (urls: string[]): number =>
textEncoder.encode (buildPostNewReviewPath (urls)).byteLength
export const isPostNewReviewPathWithinLimit = (urls: string[]): boolean =>
postNewReviewPathByteLength (urls) < MAX_POST_NEW_REVIEW_TARGET_BYTES
export const parsePostNewReviewUrls = (search: string): string[] => {
const raw = rawUrlsParam (search)
if (raw == null)
return []
return raw
.split ('+')
.filter (segment => segment !== '')
.map (segment => {
try
{
return decodeURIComponent (segment)
}
catch
{
return segment
}
})
}
export const hasPostNewReviewState = (search: string): boolean =>
rawUrlsParam (search) != null
+2 -7
ファイルの表示
@@ -25,7 +25,6 @@ import {
getEffectiveKeyBindings,
setClientKeyboardSettings,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type {
KeyBinding,
@@ -97,7 +96,6 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation ()
const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
@@ -149,11 +147,8 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
}, [])
const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
navigate (path)
}, [navigate])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
+77
ファイルの表示
@@ -0,0 +1,77 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { renderWithProviders } from '@/test/render'
const GuardHarness = () => {
const [dirty, setDirty] = useState (true)
const location = useLocation ()
const navigate = useNavigate ()
const discard = vi.fn (() => setDirty (false))
const { allowNextNavigation } = useUnsavedChangesGuard ({
dirty,
onDiscard: discard })
return (
<>
<output aria-label="location">{location.pathname}{location.search}</output>
<button type="button" onClick={() => navigate ('/next?tab=one')}>move</button>
<button
type="button"
onClick={() => {
allowNextNavigation ()
navigate ('/allowed')
}}>
allowed move
</button>
</>)
}
describe ('useUnsavedChangesGuard', () => {
it ('blocks route changes and resets a cancelled transition', async () => {
renderWithProviders (<GuardHarness/>, { route: '/current' })
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
expect (within (await screen.findByRole ('dialog')).getByText (
'変更が破棄してページ移動しますか?')).toBeInTheDocument ()
expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
fireEvent.click (screen.getByRole ('button', { name: '取消' }))
await waitFor (() => {
expect (screen.queryByRole ('dialog')).not.toBeInTheDocument ()
})
expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
})
it ('proceeds with the blocked transition after discard is confirmed', async () => {
renderWithProviders (<GuardHarness/>, { route: '/current' })
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
fireEvent.click (await screen.findByRole ('button', {
name: '変更を破棄して移動' }))
await waitFor (() => {
expect (screen.getByLabelText ('location')).toHaveTextContent ('/next?tab=one')
})
})
it ('allows only the next navigation without leaving a bypass token', async () => {
renderWithProviders (<GuardHarness/>, { route: '/current' })
fireEvent.click (screen.getByRole ('button', { name: 'allowed move' }))
await waitFor (() => {
expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
})
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
expect (within (await screen.findByRole ('dialog')).getByText (
'変更が破棄してページ移動しますか?')).toBeInTheDocument ()
expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
})
})
+163 -28
ファイルの表示
@@ -1,6 +1,15 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useBlocker } from 'react-router-dom'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import type { FC, PropsWithChildren } from 'react'
@@ -8,12 +17,16 @@ type UnsavedChangesSource = {
dirty: boolean
discard: () => void | Promise<void> }
type UseUnsavedChangesGuardOptions = {
dirty: boolean
onDiscard?: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean> }
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource,
) => () => void
allowNextNavigation: () => void }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
@@ -21,49 +34,171 @@ const UnsavedChangesGuardContext =
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
const bypassNextNavigationRef = useRef<symbol | null> (null)
const [revision, setRevision] = useState (0)
const dialogueOpenRef = useRef (false)
const handlingBlockedTransitionRef = useRef (false)
const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
source: UnsavedChangesSource,
): (() => void) => {
const sourceId = Symbol ('unsaved-changes-source')
sourcesRef.current.set (sourceId, source)
setRevision (current => current + 1)
return () => {
if (!(sourcesRef.current.delete (sourceId)))
return
setRevision (current => current + 1)
}
}, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
if (!(source?.dirty))
const allowNextNavigation = useCallback (() => {
const token = Symbol ('allowed-navigation')
bypassNextNavigationRef.current = token
queueMicrotask (() => {
if (bypassNextNavigationRef.current === token)
bypassNextNavigationRef.current = null
})
}, [])
const sources = useMemo (
() => [...sourcesRef.current.values ()],
[revision],
)
const dirtySources = useMemo (
() => sources.filter (source => source.dirty),
[sources],
)
const hasUnsavedChanges = dirtySources.length > 0
const hasUnsavedChangesRef = useRef (hasUnsavedChanges)
hasUnsavedChangesRef.current = hasUnsavedChanges
const shouldBlock = useCallback (() => {
if (bypassNextNavigationRef.current != null)
{
bypassNextNavigationRef.current = null
return false
}
return hasUnsavedChangesRef.current
}, [])
const blocker = useBlocker (shouldBlock)
const confirmDiscardChanges = useCallback (async (): Promise<boolean> => {
if (!(hasUnsavedChanges))
return true
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま移動すると、保存していない変更は失われます。',
cancelText: 'このページに残る',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
if (dialogueOpenRef.current)
return false
await source.discard ()
return true
}, [dialogue, source])
dialogueOpenRef.current = true
try
{
const confirmed = await dialogue.confirm ({
title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
return true
}
finally
{
dialogueOpenRef.current = false
}
}, [dialogue, hasUnsavedChanges])
useEffect (() => {
if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
return
handlingBlockedTransitionRef.current = true
void (async () => {
try
{
const confirmed = await confirmDiscardChanges ()
if (!(confirmed))
{
blocker.reset ()
return
}
let discardResults: Array<void | Promise<void>>
try
{
discardResults = dirtySources.map (source => source.discard ())
}
catch
{
blocker.reset ()
return
}
blocker.proceed ()
await Promise.allSettled (discardResults)
}
finally
{
handlingBlockedTransitionRef.current = false
}
}) ()
}, [blocker, confirmDiscardChanges, dirtySources])
useEffect (() => {
if (!(hasUnsavedChanges))
return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault ()
event.returnValue = ''
}
window.addEventListener ('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener ('beforeunload', handleBeforeUnload)
}
}, [hasUnsavedChanges])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true,
hasUnsavedChanges,
registerUnsavedChangesSource,
confirmDiscardNavigation,
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
allowNextNavigation,
}), [
allowNextNavigation,
hasUnsavedChanges,
registerUnsavedChangesSource,
])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
{children}
</UnsavedChangesGuardContext.Provider>)
}
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
export const useUnsavedChangesGuard = (
options?: UseUnsavedChangesGuardOptions,
): UnsavedChangesGuardContextValue => {
const context = useContext (UnsavedChangesGuardContext)
if (context == null)
throw new Error ('UnsavedChangesGuardProvider が必要です.')
const { registerUnsavedChangesSource } = context
useEffect (() => {
if (options == null)
return
return registerUnsavedChangesSource ({
dirty: options.dirty,
discard: options.onDiscard ?? (() => undefined) })
}, [options?.dirty, options?.onDiscard, registerUnsavedChangesSource])
return context
}
+31 -28
ファイルの表示
@@ -13,7 +13,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import { cn, dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
@@ -113,10 +113,11 @@ const clearedTagSelectionPath = (
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
overflow-hidden rounded-lg border border-stone-200 bg-white text-center
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100`}>
className={cn (
'flex aspect-square h-[180px] w-[180px] items-center justify-center',
'overflow-hidden rounded-lg border border-stone-200 bg-white text-center',
'text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900',
'dark:text-stone-100')}>
{material.thumbnail
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
: (
@@ -482,7 +483,7 @@ const MaterialListPage: FC = () => {
className={inputClass (invalid)}>
<option value="all"></option>
<option value="tagged"></option>
<option value="untagged"></option>
<option value="untagged"></option>
</select>)}
</FormField>
@@ -531,30 +532,32 @@ const MaterialListPage: FC = () => {
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => updateQuery ({ view: 'card' })}
className={`rounded-full border px-4 py-2 text-sm ${
view === 'card'
? [
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
<button
type="button"
onClick={() => updateQuery ({ view: 'card' })}
className={cn (
'rounded-full border px-4 py-2 text-sm',
view === 'card'
? [
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100']
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
</button>
<button
type="button"
onClick={() => updateQuery ({ view: 'list' })}
className={`rounded-full border px-4 py-2 text-sm ${
view === 'list'
? [
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
<button
type="button"
onClick={() => updateQuery ({ view: 'list' })}
className={cn (
'rounded-full border px-4 py-2 text-sm',
view === 'list'
? [
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100']
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
</button>
</div>
-82
ファイルの表示
@@ -1,82 +0,0 @@
import { fireEvent, screen } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { savePostImportSession } from '@/lib/postImportSession'
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ apiPost: vi.fn () }))
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
const ReviewLocation = () => {
const location = useLocation ()
return <div>{`review route ${ location.search }`}</div>
}
const renderPage = () => renderWithProviders (
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
<Route path="/posts/import/:sessionId/review" element={<ReviewLocation/>}/>
</Routes>,
{ route: '/posts/import/session/result' })
describe ('PostImportResultPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
})
it ('shows exclusive result counts, post links, and failed-row actions', async () => {
const rows = [
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 11 }),
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
skipReason: 'existing', existingPostId: 22 }),
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
importErrors: { base: ['登録中に失敗しました.'] } })]
savePostImportSession ('session', { source: '', rows, repairMode: 'all' })
renderPage ()
expect (await screen.findByText (
/登録成功\s*1件.*スキップ\s*1件.*失敗\s*1件/)).toBeInTheDocument ()
expect (screen.getAllByRole ('link', { name: '投稿を開く' })
.map (_1 => _1.getAttribute ('href'))).toEqual (['/posts/11', '/posts/22'])
expect (screen.getByText ('登録中に失敗しました.')).toBeInTheDocument ()
expect (screen.getByRole ('button', { name: '編輯' })).toBeInTheDocument ()
expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument ()
})
it ('returns a retry validation error to the review dialogue route', async () => {
const failed = buildPostImportRow ({
importStatus: 'failed',
importErrors: { base: ['old error'] } })
const invalid = buildPostImportRow ({
importStatus: 'pending',
status: 'error',
validationErrors: { title: ['タイトルを確認してください.'] } })
savePostImportSession ('session', {
source: failed.url,
rows: [failed],
repairMode: 'all' })
api.apiPost.mockResolvedValue ({ rows: [invalid] })
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '再試行' }))
expect (await screen.findByText ('review route ?edit=1')).toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (1)
const saved = JSON.parse (
sessionStorage.getItem ('post-import-session:session') ?? '{}')
expect (saved.rows[0]).toMatchObject ({
importStatus: 'pending',
validationErrors: { title: ['タイトルを確認してください.'] } })
})
})
-290
ファイルの表示
@@ -1,290 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate, useParams } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
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 { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft,
initialisePreviewRows,
loadPostImportSession,
mergeImportResults,
mergeValidatedImportRows,
resultSummaryCounts,
retryImportRow,
savePostImportSession } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession'
import type { PostImportSession } from '@/lib/postImportSession'
import type { User } from '@/types'
type Props = { user: User | null }
const rowMessages = (row: PostImportRow): string[] =>
Object.values (row.importErrors ?? { }).flat ()
const PostImportResultPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const navigate = useNavigate ()
const { sessionId } = useParams ()
const [session, setSession] = useState<PostImportSession | null> (null)
const [missing, setMissing] = useState (false)
const [loadingRow, setLoadingRow] = useState<number | null> (null)
useEffect (() => {
if (sessionId == null)
return
const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
useEffect (() => {
if (sessionId == null || session == null)
return
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
const counts = useMemo (
() => resultSummaryCounts (session?.rows ?? []),
[session])
const retry = async (sourceRow: number) => {
if (session == null || sessionId == null)
return
setLoadingRow (sourceRow)
try
{
const pendingRows = retryImportRow (session.rows, sourceRow)
setSession ({ ...session, rows: pendingRows })
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,
initialisePreviewRows (validated.rows))
const nextSession = {
...session,
rows: validatedRows,
repairMode: 'failed' as const }
setSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null)
return
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
const result = await apiPost<{
created: number
skipped: number
failed: number
rows: PostImportResultRow[] }> ('/posts/import', {
rows: [{
sourceRow: target.sourceRow,
url: target.url,
attributes: target.attributes,
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] })
const mergedRows = mergeImportResults (nextSession.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow)
const resultSession = {
...nextSession,
rows: nextRows,
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const }
setSession (resultSession)
if (recoverableTarget != null
&& Object.keys (recoverableTarget.validationErrors).length > 0)
{
const saved = savePostImportSession (sessionId, resultSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
}
catch
{
setSession (session)
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
}
}
const openRepair = (sourceRow: number) => {
if (session == null || sessionId == null)
return
const nextSession = { ...session, repairMode: 'failed' as const }
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
}
if (!(editable))
return <Forbidden/>
if (missing || sessionId == null || session == null)
{
return (
<MainArea>
<div className="mx-auto max-w-4xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<FieldError messages={['取込状態が見つかりません.']}/>
<Button type="button" onClick={() => navigate ('/posts/import')}>
URL
</Button>
</div>
</MainArea>)
}
return (
<MainArea>
<Helmet>
<title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="mx-auto max-w-5xl space-y-4 p-4">
<PageTitle></PageTitle>
<div className="text-sm text-neutral-700 dark:text-neutral-200">
{counts.created}  {counts.skipped}  {counts.failed}
</div>
<div className="space-y-3">
{session.rows.map (row => {
const displayStatus = displayPostImportStatus (row)
const canEdit = row.importStatus === 'failed'
const canRetry =
row.importStatus === 'failed'
&& Object.keys (row.validationErrors).length === 0
return (
<div
key={row.sourceRow}
className="rounded-lg border p-4 transition-shadow hover:shadow-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-start
md:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium"> {row.sourceRow}</span>
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-200">
{String (row.attributes.title ?? '') || row.url}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url}
</div>
<FieldError messages={rowMessages (row)}/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{(row.createdPostId != null || row.existingPostId != null) && (
<Button type="button" variant="outline" asChild>
<PrefetchLink
to={`/posts/${ row.createdPostId ?? row.existingPostId }`}>
稿
</PrefetchLink>
</Button>)}
{canEdit && (
<Button
type="button"
variant="outline"
onClick={() => openRepair (row.sourceRow)}>
</Button>)}
{canRetry && (
<Button
type="button"
onClick={() => retry (row.sourceRow)}
disabled={loadingRow === row.sourceRow}>
</Button>)}
</div>
</div>
</div>)})}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={() => {
const nextSession = { ...session, repairMode: 'all' as const }
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
}}>
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
navigate ('/posts/import')
}}>
URL
</Button>
</div>
</div>
</MainArea>)
}
export default PostImportResultPage
+131 -52
ファイルの表示
@@ -1,78 +1,157 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { savePostImportSession } from '@/lib/postImportSession'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ apiPost: vi.fn () }))
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
import type { ReactNode } from 'react'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn (() => false) }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('framer-motion', () => ({
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
motion: {
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
main: ({ children }: { children?: ReactNode }) => <main>{children}</main> } }))
const renderPage = () => renderWithProviders (
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route path="/posts/import/:sessionId/result" element={<div>result route</div>}/>
</Routes>,
{ route: '/posts/import/session/review' })
const metadata = (url: string, title: string) => ({
url,
title,
thumbnailBase: 'https://example.com/thumbnail.jpg',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
tags: '',
parentPostIds: [],
fieldWarnings: { },
baseWarnings: [],
displayTags: [] })
const LocationProbe = () => {
const location = useLocation ()
return <output aria-label="current-location">{location.pathname}{location.search}</output>
}
const renderReviewPage = (urls: string[]) => {
const search = urls.map (url => encodeURIComponent (url)).join ('+')
return renderWithProviders (
<>
<PostImportReviewPage user={buildUser ()}/>
<LocationProbe/>
</>,
{ route: `/posts/new?urls=${ search }` })
}
describe ('PostImportReviewPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('keeps the review route and opens the first invalid row dialogue', async () => {
const row = buildPostImportRow ({ attributes: { title: 'title' } })
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ({
attributes: { title: 'title' },
status: 'error',
validationErrors: { title: ['タイトルを確認してください.'] } })] })
it ('fetches metadata with at most four concurrent requests', async () => {
const resolvers: Array<(value: ReturnType<typeof metadata>) => void> = []
api.apiGet.mockImplementation ((_path, options) =>
new Promise (resolve => {
resolvers.push (resolve)
const url = String (options.params.url)
void url
}))
const urls = Array.from (
{ length: 6 },
(_, index) => `https://example.com/${ index + 1 }`)
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
renderReviewPage (urls)
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (1)
expect (screen.queryByText ('result route')).not.toBeInTheDocument ()
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (4))
resolvers[0]?.(metadata (urls[0]!, 'first'))
expect (await screen.findAllByText ('first')).toHaveLength (2)
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (5))
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
for (let index = 1; index < resolvers.length; ++index)
resolvers[index]?.(metadata (urls[index]!, `post ${ index + 1 }`))
})
it ('submits existing rows for a formal skipped result and opens result route', async () => {
const row = buildPostImportRow ({
skipReason: 'existing',
existingPostId: 10 })
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
api.apiPost
.mockResolvedValueOnce ({ rows: [row] })
.mockResolvedValueOnce ({
created: 0,
skipped: 1,
failed: 0,
rows: [{ sourceRow: 1, status: 'skipped', existingPostId: 10 }] })
it ('keeps successful rows when another metadata request fails', async () => {
api.apiGet
.mockResolvedValueOnce (metadata ('https://example.com/one', 'first'))
.mockRejectedValueOnce (new TypeError ('network'))
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
renderReviewPage (['https://example.com/one', 'https://example.com/two'])
expect (await screen.findByText ('result route')).toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (2)
expect (api.apiPost.mock.calls[1]?.[1]).toMatchObject ({
rows: [expect.objectContaining ({ sourceRow: 1, url: row.url })] })
expect (await screen.findAllByText ('first')).toHaveLength (2)
await waitFor (() => {
const saved = JSON.parse (
sessionStorage.getItem ('post-import-session:session') ?? '{}')
expect (saved.rows[0]).toMatchObject ({
importStatus: 'skipped',
existingPostId: 10,
skipReason: 'existing' })
expect (screen.getAllByText ('登録不可')).toHaveLength (2)
})
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
})
it ('shows existing posts with Active Storage thumbnail precedence', async () => {
api.apiGet.mockResolvedValue ({
...metadata ('https://example.com/post', ''),
existingPostId: 24,
existingPost: {
id: 24,
title: 'existing post',
url: 'https://example.com/post',
thumbnail: 'https://example.com/storage.jpg',
thumbnailBase: 'https://example.com/base.jpg' } })
renderReviewPage (['https://example.com/post'])
const disclosure = await screen.findByRole ('button', {
name: '既存投稿による自動スキップ 1件' })
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
fireEvent.click (disclosure)
expect ((await screen.findAllByRole ('img', { name: 'サムネール' }))[0])
.toHaveAttribute ('src', 'https://example.com/storage.jpg')
})
it ('keeps a recoverable bulk failure on the review screen', async () => {
api.apiGet.mockResolvedValue (
metadata ('https://example.com/post', 'new post'))
api.apiPost.mockResolvedValue ({
results: [{
status: 'failed',
recoverable: true,
errors: { title: ['invalid title'] } }] })
renderReviewPage (['https://example.com/post'])
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
expect (screen.getAllByText ('invalid title')).toHaveLength (2)
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
expect (screen.getAllByRole ('button', { name: '再試行' })[0]).toBeEnabled ()
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts/new?')
})
it ('navigates to /posts after every submitted row is created', async () => {
api.apiGet.mockResolvedValue (
metadata ('https://example.com/post', 'new post'))
api.apiPost.mockResolvedValue ({
results: [{ status: 'created', post: { id: 42 } }] })
renderReviewPage (['https://example.com/post'])
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
await waitFor (() => {
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
})
expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
})
})
ファイル差分が大きすぎるため省略します 差分を読込み
+22 -47
ファイルの表示
@@ -1,21 +1,12 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiPost: vi.fn (),
isApiError: vi.fn () }))
const router = vi.hoisted (() => ({ navigate: vi.fn () }))
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('react-router-dom', async importOriginal => ({
...await importOriginal<typeof import('react-router-dom')> (),
useNavigate: () => router.navigate }))
@@ -24,69 +15,53 @@ describe ('PostImportSourcePage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('shows no empty error initially and validates only after Next is pressed', () => {
it ('validates an empty source only after Next is pressed', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
expect (api.apiPost).not.toHaveBeenCalled ()
expect (router.navigate).not.toHaveBeenCalled ()
})
it ('shows frontend URL issues with line numbers without requesting preview', () => {
it ('reports frontend URL issues with original line numbers', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, {
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
expect (screen.queryByText (/2 行目/)).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
expect (input).toHaveAttribute ('aria-invalid', 'true')
expect (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues')
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('shows backend URL errors against original input without a session', async () => {
api.apiPost.mockResolvedValue ({
rows: [buildPostImportRow ({
sourceRow: 2,
url: 'https://example.com/canonical',
status: 'error',
validationErrors: { url: ['URL が重複しています.'] } })] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: '\nhttps://example.com/original' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (await screen.findByText ('2 行目: URL が重複しています.')).toBeInTheDocument ()
expect (screen.getAllByText ('https://example.com/original')).toHaveLength (2)
expect (router.navigate).not.toHaveBeenCalled ()
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index))).not.toContainEqual(
expect.stringMatching (/^post-import-session:/))
})
it ('stores a successful preview before navigating to the review route', async () => {
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
it ('navigates with individually encoded URLs without calling an API', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: 'https://example.com/post' } })
target: {
value: 'https://example.com/one+a\nhttps://example.com/two?value=b+c' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
await waitFor (() => {
expect (router.navigate).toHaveBeenCalledWith (
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
})
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index)).filter (_1 => _1?.startsWith ('post-import-session:'))
expect (sessionKeys).toHaveLength (1)
expect (router.navigate).toHaveBeenCalledWith (
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
})
it ('disables Next when the encoded request target reaches 4096 bytes', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, {
target: { value: `https://example.com/${ 'a'.repeat (6_200) }` } })
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
})
})
+35 -62
ファイルの表示
@@ -11,21 +11,24 @@ import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost, isApiError } from '@/lib/api'
import {
buildPostNewReviewPath,
isPostNewReviewPathWithinLimit,
} from '@/lib/postNewQueryState'
import {
countImportSourceLines,
extractImportSourceUrls,
validateImportSource,
} from '@/lib/postImportSourceValidation'
import {
loadPostImportSourceDraft,
savePostImportSourceDraft,
} from '@/lib/postImportStorage'
import { canEditContent } from '@/lib/users'
import { countImportSourceLines,
cleanupExpiredPostImportSessions,
createPostImportSessionId,
initialisePreviewRows,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft,
validateImportSource } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
import type { User } from '@/types'
type Props = { user: User | null }
@@ -34,16 +37,6 @@ const MAX_ROWS = 100
const SOURCE_ERROR_ID = 'post-import-source-error'
const SOURCE_ISSUES_ID = 'post-import-source-issues'
const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
const sourceLines = source.split (/\r\n|\n|\r/)
return rows.flatMap (row =>
(row.validationErrors.url ?? []).map (message => ({
sourceRow: row.sourceRow,
message,
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
}
const PostImportSourcePage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
@@ -56,22 +49,26 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
const editedRef = useRef (false)
const lineCount = countImportSourceLines (source)
const sourceUrls = extractImportSourceUrls (source)
const withinPathLimit = isPostNewReviewPathWithinLimit (sourceUrls)
const messages = sourceError != null ? [sourceError] : []
const sourceDescribedBy =
[sourceError != null ? SOURCE_ERROR_ID : null,
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
.filter (_1 => _1 != null)
.filter (value => value != null)
.join (' ')
useEffect (() => {
cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
const draft = loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
if (!(editedRef.current))
setSource (current => current === '' ? draft.source : current)
{
setSource (current =>
current === ''
? draft.source
: current)
}
}, [])
useEffect (() => {
@@ -104,40 +101,15 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
setSourceError (null)
return
}
if (!(withinPathLimit))
return
setLoading (true)
setSourceIssues ([])
setSourceError (null)
try
{
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { source })
const urlIssues = urlIssuesFromRows (data.rows, source)
if (urlIssues.length > 0)
{
setSourceIssues (urlIssues)
return
}
const sessionId = createPostImportSessionId ()
const saved = savePostImportSession (
sessionId,
{ source,
rows: initialisePreviewRows (data.rows),
repairMode: 'all' },
message => toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
}
catch (requestError)
{
const message =
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
? (requestError.response?.data?.message
?? requestError.response?.data?.baseErrors?.[0])
: undefined
setSourceError (message ?? '入力を確認してください.')
toast ({ title: '投稿情報の取得に失敗しました',
description: message ?? '入力を確認してください.' })
navigate (buildPostNewReviewPath (sourceUrls))
}
finally
{
@@ -146,17 +118,17 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
}
if (!(editable))
return <Forbidden/>
return <Forbidden/>
return (
<MainArea>
<Helmet>
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<Form className="max-w-4xl">
<PageTitle>稿</PageTitle>
<FormField label="URL リスト">
<PageTitle>稿</PageTitle>
<FormField label="URL リスト(1 行 1 URL)">
{() => (
<TextArea
value={source}
@@ -166,8 +138,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
className="h-80 font-mono text-sm"
onBlur={() => {
savePostImportSourceDraft (source, message =>
toast ({ title: '入力内容を保存できませんでした',
description: message }))
toast ({
title: '入力内容を保存できませんでした',
description: message }))
}}
onChange={ev => {
editedRef.current = true
@@ -189,15 +162,15 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
</li>))}
</ul>
<div className="flex items-center justify-between gap-3">
<div className="relative z-10 flex items-center justify-between gap-3">
<div className="text-sm text-neutral-600 dark:text-neutral-300">
{lineCount} / {MAX_ROWS}
</div>
<Button
type="button"
className="shrink-0"
className="pointer-events-auto shrink-0"
onClick={preview}
disabled={loading}>
disabled={loading || !(withinPathLimit)}>
</Button>
</div>
+18 -84
ファイルの表示
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostNewPage from '@/pages/posts/PostNewPage'
@@ -6,101 +6,35 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn () }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
describe ('PostNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
api.apiGet.mockResolvedValue ({
url: 'https://example.com/post',
title: 'post',
tags: '' })
sessionStorage.clear ()
})
it ('blocks guests', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
it ('shows the source page on /posts/new', () => {
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
route: '/posts/new' })
expect (screen.getByText ('403')).toBeInTheDocument ()
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
})
it ('submits a new post with manual title and thumbnail fetch UI', async () => {
api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([])
it ('shows the review page when query state is present', () => {
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
route: '/posts/new?urls=https%3A%2F%2Fexample.com%2Fpost' })
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[2], { target: { value: '1 2' } })
fireEvent.change (textboxes[3], { target: { value: 'tag1 tag2' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => {
expect (api.apiPost).toHaveBeenCalledWith (
'/posts',
expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } },
)
})
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (formData.get ('url')).toBe ('https://example.com/post')
expect (formData.get ('title')).toBe ('投稿タイトル')
expect (formData.get ('parent_post_ids')).toBe ('1 2')
expect (formData.get ('tags')).toBe ('tag1 tag2')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
})
it ('preserves duration while the video tag is temporarily removed', () => {
api.apiGet.mockResolvedValue ([])
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const tags = screen.getAllByRole ('textbox')[3]
fireEvent.change (tags, { target: { value: '動画' } })
fireEvent.change (screen.getByRole ('spinbutton'), { target: { value: '180.5' } })
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
})
it ('shows 422 validation errors for post fields', async () => {
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
base_errors: ['投稿内容を確認してください.'],
},
},
})
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[3], { target: { value: 'nico:nico_tag' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox')[3]).toHaveAttribute ('aria-invalid', 'true')
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
})
})
+12 -246
ファイルの表示
@@ -1,22 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
import { useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import { hasPostNewReviewState } from '@/lib/postNewQueryState'
import type { FC } from 'react'
@@ -24,237 +11,16 @@ import type { User } from '@/types'
type Props = { user: User | null }
type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds'
| 'videoMs' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const location = useLocation ()
const reviewMode = useMemo (
() => hasPostNewReviewState (location.search),
[location.search])
const navigate = useNavigate ()
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('')
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
const [title, setTitle] = useState ('')
const [titleLoading, setTitleLoading] = useState (false)
const [url, setURL] = useState ('')
const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData
formData.append ('title', title)
formData.append ('url', url)
formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds)
if (videoFlg && duration !== '')
formData.append ('duration', duration)
if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom)
formData.append ('original_created_from', originalCreatedFrom)
if (originalCreatedBefore)
formData.append ('original_created_before', originalCreatedBefore)
try
{
await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
toast ({ title: '投稿成功!' })
navigate ('/posts')
}
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください.' })
}
}
const fetchTitle = useCallback (async () => {
setTitleLoading (true)
try
{
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
}
finally
{
setTitleLoading (false)
}
}, [url])
const fetchThumbnail = useCallback (async () => {
setThumbnailPreview ('')
setThumbnailFile (null)
setThumbnailLoading (true)
if (thumbnailPreviewRef.current)
URL.revokeObjectURL (thumbnailPreviewRef.current)
try
{
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url },
responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
}
finally
{
setThumbnailLoading (false)
}
}, [url])
useEffect (() => {
thumbnailPreviewRef.current = thumbnailPreview
}, [thumbnailPreview])
if (!(editable))
return <Forbidden/>
return (
<MainArea>
<Helmet>
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<Form>
<PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/>
{/* URL */}
<FormField label="URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input type="url"
placeholder="例:https://www.nicovideo.jp/watch/..."
value={url}
onChange={e => setURL (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タイトル */}
<FormField label="タイトル" messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<div className="space-y-2">
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>
</div>
</div>)}
</FormField>
{/* サムネール */}
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
{({ describedBy, invalid }) => (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
{thumbnailPreview && (
<img src={thumbnailPreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>)}
</>)}
</FormField>
{/* 親投稿 */}
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{(videoFlg &&
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
value={duration}
onChange={e => setDuration (e.target.value)}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>)}
{/* 送信 */}
<Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={titleLoading || thumbnailLoading}>
</Button>
</Form>
</MainArea>)
return reviewMode
? <PostImportReviewPage user={user}/>
: <PostImportSourcePage user={user}/>
}
export default PostNewPage
+4 -5
ファイルの表示
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({
@@ -27,10 +26,10 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (),
useDialogue: () => dialogue }))
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
...buildTag (),
+8 -6
ファイルの表示
@@ -40,10 +40,10 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (),
useDialogue: () => dialogue }))
vi.mock ('@/components/PostEmbed', () => ({
default: (props: {
ref?: { current: unknown }
@@ -262,7 +262,9 @@ describe ('TheatreDetailPage', () => {
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
})
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
it (
'shows child tags from the post tag tree in both vertical and horizontal layouts',
async () => {
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
const parentTag = buildTag ({
id: 11,
@@ -307,7 +309,7 @@ describe ('TheatreDetailPage', () => {
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
.toHaveLength (1)
})
})
})
it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => {
+3 -7
ファイルの表示
@@ -1197,13 +1197,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}, [hasUnsavedThemeChanges, savedThemeSlots])
useEffect (() => {
registerUnsavedChangesSource ({
dirty: hasPageUnsavedChanges,
discard: discardAllDirtyChanges })
return () => {
registerUnsavedChangesSource (null)
}
return registerUnsavedChangesSource ({
dirty: hasPageUnsavedChanges,
discard: discardAllDirtyChanges })
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
useKeyboardShortcuts ({

変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示