コミットを比較

..

78 コミット

作成者 SHA1 メッセージ 日付
みてるぞ e6c3c635b8 #399 2026-07-18 02:29:55 +09:00
みてるぞ 9552081133 #399 2026-07-18 02:08:20 +09:00
みてるぞ 2d2a9b4bd6 #399 2026-07-18 01:37:11 +09:00
みてるぞ 09ac2576bb #399 2026-07-18 01:28:22 +09:00
みてるぞ 9eca670934 #399 2026-07-18 01:12:55 +09:00
みてるぞ c31d84115d #399 2026-07-18 00:31:02 +09:00
みてるぞ ff970f8171 #399 2026-07-18 00:01:12 +09:00
みてるぞ 0ae41b6266 #399 2026-07-17 23:14:37 +09:00
みてるぞ eb4bf5e35c #399 2026-07-17 22:21:18 +09:00
みてるぞ e6b7e33b83 #399 2026-07-17 21:53:24 +09:00
みてるぞ 3820d3d4d5 #399 2026-07-17 21:06:26 +09:00
みてるぞ dd2d199d04 #399 2026-07-16 23:39:13 +09:00
みてるぞ 0a8ffc38b8 #399 2026-07-16 23:23:32 +09:00
みてるぞ 3f75994bd4 #399 2026-07-16 23:12:14 +09:00
みてるぞ f91b78bd47 #399 2026-07-16 22:31:32 +09:00
みてるぞ 7f8cce39bc #399 2026-07-16 22:05:40 +09:00
みてるぞ 5c5a9fa1b0 #399 2026-07-16 22:00:16 +09:00
みてるぞ 66c738cbff #399 2026-07-16 21:45:02 +09:00
みてるぞ cde0a2deae #399 2026-07-16 20:54:42 +09:00
みてるぞ 43a3772976 #399 2026-07-16 20:35:07 +09:00
みてるぞ 90d8d3ff08 #399 2026-07-16 20:05:09 +09:00
みてるぞ d1de631eed #399 2026-07-16 19:35:21 +09:00
みてるぞ 6f3ccb0ffa #399 2026-07-16 19:18:35 +09:00
みてるぞ 95c3f08a44 #399 2026-07-16 18:52:07 +09:00
みてるぞ ef95b20a7e #399 2026-07-16 18:33:45 +09:00
みてるぞ f76fbe6711 #399 2026-07-16 18:16:20 +09:00
みてるぞ 0224c4d2f4 #399 2026-07-16 12:39:35 +09:00
みてるぞ ead825e3ee #399 2026-07-16 07:56:45 +09:00
みてるぞ 3d88fdecb1 #399 2026-07-16 01:19:36 +09:00
みてるぞ 2e1b4449ba #399 2026-07-16 00:44:11 +09:00
みてるぞ 4eb483c049 #399 2026-07-16 00:24:24 +09:00
みてるぞ e197f27e9a #399 2026-07-15 23:59:01 +09:00
みてるぞ 34f81325d2 #399 2026-07-15 23:42:05 +09:00
みてるぞ 9af4670c6c #399 2026-07-15 23:29:19 +09:00
みてるぞ 5183b0b354 #399 2026-07-15 23:06:28 +09:00
みてるぞ d0914fce3b #399 2026-07-15 22:51:01 +09:00
みてるぞ 0ac7332458 #399 2026-07-15 22:41:06 +09:00
みてるぞ 07ce19e32d #399 2026-07-15 22:26:58 +09:00
みてるぞ 5f1d619139 #399 2026-07-15 22:04:47 +09:00
みてるぞ 3cd22855b3 #399 2026-07-15 21:42:00 +09:00
みてるぞ 55c69866a7 #399 2026-07-15 21:37:03 +09:00
みてるぞ 04d2f0f5a9 #399 2026-07-15 20:59:00 +09:00
みてるぞ 025f49cbcb #399 2026-07-15 20:51:24 +09:00
みてるぞ d2f1b1ed48 #399 2026-07-15 20:25:51 +09:00
みてるぞ 6e5aa1e30f #399 2026-07-15 19:57:09 +09:00
みてるぞ 21d6c1408a #399 2026-07-15 19:43:39 +09:00
みてるぞ eebbd5d069 #399 2026-07-15 19:33:18 +09:00
みてるぞ 58828597d7 #399 2026-07-15 08:03:25 +09:00
みてるぞ b0c24f319a #399 2026-07-14 20:03:11 +09:00
みてるぞ f9463f383f #399 2026-07-14 19:37:04 +09:00
みてるぞ 6d037192c4 #399 2026-07-14 19:12:23 +09:00
みてるぞ d035da99ad #399 2026-07-14 18:39:24 +09:00
みてるぞ bc660676ef Merge remote-tracking branch 'origin/main' into feature/399 2026-07-14 12:40:32 +09:00
みてるぞ 97b132e5ab #399 2026-07-13 01:31:45 +09:00
みてるぞ be68841bd3 #399 2026-07-13 01:17:11 +09:00
みてるぞ ac82adc6b3 #399 2026-07-13 00:58:02 +09:00
みてるぞ aa96ec95d1 #399 2026-07-12 14:03:05 +09:00
みてるぞ df17f20907 #399 2026-07-12 13:14:36 +09:00
みてるぞ 769966648b #399 2026-07-12 12:43:31 +09:00
みてるぞ 7bcb76516c #399 2026-07-12 12:23:53 +09:00
みてるぞ 8970edc59f #399 2026-07-12 11:17:23 +09:00
みてるぞ 9b1ea56e36 #399 2026-07-12 10:27:13 +09:00
みてるぞ 155edfe018 #399 2026-07-12 02:22:36 +09:00
みてるぞ 23f1ffb04b #399 2026-07-12 02:08:32 +09:00
みてるぞ ccfe65a6a4 #399 2026-07-12 02:01:25 +09:00
みてるぞ 06d6e512e4 #399 2026-07-12 01:56:24 +09:00
みてるぞ 5bd097bcfe #399 2026-07-12 01:47:34 +09:00
みてるぞ f636d2a177 #399 2026-07-12 01:39:31 +09:00
みてるぞ 10dc776313 #399 2026-07-12 01:17:45 +09:00
みてるぞ a9e16735f8 #399 2026-07-12 01:11:07 +09:00
みてるぞ 440a6c9961 #399 2026-07-12 00:54:21 +09:00
みてるぞ 4535a9d260 #399 2026-07-12 00:46:53 +09:00
みてるぞ 440d3d38be #399 2026-07-11 23:21:59 +09:00
みてるぞ cb33d9ca25 #399 2026-07-11 23:08:03 +09:00
みてるぞ 19bf24432a #399 2026-07-11 23:07:51 +09:00
みてるぞ ac41385962 Merge remote-tracking branch 'origin/main' into feature/399 2026-07-11 22:36:03 +09:00
みてるぞ 08bf92ff79 #399 2026-07-11 21:46:10 +09:00
みてるぞ c51d7b98ba #399 2026-07-11 21:11:36 +09:00
70個のファイルの変更3970行の追加2513行の削除
+24 -157
ファイルの表示
@@ -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 4 spaces deeper than the statement's base indentation.
elements by 4 spaces.
### Ruby delimiter and wrapping rules
@@ -140,32 +140,6 @@ 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:
@@ -217,63 +191,6 @@ 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) }
@@ -308,37 +225,20 @@ 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 TypeScript / TSX indentation
and alignment whitespace compress every complete run of 8 spaces to tabs.
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.
- A tab does not represent one indentation level.
- 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.
- 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.
- 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, 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 }
```
tab + 4 spaces, 16 columns = 2 tabs.
- 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.
@@ -574,32 +474,8 @@ and layout reuse, follow `frontend/AGENTS.md`.
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 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.
test work. When the user asks for tests, keep working and rerun them until
they pass or the remaining failure is clearly blocked.
## Backend rules
@@ -722,10 +598,10 @@ 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 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.
- 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.
- 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.
@@ -738,18 +614,9 @@ and layout reuse, follow `frontend/AGENTS.md`.
- 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, 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 function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing
`)`.
@@ -1392,9 +1259,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. 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.
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.
10. No line has trailing whitespace.
Preferred:
+3 -30
ファイルの表示
@@ -131,7 +131,6 @@ class PostsController < ApplicationController
title: nil,
thumbnail_base: nil,
tags: nil,
display_tags: [],
original_created_from: nil,
original_created_before: nil,
duration: nil,
@@ -154,7 +153,6 @@ class PostsController < ApplicationController
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],
@@ -175,7 +173,6 @@ class PostsController < ApplicationController
title: nil,
thumbnail_base: nil,
tags: nil,
display_tags: [],
original_created_from: nil,
original_created_before: nil,
duration: nil,
@@ -216,8 +213,7 @@ class PostsController < ApplicationController
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail],
host: request.base_url).run
thumbnail: params[:thumbnail]).run
return render json: dry_run_json(preflight) if bool?(:dry)
if preflight[:existing_post_id].present?
post = Post.new(url: preflight[:url])
@@ -274,11 +270,7 @@ class PostsController < ApplicationController
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
result = PostBulkCreator.new(actor: current_user, posts:, thumbnails:).run
render json: result
rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.'
@@ -632,7 +624,7 @@ class PostsController < ApplicationController
return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post, host: request.base_url)
PostCompactRepr.base(post)
end
def dry_run_json preflight
@@ -641,7 +633,6 @@ class PostsController < ApplicationController
:title,
:thumbnail_base,
:tags,
:display_tags,
:parent_post_ids,
:original_created_from,
:original_created_before,
@@ -732,7 +723,6 @@ 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: }
@@ -770,23 +760,6 @@ 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
+8 -6
ファイルの表示
@@ -18,12 +18,14 @@ class PreviewController < ApplicationController
def thumbnail
return render_bad_request('URL は必須です.') if params[:url].blank?
attachment =
Post.resized_thumbnail_attachment(
StringIO.new(Preview::ThumbnailFetcher.fetch(params[:url])))
send_data attachment[:io].read,
type: attachment[:content_type],
disposition: 'inline'
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'
rescue Preview::UrlSafety::UnsafeUrl => e
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
+7 -49
ファイルの表示
@@ -17,12 +17,12 @@ class Post < ApplicationRecord
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
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
@@ -138,10 +138,7 @@ class Post < ApplicationRecord
end
def self.section_literal section
end_ms =
section.end_ms ? Post.ms_to_time(section.end_ms) : ''
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
end
def self.ms_to_time ms
@@ -228,23 +225,9 @@ class Post < ApplicationRecord
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
return decode_raster_thumbnail(bytes) unless svg_content_type?(content_type) || svg_document_bytes?(bytes)
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)
decode_svg_thumbnail(bytes)
end
def self.svg_content_type?(content_type)
@@ -271,18 +254,6 @@ class Post < ApplicationRecord
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 |
@@ -326,9 +297,7 @@ class Post < ApplicationRecord
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 external_svg_url?(attribute_value) if ['href', 'xlink:href', 'src'].include?(attribute_name)
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)
@@ -349,12 +318,7 @@ class Post < ApplicationRecord
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 = entry.to_s.strip.delete_prefix("'").delete_prefix('"').delete_suffix("'").delete_suffix('"')
reference.present? && !(reference.start_with?('#'))
end
end
@@ -390,7 +354,6 @@ class Post < ApplicationRecord
:svg_content_type?,
:decode_raster_thumbnail,
:decode_svg_thumbnail,
:raster_thumbnail_format,
:sanitised_svg_bytes,
:svg_uses_disallowed_features?,
:external_svg_reference?,
@@ -443,12 +406,7 @@ class Post < ApplicationRecord
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)
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
return nil if match.nil?
year = match[1].to_i
+16 -9
ファイルの表示
@@ -4,16 +4,23 @@
module PostCompactRepr
module_function
def base post, host: nil
def base post
return nil if post.nil?
PostRepr
.common(post, host:)
.slice(
'id',
'title',
'url',
'thumbnail',
'thumbnail_base')
{
id: post.id,
title: post.title,
url: post.url,
thumbnail_url: thumbnail_url(post) }
end
def thumbnail_url post
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
post.thumbnail,
only_path: false)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError
nil
end
end
+21 -65
ファイルの表示
@@ -17,13 +17,8 @@ module PostRepr
module_function
def base post, current_user = nil, host: nil
json =
if host.present?
common(post, host:)
else
common(post)
end
def base post, current_user = nil
json = common(post)
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
@@ -31,59 +26,31 @@ module PostRepr
end
def detail post, current_user = nil, parent_posts: [], child_posts: [],
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
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))
end
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
def card post
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
def cards posts, host: nil
if host.present?
posts.map { |post| card(post, host:) }
else
posts.map { |post| card(post) }
end
def cards posts
posts.map { |post| card(post) }
end
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
def many posts, current_user = nil
posts.map { |p| base(p, current_user) }
end
def common post, host: nil
def common post
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge(
'thumbnail' =>
if host.present?
thumbnail_url(post, host:)
else
thumbnail_url(post)
end)
.merge('thumbnail' => thumbnail_url(post))
end
def tag_json post
@@ -98,22 +65,11 @@ module PostRepr
}
end
def thumbnail_url post, host: nil
def thumbnail_url post
return nil unless post.thumbnail.attached?
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 }")
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
rescue
nil
end
end
+3 -5
ファイルの表示
@@ -1,9 +1,8 @@
class PostBulkCreator
def initialize actor:, posts:, thumbnails:, host: nil
def initialize actor:, posts:, thumbnails:
@actor_id = actor.id
@posts = posts
@thumbnails = thumbnails
@host = host
end
def run
@@ -65,8 +64,7 @@ class PostBulkCreator
preflight =
PostCreatePreflight.new(
attributes: attributes,
thumbnail: thumbnail_for(index, attributes),
host: @host).run
thumbnail: thumbnail_for(index, attributes)).run
if preflight[:existing_post_id].present?
return {
status: 'skipped',
@@ -197,6 +195,6 @@ class PostBulkCreator
end
def compact_existing_post post
PostCompactRepr.base(post, host: @host)
PostCompactRepr.base(post)
end
end
+3 -65
ファイルの表示
@@ -12,8 +12,6 @@ class PostCreatePlan
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)
@@ -27,7 +25,6 @@ class PostCreatePlan
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(' '),
@@ -85,11 +82,9 @@ class PostCreatePlan
end_raw: match[3],
tag_name: name)
end
if name.include?('[') || name.include?(']')
raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
end
raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']')
[resolved_tag_name(name), category&.to_sym, sections]
[TagName.canonicalise(name).first, category&.to_sym, sections]
end
def build_default_tag_specs direct_tag_specs
@@ -108,31 +103,6 @@ class PostCreatePlan
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 =
@@ -165,17 +135,6 @@ class PostCreatePlan
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
@@ -203,24 +162,10 @@ class PostCreatePlan
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 }"
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { Post.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 }
@@ -261,11 +206,4 @@ class PostCreatePlan
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
+2 -5
ファイルの表示
@@ -9,10 +9,9 @@ class PostCreatePreflight
end
end
def initialize attributes:, thumbnail: nil, host: nil
def initialize attributes:, thumbnail: nil
@attributes = attributes.symbolize_keys
@thumbnail = thumbnail
@host = host
end
def run
@@ -30,7 +29,6 @@ class PostCreatePreflight
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],
@@ -65,7 +63,6 @@ class PostCreatePreflight
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],
@@ -125,7 +122,7 @@ class PostCreatePreflight
return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post, host: @host)
PostCompactRepr.base(post)
end
def final_field_warnings field_warnings
+23 -17
ファイルの表示
@@ -3,14 +3,14 @@ require 'timeout'
class PostImportPreviewer
FIELDS = [
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'video_ms',
'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
@@ -126,6 +126,7 @@ 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')
@@ -225,15 +226,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 #{ payload.to_json }")
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.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 #{ payload.to_json }")
"post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
@@ -335,14 +336,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 #{ payload.to_json }")
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.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 #{ payload.to_json }")
"post_import_metadata_fetch_unexpected_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
@@ -449,7 +450,7 @@ class PostImportPreviewer
[]
end
def validate_preview_tags raw, errors, known_tags
def validate_preview_tags raw, errors, field_warnings, known_tags
names = raw.to_s.split
return if names.empty?
if names.any? { _1.downcase.start_with?('nico:') }
@@ -461,6 +462,11 @@ 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
+4 -23
ファイルの表示
@@ -3,9 +3,10 @@ require 'date'
class PostMetadataFetcher
TIMESTAMP_PATTERN =
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2})
(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?
(Z|[+-]\d{2}:?\d{2})?\z/x
Regexp.new(
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
'(Z|[+-]\d{2}:?\d{2})?\z')
def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url)
@@ -31,7 +32,6 @@ class PostMetadataFetcher
original_created_from: serialise_time(created_range&.first),
original_created_before: serialise_time(created_range&.last),
duration: serialise_duration(duration),
display_tags: display_tags(platform_tags),
tags: platform_tags.join(' ') }
end
@@ -42,24 +42,6 @@ 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?
@@ -186,7 +168,6 @@ class PostMetadataFetcher
end
private_class_method :platform_tags,
:display_tags,
:original_created_range,
:parse_timestamp_range,
:parse_nanoseconds,
+3 -8
ファイルの表示
@@ -11,15 +11,10 @@ class PostThumbnailUploadValidator
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
raise InvalidUpload, 'サムネイル画像の形式が不正です.' if Post.svg_document_bytes?(thumbnail.read)
thumbnail.rewind
attachment = Post.resized_thumbnail_attachment(
StringIO.new(bytes),
content_type: thumbnail.content_type)
attachment = Post.resized_thumbnail_attachment(thumbnail, content_type: thumbnail.content_type)
attachment[:io].close if attachment[:io].respond_to?(:close)
rescue MiniMagick::Error, Timeout::Error
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
+13 -7
ファイルの表示
@@ -4,6 +4,10 @@ module Preview
RASTER_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze
REMOTE_IMAGE_CONTENT_TYPES = [
*RASTER_IMAGE_CONTENT_TYPES,
'image/svg+xml'
].freeze
HTML_MAX_BYTES = 1.megabyte
NICONICO_XML_MAX_BYTES = 256.kilobytes
@@ -28,9 +32,7 @@ module Preview
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)
unless allowed_remote_image_content_type?(response.content_type) || Post.svg_document_bytes?(response.body)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
@@ -53,9 +55,7 @@ module Preview
return nil if url.blank?
response = HttpFetcher.fetch(url)
return nil unless Post.remote_thumbnail_image_bytes?(
response.body,
content_type: response.content_type)
return nil unless allowed_remote_image_content_type?(response.content_type)
response.body
rescue HttpFetcher::FetchTimeout
@@ -96,7 +96,13 @@ module Preview
nil
end
def self.allowed_remote_image_content_type?(content_type)
mime_type = content_type.to_s.split(';', 2).first.downcase.strip
REMOTE_IMAGE_CONTENT_TYPES.include?(mime_type)
end
private_class_method :fetch_image_or_nil, :fetch_image!,
:niconico_thumbnail_url
:niconico_thumbnail_url,
:allowed_remote_image_content_type?
end
end
+4 -8
ファイルの表示
@@ -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,11 +36,7 @@ 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 }
Rails.application.routes.default_url_options.merge!(
host: 'localhost',
port: 3002,
protocol: 'http')
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
+11 -11
ファイルの表示
@@ -110,7 +110,7 @@ RSpec.describe Post, type: :model do
end
describe '.resized_thumbnail_attachment' do
it 'fits a wide image within 180x180 without distorting it' do
it 'centre-crops a wide image to 180x180 without distorting it' do
blob = image_blob(
width: 360,
height: 180,
@@ -123,13 +123,13 @@ RSpec.describe Post, type: :model do
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))
expect(image.dimensions).to eq([180, 180])
expect_green(colour_at(image, 0, 90))
expect_green(colour_at(image, 90, 90))
expect_green(colour_at(image, 179, 90))
end
it 'fits a tall image within 180x180 without distorting it' do
it 'centre-crops a tall image to 180x180 without distorting it' do
blob = image_blob(
width: 180,
height: 360,
@@ -142,10 +142,10 @@ RSpec.describe Post, type: :model do
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))
expect(image.dimensions).to eq([180, 180])
expect_green(colour_at(image, 90, 0))
expect_green(colour_at(image, 90, 90))
expect_green(colour_at(image, 90, 179))
end
it 'keeps a square image square without distortion' do
@@ -192,7 +192,7 @@ RSpec.describe Post, type: :model do
expect(post.thumbnail).to be_attached
image = read_image(post.thumbnail)
expect(image.dimensions).to eq([180, 135])
expect(image.dimensions).to eq([180, 180])
end
it 'does not attach anything when thumbnail conversion fails' do
+184
ファイルの表示
@@ -0,0 +1,184 @@
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
it 'returns original created datetime validation errors for minute precision' do
sign_in_as(member)
post '/posts/import/validate', params: {
rows: [{
sourceRow: 1,
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: {
originalCreatedFrom: '2020-01-01T00:00:30Z',
originalCreatedBefore: '2020-01-01T00:01Z' },
provenance: {
url: 'manual',
originalCreatedFrom: 'manual',
originalCreatedBefore: 'manual' },
tagSources: { automatic: '', manual: '' }
}],
changed_row: -1
}
expect(response).to have_http_status(:ok)
expect(json.fetch('rows').first.fetch('validation_errors')).to include(
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
)
expect(json.fetch('rows').first.fetch('validation_errors'))
.not_to have_key('original_created_at')
end
end
describe 'POST /posts/import' do
it 'keeps the duration string contract through preview, validate, and import' do
sign_in_as(member)
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: nil,
duration: '2.5',
tags: '動画'
)
post '/posts/import/preview', params: {
source: 'https://example.com/video'
}
preview_row = json.fetch('rows').first
expect(preview_row.dig('attributes', 'duration')).to eq('2.5')
post '/posts/import/validate', params: {
rows: [{
sourceRow: preview_row.fetch('source_row'),
url: preview_row.fetch('url'),
attributes: preview_row.fetch('attributes'),
provenance: preview_row.fetch('provenance'),
tagSources: preview_row.fetch('tag_sources'),
metadataUrl: preview_row.fetch('metadata_url')
}],
changed_row: -1
}
validated_row = json.fetch('rows').first
expect(validated_row.dig('attributes', 'duration')).to eq('2.5')
post '/posts/import', params: {
rows: [{
sourceRow: validated_row.fetch('source_row'),
url: validated_row.fetch('url'),
attributes: validated_row.fetch('attributes'),
provenance: validated_row.fetch('provenance'),
tagSources: validated_row.fetch('tag_sources'),
metadataUrl: validated_row.fetch('metadata_url')
}]
}
expect(response).to have_http_status(:ok)
expect(Post.order(:id).last.video_ms).to eq(2_500)
end
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
+31 -179
ファイルの表示
@@ -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) do
{ io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
end
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg'
)
end
def create_nico_tag!(name)
@@ -21,7 +21,8 @@ RSpec.describe 'Posts API', type: :request do
end
def dummy_upload
real_thumbnail_upload
# 中身は何でもいい(加工処理はスタブしてる)
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
end
def real_thumbnail_upload
@@ -101,34 +102,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
@@ -141,8 +142,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
@@ -161,9 +162,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')
@@ -181,8 +182,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')
@@ -200,11 +201,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
@@ -692,73 +693,6 @@ 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) }
@@ -778,46 +712,6 @@ 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)
@@ -875,8 +769,7 @@ RSpec.describe 'Posts API', type: :request do
)
expect(response).to have_http_status(:created)
expect(open_transactions).to eq(
[baseline_open_transactions, baseline_open_transactions])
expect(open_transactions).to eq([baseline_open_transactions])
end
it 'returns 422 and does not create a post when thumbnail resize fails' do
@@ -934,7 +827,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
'tags' => ['廃止済みタグは付与できません.']
)
end
@@ -1161,7 +1054,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'),
@@ -1358,47 +1251,6 @@ 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) }
@@ -1455,7 +1307,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'),
@@ -1712,7 +1564,7 @@ RSpec.describe 'Posts API', type: :request do
expect(post_record.reload.title).to eq('updated by other user')
end
it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
@@ -2419,9 +2271,9 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created)
expect(Time.iso8601(json.fetch('original_created_from')))
.to eq(Time.utc(2020, 1, 1, 0, 0))
.to eq(Time.iso8601('2020-01-01T00:00Z'))
expect(Time.iso8601(json.fetch('original_created_before')))
.to eq(Time.utc(2020, 1, 1, 0, 1))
.to eq(Time.iso8601('2020-01-01T00:01Z'))
end
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
+27
ファイルの表示
@@ -0,0 +1,27 @@
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
ファイルの表示
@@ -0,0 +1,37 @@
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
ファイルの表示
@@ -1,59 +0,0 @@
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
ファイルの表示
@@ -1,82 +0,0 @@
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
+19 -15
ファイルの表示
@@ -20,12 +20,12 @@ RSpec.describe PostCreator do
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'),
it 'prefers an explicit upload over thumbnail_base' do
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
io: StringIO.new('upload'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg')
expect_any_instance_of(Post).not_to receive(:attach_thumbnail_from_url!)
post = described_class.new(
actor:,
@@ -41,12 +41,13 @@ RSpec.describe PostCreator do
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')
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg') do |post, _url|
post.thumbnail.attach(
io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg',
content_type: 'image/jpeg')
end
post = described_class.new(
actor:,
@@ -60,8 +61,8 @@ RSpec.describe PostCreator do
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)
it 'keeps creating the post and records a warning when remote thumbnail fetch fails' do
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
creator = described_class.new(
actor:,
@@ -71,8 +72,11 @@ RSpec.describe PostCreator do
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)
post = creator.create!
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
expect(post.thumbnail).not_to be_attached
expect(creator.field_warnings).to eq(
thumbnail_base: ['サムネール画像を取得できませんでした.'])
end
end
+77
ファイルの表示
@@ -0,0 +1,77 @@
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' },
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' },
'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
+152
ファイルの表示
@@ -0,0 +1,152 @@
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
it 'returns thumbnail warnings from PostCreator on a created row' do
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
created_post = create(:post)
creator = instance_double(
PostCreator,
create!: created_post,
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] })
allow(PostCreator).to receive(:new).and_return(creator)
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to include(
status: 'created',
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] }
)
end
end
+40
ファイルの表示
@@ -0,0 +1,40 @@
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
-52
ファイルの表示
@@ -1,52 +0,0 @@
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
+10 -11
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Preview::ThumbnailFetcher do
describe '.fetch' do
it 'accepts svg thumbnails for the common safe rasterisation path' do
it 'rejects svg thumbnails' do
page = Preview::HttpFetcher::Response.new(
'<meta property="og:image" content="https://example.com/thumb.svg">',
'text/html',
@@ -12,9 +12,8 @@ RSpec.describe Preview::ThumbnailFetcher do
'image/svg+xml',
'https://example.com/thumb.svg')
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['203.0.113.10']]
end
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
@@ -22,7 +21,9 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.svg')
.and_return(svg)
expect(described_class.fetch('https://example.com/page')).to eq('<svg></svg>')
expect {
described_class.fetch('https://example.com/page')
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
end
it 'accepts allowed image content type with parameters' do
@@ -31,13 +32,12 @@ RSpec.describe Preview::ThumbnailFetcher do
'text/html',
'https://example.com/page')
image = Preview::HttpFetcher::Response.new(
"\xFF\xD8\xFFjpeg-bytes".b,
'jpeg-bytes',
'image/jpeg; charset=binary',
'https://example.com/thumb.jpg')
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['203.0.113.10']]
end
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
@@ -45,8 +45,7 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.jpg')
.and_return(image)
expect(described_class.fetch('https://example.com/page'))
.to eq("\xFF\xD8\xFFjpeg-bytes".b)
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
end
end
+40 -42
ファイルの表示
@@ -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,21 +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',
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 がある前提)
@@ -46,22 +46,22 @@ 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 のサムネール取得に共通 attach 経路を使ふ' do
@@ -73,11 +73,11 @@ RSpec.describe 'nico:sync' do
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!)
allow(URI).to receive(:open)
.and_return(
StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
expect(post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg')
run_rake_task('nico:sync')
@@ -92,45 +92,43 @@ RSpec.describe 'nico:sync' do
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
allow(URI).to receive(:open)
.and_return(
StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
expect(post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg')
.twice
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
2.times do
run_rake_task('nico:sync')
end
expect(calls).to eq(2)
end
it '既存 post にあった古い nico tag は active から外され、履歴として discard される' do
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
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
@@ -138,9 +136,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)
+37 -42
ファイルの表示
@@ -1,9 +1,8 @@
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { createBrowserRouter,
import { BrowserRouter,
Navigate,
Route,
RouterProvider,
Routes,
useLocation } from 'react-router-dom'
@@ -155,7 +154,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
}
const RoutedApp: FC = () => {
const App: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
@@ -254,46 +253,42 @@ const RoutedApp: FC = () => {
}
return (
<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>
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>)
<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>
</>)
}
const router = createBrowserRouter ([{
path: '*',
element: <RoutedApp/> }])
const App: FC = () => (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<RouterProvider router={router}/>
</>)
export default App
+5 -9
ファイルの表示
@@ -78,23 +78,19 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByText ('動画時間')).not.toBeInTheDocument ()
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
})
it (
'shows deduplicated original-created endpoint errors on the shared datetime field',
async () => {
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
const post = buildPost ()
api.isApiError.mockReturnValue (true)
postsApi.updatePost.mockRejectedValueOnce ({
@@ -116,5 +112,5 @@ describe ('PostEditForm', () => {
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
})
})
})
+1 -1
ファイルの表示
@@ -73,7 +73,7 @@ describe ('PostOriginalCreatedTimeField', () => {
setOriginalCreatedBefore={vi.fn ()}/>,
)
const input = screen.getByDisplayValue ('2024-01-01T12:34')
const input = screen.getDisplayValue ('2024-01-01T12:34')
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
+13
ファイルの表示
@@ -6,6 +6,7 @@ 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'
@@ -35,6 +36,7 @@ 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),
@@ -43,6 +45,10 @@ 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 () => {
@@ -87,6 +93,13 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => {
setOverlay (true)
})
+12 -43
ファイルの表示
@@ -4,13 +4,9 @@ import { cn } from '@/lib/utils'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
import type { Category, Tag } from '@/types'
import type { Tag } from '@/types'
type LightweightTag = {
name: string
category: Category }
type FullCommonProps = {
type CommonProps = {
tag: Tag
nestLevel?: number
truncateOnMobile?: boolean
@@ -18,43 +14,18 @@ type FullCommonProps = {
withCount?: boolean }
type PropsWithLink =
& FullCommonProps
& CommonProps
& { linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
type PropsWithoutLink =
& FullCommonProps
& CommonProps
& { 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,
@@ -75,18 +46,16 @@ 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
?? (matchedAlias == null ? tag.name : `${ matchedAlias }${ tag.name }`)
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias }${ tag.name }`)
return (
<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={rootClass}>
{(linkFlg && withWiki) && (
<span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? (
@@ -149,7 +118,7 @@ const TagLink: FC<Props> = ({ tag,
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)}
{matchedAlias != null && (
{tag.matchedAlias != null && (
<>
<span
title={textTitle}
@@ -157,7 +126,7 @@ const TagLink: FC<Props> = ({ tag,
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={matchedAlias}
text={tag.matchedAlias}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>
@@ -187,7 +156,7 @@ const TagLink: FC<Props> = ({ tag,
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>)}
{(withCount && isFullTag (tag)) && (
{withCount && (
<span className={countClass}>{tag.postCount}</span>)}
</span>)
}
+4 -2
ファイルの表示
@@ -17,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 ('uses /posts/new as the import entrypoint', () => {
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
})
it ('keeps material suppression admin-only', () => {
+1 -3
ファイルの表示
@@ -80,9 +80,7 @@ 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',
'md:w-auto')
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto')
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
+10
ファイルの表示
@@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -12,6 +14,14 @@ vi.mock ('@/components/posts/PostCreationDataFields', () => ({
}))
describe ('PostCreationDataFields usage', () => {
it ('is used by PostNewPage', async () => {
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm')
+2 -4
ファイルの表示
@@ -135,7 +135,7 @@ describe ('PostImportRowForm', () => {
})
it (
'shows upload input only when the thumbnail URL is blank',
'shows the shared creation field order without duration and without file upload UI',
async () => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
@@ -164,9 +164,7 @@ describe ('PostImportRowForm', () => {
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (container.querySelector ('input[type="file"]')).toHaveAttribute (
'accept',
'image/*')
expect (container.querySelector ('input[type="file"]')).toBeNull ()
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
+1 -1
ファイルの表示
@@ -10,8 +10,8 @@ import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import type { FC } from 'react'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
type Draft = PostImportEditableDraft
+128 -126
ファイルの表示
@@ -1,25 +1,21 @@
import FieldError from '@/components/common/FieldError'
import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import {
canEditReviewRow,
canRetryResultRow,
hasVideoTag,
} from '@/lib/postImportRows'
import { hasVideoTag } from '@/lib/postImportSession'
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportTypes'
import type { PostImportRow } from '@/lib/postImportSession'
type Props = {
row: PostImportRow
row: PostImportRow
displayNumber?: number
onEdit?: () => void
onRetry?: () => void
onEdit?: () => void
onRetry?: () => void
onToggleSkip?: (checked: boolean) => void
rowMessages?: string[]
editDisabled?: boolean
@@ -75,123 +71,129 @@ const PostImportRowSummary: FC<Props> = (
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>
<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 (
'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>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url}
</div>
{String (row.attributes.tags ?? '') && (
<div className="truncate text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '')}
</div>)}
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
</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 ?? '')}
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 ?? '')}
</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>
<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}/>
{skipControl}
</div>
</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>
<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 ?? '')}
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 ?? '')}
</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>
{String (row.attributes.tags ?? '') && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '')}
</div>)}
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
</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}/>
{skipControl}
</div>
</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>
</>)
}
-36
ファイルの表示
@@ -1,36 +0,0 @@
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
+58 -33
ファイルの表示
@@ -1,8 +1,14 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostImportThumbnailPreview', () => {
beforeEach (() => {
vi.clearAllMocks ()
@@ -10,50 +16,69 @@ describe ('PostImportThumbnailPreview', () => {
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"/>)
it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => {
api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
expect (screen.getByRole ('img')).toHaveAttribute (
'src',
'https://example.com/thumbnail.jpg')
expect (screen.getByRole ('img')).toHaveAttribute (
'referrerpolicy',
'no-referrer')
render (
<PostImportThumbnailPreview
url="https://example.com/thumbnail.jpg"
className="h-10 w-10"/>)
await waitFor (() => {
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
})
expect (screen.getByRole ('img')).not.toHaveAttribute (
'src',
'https://example.com/thumbnail.jpg')
expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', {
params: { url: 'https://example.com/thumbnail.jpg' },
responseType: 'blob' })
})
it ('shows the empty frame after the remote image fails', () => {
it ('does not render the unsafe URL directly when preview fetching fails', async () => {
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
const { container } = render (
<PostImportThumbnailPreview
url="https://example.com/missing.jpg"
className="h-10 w-10"/>)
<PostImportThumbnailPreview
url="http://127.0.0.1/private.png"
className="h-10 w-10"/>)
fireEvent.error (screen.getByRole ('img'))
expect (screen.queryByRole ('img')).toBeNull ()
await waitFor (() => {
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 ('')
expect (screen.queryByRole ('img')).toBeNull ()
})
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"/>)
it ('revokes the old object URL when the source URL changes', async () => {
const createObjectUrlMock =
globalThis.URL.createObjectURL as unknown as ReturnType<typeof vi.fn>
createObjectUrlMock
.mockReturnValueOnce ('blob:first')
.mockReturnValueOnce ('blob:second')
api.apiGet
.mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
.mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
const { rerender } = render (
<PostImportThumbnailPreview
url="https://example.com/first.jpg"
className="h-10 w-10"/>)
await waitFor (() => {
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
})
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')
<PostImportThumbnailPreview
url="https://example.com/second.jpg"
className="h-10 w-10"/>)
unmount ()
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:preview')
await waitFor (() => {
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second')
})
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
})
})
+4 -4
ファイルの表示
@@ -16,14 +16,14 @@ describe ('displayPostImportStatus', () => {
skipReason: 'manual' }))).toBe ('skipped')
})
it ('distinguishes validation, failure, and created states', () => {
it ('does not expose validation, failure, or created states as badges', () => {
expect (displayPostImportStatus (buildPostImportRow ({
status: 'error',
validationErrors: { title: ['invalid'] } }))).toBe ('error')
validationErrors: { title: ['invalid'] } }))).toBeNull ()
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'failed' }))).toBe ('failed')
importStatus: 'failed' }))).toBeNull ()
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'created',
createdPostId: 3 }))).toBe ('created')
createdPostId: 3 }))).toBeNull ()
})
})
+1 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { PostImportRow } from '@/lib/postImportTypes'
import type { PostImportRow } from '@/lib/postImportSession'
export type PostImportDisplayStatus =
'ready'
+97 -95
ファイルの表示
@@ -1,10 +1,10 @@
'use client'
"use client"
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from 'react'
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
@@ -15,109 +15,111 @@ 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 md: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 md:flex-row md:justify-end md: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 -2
ファイルの表示
@@ -16,7 +16,7 @@ import { creatableImportRows,
resultRowMessages,
resultSummaryCounts,
retryImportRow,
reviewSummaryCounts } from '@/lib/postImportRows'
reviewSummaryCounts } from '@/lib/postImportSession'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import row state', () => {
@@ -274,7 +274,6 @@ describe ('post import row state', () => {
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '2',
tags: 'edited-tag',
parentPostIds: '' },
true)
+5 -15
ファイルの表示
@@ -68,10 +68,6 @@ export const validatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
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 ?? '',
@@ -226,6 +222,11 @@ export const buildNextEditedRow = (
? 'manual'
: (editingRow.provenance[field] ?? 'automatic')
})
if (nextProvenance.duration === 'manual')
{
nextAttributes.videoMs = ''
nextProvenance.videoMs = 'manual'
}
nextAttributes.tags = draft.tags
if (isManualChange (editingRow.attributes.tags, draft.tags))
{
@@ -242,11 +243,6 @@ export const buildNextEditedRow = (
...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,
@@ -320,12 +316,6 @@ export const mergeValidatedImportRows = (
...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,
+4
ファイルの表示
@@ -0,0 +1,4 @@
export * from '@/lib/postImportTypes'
export * from '@/lib/postImportStorage'
export * from '@/lib/postImportSourceValidation'
export * from '@/lib/postImportRows'
+1 -4
ファイルの表示
@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
countImportSourceLines,
validateImportSource,
} from '@/lib/postImportSourceValidation'
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession'
describe ('post import source validation', () => {
it ('counts trimmed non-empty CRLF and LF rows', () => {
+23 -34
ファイルの表示
@@ -12,14 +12,23 @@ const bytesize = (value: string): number =>
new TextEncoder ().encode (value).length
const parseImportUrl = (value: string): URL | null => {
const normaliseImportUrl = (value: string): string | null => {
const trimmed = value.trim ()
if (!(trimmed))
return null
try
{
return new URL (trimmed)
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 ()
}
catch
{
@@ -28,23 +37,12 @@ const parseImportUrl = (value: string): URL | null => {
}
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[] =>
export const countImportSourceLines = (source: string): number =>
source
.split (/\r\n|\n|\r/)
.map (line => line.trim ())
.filter (line => line !== '')
export const countImportSourceLines = (source: string): number =>
extractImportSourceUrls (source).length
.length
export const validateImportSource = (
@@ -63,6 +61,7 @@ export const validateImportSource = (
++count
const sourceRow = index + 1
const displayUrl = truncateUrl (value)
const normalised = normaliseImportUrl (value)
if (count > MAX_ROWS)
{
issues.push ({
@@ -71,6 +70,14 @@ export const validateImportSource = (
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 ({
@@ -79,8 +86,7 @@ export const validateImportSource = (
url: displayUrl })
return
}
const parsed = parseImportUrl (value)
if (parsed == null)
if (normalised == null)
{
issues.push ({
sourceRow,
@@ -88,23 +94,6 @@ export const validateImportSource = (
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)
{
+108 -15
ファイルの表示
@@ -1,31 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearPostImportSourceDraft,
loadPostImportSourceDraft,
savePostImportSourceDraft,
} from '@/lib/postImportStorage'
import { cleanupExpiredPostImportSessions,
clearPostImportSourceDraft,
loadPostImportSession,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft } from '@/lib/postImportSession'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import source draft storage', () => {
describe ('post import storage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.restoreAllMocks ()
vi.useRealTimers ()
})
it ('round-trips and clears the URL list source draft', () => {
it ('round-trips a valid session and source draft', () => {
const row = buildPostImportRow ({
attributes: { duration: '2.5' },
recoverable: true,
importStatus: 'pending',
validationErrors: { title: ['invalid'] } })
const skipped = buildPostImportRow ({
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 })
const manual = buildPostImportRow ({
sourceRow: 3,
skipReason: 'manual' })
expect (savePostImportSession ({
source: skipped.url,
rows: [row, skipped, manual],
repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ()).toMatchObject ({
version: 2,
source: skipped.url,
rows: [{
attributes: { duration: '2.5' },
recoverable: true,
importStatus: 'pending' },
{
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 },
{
skipReason: 'manual' }] })
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 ('ignores malformed stored drafts', () => {
sessionStorage.setItem ('post-import-source-draft', '{')
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 },
{ ...session.rows[0], importStatus: 'created', recoverable: true },
{ ...session.rows[0], importStatus: 'skipped', recoverable: true },
{ ...session.rows[0], recoverable: true }]
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
for (const row of invalidRows)
{
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
...session,
rows: [row] }))
expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
}
})
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 => {
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [invalidRow],
repairMode: 'all' }))
expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
})
})
it ('keeps only the current fixed-key session and removes legacy prefixed entries', () => {
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 ()
})
it ('reports storage access failures without throwing', () => {
+628 -47
ファイルの表示
@@ -1,24 +1,123 @@
import type { StorageErrorHandler } from '@/lib/postImportTypes'
import type { PostImportOrigin,
PostImportExistingPost,
PostImportResetSnapshot,
PostImportRow,
PostImportSession,
PostImportStatus,
PostImportSkipReason,
StorageErrorHandler } from '@/lib/postImportTypes'
const SESSION_VERSION = 3
const SESSION_PREFIX = 'post-import-session:'
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const MAX_SESSION_JSON_BYTES = 262_144
const MAX_SESSION_ROWS = 100
const MAX_SOURCE_BYTES = 262_144
const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
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 ROW_KEYS = [
'sourceRow',
'url',
'attributes',
'fieldWarnings',
'baseWarnings',
'validationErrors',
'importErrors',
'provenance',
'tagSources',
'status',
'skipReason',
'existingPostId',
'existingPost',
'metadataUrl',
'resetSnapshot',
'createdPostId',
'importStatus',
'recoverable'] as const
const SESSION_KEYS = [
'version',
'expiresAt',
'source',
'rows',
'repairMode'] as const
const textEncoder = new TextEncoder ()
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const byteLength = (value: string): number =>
textEncoder.encode (value).byteLength
export const validatePostImportSessionId = (value: unknown): string | null => {
if (typeof value !== 'string')
return null
if (value.length > 36 || !(SESSION_ID_PATTERN.test (value)))
return null
return value.toLowerCase ()
}
const generateUuidV4FromRandomValues = (): string => {
const bytes = new Uint8Array (16)
crypto.getRandomValues (bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from (
bytes,
byte => byte.toString (16).padStart (2, '0'))
return [
hex.slice (0, 4).join (''),
hex.slice (4, 6).join (''),
hex.slice (6, 8).join (''),
hex.slice (8, 10).join (''),
hex.slice (10, 16).join ('')].join ('-')
}
export const generatePostImportSessionId = (): string => {
if (typeof crypto.randomUUID === 'function')
return crypto.randomUUID ()
return generateUuidV4FromRandomValues ()
}
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
}
}
@@ -27,19 +126,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
}
}
@@ -47,36 +146,450 @@ const removeStorage = (
key: string,
onError?: StorageErrorHandler,
) => {
if (typeof window === 'undefined')
return
if (typeof window === 'undefined')
return
try
{
sessionStorage.removeItem (key)
}
catch
{
onError?.('保存済みデータを削除できませんでした.')
}
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 (item => typeof item === 'string')))
return null
result[key] = entry
}
return result
}
const isValidStatus = (
value: unknown,
): value is PostImportRow['status'] =>
value === 'pending'
|| 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' || value === 'manual'
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 (!(hasOnlyKeys (value, ['url',
'attributes',
'provenance',
'tagSources',
'fieldWarnings',
'baseWarnings',
'metadataUrl'])))
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 (entry => typeof entry === '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 (warning => typeof warning === '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 sanitiseExistingPost = (value: unknown): PostImportExistingPost | undefined => {
if (value == null)
return undefined
if (!(isPlainObject (value)))
return undefined
if (!(isPositiveInteger (value.id)))
return undefined
if (typeof value.title !== 'string' || typeof value.url !== 'string')
return undefined
if (value.thumbnailUrl != null && typeof value.thumbnailUrl !== 'string')
return undefined
return {
id: Number (value.id),
title: value.title,
url: value.url,
thumbnailUrl: value.thumbnailUrl as string | undefined }
}
const recalculateThumbnailWarning = (row: PostImportRow): PostImportRow => {
const others = (row.fieldWarnings.thumbnailBase ?? []).filter (
key => key !== 'サムネールなし')
const thumbnailBasePresent =
typeof row.attributes.thumbnailBase === 'string'
&& row.attributes.thumbnailBase.trim () !== ''
return {
...row,
fieldWarnings: {
...row.fieldWarnings,
thumbnailBase:
thumbnailBasePresent
? others
: [...new Set ([...others, 'サムネールなし'])] } }
}
const serialiseExistingPost = (value: PostImportExistingPost | undefined) =>
value == null
? undefined
: {
id: value.id,
title: value.title,
url: value.url,
thumbnailUrl: value.thumbnailUrl }
const serialiseResetSnapshot = (value: PostImportResetSnapshot) => ({
url: value.url,
attributes: Object.fromEntries (
Object.entries (value.attributes).map (([key, entry]) => [key, entry])),
provenance: Object.fromEntries (
Object.entries (value.provenance).map (([key, entry]) => [key, entry])),
tagSources: {
automatic: value.tagSources.automatic,
manual: value.tagSources.manual },
fieldWarnings: Object.fromEntries (
Object.entries (value.fieldWarnings).map (([key, entry]) => [key, [...entry]])),
baseWarnings: [...value.baseWarnings],
metadataUrl: value.metadataUrl })
export const serialisePostImportRow = (row: PostImportRow) => {
const thumbnailBaseWarnings = (() => {
const others = (row.fieldWarnings.thumbnailBase ?? []).filter (
message => message !== 'サムネールなし')
const thumbnailBasePresent =
typeof row.attributes.thumbnailBase === 'string'
&& row.attributes.thumbnailBase.trim () !== ''
return thumbnailBasePresent
? others
: [...new Set ([...others, 'サムネールなし'])]
}) ()
return {
sourceRow: row.sourceRow,
url: row.url,
attributes: Object.fromEntries (
Object.entries (row.attributes).map (([key, entry]) => [key, entry])),
fieldWarnings: Object.fromEntries (
Object.entries ({
...row.fieldWarnings,
thumbnailBase: thumbnailBaseWarnings }).map (([key, entry]) => [key, [...entry]])),
baseWarnings: [...row.baseWarnings],
validationErrors: Object.fromEntries (
Object.entries (row.validationErrors).map (([key, entry]) => [key, [...entry]])),
importErrors:
row.importErrors == null
? undefined
: Object.fromEntries (
Object.entries (row.importErrors).map (([key, entry]) => [key, [...entry]])),
provenance: Object.fromEntries (
Object.entries (row.provenance).map (([key, entry]) => [key, entry])),
tagSources:
row.tagSources == null
? undefined
: {
automatic: row.tagSources.automatic,
manual: row.tagSources.manual },
status: row.status,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
existingPost: serialiseExistingPost (row.existingPost),
metadataUrl: row.metadataUrl,
resetSnapshot: serialiseResetSnapshot (row.resetSnapshot),
createdPostId: row.createdPostId,
importStatus: row.importStatus,
recoverable: row.recoverable }
}
export const serialisePostImportRows = (rows: PostImportRow[]) =>
rows.map (row => serialisePostImportRow (row))
export const serialisedPostImportRowsEqual = (
left: PostImportRow[],
right: PostImportRow[],
): boolean =>
JSON.stringify (serialisePostImportRows (left))
=== JSON.stringify (serialisePostImportRows (right))
const sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(isPlainObject (value)))
return null
if (!(hasOnlyKeys (value, ROW_KEYS)))
return null
if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
return null
if (typeof value.url !== 'string')
return null
if (byteLength (value.url) > 20_480)
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.recoverable != null && value.recoverable !== true)
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
if (value.recoverable === true
&& value.importStatus !== 'failed'
&& value.importStatus !== 'pending')
return null
if ((value.importStatus === 'created' || value.importStatus === 'skipped')
&& value.recoverable != 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 (warning => typeof warning === '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 (entry => typeof entry === '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,
existingPost: sanitiseExistingPost (value.existingPost),
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
resetSnapshot,
createdPostId:
isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
importStatus: value.importStatus ?? undefined,
recoverable: value.recoverable === true ? true : undefined }
}
const sanitiseSession = (value: unknown): PostImportSession | null => {
if (!(isPlainObject (value)))
return null
if (!(hasOnlyKeys (value, SESSION_KEYS)))
return null
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
return null
if (value.rows.length === 0 || value.rows.length > MAX_SESSION_ROWS)
return null
if (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt))
return null
if (typeof value.source !== 'string' || byteLength (value.source) > MAX_SOURCE_BYTES)
return null
const rows = value.rows.map (sanitiseRow)
if (rows.some (row => row == null))
return null
if ((new Set (rows.map (row => row?.sourceRow))).size !== rows.length)
return null
return {
version: SESSION_VERSION,
expiresAt: value.expiresAt,
source: value.source,
rows: (rows as PostImportRow[]).map (row => recalculateThumbnailWarning (row)),
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
}
const isExpiredSession = (expiresAt: string): boolean => {
const value = Date.parse (expiresAt)
return Number.isNaN (value) || value <= Date.now ()
}
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 sessionId = validatePostImportSessionId (key.slice (SESSION_PREFIX.length))
if (sessionId == null)
{
sessionStorage.removeItem (key)
--i
continue
}
const raw = sessionStorage.getItem (key)
if (raw == null)
continue
if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
{
sessionStorage.removeItem (key)
--i
continue
}
try
{
if (sanitiseSession (JSON.parse (raw)) == null)
{
sessionStorage.removeItem (key)
--i
}
}
catch
{
sessionStorage.removeItem (key)
--i
}
}
}
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: '' }
}
}
@@ -90,5 +603,73 @@ export const savePostImportSourceDraft = (
export const clearPostImportSourceDraft = (
onError?: StorageErrorHandler,
) => {
removeStorage (SOURCE_DRAFT_KEY, onError)
removeStorage (SOURCE_DRAFT_KEY, onError)
}
export const savePostImportSession = (
sessionId: string,
session: Omit<PostImportSession, 'version' | 'expiresAt'>,
onError?: StorageErrorHandler,
): boolean => {
const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return false
return writeStorage (
sessionKey (validatedId),
JSON.stringify ({
source: session.source,
rows: serialisePostImportRows (session.rows),
repairMode: session.repairMode,
version: SESSION_VERSION,
expiresAt: new Date (Date.now () + SESSION_MAX_AGE_MS).toISOString () }),
onError)
}
export const loadPostImportSession = (
sessionId: string,
onError?: StorageErrorHandler,
): PostImportSession | null => {
const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return null
const raw = readStorage (sessionKey (validatedId), onError)
if (raw == null)
return null
if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
{
removeStorage (sessionKey (validatedId), onError)
return null
}
try
{
const session = sanitiseSession (JSON.parse (raw))
if (session == null)
{
removeStorage (sessionKey (validatedId), onError)
return null
}
return session
}
catch
{
removeStorage (sessionKey (validatedId), onError)
return null
}
}
export const clearPostImportSession = (
sessionId: string,
onError?: StorageErrorHandler,
) => {
const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return
removeStorage (sessionKey (validatedId), onError)
}
+8 -11
ファイルの表示
@@ -1,5 +1,3 @@
import type { Category } from '@/types'
export type PostImportOrigin = 'automatic' | 'manual'
export type PostImportRepairMode = 'all' | 'failed'
export type PostImportStatus =
@@ -11,15 +9,9 @@ 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[]>
@@ -30,8 +22,7 @@ export type PostImportExistingPost = {
id: number
title: string
url: string
thumbnail?: string | null
thumbnailBase?: string | null }
thumbnailUrl?: string }
export type PostImportRow = {
sourceRow: number
@@ -48,7 +39,6 @@ export type PostImportRow = {
existingPostId?: number
existingPost?: PostImportExistingPost
metadataUrl?: string
displayTags?: PostImportDisplayTag[]
resetSnapshot: PostImportResetSnapshot
createdPostId?: number
importStatus?: PostImportStatus
@@ -79,6 +69,13 @@ export type PostImportResultRow =
errors?: Record<string, string[]>
recoverable?: boolean }
export type PostImportSession = {
version: number
expiresAt: string
source: string
rows: PostImportRow[]
repairMode: PostImportRepairMode }
export type PostImportSourceIssue = {
sourceRow: number
message: string
-43
ファイルの表示
@@ -1,43 +0,0 @@
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)
})
})
+796 -43
ファイルの表示
@@ -1,58 +1,811 @@
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144
import { resultRepairMode } from '@/lib/postImportRows'
import { validatePostImportSessionId } from '@/lib/postImportStorage'
import type {
PostImportOrigin,
PostImportRepairMode,
PostImportRow,
PostImportSkipReason,
} from '@/lib/postImportTypes'
type QueryRowState = {
source_row: number
title: string
thumbnail_base: string
original_created_from: string
original_created_before: string
video_ms: string
duration: string
tags: string
parent_post_ids: string
manual: string[]
skip: PostImportSkipReason | null
existing_post_id: number | null
import_status: PostImportRow['importStatus'] | null
created_post_id: number | null
recoverable: boolean | null }
type FullRowState = QueryRowState & { url: string }
type MultiRowMetaState = {
v: 1
rows: EncodedRowState[] }
type EncodedSkip = 'e' | 'm'
type EncodedImportStatus = 'p' | 'c' | 's' | 'f'
type EncodedRowState = {
n?: number
u?: string
t?: string
h?: string
f?: string
b?: string
x?: string
d?: string
g?: string
p?: string
m?: string[]
s?: EncodedSkip
e?: number
i?: EncodedImportStatus
c?: number
r?: boolean }
type EncodedState = {
v: 1
rows: EncodedRowState[] }
export type ParsedPostNewState = {
rows: PostImportRow[]
source: string
repairMode: PostImportRepairMode
shortcut: boolean }
export type SerialisedPostNewState = {
path: string
complete: boolean }
const BASIC_KEYS = [
'url',
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'video_ms',
'duration',
'tags',
'parent_post_ids'] as const
const STATE_KEYS = [
'manual',
'skip',
'existing_post_id',
'import_status',
'created_post_id',
'recoverable'] as const
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS, 'session_id'] as const
const ENCODED_ROW_KEYS = [
'n',
'u',
't',
'h',
'f',
'b',
'x',
'd',
'g',
'p',
'm',
's',
'e',
'i',
'c',
'r'] as const
const ALLOWED_MANUAL_FIELDS = [
'title',
'thumbnailBase',
'originalCreatedFrom',
'originalCreatedBefore',
'duration',
'tags',
'parentPostIds'] as const
const MAX_REGULAR_URL_LENGTH = 768
const MAX_COMPRESSED_STATE_LENGTH = 767
const COMPRESSED_STATE_PREFIX = 'z1.'
const MAX_COMPRESSED_STATE_PARAM_LENGTH = 8_192
const MAX_COMPRESSED_STATE_BYTES = 16_384
const MAX_DECOMPRESSED_STATE_BYTES = 262_144
const MAX_STATE_ROWS = 100
const MAX_QUERY_STRING_BYTES = 20_480
const textEncoder = new TextEncoder ()
const textDecoder = new TextDecoder ()
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const hasOnlyKeys = (
value: Record<string, unknown>,
allowedKeys: readonly string[],
): boolean =>
Object.keys (value).every (key => allowedKeys.includes (key))
const byteLength = (value: string): number =>
textEncoder.encode (value).byteLength
const isValidSkipReason = (value: unknown): value is PostImportSkipReason =>
value === 'existing' || value === 'manual'
const rawUrlsParam = (search: string): string | null => {
const query = search.startsWith ('?') ? search.slice (1) : search
if (query === '')
const isValidImportStatus = (
value: unknown,
): value is PostImportRow['importStatus'] =>
value === 'pending'
|| value === 'created'
|| value === 'skipped'
|| value === 'failed'
const encodeSkip = (
value: PostImportSkipReason | null,
): EncodedSkip | undefined =>
value === 'existing'
? 'e'
: value === 'manual'
? 'm'
: undefined
const decodeSkip = (
value: unknown,
): PostImportSkipReason | null => {
if (value == null)
return null
if (value === 'e')
return 'existing'
if (value === 'm')
return 'manual'
return null
}
const encodeImportStatus = (
value: PostImportRow['importStatus'] | null,
): EncodedImportStatus | undefined =>
value === 'pending'
? 'p'
: value === 'created'
? 'c'
: value === 'skipped'
? 's'
: value === 'failed'
? 'f'
: undefined
const decodeImportStatus = (
value: unknown,
): PostImportRow['importStatus'] | null => {
if (value == null)
return null
if (value === 'p')
return 'pending'
if (value === 'c')
return 'created'
if (value === 's')
return 'skipped'
if (value === 'f')
return 'failed'
return null
}
const parsePositiveInt = (value: string | null): number | null => {
if (value == null || value === '')
return null
for (const segment of query.split ('&'))
const parsed = Number.parseInt (value, 10)
return Number.isInteger (parsed) && parsed > 0 ? parsed : null
}
const decodeBase64UrlBytes = (value: string): Uint8Array | null => {
try
{
const padded = value.replaceAll ('-', '+').replaceAll ('_', '/')
const remainder = padded.length % 4
const base64 =
remainder === 0
? padded
: `${ padded }${ '='.repeat (4 - remainder) }`
const binary = window.atob (base64)
return Uint8Array.from (binary, char => char.charCodeAt (0))
}
catch
{
return null
}
}
const encodeBase64UrlBytes = (value: Uint8Array): string => {
const binary = Array.from (value, byte => String.fromCharCode (byte)).join ('')
return window.btoa (binary).replaceAll ('+', '-').replaceAll ('/', '_').replaceAll ('=', '')
}
const compressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
const stream =
new Blob ([value]).stream ().pipeThrough (new CompressionStream ('gzip'))
return new Uint8Array (await new Response (stream).arrayBuffer ())
}
const decompressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
const stream =
new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip'))
const bytes = new Uint8Array (await new Response (stream).arrayBuffer ())
if (bytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES)
throw new Error ('decompressed state too large')
return bytes
}
const decodeCompressedState = async (value: string): Promise<string | null> => {
if (!(value.startsWith (COMPRESSED_STATE_PREFIX)))
return null
if (value.length > MAX_COMPRESSED_STATE_PARAM_LENGTH)
return null
const encoded = value.slice (COMPRESSED_STATE_PREFIX.length)
const compressed = decodeBase64UrlBytes (encoded)
if (compressed == null)
return null
if (compressed.byteLength > MAX_COMPRESSED_STATE_BYTES)
return null
try
{
return textDecoder.decode (await decompressBytes (compressed))
}
catch
{
return null
}
}
const encodeCompressedState = async (value: string): Promise<string> => {
const compressed = await compressBytes (textEncoder.encode (value))
return `${ COMPRESSED_STATE_PREFIX }${ encodeBase64UrlBytes (compressed) }`
}
const manualFields = (row: PostImportRow): string[] =>
['title',
'thumbnailBase',
'originalCreatedFrom',
'originalCreatedBefore',
'duration',
'tags',
'parentPostIds']
.filter (field => row.provenance[field] === 'manual')
const baseRowState = (row: PostImportRow): QueryRowState => ({
source_row: row.sourceRow,
title: String (row.attributes.title ?? ''),
thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
original_created_before: String (row.attributes.originalCreatedBefore ?? ''),
video_ms: String (row.attributes.videoMs ?? ''),
duration: String (row.attributes.duration ?? ''),
tags: String (row.attributes.tags ?? ''),
parent_post_ids: String (row.attributes.parentPostIds ?? ''),
manual: manualFields (row),
skip: row.skipReason ?? null,
existing_post_id: row.existingPostId ?? null,
import_status: row.importStatus ?? null,
created_post_id: row.createdPostId ?? null,
recoverable: row.recoverable ?? null })
const buildRow = (state: FullRowState): PostImportRow => {
const manual = new Set (state.manual)
const provenance: Record<string, PostImportOrigin> = {
url: 'manual',
title: manual.has ('title') ? 'manual' : 'automatic',
thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic',
originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic',
originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic',
videoMs: manual.has ('duration') ? 'manual' : 'automatic',
duration: manual.has ('duration') ? 'manual' : 'automatic',
tags: manual.has ('tags') ? 'manual' : 'automatic',
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
const tagSources = provenance.tags === 'manual'
? { automatic: '', manual: state.tags }
: { automatic: state.tags, manual: '' }
const attributes = {
title: state.title,
thumbnailBase: state.thumbnail_base,
originalCreatedFrom: state.original_created_from,
originalCreatedBefore: state.original_created_before,
videoMs: manual.has ('duration') ? '' : state.video_ms,
duration: state.duration,
tags: state.tags,
parentPostIds: state.parent_post_ids }
return {
sourceRow: state.source_row,
url: state.url,
attributes,
fieldWarnings: { },
baseWarnings: [],
validationErrors: { },
provenance,
tagSources,
status: 'pending',
skipReason: state.skip ?? undefined,
existingPostId: state.existing_post_id ?? undefined,
resetSnapshot: {
url: state.url,
attributes: { ...attributes },
provenance: { ...provenance },
tagSources: { ...tagSources },
fieldWarnings: { },
baseWarnings: [] },
createdPostId: state.created_post_id ?? undefined,
importStatus: state.import_status ?? undefined,
recoverable: state.recoverable ?? undefined }
}
const parseManual = (value: string | null): string[] =>
(value ?? '')
.split (',')
.map (entry => entry.trim ())
.filter (entry => entry !== '')
const hasOnlyAllowedManualFields = (value: string[]): boolean =>
value.every (field => ALLOWED_MANUAL_FIELDS.includes (field as never))
const isStringArray = (value: unknown): value is string[] =>
Array.isArray (value) && value.every (entry => typeof entry === 'string')
const parseEncodedRow = (
value: unknown,
requireUrl: boolean,
): FullRowState | null => {
if (!(isPlainObject (value)))
return null
const row = value as Record<string, unknown>
if (!(hasOnlyKeys (row, ENCODED_ROW_KEYS)))
return null
const url = row['u']
const manual = row['m']
const skip = decodeSkip (row['s'])
const importStatus = decodeImportStatus (row['i'])
const existingPostId = row['e']
const createdPostId = row['c']
const recoverable = row['r']
const sourceRow = row['n']
if (requireUrl && typeof url !== 'string')
return null
if (typeof url === 'string' && byteLength (url) > MAX_QUERY_STRING_BYTES)
return null
if (manual != null && !(isStringArray (manual)))
return null
if ((manual ?? []).some (field => !(ALLOWED_MANUAL_FIELDS.includes (field as never))))
return null
if (row['s'] != null && skip == null)
return null
if (row['i'] != null && importStatus == null)
return null
if (existingPostId != null
&& !(typeof existingPostId === 'number'
&& Number.isInteger (existingPostId)
&& existingPostId > 0))
return null
if (createdPostId != null
&& !(typeof createdPostId === 'number'
&& Number.isInteger (createdPostId)
&& createdPostId > 0))
return null
if (recoverable != null && typeof recoverable !== 'boolean')
return null
if (sourceRow != null
&& !(typeof sourceRow === 'number'
&& Number.isInteger (sourceRow)
&& sourceRow > 0))
return null
const stringFields = [
['t', 'title'],
['h', 'thumbnail_base'],
['f', 'original_created_from'],
['b', 'original_created_before'],
['x', 'video_ms'],
['d', 'duration'],
['g', 'tags'],
['p', 'parent_post_ids']] as const
let parsedStrings: Record<(typeof stringFields)[number][1], string>
try
{
parsedStrings = Object.fromEntries (
stringFields.map (([key, target]) => {
const fieldValue = row[key]
if (fieldValue != null && typeof fieldValue !== 'string')
throw new Error ('invalid row')
if (typeof fieldValue === 'string' && byteLength (fieldValue) > MAX_QUERY_STRING_BYTES)
throw new Error ('invalid row')
return [target, fieldValue ?? '']
}),
) as Record<(typeof stringFields)[number][1], string>
}
catch
{
return null
}
return {
source_row:
typeof sourceRow === 'number'
? sourceRow
: 1,
url: typeof url === 'string' ? url : '',
title: parsedStrings.title,
thumbnail_base: parsedStrings.thumbnail_base,
original_created_from: parsedStrings.original_created_from,
original_created_before: parsedStrings.original_created_before,
video_ms: parsedStrings.video_ms,
duration: parsedStrings.duration,
tags: parsedStrings.tags,
parent_post_ids: parsedStrings.parent_post_ids,
manual: manual ?? [],
skip,
existing_post_id: existingPostId ?? null,
import_status: importStatus,
created_post_id: createdPostId ?? null,
recoverable: recoverable ?? null }
}
const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
const url = params.get ('url')
if (url == null || url === '')
return null
if (byteLength (url) > MAX_QUERY_STRING_BYTES)
return null
const hasOnlyUrl = Array.from (params.keys ()).every (
key => key === 'url' || key === 'session_id')
if (hasOnlyUrl)
{
if (segment === 'urls')
return ''
if (segment.startsWith ('urls='))
return segment.slice ('urls='.length)
const row = buildRow ({
source_row: 1,
url,
title: '',
thumbnail_base: '',
original_created_from: '',
original_created_before: '',
video_ms: '',
duration: '',
tags: '',
parent_post_ids: '',
manual: [],
skip: null,
existing_post_id: null,
import_status: null,
created_post_id: null,
recoverable: null })
return {
rows: [row],
source: url,
repairMode: 'all',
shortcut: true }
}
if (!(Array.from (params.keys ()).every (key => SINGLE_ROW_KEYS.includes (key as never))))
return null
const skipValue = params.get ('skip')
const importStatus = params.get ('import_status')
const recoverable =
params.get ('recoverable') == null
? null
: params.get ('recoverable') === 'true'
if (skipValue != null && !(isValidSkipReason (skipValue)))
return null
if (importStatus != null && !(isValidImportStatus (importStatus)))
return null
if (!(hasOnlyAllowedManualFields (parseManual (params.get ('manual')))))
return null
const row = buildRow ({
source_row: 1,
url,
title: params.get ('title') ?? '',
thumbnail_base: params.get ('thumbnail_base') ?? '',
original_created_from: params.get ('original_created_from') ?? '',
original_created_before: params.get ('original_created_before') ?? '',
video_ms: params.get ('video_ms') ?? '',
duration: params.get ('duration') ?? '',
tags: params.get ('tags') ?? '',
parent_post_ids: params.get ('parent_post_ids') ?? '',
manual: parseManual (params.get ('manual')),
skip: skipValue,
existing_post_id: parsePositiveInt (params.get ('existing_post_id')),
import_status: importStatus,
created_post_id: parsePositiveInt (params.get ('created_post_id')),
recoverable })
return {
rows: [row],
source: url,
repairMode: resultRepairMode ([row]),
shortcut: false }
}
const parseMetaRows = (
urlsValue: string,
metaValue: string,
): ParsedPostNewState | null => {
const decodedBytes = decodeBase64UrlBytes (metaValue)
if (decodedBytes == null)
return null
let parsed: MultiRowMetaState
try
{
parsed = JSON.parse (textDecoder.decode (decodedBytes)) as MultiRowMetaState
}
catch
{
return null
}
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)))
return null
const urls = urlsValue.split (' ').filter (url => url !== '')
if (urls.length < 2
|| urls.length > MAX_STATE_ROWS
|| urls.length !== parsed.rows.length
|| parsed.rows.length > MAX_STATE_ROWS)
return null
if (urls.some (url => byteLength (url) > MAX_QUERY_STRING_BYTES))
return null
const fullRows = parsed.rows.map ((row, index) => {
const parsedRow = parseEncodedRow (row, false)
return parsedRow == null
? null
: { ...parsedRow, url: urls[index] ?? '' }
})
if (fullRows.some (row => row == null))
return null
if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length)
return null
const rows = fullRows.map (row => buildRow (row as FullRowState))
return {
rows,
source: urls.join ('\n'),
repairMode: resultRepairMode (rows),
shortcut: false }
}
const parseFullState = async (stateValue: string): Promise<ParsedPostNewState | null> => {
const decoded = await decodeCompressedState (stateValue)
if (decoded == null)
return null
let parsed: EncodedState
try
{
parsed = JSON.parse (decoded) as EncodedState
}
catch
{
return null
}
if (parsed.v !== 1
|| !(Array.isArray (parsed.rows))
|| parsed.rows.length === 0
|| parsed.rows.length > MAX_STATE_ROWS)
return null
const fullRows = parsed.rows.map (row => parseEncodedRow (row, true))
if (fullRows.some (row => row == null))
return null
if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length)
return null
const rows = fullRows.map (row => buildRow (row as FullRowState))
return {
rows,
source: rows.map (row => row.url).join ('\n'),
repairMode: resultRepairMode (rows),
shortcut: false }
}
const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
if (rows.length === 1)
{
const row = rows[0]
const params = new URLSearchParams ()
params.set ('url', row?.url ?? '')
params.set ('title', String (row?.attributes.title ?? ''))
params.set ('thumbnail_base', String (row?.attributes.thumbnailBase ?? ''))
params.set ('original_created_from', String (row?.attributes.originalCreatedFrom ?? ''))
params.set ('original_created_before', String (row?.attributes.originalCreatedBefore ?? ''))
params.set ('video_ms', String (row?.attributes.videoMs ?? ''))
params.set ('duration', String (row?.attributes.duration ?? ''))
params.set ('tags', String (row?.attributes.tags ?? ''))
params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? ''))
const manual = manualFields (row).join (',')
if (manual !== '')
params.set ('manual', manual)
if (row.skipReason != null)
params.set ('skip', row.skipReason)
if (row.existingPostId != null)
params.set ('existing_post_id', String (row.existingPostId))
if (row.importStatus != null)
params.set ('import_status', row.importStatus)
if (row.createdPostId != null)
params.set ('created_post_id', String (row.createdPostId))
if (row.recoverable != null)
params.set ('recoverable', row.recoverable ? 'true' : 'false')
return params
}
const params = new URLSearchParams ()
params.set ('urls', rows.map (row => row.url).join (' '))
params.set ('meta', encodeBase64UrlBytes (textEncoder.encode (JSON.stringify ({
v: 1,
rows: rows.map (row => encodeRowState (row, false)) }))))
return params
}
const encodeRowState = (
row: PostImportRow,
includeUrl: boolean,
): EncodedRowState => {
const base = baseRowState (row)
const encoded: EncodedRowState = includeUrl ? { u: row.url } : { }
encoded.n = base.source_row
if (base.title !== '')
encoded.t = base.title
if (base.thumbnail_base !== '')
encoded.h = base.thumbnail_base
if (base.original_created_from !== '')
encoded.f = base.original_created_from
if (base.original_created_before !== '')
encoded.b = base.original_created_before
if (base.video_ms !== '')
encoded.x = base.video_ms
if (base.duration !== '')
encoded.d = base.duration
if (base.tags !== '')
encoded.g = base.tags
if (base.parent_post_ids !== '')
encoded.p = base.parent_post_ids
if (base.manual.length > 0)
encoded.m = [...base.manual]
if (base.skip != null)
encoded.s = encodeSkip (base.skip)
if (base.existing_post_id != null)
encoded.e = base.existing_post_id
if (base.import_status != null)
encoded.i = encodeImportStatus (base.import_status)
if (base.created_post_id != null)
encoded.c = base.created_post_id
if (base.recoverable != null)
encoded.r = base.recoverable
return encoded
}
const encodeStateRows = (rows: PostImportRow[]): EncodedState => ({
v: 1,
rows: rows.map (row => encodeRowState (row, true)) })
export const hasPostNewReviewState = (search: string): boolean => {
const params = new URLSearchParams (search)
return params.has ('state')
|| params.has ('urls')
|| params.has ('meta')
|| params.has ('url')
|| parsePostNewSessionId (search) != null
}
export const parsePostNewSessionId = (search: string): string | null =>
validatePostImportSessionId (
new URLSearchParams (search).get ('session_id'))
export const appendPostNewSessionId = (
path: string,
sessionId: string,
): string => {
const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return path
const separator = path.includes ('?') ? '&' : '?'
return `${ path }${ separator }session_id=${ validatedId }`
}
export const parsePostNewState = async (
search: string,
): Promise<ParsedPostNewState | null> => {
const params = new URLSearchParams (search)
const stateValue = params.get ('state')
if (stateValue != null)
return await parseFullState (stateValue)
const urlsValue = params.get ('urls')
const metaValue = params.get ('meta')
if (urlsValue != null || metaValue != null)
{
if (urlsValue == null || metaValue == null)
return null
return parseMetaRows (urlsValue, metaValue)
}
return parseSingleRow (params)
}
const serialiseCompressedRows = async (
rows: PostImportRow[],
): Promise<SerialisedPostNewState | null> => {
try
{
for (let count = rows.length; count > 0; --count)
{
const nextRows = rows.slice (0, count)
const encoded = encodeStateRows (nextRows)
const state = await encodeCompressedState (JSON.stringify (encoded))
const path = `/posts/new?state=${ state }`
if (path.length <= MAX_COMPRESSED_STATE_LENGTH)
return {
path,
complete: count === rows.length }
}
}
catch
{
}
return null
}
export const buildPostNewReviewPath = (urls: string[]): string =>
`${ POST_NEW_REVIEW_PATH_PREFIX }${ urls.map (url => encodeURIComponent (url)).join ('+') }`
export const serialisePostNewState = async (
rows: PostImportRow[],
): Promise<SerialisedPostNewState | null> => {
const regular = regularQuery (rows).toString ()
const regularPath = `/posts/new?${ regular }`
if (regularPath.length < MAX_REGULAR_URL_LENGTH)
return {
path: regularPath,
complete: true }
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
}
})
return await serialiseCompressedRows (rows)
}
export const hasPostNewReviewState = (search: string): boolean =>
rawUrlsParam (search) != null
+7 -2
ファイルの表示
@@ -25,6 +25,7 @@ import {
getEffectiveKeyBindings,
setClientKeyboardSettings,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type {
KeyBinding,
@@ -96,6 +97,7 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation ()
const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
@@ -147,8 +149,11 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
}, [])
const guardedNavigate = useCallback ((path: string) => {
navigate (path)
}, [navigate])
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
-77
ファイルの表示
@@ -1,77 +0,0 @@
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')
})
})
+28 -163
ファイルの表示
@@ -1,15 +1,6 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useBlocker } from 'react-router-dom'
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, PropsWithChildren } from 'react'
@@ -17,16 +8,12 @@ type UnsavedChangesSource = {
dirty: boolean
discard: () => void | Promise<void> }
type UseUnsavedChangesGuardOptions = {
dirty: boolean
onDiscard?: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource,
) => () => void
allowNextNavigation: () => void }
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean> }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
@@ -34,171 +21,49 @@ const UnsavedChangesGuardContext =
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
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 [source, setSource] = useState<UnsavedChangesSource | null> (null)
const registerUnsavedChangesSource = useCallback ((
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)
}
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
}, [])
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))
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
if (!(source?.dirty))
return true
if (dialogueOpenRef.current)
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま移動すると、保存していない変更は失われます。',
cancelText: 'このページに残る',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
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])
await source.discard ()
return true
}, [dialogue, source])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges,
hasUnsavedChanges: source?.dirty === true,
registerUnsavedChangesSource,
allowNextNavigation,
}), [
allowNextNavigation,
hasUnsavedChanges,
registerUnsavedChangesSource,
])
confirmDiscardNavigation,
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
{children}
</UnsavedChangesGuardContext.Provider>)
}
export const useUnsavedChangesGuard = (
options?: UseUnsavedChangesGuardOptions,
): UnsavedChangesGuardContextValue => {
export const useUnsavedChangesGuard = (): 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
}
+27 -30
ファイルの表示
@@ -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 { cn, dateString, inputClass } from '@/lib/utils'
import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
@@ -113,11 +113,10 @@ const clearedTagSelectionPath = (
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div
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')}>
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`}>
{material.thumbnail
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
: (
@@ -532,32 +531,30 @@ 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={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
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>
<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
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>
</div>
+48
ファイルの表示
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
describe ('legacy post import routes', () => {
it ('redirects /posts/import to /posts/new', () => {
render (
<MemoryRouter initialEntries={['/posts/import']}>
<Routes>
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('redirects legacy review routes to /posts/new', () => {
render (
<MemoryRouter initialEntries={['/posts/import/legacy/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('redirects legacy result routes to /posts', () => {
render (
<MemoryRouter initialEntries={['/posts/import/legacy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route
path="/posts/new/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
})
+425 -134
ファイルの表示
@@ -1,157 +1,448 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { useLocation } from 'react-router-dom'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import { buildPostImportRow } from '@/test/postImportFactories'
import type { ReactNode } from 'react'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportRow } from '@/lib/postImportSession'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn (() => false) }))
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
form: vi.fn (() => Promise.resolve ()),
}))
vi.mock ('@/lib/api', () => api)
vi.mock ('framer-motion', () => ({
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
motion: {
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
main: ({ children }: { children?: ReactNode }) => <main>{children}</main> } }))
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
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 }` })
}
const renderReviewPage = (route = '/posts/new/review') =>
render (
<HelmetProvider>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
<Route
path="/posts/new/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
describe ('PostImportReviewPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
})
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 }`)
renderReviewPage (urls)
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 ('keeps successful rows when another metadata request fails', async () => {
api.apiGet
.mockResolvedValueOnce (metadata ('https://example.com/one', 'first'))
.mockRejectedValueOnce (new TypeError ('network'))
renderReviewPage (['https://example.com/one', 'https://example.com/two'])
expect (await screen.findAllByText ('first')).toHaveLength (2)
await waitFor (() => {
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: '追加' }))
it ('redirects to /posts/new when no current work exists', async () => {
renderReviewPage ()
await waitFor (() => {
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
})
it ('restores the current work on review reload', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
expect (await screen.findByText ('restored row')).toBeInTheDocument ()
})
it ('does not expose メタデータ to the user', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
fieldWarnings: { title: ['自動取得に失敗しました.'] } })] })
renderReviewPage ()
expect (screen.queryByText (/メタデータ/)).not.toBeInTheDocument ()
})
it ('returns to /posts/new while keeping the current work', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
fireEvent.click (await screen.findByRole ('button', { name: 'URL リスト入力へ戻る' }))
await waitFor (() => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()?.rows[0]?.attributes.title).toBe ('restored row')
})
it ('disables row editing and navigation while batch import is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('does not call import when validation omits a requested source row', async () => {
savePostImportSession ({
source: 'https://example.com/post-1\nhttps://example.com/post-2',
repairMode: 'all',
rows: [
buildPostImportRow ({ sourceRow: 1 }),
buildPostImportRow ({ sourceRow: 2 })] })
api.apiPost.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
it ('navigates to /posts when all rows finish as created or skipped', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 1 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()).toBeNull ()
})
it ('stays on review when a recoverable failed row remains', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 1,
rows: [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { title: ['invalid'] } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
expect (await screen.findByText ('invalid')).toBeInTheDocument ()
expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
})
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'old title', duration: '2' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 1,
rows: [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { title: ['invalid'] } }] })
dialogue.form.mockImplementationOnce (async options => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
render (options.body (controls))
await waitFor (() => expect (actions.length).toBe (2))
fireEvent.change (screen.getByDisplayValue ('old title'), {
target: { value: 'edited title' } })
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ()
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
expect (saved?.rows[0]?.importStatus).toBe ('pending')
})
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title')
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration)
.toBe ('2')
})
})
it ('retries only failed rows and does not resend created or skipped rows', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 2 }),
buildPostImportRow ({
sourceRow: 3,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 3,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 1,
failed: 0,
rows: [{
sourceRow: 3,
status: 'skipped',
existingPostId: 9 }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([
expect.objectContaining ({ sourceRow: 3 })])
})
})
it ('allows ready rows to be manually skipped and excludes them from API payloads', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('restores values and errors when manual skip is cleared', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
status: 'error',
attributes: { title: 'kept title' },
validationErrors: { title: ['invalid'] } })] })
renderReviewPage ()
const toggle = await screen.findByRole ('checkbox', { name: 'スキップ' })
fireEvent.click (toggle)
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
expect (screen.getByText ('kept title')).toBeInTheDocument ()
expect (screen.getByText ('invalid')).toBeInTheDocument ()
})
it ('moves existing skipped rows into the collapsed section', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'existing row' },
skipReason: 'existing',
existingPostId: 2 }),
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'normal row' } })] })
renderReviewPage ()
expect (screen.getByText ('既存投稿による自動スキップ 1件')).toBeInTheDocument ()
expect (screen.queryByText ('existing row')).not.toBeInTheDocument ()
expect (screen.getByText ('normal row')).toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '既存投稿による自動スキップ 1件' }))
expect (screen.getByText ('existing row')).toBeInTheDocument ()
expect (screen.queryAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.queryAllByRole ('checkbox', { name: 'スキップ' })).toHaveLength (1)
})
it ('navigates to /posts when the last failed row completes', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 2,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 2,
status: 'created',
post: { id: 2 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
})
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
savePostImportSession ({
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'normal row' } }),
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'repair row' },
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } })] })
const { container } = renderReviewPage ()
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
node => node.textContent)
expect (titles[0]).toBe ('repair row')
})
})
ファイル差分が大きすぎるため省略します 差分を読込み
+45 -18
ファイルの表示
@@ -1,12 +1,21 @@
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } 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 }))
@@ -15,53 +24,71 @@ describe ('PostImportSourcePage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('validates an empty source only after Next is pressed', () => {
it ('shows no empty error initially and validates only after Next is pressed', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
expect (router.navigate).not.toHaveBeenCalled ()
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('reports frontend URL issues with original line numbers', () => {
it ('shows frontend URL issues with line numbers without requesting preview', () => {
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 (router.navigate).not.toHaveBeenCalled ()
expect (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues')
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('navigates with individually encoded URLs without calling an API', () => {
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: 'https://example.com/one+a\nhttps://example.com/two?value=b+c' } })
target: { value: '\nhttps://example.com/original' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (router.navigate).toHaveBeenCalledWith (
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
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 ('disables Next when the encoded request target reaches 4096 bytes', () => {
it ('navigates to /posts/new with query state after a successful preview', async () => {
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, {
target: { value: `https://example.com/${ 'a'.repeat (6_200) }` } })
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: 'https://example.com/post' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
await waitFor (() => {
expect (router.navigate).toHaveBeenCalledWith (
expect.stringMatching (/^\/posts\/new\?/))
})
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index))).not.toContainEqual(
expect.stringMatching (/^post-import-session:/))
})
})
+148 -21
ファイルの表示
@@ -12,23 +12,26 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import {
buildPostNewReviewPath,
isPostNewReviewPathWithinLimit,
appendPostNewSessionId,
serialisePostNewState,
} from '@/lib/postNewQueryState'
import {
countImportSourceLines,
extractImportSourceUrls,
validateImportSource,
} from '@/lib/postImportSourceValidation'
import {
loadPostImportSourceDraft,
savePostImportSourceDraft,
} from '@/lib/postImportStorage'
import { canEditContent } from '@/lib/users'
import { countImportSourceLines,
generatePostImportSessionId,
cleanupExpiredPostImportSessions,
initialisePreviewRows,
loadPostImportSession,
loadPostImportSourceDraft,
resultRepairMode,
serialisedPostImportRowsEqual,
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 }
@@ -37,6 +40,69 @@ const MAX_ROWS = 100
const SOURCE_ERROR_ID = 'post-import-source-error'
const SOURCE_ISSUES_ID = 'post-import-source-issues'
const buildInitialRows = (source: string): PostImportRow[] =>
source
.split (/\r\n|\n|\r/)
.map ((line, index) => ({
sourceRow: index + 1,
url: line.trim () }))
.filter (row => row.url !== '')
.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: {
title: '',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
videoMs: '',
tags: '',
parentPostIds: '' },
fieldWarnings: { },
baseWarnings: [],
validationErrors: { },
provenance: {
url: 'manual',
title: 'automatic',
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
duration: 'automatic',
videoMs: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' },
tagSources: {
automatic: '',
manual: '' },
status: 'pending' as const,
resetSnapshot: {
url: row.url,
attributes: {
title: '',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
videoMs: '',
tags: '',
parentPostIds: '' },
provenance: {
url: 'manual',
title: 'automatic',
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
duration: 'automatic',
videoMs: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' },
tagSources: {
automatic: '',
manual: '' },
fieldWarnings: { },
baseWarnings: [] } }))
const PostImportSourcePage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
@@ -49,8 +115,6 @@ 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,
@@ -58,7 +122,13 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
.filter (value => value != null)
.join (' ')
const showStorageError = (message: string) =>
toast ({ description: message })
useEffect (() => {
cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
const draft = loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
@@ -101,15 +171,73 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
setSourceError (null)
return
}
if (!(withinPathLimit))
return
setLoading (true)
setSourceIssues ([])
setSourceError (null)
try
{
navigate (buildPostNewReviewPath (sourceUrls))
const nextRows = initialisePreviewRows (buildInitialRows (source))
const serialised = await serialisePostNewState (nextRows)
const sessionId = (() => {
try
{
return generatePostImportSessionId ()
}
catch
{
return null
}
}) ()
const session = {
source,
rows: nextRows,
repairMode: resultRepairMode (nextRows) }
const saved =
sessionId == null
? false
: savePostImportSession (sessionId, session, showStorageError)
const loadedSession =
sessionId == null
? null
: loadPostImportSession (sessionId, showStorageError)
const sessionRoundTripSucceeded =
saved
&& serialisedPostImportRowsEqual (
loadedSession?.rows ?? [],
nextRows)
const canNavigate =
serialised?.complete === true
|| sessionRoundTripSucceeded
if (!(canNavigate))
{
showStorageError ('ブラウザへ保存できませんでした.')
return
}
const nextPath = (() => {
if (serialised != null)
{
return sessionId == null
? serialised.path
: appendPostNewSessionId (serialised.path, sessionId)
}
if (sessionRoundTripSucceeded && sessionId != null)
return `/posts/new?session_id=${ sessionId }`
return null
}) ()
if (nextPath == null)
{
showStorageError ('ブラウザへ保存できませんでした.')
return
}
navigate (nextPath)
}
catch (error)
{
console.error (error)
showStorageError ('ブラウザへ保存できませんでした.')
}
finally
{
@@ -128,7 +256,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
<Form className="max-w-4xl">
<PageTitle>稿</PageTitle>
<FormField label="URL リスト(1 行 1 URL)">
<FormField label="URL リスト">
{() => (
<TextArea
value={source}
@@ -138,9 +266,8 @@ 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
@@ -170,7 +297,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
type="button"
className="pointer-events-auto shrink-0"
onClick={preview}
disabled={loading || !(withinPathLimit)}>
disabled={loading}>
</Button>
</div>
+13 -9
ファイルの表示
@@ -1,4 +1,5 @@
import { screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostNewPage from '@/pages/posts/PostNewPage'
@@ -6,7 +7,6 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn () }))
@@ -16,23 +16,27 @@ describe ('PostNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
api.apiGet.mockResolvedValue ({
url: 'https://example.com/post',
title: 'post',
tags: '' })
sessionStorage.clear ()
})
it ('shows the source page on /posts/new', () => {
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
route: '/posts/new' })
renderWithProviders (
<MemoryRouter initialEntries={['/posts/new']}>
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
})
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 (
<MemoryRouter initialEntries={['/posts/new?url=https%3A%2F%2Fexample.com%2Fpost']}>
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
+5 -4
ファイルの表示
@@ -6,6 +6,7 @@ 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 (() => ({
@@ -26,10 +27,10 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (),
useDialogue: () => dialogue }))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
...buildTag (),
+6 -8
ファイルの表示
@@ -40,10 +40,10 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (),
useDialogue: () => dialogue }))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
vi.mock ('@/components/PostEmbed', () => ({
default: (props: {
ref?: { current: unknown }
@@ -262,9 +262,7 @@ 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,
@@ -309,7 +307,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) => {
+7 -3
ファイルの表示
@@ -1197,9 +1197,13 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}, [hasUnsavedThemeChanges, savedThemeSlots])
useEffect (() => {
return registerUnsavedChangesSource ({
dirty: hasPageUnsavedChanges,
discard: discardAllDirtyChanges })
registerUnsavedChangesSource ({
dirty: hasPageUnsavedChanges,
discard: discardAllDirtyChanges })
return () => {
registerUnsavedChangesSource (null)
}
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
useKeyboardShortcuts ({
+10
ファイルの表示
@@ -0,0 +1,10 @@
import { describe, it } from 'vitest'
describe ('pending high-level browser coverage', () => {
it.todo ('adds MSW-backed API boundary tests in a follow-up issue')
it.todo ('covers TheatreDetailPage with timer polling, comment posting, and next-post updates')
it.todo ('covers NicoTagListPage linking and pagination against realistic API payloads')
it.todo ('covers TagDetailSidebar drag/drop parent-child editing with pointer-event fidelity')
it.todo ('covers TopNav desktop and mobile menu flows as browser-level integration tests')
it.todo ('covers full App bootstrap for user creation, user verification, and 503 handling')
})
+1 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { PostImportRow } from '@/lib/postImportTypes'
import type { PostImportRow } from '@/lib/postImportSession'
export const buildPostImportRow = (
overrides: Partial<PostImportRow> = {},
+15 -33
ファイルの表示
@@ -1,8 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { createContext, useContext, useState } from 'react'
import { HelmetProvider } from 'react-helmet-async'
import { createMemoryRouter, RouterProvider } from 'react-router-dom'
import { MemoryRouter } from 'react-router-dom'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
@@ -14,20 +13,6 @@ type Options = {
route?: string
}
const TestContentContext = createContext<ReactNode> (null)
const TestRoute = () => {
const children = useContext (TestContentContext)
return (
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>)
}
export const renderWithProviders = (
ui: ReactElement,
options: Options = {},
@@ -37,23 +22,20 @@ export const renderWithProviders = (
queries: { retry: false } },
})
const Wrapper = ({ children }: { children: ReactNode }) => {
const [router] = useState (() => createMemoryRouter ([{
path: '*',
element: <TestRoute/> }], {
initialEntries: [options.route ?? '/'] }))
return (
<QueryClientProvider client={queryClient}>
<HelmetProvider>
<DialogueProvider>
<TestContentContext.Provider value={children}>
<RouterProvider router={router}/>
</TestContentContext.Provider>
</DialogueProvider>
</HelmetProvider>
</QueryClientProvider>)
}
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<HelmetProvider>
<MemoryRouter initialEntries={[options.route ?? '/']}>
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</MemoryRouter>
</HelmetProvider>
</QueryClientProvider>)
return {
queryClient,