コミットを比較

..

2 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 9c78a96ac9 #406 2026-07-11 04:26:25 +09:00
みてるぞ cc9c1e5aee #406 2026-07-11 04:17:23 +09:00
89個のファイルの変更665行の追加9796行の削除
+17 -321
ファイルの表示
@@ -114,142 +114,28 @@ npm run preview
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
- Ruby: never put a line break immediately before `)`.
- Ruby: do not use `%w` or `%i`.
- Ruby: TypeScript / TSX の associative syntax に近い感覚で読むこと。
Hash の `}` は block の `}` ではない。call の `)` も function 定義の
parameter-list `)` ではない。delimiter の役割を見誤らないこと。
- In Ruby, when an `if` condition is split across multiple lines and combines
clauses with `&&` or `||`, wrap the whole condition in parentheses.
- Ruby hash / block / call delimiter の詳細は、下の
`Ruby delimiter and wrapping rules` を正本として扱ふこと。
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
- Ruby hashes keep the first pair on the same line as `{` unless line length
requires a break.
- Short Ruby hashes may stay visually compact across two lines with the first
pair kept on the opening line and aligned continuation pairs below it.
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
indentation.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
### Ruby delimiter and wrapping rules
- Ruby でも、closing delimiter は glyph ではなく syntax role で判定する。
`}` が block close なのか hash close なのか、`)` が method call なのか
grouping なのかを区別してから配置すること。
- Ruby の multi-line method call では、receiver / method と opening `(`
同じ行に保ち、closing `)` を単独行へ落とさない。
- Ruby の multi-line hash literal / keyword-like argument hash では、opening
`{` を最初の pair と同じ行に置き、closing `}` を最後の pair と同じ行に
置く。Prettier 的な縦開き・縦閉じをしない。
- Ruby の guard 条件は、1 行で収まるなら modifier 形式を優先する。
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
- Ruby の method chain や call argument を折り返す際、call-site の `)`
block close のやうに独立させない。
Bad:
```rb
response = Example.fetch(
value,
option: option,
)
```
Good:
```rb
response = Example.fetch(
value,
option: option)
```
Bad:
```rb
payload = {
title: title,
url: url,
}
```
Good:
```rb
payload = {
title: title,
url: url }
```
Bad:
```rb
raise ArgumentError, 'URL が長すぎます.' if url.bytesize > MAX_URL_BYTES && flag.present?
```
Good:
```rb
if url.bytesize > MAX_URL_BYTES && flag.present?
raise ArgumentError, 'URL が長すぎます.'
end
```
Bad:
```rb
records.each {
do_work(_1) }
```
Good:
```rb
records.each {
do_work(_1)
}
```
- TypeScript and Python: use GNU-style spacing before parentheses where
syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
- Aim to keep Ruby, TypeScript, and TSX lines within 79 characters where practical.
- In TypeScript and TSX, use 2-space block indentation.
- In TypeScript and TSX, block bodies for components, functions, callbacks,
`if`, `try`, `catch`, `finally`, loops, and JSX nesting use 2 spaces per
level.
- In TypeScript and TSX, put the opening brace of `try`, `catch`, and
`finally` blocks on the next line at the same indentation as the keyword.
- Do not indent the opening `{` one level deeper than `try`, `catch`, or
`finally`.
- Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`.
- In TypeScript and TSX, use 4-space continuation indentation for wrapped
expressions, arguments, conditions, arrays, object literals, JSX
attributes, and similar continuations.
- Treat the user's `PostImportSourcePage.tsx` and `PostImportReviewPage.tsx`
formatting as the local reference shape: component and callback bodies use
2-space block indentation, single-line bodies do not gain unnecessary
braces, wrapped expressions use 4-space continuation indentation, multi-
stage ternaries use explicit parentheses, and only complete leading runs
of 8 spaces are compressed to tabs.
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
- A tab does not represent one indentation level.
- Do not replace 2-space or 4-space indentation with tabs.
- First determine visible indentation using 2-space block indentation and
4-space continuation indentation, then compress only complete leading runs
of 8 spaces into tabs.
- Tabs are only for leading indentation, never for spaces after non-space
text.
- Keep residual leading 2, 4, or 6 spaces after any tab compression.
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
tab + 4 spaces, 16 columns = 2 tabs.
- TypeScript and TSX use 4-space logical indentation.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
- Tabs are only for leading indentation, never for spaces after non-space text.
- 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.
- Do not write `import { value, type TypeName } from ...`.
- Type-only declarations must use `import type`.
- Do not merge type imports into value imports merely to save lines.
- In TypeScript and TSX, order imports as four groups with a blank line
between groups: external value imports, `@/...` value imports, external
type imports, `@/...` type imports.
- Do not mechanically split a short value import from one module across
multiple lines when it still fits within 99 characters.
- In TypeScript and TSX, when breaking a line at an operator, break before the
operator and put the operator at the beginning of the next line. A trailing
operator at end of line is unacceptable. This rule does not apply to Ruby,
@@ -281,10 +167,6 @@ case 'no':
- In TypeScript and TSX, use `value == null` and `value != null` as the
default nullish checks. Do not use `=== null`, `=== undefined`,
`!== null`, or `!== undefined`.
- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
Use a meaningful callback parameter name such as `row`, `item`, `value`,
`entry`, or `result`.
- If code appears to need a distinction between `null` and `undefined`, treat
that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the
@@ -308,151 +190,6 @@ const value =
value selection. Do not replace a clear ternary with `if` statements, and do
not introduce immediately invoked functions just to avoid or reformat a
ternary expression.
- In TypeScript and TSX, multi-stage ternary expressions must make branch
boundaries explicit. Wrap each condition group in parentheses instead of
relying on indentation alone to show which `?` matches which `:`.
- In TypeScript and TSX, wrap nested ternary expressions in parentheses. Do
not write flat vertical chains of `?` and `:` without explicit grouping.
- In TypeScript and TSX, when a ternary condition contains `&&` or `||`, wrap
the whole condition in parentheses before `?`.
- In TypeScript and TSX, if a ternary reaches three or more stages and still
reads poorly after explicit grouping, extract a helper function or use `if`
statements instead of keeping a flat multi-stage ternary.
Bad:
```ts
row.skipReason === 'existing' || row.importStatus === 'skipped'
? 'skipped'
: Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed'
|| row.importStatus === 'created'
? null
: hasWarnings (row) || row.status === 'warning'
? 'warning'
: 'ready'
```
Good:
```ts
(row.skipReason === 'existing' || row.importStatus === 'skipped')
? 'skipped'
: ((Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed'
|| row.importStatus === 'created')
? null
: ((hasWarnings (row) || row.status === 'warning')
? 'warning'
: 'ready'))
```
## Shared-system discovery and reuse
Before creating a new component, hook, service, helper, utility, concern,
representation, normaliser, validator, parser, fetcher, store, context, event
bus, API client, query key, permission helper, dialogue, toast, form field, or
version recorder, search the existing repository first.
Do not search by name alone. Search by responsibility, behaviour, and usage
intent as well. Typical search themes include:
- dialogue, modal, confirm, alert, choice
- validation error, field error, unprocessable entity
- permission, role, member, admin, editable
- URL normalise, sanitise, canonicalise
- API call, query key, prefetch, cache invalidation
- version, snapshot, history, restore
- thumbnail, metadata, HTTP fetch, URL safety
- form, field, input, textarea, warning, status badge
- file storage, Active Storage, ZIP, export
- transaction, locking, race, idempotency
Before deciding that something new is needed, confirm at least:
1. the existing definition
2. its public API
3. representative call sites
4. other implementations with similar responsibility
5. the nearest directory-level `AGENTS.md`
Do not reject an existing implementation by name alone. Read the code and its
usage first.
When new behaviour is needed, make the decision in this order:
1. use the existing common API as-is
2. use the existing extension points of that API
3. extend the existing common API minimally
4. keep the implementation local when the meaning is feature-specific
5. add a new common system only when multiple real users and a stable contract
are already clear
Do not create a parallel foundation merely because the existing one feels
slightly awkward, because a new one seems faster, or because the current task
looks special.
Do not create a second common platform with names such as `CommonFoo`,
`SharedFoo`, `BaseFoo`, `FooManager`, `FooService`, `FooProvider`,
`FooWrapper`, `FooUtils`, or `useFoo` when an existing system already owns the
same responsibility. Judge by responsibility, not by spelling.
Wrapper aliases, barrel exports, and re-export shims do not count as reuse.
Using a different import path that merely forwards to the existing system does
not satisfy a reuse requirement.
Keep this boundary explicit:
- business meaning stays in feature code
- visual and mechanical shell stays in common code
Common code may hold generic primitives, interaction shells, API transport,
auth and permission helpers, query-key and prefetch conventions, validation
error conversion, shared normalisation, version-recording mechanisms, storage
helpers, HTTP safety, and other contracts that carry the same meaning across
multiple features.
Feature code should keep feature-specific states, labels, input fields,
workflow steps, payload shapes, validation rules, and business decisions.
Do not commonise business logic merely because the visuals look similar or two
code fragments resemble each other.
Create a new common system only when all of the following are true:
- no existing implementation already owns the responsibility
- there are multiple real consumers, or a clearly defined platform contract
- the differences between consumers do not require option bloat
- feature-specific vocabulary does not leak into common types, props, tones, or
state names
- the location matches the existing directory structure
- the new abstraction does not compete with an existing common system
- it is not just a one-off wrapper for a single task
Do not add unused tones, variants, options, callbacks, states, or abstractions
for speculative future use.
Before finishing work that touches shared systems, verify:
- you searched for an existing implementation with the same responsibility
- you are using the canonical import path or entrypoint
- feature code is not reaching directly for a low-level primitive that already
has a higher-level common API
- you did not bypass an existing common API
- any new wrapper or shim is genuinely necessary
- feature-specific vocabulary did not leak into common code
- the feature did not reimplement a common shell
- existing unrelated consumers were not changed without need
- one task did not trigger a needless redesign of the whole foundation
- any newly introduced common system really has multiple consumers
For backend-specific discovery, Rails structure, services, representation
selection, versioning, normalisation, and HTTP safety, follow
`backend/AGENTS.md`.
For frontend-specific component hierarchy, low-level primitive reuse, dialogue
entrypoints, API/query helpers, validation/form infrastructure, state/storage,
and layout reuse, follow `frontend/AGENTS.md`.
- In TypeScript and TSX, do not write `let` followed by later `if` assignments
when the value can be expressed as a single `const` initializer. Prefer
`const` because it prevents accidental later reassignment.
@@ -528,20 +265,6 @@ and layout reuse, follow `frontend/AGENTS.md`.
- For user-facing Japanese kanji spelling, do not normalize to
《当用漢字による書きかえ》; prefer original forms such as `編輯`.
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
- Frontend dialogue work must use `@/lib/dialogues/useDialogue` as the
feature-facing entrypoint.
- Reuse the existing common dialogue API and common dialogue component
instead of building feature-local overlay, close button, header, footer,
focus handling, outside click handling, Escape handling, or confirmation
flows.
- Do not import `@/components/ui/dialog` directly in feature code to build a
one-off dialogue, and do not evade this rule with aliases such as
`Dialog as Dialogue`.
- Keep business-specific form content in feature code, and keep the visual
and behavioural dialogue shell in common code.
- Use British spelling `Dialogue` for project-defined dialogue identifiers.
Keep an exact third-party API spelling only at the external boundary where
compatibility requires it.
### Frontend TypeScript and TSX style
@@ -580,24 +303,14 @@ and layout reuse, follow `frontend/AGENTS.md`.
beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading block indentation uses
2 spaces per level, wrapped continuations use the repository's 4-space
continuation alignment, and complete leading runs of 8 spaces may be
compressed to tabs.
- The TypeScript/TSX self-review must confirm leading indentation follows
4-space logical indentation with tabs only as leading 8-space compression.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, use 2-space block indentation and 4-space continuation
indentation.
- In TypeScript and TSX, put the opening brace of `try`, `catch`, and
`finally` blocks on the next line at the same indentation as the keyword.
- Do not indent the opening `{` one level deeper than `try`, `catch`, or
`finally`.
- Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`.
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a
tab character.
- In TSX, indent with 4-space logical indentation.
- In TypeScript and TSX, convert every 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
@@ -646,22 +359,6 @@ and layout reuse, follow `frontend/AGENTS.md`.
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- `try` / `catch` / `finally` brace placement example:
```ts
try
{
doWork ()
}
catch
{
recover ()
}
finally
{
cleanUp ()
}
```
- Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`.
@@ -1241,9 +938,8 @@ to `.ts` and `.tsx`:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading block indentation uses 2 spaces per level, wrapped continuations
use the repository's 4-space continuation alignment, and complete leading
runs of 8 spaces may be compressed to tabs.
9. Leading indentation is 4-space logical indentation with tabs used only as
leading 8-space compression.
10. No line has trailing whitespace.
Preferred:
-186
ファイルの表示
@@ -67,192 +67,6 @@ pass or the remaining failure is clearly blocked.
Before changing behavior, inspect the matching route, controller, model,
service, representation, and spec.
## Shared backend systems
Before adding backend behaviour, search the existing backend first. At minimum,
check these locations:
- `app/controllers`
- `app/controllers/concerns`
- `app/models`
- `app/models/concerns`
- `app/representations`
- `app/services`
- `app/services/*`
- `app/jobs`
- `lib`
- `lib/tasks`
- `config/initializers`
Do not infer commonality from directory names alone. Read the actual
responsibility and representative usage sites.
### Controller reuse
Before adding logic to a controller, inspect:
- `ApplicationController` authentication, authorization, BAN, and IP BAN
- existing render and validation-error helpers
- existing param parsing
- controller concerns
- the controller for the same resource
- existing services
- existing representations
Keep controllers focused on:
- authentication and authorization
- parameter intake
- service and model invocation
- HTTP status selection
- representation selection
Do not reimplement these per controller when an existing path already owns
them:
- authentication and role checks
- validation error JSON
- URL normalisation
- tag normalisation
- thumbnail handling
- version recording
- complex transactions
- external HTTP fetching
- response representation assembly
### Authentication, authorization, and BAN
Treat these as the canonical backend entrypoints:
- `ApplicationController#authenticate_user`
- `current_user`
- `X-Transfer-Code`
- `reject_banned_ip_address!`
- `reject_banned_user!`
- `gte_member?`
- `admin?`
Do not create feature-local permission services, role comparisons, or header
parsing when the existing authentication boundary already owns the behaviour.
If the current boundary is insufficient, extend it minimally instead of adding
another permission path.
### Representations
If an endpoint for the same resource already uses `app/representations`, do not
assemble a separate JSON shape directly inside the controller without first
checking the existing representation contract.
Inspect at least:
- `PostRepr`
- `TagRepr`
- `MaterialRepr`
- `TheatreRepr`
- `UserRepr`
- `WikiPageRepr`
- `DeerjikistRepr`
When a lightweight response is genuinely different in purpose, keep it
deliberate and compatible with the surrounding contracts. Do not force every
identifier list into a large representation, but do not fork the same resource
shape casually either.
### Domain services
When work touches multiple models, transactions, external APIs, file handling,
history creation, or multi-step workflow, search `app/services` first.
At minimum, search for existing services in these responsibility areas:
- version recorder and versioning
- wiki commit
- YouTube or Google Drive API client
- material sync or ZIP export
- similarity calculation
- theatre selection or skip finalisation
- metadata, thumbnail, or file processing
- URL normaliser or sanitisation
- import or export
- preview safety or HTTP fetch
Do not create a same-responsibility service under another namespace or another
name. If an existing service is close, extend that API minimally instead of
wrapping it in a feature-local service.
### Versioning
When a feature writes history, snapshots, or restore roots, search the existing
versioning path first. At minimum, inspect:
- `VersionRecorder`
- `PostVersionRecorder`
- `TagVersionRecorder`
- `TagVersioning`
- `MaterialVersionRecorder`
- `NicoTagVersionRecorder`
- `WikiVersionRecorder`
Do not implement history writes in controllers, callbacks, or ad hoc feature
services when the recorder layer already owns the transaction boundary and
meaning.
### Normalisation, sanitisation, and parsing
For URLs, tag names, times, video durations, identifiers, and paths, search the
existing normaliser, sanitisation rule, parser, and model-callback path first.
Do not let frontend, controller, service, and model each invent different rules
for the same value. Use one canonical normalisation path and keep input
validation distinct from pre-persistence normalisation.
### External HTTP and URL safety
When fetching external URLs, reuse the existing preview-safety stack. Search at
least for:
- URL safety
- redirect validation
- response size limits
- timeouts
- network failure mapping
- HTML metadata extraction
- known-site extraction
- thumbnail fetching
Do not add direct `Net::HTTP`, `Faraday`, or equivalent feature-local HTTP code
that reimplements SSRF checks, redirect restrictions, size limits, or timeouts.
If the current fetcher is insufficient, extend its existing safety contract.
### Storage, files, and Active Storage
When handling files, thumbnails, ZIP output, object storage, or Active Storage
blobs, inspect existing storage helpers, exporters, thumbnail generators, and
checksum helpers first. Do not reimplement the same attach, export path,
download, resize, or checksum flow in a controller or one-off service.
### Concerns
Do not create controller or model concerns merely because some code is shared.
Use a concern only when multiple classes share the same lifecycle, macro,
callback, or tightly cohesive behaviour. Utility collections belong in explicit
objects or services, not in `CommonConcern`, `SharedMethods`, or `Utils`.
### Model boundaries
Model-specific invariants, associations, validations, and normalisation may
live in the model. Multi-model workflow, external access, complex transaction
flow, and feature orchestration belong in services. Do not hide feature
workflow in model callbacks.
### Transactions, locking, and race handling
If transactions, locking, idempotency, or race recovery already exist in a
service or model method, do not add a second implementation in a controller or
new service. Inspect the existing transaction boundary first, avoid wrapping
the same operation in needless nested transactions, and handle unique-constraint
races according to the target constraint's business meaning.
## Ruby style
- Prefer precise, minimal changes.
-44
ファイルの表示
@@ -1,44 +0,0 @@
class PostImportsController < ApplicationController
before_action :require_member!
def preview
rows = PostImportPreviewer.new.preview_rows(
rows: PostImportUrlListParser.parse(params[:source]))
render json: { rows: }
rescue ArgumentError => e
render_bad_request e.message
end
def validate
rows = normalised_import_rows allow_warning_fields: true
changed_row = Integer(params[:changed_row], exception: false)
result =
PostImportPreviewer.new.preview_rows(rows:,
fetch_metadata: changed_row,
metadata_cache: { })
render json: { rows: result }
rescue ArgumentError => e
render_bad_request e.message
end
def create
result = PostImportRunner.new(actor: current_user,
rows: normalised_import_rows).run
render json: result, status: result[:created].positive? ? :created : :ok
rescue ArgumentError => e
render_bad_request e.message
end
private
def require_member!
return head :unauthorized unless current_user
return if current_user.gte_member?
head :forbidden
end
def normalised_import_rows allow_warning_fields: false
PostImportRowNormaliser.normalise!(params[:rows], allow_warning_fields:)
end
end
+32 -9
ファイルの表示
@@ -142,14 +142,37 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
post = PostCreator.new(actor: current_user,
attributes: { title: params[:title], url: params[:url],
thumbnail: params[:thumbnail], tags: params[:tags],
original_created_from: params[:original_created_from],
original_created_before: params[:original_created_before],
parent_post_ids: parse_parent_post_ids,
video_ms: params[:video_ms],
duration: params[:duration] }).create!
# TODO: サイトに応じて thumbnail_base 設定
title = params[:title].presence
url = params[:url]
thumbnail = params[:thumbnail]
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids
resized_thumbnail = thumbnail.present? ? Post.resized_thumbnail_attachment(thumbnail) : nil
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:)
post.thumbnail.attach(resized_thumbnail) if resized_thumbnail
ApplicationRecord.transaction do
post.save!
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = normalise_video_ms(tags)
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end
post.reload
render json: PostRepr.base(post), status: :created
@@ -159,7 +182,7 @@ class PostsController < ApplicationController
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue PostCreator::VideoMsParseError
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
+115 -7
ファイルの表示
@@ -200,14 +200,45 @@ class TagsController < ApplicationController
.find_by(id: params[:id])
return head :not_found unless tag
rows = normalise_deerjikist_rows(tag)
return if performed?
ApplicationRecord.transaction do
tag.deerjikists = []
params[:_json].each.with_index do |item, i|
platform = item[:platform]
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
tag.lock!
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
row_indexes_by_key = rows_by_key(rows)
locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
current_deerjikists = locked_deerjikists.filter { |deerjikist|
deerjikist.tag_id == tag.id
}
requested_deerjikists = locked_deerjikists.filter { |deerjikist|
row_indexes_by_key.key?([deerjikist.platform, deerjikist.code])
}
render_deerjikist_conflicts(requested_deerjikists, row_indexes_by_key, tag)
raise ActiveRecord::Rollback if performed?
requested_keys_set = requested_keys.to_set
current_deerjikists.each do |deerjikist|
key = [deerjikist.platform, deerjikist.code]
deerjikist.destroy! unless requested_keys_set.include?(key)
end
existing_keys = requested_deerjikists.to_h { |deerjikist|
[[deerjikist.platform, deerjikist.code], true]
}
requested_keys.each do |platform, code|
next if existing_keys[[platform, code]]
deerjikist = Deerjikist.new(platform:, code:, tag:)
row_index = row_indexes_by_key[[platform, code]].first
begin
render_deerjikist_form_record_invalid(deerjikist, row_index) unless deerjikist.save
rescue ActiveRecord::RecordNotUnique
conflicts = lock_deerjikists_for_tag_update(tag.id, [[platform, code]])
render_deerjikist_conflicts(conflicts, row_indexes_by_key, tag)
end
raise ActiveRecord::Rollback if performed?
end
end
@@ -653,6 +684,7 @@ class TagsController < ApplicationController
end
def normalise_deerjikist_code platform, code
code = code.to_s
return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }"
@@ -669,6 +701,82 @@ class TagsController < ApplicationController
nil
end
def normalise_deerjikist_rows tag
rows = []
params[:_json].each.with_index do |item, index|
platform = item[:platform]
unless Deerjikist.platforms.key?(platform)
render_deerjikist_platform_invalid(index)
return rows
end
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.new(platform:, code:, tag:)
unless deerjikist.valid?
render_deerjikist_form_record_invalid(deerjikist, index)
return rows
end
rows << { index:, platform:, code: }
end
rows
end
def lock_deerjikists_for_tag_update tag_id, keys
clauses = ['tag_id = ?']
values = [tag_id]
keys.each do |platform, code|
clauses << '(platform = ? AND code = ?)'
values << platform << code
end
Deerjikist
.where(clauses.join(' OR '), *values)
.order(:platform, :code)
.lock
.to_a
end
def render_deerjikist_conflicts deerjikists, row_indexes_by_key, tag
conflicts = deerjikists.filter { |deerjikist| deerjikist.tag_id != tag.id }
return if conflicts.empty?
tag_names_by_id = Tag
.joins(:tag_name)
.where(id: conflicts.map(&:tag_id).uniq)
.pluck('tags.id', 'tag_names.name')
.to_h
fields = { }
conflicts.each do |deerjikist|
message = "この情報は既に「#{ tag_names_by_id[deerjikist.tag_id] }」に紐づいてゐます."
row_indexes_by_key[[deerjikist.platform, deerjikist.code]].each do |index|
field = :"deerjikists.#{ index }.code"
fields[field] ||= []
fields[field] << message
end
end
render_validation_error fields:
end
def render_deerjikist_platform_invalid index
render_validation_error fields: {
:"deerjikists.#{ index }.platform" => ['値が不正です.'],
}
end
def rows_by_key rows
rows.each_with_object({ }) do |row, result|
key = [row[:platform], row[:code]]
result[key] ||= []
result[key] << row[:index]
end
end
def render_deerjikist_form_record_invalid deerjikist, index
fields = { }
+20 -168
ファイルの表示
@@ -1,23 +1,11 @@
class Post < ApplicationRecord
require 'date'
require 'mini_magick'
require 'stringio'
class RemoteThumbnailFetchFailed < StandardError; end
ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze
ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE =
'オリジナルの作成日時は分単位で入力してください.'.freeze
ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze
ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
'オリジナルの作成日時の範囲は1分以上必要です.'.freeze
def self.resized_thumbnail_attachment(upload)
upload.rewind
image = MiniMagick::Image.read(upload.read)
image.resize '180x180^'
image.gravity 'Center'
image.extent '180x180'
image.resize '180x180'
image.format 'jpg'
{ io: StringIO.new(image.to_blob),
@@ -69,9 +57,9 @@ class Post < ApplicationRecord
attribute :version_no, :integer, default: 1
before_validation :normalise_url, if: :will_save_change_to_url?
before_validation :normalise_url
validates :url, presence: true, uniqueness: true, length: { maximum: 768 }
validates :url, presence: true, uniqueness: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range
@@ -130,11 +118,7 @@ class Post < ApplicationRecord
'%d:%02d' % [min, s]
end
if remainder_ms.positive?
"#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }"
else
base
end
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
@@ -156,36 +140,19 @@ class Post < ApplicationRecord
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
end
def attach_thumbnail_from_url! raw_url
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
thumbnail.attach(
self.class.resized_thumbnail_attachment(
StringIO.new(response.body)))
rescue Preview::UrlSafety::UnsafeUrl,
Preview::ThumbnailFetcher::GenerationFailed,
Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge,
MiniMagick::Error => e
raise RemoteThumbnailFetchFailed, e.message
end
private
def validate_original_created_range
return if skip_original_created_validation?
f = original_created_from
b = original_created_before
return if f.blank? || b.blank?
f = parse_original_created_value(:original_created_from)
b = parse_original_created_value(:original_created_before)
return if f.nil? || b.nil?
f = Time.zone.parse(f) if String === f
b = Time.zone.parse(b) if String === b
return if !(f) || !(b)
if b <= f
errors.add :original_created_at, ORIGINAL_CREATED_ORDER_MESSAGE
return
end
if b - f < 1.minute
errors.add :original_created_at, ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE
if f >= b
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.'
end
end
@@ -206,130 +173,15 @@ class Post < ApplicationRecord
def normalise_url
return if url.blank?
self.url = PostUrlNormaliser.normalise(url) || url.strip
end
self.url = url.strip
def parse_original_created_value field
raw_value = public_send("#{ field }_before_type_cast")
value = public_send(field)
return nil if raw_value.blank? && value.blank?
u = URI.parse(url)
return unless u in URI::HTTP
time =
case raw_value
when String
parse_original_created_string(raw_value)
when Time, ActiveSupport::TimeWithZone
raw_value.in_time_zone
else
value&.in_time_zone
end
if time.nil?
errors.add field, ORIGINAL_CREATED_INVALID_MESSAGE
return nil
end
unless minute_precision_time?(time)
errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE
return nil
end
time
end
def parse_original_created_string raw_value
value = raw_value.to_s.strip
return nil if value.blank?
if (match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/))
year = match[1].to_i
month = match[2].to_i
day = match[3].to_i
hour = match[4].to_i
minute = match[5].to_i
return nil unless valid_original_created_components?(year, month, day, hour, minute, 0)
return Time.zone.local(year, month, day, hour, minute)
end
match =
value.match(
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \
'(?::(\d{2})(?:\.(\d+))?)?' \
'(Z|[+-]\d{2}:?\d{2})?\z/')
return nil if match.nil?
year = match[1].to_i
month = match[2].to_i
day = match[3].to_i
hour = match[4].to_i
minute = match[5].to_i
second = match[6]&.to_i || 0
fraction = match[7]
offset = match[8]
return nil unless valid_original_created_components?(year, month, day, hour, minute, second)
return nil if offset.present? && !(valid_original_created_offset?(offset))
if offset.present?
return Time.new(
year,
month,
day,
hour,
minute,
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
normalise_original_created_offset(offset)).in_time_zone
end
Time.zone.local(
year,
month,
day,
hour,
minute,
second).change(nsec: parse_original_created_nanoseconds(fraction))
rescue ArgumentError, TypeError
nil
end
def minute_precision_time? value
value.sec.zero? && value.nsec.zero?
end
def valid_original_created_components? year, month, day, hour, minute, second
return false unless Date.valid_date?(year, month, day)
return false unless hour.between?(0, 23)
return false unless minute.between?(0, 59)
return false unless second.between?(0, 59)
true
end
def valid_original_created_offset? value
match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
return true if value == 'Z'
return false if match.nil?
hours = match[2].to_i
minutes = match[3].to_i
hours.between?(0, 23) && minutes.between?(0, 59)
end
def parse_original_created_nanoseconds value
return 0 if value.blank?
digits = value[0, 9].ljust(9, '0')
Integer(digits, 10)
end
def normalise_original_created_offset value
return '+00:00' if value == 'Z'
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
end
def skip_original_created_validation?
return false if new_record?
return false if will_save_change_to_original_created_from?
return false if will_save_change_to_original_created_before?
true
u.host = u.host.downcase if u.host
u.path = u.path.sub(/\/\Z/, '') if u.path.present?
self.url = u.to_s
rescue URI::InvalidURIError
;
end
end
-163
ファイルの表示
@@ -1,163 +0,0 @@
class PostUrlSanitisationRule < ApplicationRecord
include Discard::Model
class InvalidUrlError < StandardError
attr_reader :invalid_rows
def initialize(invalid_rows)
@invalid_rows = invalid_rows
ids = invalid_rows.map { _1.fetch(:post_id) }.join(', ')
super("post URL sanitisation produced invalid URLs for posts #{ ids }")
end
end
class UrlConflictError < StandardError
attr_reader :conflicts
def initialize(conflicts)
@conflicts = conflicts
urls = conflicts.map { _1.fetch(:url) }.uniq.join(', ')
super("post URL sanitisation conflicts detected for #{ urls }")
end
end
self.primary_key = :priority
default_scope -> { kept }
validates :source_pattern, presence: true, uniqueness: true
validate :source_pattern_must_be_regexp
class << self
def sanitise(url) = sanitise_with_rules(url, rules)
def apply!
rewrites = nil
Post.transaction do
compiled_rules = rules
rewrites = Post.order(:id)
.lock('FOR UPDATE')
.pluck(:id, :url)
.map do |post_id, original_url|
{ post_id:,
original_url:,
sanitised_url: sanitise_with_rules(original_url, compiled_rules) }
end
invalid_rows = rewrites.filter { invalid_sanitised_url?(_1.fetch(:sanitised_url)) }
.map { { post_id: _1.fetch(:post_id),
original_url: _1.fetch(:original_url),
sanitised_url: _1.fetch(:sanitised_url) } }
raise InvalidUrlError.new(invalid_rows) if invalid_rows.present?
conflicts = build_conflicts(rewrites)
raise UrlConflictError.new(conflicts) if conflicts.present?
changed = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) }
return if changed.empty?
token = SecureRandom.hex(6)
changed.each do |row|
Post.where(id: row.fetch(:post_id))
.update_all(url: temporary_url_for(row.fetch(:post_id), token))
end
changed.each do |row|
Post.where(id: row.fetch(:post_id))
.update_all(url: row.fetch(:sanitised_url))
end
end
rescue ActiveRecord::RecordNotUnique => error
conflicts = build_persisted_conflicts(rewrites)
raise error if conflicts.empty?
raise UrlConflictError.new(conflicts), cause: error
end
private
def rules = kept.order(:priority).map { |r| [Regexp.new(r.source_pattern), r.replacement] }
def sanitise_with_rules(url, compiled_rules)
compiled_rules.reduce(url.dup) do |value, (pattern, replacement)|
value.sub(pattern, replacement)
end
end
def temporary_url_for(post_id, token) =
"https://post-url-sanitising.invalid/#{ token }/#{ post_id }"
def invalid_sanitised_url?(url)
return true if url.blank?
return true if url.length > 768
parsed = URI.parse(url)
return true if !(parsed in URI::HTTP)
return true if parsed.host.blank?
false
rescue URI::InvalidURIError
true
end
def build_conflicts(rewrites)
rewrites
.group_by { _1.fetch(:sanitised_url).downcase }
.values
.filter { _1.size > 1 }
.flatten
.map { { url: _1.fetch(:sanitised_url),
post_id: _1.fetch(:post_id),
original_url: _1.fetch(:original_url) } }
end
def build_persisted_conflicts(rewrites)
return [] if rewrites.blank?
target_rows = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) }
target_keys = target_rows.map { _1.fetch(:sanitised_url).downcase }.uniq
return [] if target_keys.empty?
target_pairs = target_rows.to_h do |row|
[row.fetch(:post_id), row.fetch(:sanitised_url).downcase]
end
persisted_rows = Post.order(:id)
.where('LOWER(url) IN (?)', target_keys)
.pluck(:id, :url)
.reject { |post_id, original_url| target_pairs[post_id] == original_url.downcase }
.map { |post_id, original_url|
{ url: original_url,
post_id:,
original_url:,
conflict_key: original_url.downcase }
}
target_conflicts = target_rows.map { { url: _1.fetch(:sanitised_url),
post_id: _1.fetch(:post_id),
original_url: _1.fetch(:original_url),
conflict_key: _1.fetch(:sanitised_url).downcase } }
(persisted_rows + target_conflicts)
.group_by { _1.fetch(:conflict_key) }
.values
.filter { _1.size > 1 }
.flatten
.map { _1.except(:conflict_key) }
end
end
private
def source_pattern_must_be_regexp
return if source_pattern.blank?
Regexp.new(source_pattern)
rescue RegexpError
errors.add :source_pattern, '変な正規表現だね〜(笑)'
end
end
-137
ファイルの表示
@@ -1,137 +0,0 @@
class PostCreator
class VideoMsParseError < ArgumentError; end
attr_reader :field_warnings
def initialize actor:, attributes:
@actor = actor
@attributes = attributes.symbolize_keys
@field_warnings = { }
end
def create!
post = Post.new(title: @attributes[:title].presence,
url: @attributes[:url],
thumbnail_base: @attributes[:thumbnail_base].presence,
uploaded_user: @actor,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence)
attach_thumbnail!(post)
ApplicationRecord.transaction do
post.save!
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = normalise_video_ms(tags)
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
end
post
end
private
def attach_thumbnail! post
thumbnail = @attributes[:thumbnail]
if thumbnail.present?
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail))
return
end
thumbnail_base = post.thumbnail_base
return if thumbnail_base.blank?
post.attach_thumbnail_from_url!(thumbnail_base)
rescue Post::RemoteThumbnailFetchFailed => e
@field_warnings[:thumbnail_base] = [e.message]
end
def tag_names = @attributes[:tags].to_s.split
def parent_post_ids
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id
}.uniq
end
def normalise_video_ms tags
return nil unless tags.any? { _1.id == Tag.video.id }
video_ms = @attributes[:video_ms]
if video_ms.present?
value = Integer(video_ms, exception: false)
raise VideoMsParseError unless value&.positive?
return value
end
duration = @attributes[:duration]
return nil if duration.blank?
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
raise VideoMsParseError unless value.positive?
value
rescue Tag::SectionLiteralParseError
raise VideoMsParseError
end
def validate_video_sections! video_ms, sections
return unless video_ms
sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
post = Post.new
if begin_ms >= video_ms
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
end
end
end
def sync_post_tags! post, desired_tags, sections
desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set
Tag.where(id: desired_ids - current_ids).find_each do |tag|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
PostTagSection.create!(post_id: post.id,
tag_id:,
begin_ms:,
end_ms:)
end
end
PostTag.where(post_id: post.id,
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag|
post_tag.discard_by!(@actor)
end
end
def sync_parent_posts! post, ids
if ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
raise ActiveRecord::RecordInvalid, post
end
missing = ids - Post.where(id: ids).pluck(:id)
if missing.present?
post.errors.add :parent_post_ids, "存在しない親投稿 Id. があります: #{ missing.join(' ') }"
raise ActiveRecord::RecordInvalid, post
end
ids.each { |parent_post_id| PostImplication.create_or_find_by!(post:, parent_post_id:) }
end
end
-503
ファイルの表示
@@ -1,503 +0,0 @@
require 'time'
require 'timeout'
class PostImportPreviewer
FIELDS = [
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'duration',
'tags',
'parent_post_ids'].freeze
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
prepared_rows = rows.map { prepare_row(_1) }
url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally
existing_posts =
Post.where(url: prepared_rows.map { _1[:normal_url] }.compact.uniq).index_by(&:url)
existing_parent_ids = preload_parent_ids(prepared_rows)
preload_metadata!(
prepared_rows,
fetch_metadata,
metadata_cache,
existing_posts,
url_counts)
known_tags =
preload_known_tags(
prepared_rows,
fetch_metadata,
metadata_cache,
existing_posts,
url_counts)
prepared_rows.map { |row|
preview_row(row,
fetch_metadata:,
metadata_cache:,
existing_posts:,
url_counts:,
known_tags:,
existing_parent_ids:)
}
end
def normalised_url value
PostUrlNormaliser.normalise(value)
end
private
def prepare_row row
source = row.symbolize_keys
url = source[:url].to_s.strip
normal_url = normalised_url(url)
source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url))
end
def preview_row row,
fetch_metadata:,
metadata_cache:,
existing_posts:,
url_counts:,
known_tags:,
existing_parent_ids:
attributes = initial_attributes(row)
provenance = initial_provenance(row)
tag_sources = initial_tag_sources(row, attributes, provenance)
field_warnings = initial_field_warnings(row)
base_warnings = initial_base_warnings(row)
url = row[:url_text]
provenance['url'] = 'manual'
normal_url = row[:normal_url]
url_for_metadata = normal_url || url
existing_post = normal_url.present? ? existing_posts[normal_url] : nil
validation_errors = {}
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
if row[:url_error].present?
validation_errors[:url] = [row[:url_error]]
end
if normal_url.present? && url_counts[normal_url].to_i > 1
validation_errors[:url] = ['URL が重複しています.']
end
if row[:metadata_url].present? && row[:metadata_url] != url_for_metadata
clear_automatic_values!(attributes, provenance, tag_sources)
field_warnings = { }
base_warnings = [ ]
end
if validation_errors.blank? && normal_url.present? && existing_post
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
return { source_row: row[:source_row],
url: normal_url,
attributes:,
provenance:,
tag_sources:,
metadata_url: url_for_metadata,
skip_reason: 'existing',
existing_post_id: existing_post.id,
field_warnings:,
base_warnings:,
validation_errors:,
status: warnings_present ? 'warning' : 'ready' }
end
should_fetch = validation_errors.blank?
should_fetch &&= should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
if should_fetch
clear_fetch_warnings!(field_warnings)
metadata = metadata_for(url_for_metadata, metadata_cache)
if metadata[:validation_errors].present?
validation_errors.merge!(metadata[:validation_errors])
else
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
apply_fetch_warnings!(field_warnings, metadata[:warnings])
end
end
attributes['url'] = url
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')
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
{ source_row: row[:source_row],
url: validation_errors[:url].present? ? url : (normal_url || url),
attributes:,
provenance:,
tag_sources:,
metadata_url: url_for_metadata,
skip_reason: nil,
existing_post_id: nil,
field_warnings:,
base_warnings:,
validation_errors:,
status: validation_errors.present? ? 'error' : (warnings_present ? 'warning' : 'ready') }
end
def should_fetch_metadata? fetch_metadata, source_row
case fetch_metadata
when true then true
when Integer then fetch_metadata == source_row
when false, nil then false
else raise ArgumentError, 'メタデータ取得対象が不正です.'
end
end
def initial_attributes row
attributes = row[:attributes]&.stringify_keys || { }
FIELDS.to_h { |field|
value = attributes[field]
normalised =
if field == 'duration'
normalise_duration_attribute(value)
else
value.to_s
end
[field, normalised] }
end
def initial_provenance row
provenance = row[:provenance]&.stringify_keys || { }
FIELDS.to_h { |field| [field, provenance[field].presence || 'automatic'] }
.merge('url' => 'manual')
end
def initial_tag_sources row, attributes, provenance
sources = row[:tag_sources]&.stringify_keys || { 'automatic' => '', 'manual' => '' }
sources['automatic'] = sources['automatic'].to_s
sources['manual'] =
provenance['tags'] == 'manual' ? attributes['tags'].to_s : sources['manual'].to_s
sources
end
def initial_field_warnings row
(row[:field_warnings] || { })
.stringify_keys
.transform_values { |value| Array(value).map(&:to_s) }
end
def initial_base_warnings row
Array(row[:base_warnings]).map(&:to_s)
end
def clear_automatic_values! attributes, provenance, tag_sources
['title', 'thumbnail_base', 'original_created_from',
'original_created_before', 'duration'].each do |field|
attributes[field] = '' if provenance[field] == 'automatic'
end
tag_sources['automatic'] = ''
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
end
def clear_fetch_warnings! field_warnings
FETCH_WARNING_FIELDS.each do |field|
field_warnings.delete(field)
end
end
def metadata_for url, cache
cache[url] ||= fetch_metadata(url)
end
def fetch_metadata url
return { data: { }, warnings: { 'url' => ['URL が空です.'] },
validation_errors: { } } if url.blank?
data = sanitise_metadata(PostMetadataFetcher.fetch(url).stringify_keys.compact)
warnings = { }
add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank?
if data['thumbnail_base'].blank?
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING)
end
{ data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e
Rails.logger.info(
"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
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
def sanitise_metadata metadata
{
'title' => sanitise_metadata_title(metadata['title']),
'thumbnail_base' => sanitise_metadata_url(metadata['thumbnail_base']),
'original_created_from' => sanitise_metadata_time(metadata['original_created_from']),
'original_created_before' => sanitise_metadata_time(metadata['original_created_before']),
'duration' => sanitise_metadata_duration(metadata['duration']),
'tags' => metadata['tags'].to_s.presence }.compact
end
def sanitise_metadata_title value
value.is_a?(String) ? value.presence : nil
end
def sanitise_metadata_url value
return nil unless value.is_a?(String)
PostUrlNormaliser.normalise(value)
end
def sanitise_metadata_time value
return nil unless value.is_a?(String)
Time.iso8601(value).in_time_zone.change(sec: 0, nsec: 0).iso8601
rescue ArgumentError, TypeError
nil
end
def sanitise_metadata_duration value
return nil unless value.is_a?(String)
value.presence
end
def normalise_duration_attribute value
return '' if value.nil?
return value if value.is_a?(String)
milliseconds = Integer(value, exception: false)
return value.to_s if milliseconds.nil? || milliseconds <= 0
seconds_string = (milliseconds / 1_000.0).to_s
seconds_string.end_with?('.0') ? seconds_string.delete_suffix('.0') : seconds_string
end
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
urls = prepared_rows.filter_map { |row|
next unless row[:normal_url].present?
next if row[:url_error].present?
next unless should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
next if url_counts[row[:normal_url]].to_i > 1
next if existing_posts.key?(row[:normal_url])
row[:normal_url]
}.uniq
urls = urls.reject { metadata_cache.key?(_1) }
return if urls.empty?
url_queue = Queue.new
result_queue = Queue.new
urls.each { url_queue << _1 }
workers = [urls.length, 4].min.times.map {
Thread.new {
Rails.application.executor.wrap do
loop do
url = url_queue.pop(true)
result_queue << [url, safe_fetch_metadata(url)]
rescue ThreadError
break
end
end
}
}
Timeout.timeout(15) { workers.each(&:join) }
rescue Timeout::Error
workers&.each(&:kill)
ensure
workers&.each(&:join)
while result_queue&.size.to_i.positive?
url, result = result_queue.pop
metadata_cache[url] = result
end
urls&.each do |url|
metadata_cache[url] ||= { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } }
end
end
def safe_fetch_metadata url
fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e
Rails.logger.info(
"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
Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
names = prepared_rows.flat_map { |row|
attributes = initial_attributes(row)
provenance = initial_provenance(row)
tag_sources = initial_tag_sources(row, attributes, provenance)
if metadata_url_changed?(row)
clear_automatic_values!(attributes, provenance, tag_sources)
end
if should_apply_metadata_to_row?(row, fetch_metadata, existing_posts, url_counts)
metadata = metadata_for(row[:normal_url], metadata_cache)
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
end
preview_tag_names(merged_tags(tag_sources, provenance['tags']))
}.compact.uniq
return { } if names.empty?
Tag.joins(:tag_name)
.where(tag_names: { name: names })
.includes(:tag_name)
.to_a
.index_by(&:name)
end
def preload_parent_ids prepared_rows
ids = prepared_rows.flat_map { |row|
attributes = initial_attributes(row)
preview_parent_ids(attributes['parent_post_ids'])
}.uniq
return { } if ids.empty?
Post.where(id: ids).pluck(:id).to_h { [_1, true] }
end
def metadata_url_changed? row
row[:metadata_url].present? && row[:metadata_url] != (row[:normal_url] || row[:url_text])
end
def should_apply_metadata_to_row? row, fetch_metadata, existing_posts, url_counts
normal_url = row[:normal_url]
return false if normal_url.blank?
return false if row[:url_error].present?
return false if url_counts[normal_url].to_i > 1
return false if existing_posts.key?(normal_url)
should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
end
def validate_url_safety normal_url
return nil if normal_url.blank?
Preview::UrlSafety.validate(normal_url)
nil
rescue Preview::UrlSafety::UnsafeUrl => e
e.message
end
def apply_metadata! attributes, provenance, tag_sources, metadata
metadata.each do |field, value|
if field == 'tags'
next if provenance['tags'] == 'manual'
tag_sources['automatic'] = value.to_s
attributes['tags'] = merged_tags(tag_sources)
next
end
next unless provenance[field] == 'automatic'
attributes[field] = value
provenance[field] = 'automatic'
end
end
def apply_fetch_warnings! field_warnings, warnings
warnings.each do |field, values|
values.each do |value|
add_field_warning!(field_warnings, field, value)
end
end
end
def add_field_warning! field_warnings, field, message
field_warnings[field] ||= []
field_warnings[field] << message unless field_warnings[field].include?(message)
end
def merged_tags sources, origin = nil
return sources['manual'].to_s if origin == 'manual'
sources['automatic'].to_s
end
def preview_tag_names raw
names = raw.to_s.split
return [] if names.empty?
if names.any? { _1.downcase.start_with?('nico:') }
return []
end
names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
rescue Tag::SectionLiteralParseError
[]
end
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:') }
errors[:tags] = ['ニコニコ・タグは直接指定できません.']
return
end
parsed = names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
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
def validate_basic_data attributes, errors
post = Post.new(title: attributes['title'].presence,
url: attributes['url'],
thumbnail_base: attributes['thumbnail_base'].presence,
original_created_from: attributes['original_created_from'].presence,
original_created_before: attributes['original_created_before'].presence,
video_ms: parse_duration(attributes['duration'], errors))
post.valid?
post.errors.each do |error|
next if error.attribute == :url && error.type == :taken
(errors[error.attribute] ||= []) << error.message
end
end
def parse_duration value, errors
return nil if value.blank?
Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
rescue Tag::SectionLiteralParseError
errors[:video_ms] = ['動画時間の記法が不正です.']
nil
end
def preview_parent_ids raw
raw.to_s.split.map { Integer(_1, exception: false) }.compact
end
def validate_parents raw, errors, existing_parent_ids
ids = raw.to_s.split.map { Integer(_1, exception: false) }
return if ids.compact.empty? && raw.to_s.blank?
if ids.any? { _1.nil? || _1 <= 0 }
errors[:parent_post_ids] = ['親投稿 Id. が不正です.']
return
end
if ids.uniq.any? { !existing_parent_ids[_1] }
errors[:parent_post_ids] = ['存在しない親投稿 Id. があります.']
end
end
end
-190
ファイルの表示
@@ -1,190 +0,0 @@
class PostImportRowNormaliser
ORIGINS = ['automatic', 'manual'].freeze
STRING_FIELDS = [
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'duration',
'tags',
'parent_post_ids'].freeze
FLEXIBLE_FIELDS = ['video_ms'].freeze
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
def self.normalise! rows, allow_warning_fields: false
raise ArgumentError, '取込行の形式が不正です.' unless rows.is_a?(Array)
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportUrlListParser::MAX_ROWS
normalised_rows = rows.map { normalise_row!(_1, allow_warning_fields:) }
source_rows = normalised_rows.map { _1['source_row'] }
raise ArgumentError, '元行番号が重複しています.' if source_rows.uniq.length != source_rows.length
if normalised_rows.sum { row_bytesize(_1) } > PostImportUrlListParser::MAX_BYTES
raise ArgumentError, '取込データが大きすぎます.'
end
normalised_rows
end
def self.normalise_row! row, allow_warning_fields:
unless row.is_a?(Hash) || row.is_a?(ActionController::Parameters)
raise ArgumentError, '取込行の形式が不正です.'
end
parameters =
row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row)
permitted = parameters.permit(*permitted_keys(allow_warning_fields))
normalised = permitted.to_h.deep_transform_keys { _1.to_s.underscore }
normalised['source_row'] = normalise_source_row!(normalised['source_row'])
normalise_url!(normalised['url'])
normalise_metadata_url!(normalised['metadata_url'])
normalise_attributes!(normalised.fetch('attributes', { }))
normalise_provenance!(normalised.fetch('provenance', { }))
normalise_tag_sources!(normalised['tag_sources'])
normalise_warning_values!(normalised, allow_warning_fields:)
if row_bytesize(normalised) > PostImportUrlListParser::MAX_BYTES
raise ArgumentError, '取込行が大きすぎます.'
end
normalised
end
def self.permitted_keys allow_warning_fields
keys = [
:source_row,
:sourceRow,
:url,
:metadata_url,
:metadataUrl,
{ attributes: {} },
{ provenance: {} },
{ tag_sources: {} },
{ tagSources: {} }]
return keys unless allow_warning_fields
keys + [
{ field_warnings: {} },
{ fieldWarnings: {} },
{ base_warnings: [] },
{ baseWarnings: [] }]
end
private_class_method :permitted_keys
def self.normalise_source_row! value
source_row = Integer(value, exception: false)
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
source_row
end
private_class_method :normalise_source_row!
def self.normalise_url! value
unless value.is_a?(String)
raise ArgumentError, 'URL の形式が不正です.'
end
if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'URL が長すぎます.'
end
end
private_class_method :normalise_url!
def self.normalise_metadata_url! value
raise ArgumentError, 'metadata_url の形式が不正です.' unless value.nil? || value.is_a?(String)
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'metadata_url が長すぎます.'
end
end
private_class_method :normalise_metadata_url!
def self.normalise_attributes! attributes
raise ArgumentError, 'attributes の形式が不正です.' unless attributes.is_a?(Hash)
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_FIELDS).empty?
attributes.each do |key, value|
case key
when *STRING_FIELDS
raise ArgumentError, '取込項目の型が不正です.' unless value.nil? || value.is_a?(String)
when *FLEXIBLE_FIELDS
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric)
raise ArgumentError, '取込項目の型が不正です.'
end
end
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, '取込項目が大きすぎます.'
end
end
end
private_class_method :normalise_attributes!
def self.normalise_provenance! provenance
unless provenance.is_a?(Hash)
raise ArgumentError, 'provenance の形式が不正です.'
end
allowed = ATTRIBUTE_FIELDS + ['url']
unless (provenance.keys - allowed).empty?
raise ArgumentError, '値の由来が不正です.'
end
unless provenance.values.all? { ORIGINS.include?(_1) }
raise ArgumentError, '値の由来が不正です.'
end
end
private_class_method :normalise_provenance!
def self.normalise_tag_sources! tag_sources
return if tag_sources.nil?
unless tag_sources.is_a?(Hash)
raise ArgumentError, 'タグ由来の形式が不正です.'
end
unless (tag_sources.keys - ORIGINS).empty?
raise ArgumentError, 'タグ由来の形式が不正です.'
end
unless tag_sources.values.all? { _1.is_a?(String) }
raise ArgumentError, 'タグ由来の形式が不正です.'
end
if tag_sources.values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, 'タグ由来が大きすぎます.'
end
if tag_sources.values.sum(&:bytesize) > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'タグ由来が大きすぎます.'
end
end
private_class_method :normalise_tag_sources!
def self.normalise_warning_values! normalised, allow_warning_fields:
return unless allow_warning_fields
field_warnings = normalised['field_warnings']
unless field_warnings.nil? || field_warnings.is_a?(Hash)
raise ArgumentError, '警告の形式が不正です.'
end
field_warnings&.each do |key, values|
unless ATTRIBUTE_FIELDS.include?(key) || key == 'url'
raise ArgumentError, '警告の形式が不正です.'
end
unless values.is_a?(Array) && values.all? { _1.is_a?(String) }
raise ArgumentError, '警告の形式が不正です.'
end
if values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, '警告が大きすぎます.'
end
end
base_warnings = normalised['base_warnings']
return if base_warnings.nil?
unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) }
raise ArgumentError, '警告の形式が不正です.'
end
if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, '警告が大きすぎます.'
end
end
private_class_method :normalise_warning_values!
def self.row_bytesize row
row.to_json.bytesize
end
private_class_method :row_bytesize
end
-109
ファイルの表示
@@ -1,109 +0,0 @@
class PostImportRunner
def initialize actor:, rows:
@actor = actor
@rows = rows
end
def run
normalised_rows = PostImportRowNormaliser.normalise!(@rows)
previews = PostImportPreviewer.new.preview_rows(rows: normalised_rows,
fetch_metadata: false)
preview_map = previews.index_by { _1[:source_row] }
results = normalised_rows.map do |row|
run_row(row, preview_map.fetch(row['source_row']))
end
{ created: results.count { _1[:status] == 'created' },
skipped: results.count { _1[:status] == 'skipped' },
failed: results.count { _1[:status] == 'failed' },
rows: results }
end
private
def run_row row, preview
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
return { source_row: row['source_row'],
status: 'failed',
errors: preview[:validation_errors],
recoverable: true } if preview[:validation_errors].present?
if preview[:skip_reason] == 'existing'
return { source_row: row['source_row'],
status: 'skipped',
existing_post_id: preview[:existing_post_id] }
end
attributes['tags'] = preview[:attributes]['tags']
attributes['url'] = row['url']
creator = PostCreator.new(actor: @actor, attributes:)
post = creator.create!
result = { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
if creator.field_warnings.present?
result[:field_warnings] = creator.field_warnings
end
result
rescue ActiveRecord::RecordInvalid => e
existing_post = existing_post_for_race(row, e.record)
if existing_post
return { source_row: row['source_row'],
status: 'skipped',
existing_post_id: existing_post.id }
end
{ source_row: row['source_row'],
status: 'failed',
errors: e.record.errors.to_hash,
recoverable: true }
rescue ActiveRecord::RecordNotUnique => e
raise unless url_record_not_unique?(e)
existing_post = existing_post_for_race(row)
raise unless existing_post
{ source_row: row['source_row'],
status: 'skipped',
existing_post_id: existing_post.id }
rescue Tag::NicoTagNormalisationError
{ source_row: row['source_row'],
status: 'failed',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
recoverable: true }
rescue Tag::DeprecatedTagNormalisationError
{ source_row: row['source_row'],
status: 'failed',
errors: { tags: ['廃止済みタグは付与できません.'] },
recoverable: true }
rescue PostCreator::VideoMsParseError
{ source_row: row['source_row'],
status: 'failed',
errors: { duration: ['動画時間の記法が不正です.'] },
recoverable: true }
rescue ArgumentError
{ source_row: row['source_row'],
status: 'failed',
errors: { base: ['入力値が不正です.'] },
recoverable: true }
rescue StandardError => e
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name,
message: e.message }.to_json }")
{ source_row: row['source_row'],
status: 'failed',
errors: { base: ['登録中にエラーが発生しました.'] } }
end
def existing_post_for_race row, record = nil
if record && !(record.errors.of_kind?(:url, :taken))
return nil
end
normal_url = PostUrlNormaliser.normalise(row['url'])
return nil if normal_url.blank?
Post.find_by(url: normal_url)
end
def url_record_not_unique? error
error.message.include?('index_posts_on_url')
end
end
-25
ファイルの表示
@@ -1,25 +0,0 @@
class PostImportUrlListParser
MAX_ROWS = 100
MAX_BYTES = 1.megabyte
MAX_URL_BYTES = 20.kilobytes
def self.parse source
raw = source.to_s
raise ArgumentError, '入力が大きすぎます.' if raw.bytesize > MAX_BYTES
rows = raw.split(/\r\n|\n|\r/).each_with_index.filter_map { |line, index|
url = line.strip
next if url.blank?
if url.bytesize > MAX_URL_BYTES
raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます."
end
{ source_row: index + 1, url: }
}
raise ArgumentError, 'URL を入力してください.' if rows.empty?
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
rows
end
end
-179
ファイルの表示
@@ -1,179 +0,0 @@
require 'time'
require 'date'
class PostMetadataFetcher
TIMESTAMP_PATTERN =
Regexp.new(
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
'(Z|[+-]\d{2}:?\d{2})?\z')
def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url)
response = Preview::HttpFetcher.fetch(
uri.to_s,
max_bytes: Preview::ThumbnailFetcher::HTML_MAX_BYTES)
metadata = Preview::HtmlMetadataExtractor.extract(response)
document = Nokogiri::HTML.parse(response.body)
content = lambda { |name|
document
.at_css("meta[property='#{ name }'], meta[name='#{ name }']")
&.[]('content')
&.strip
&.presence
}
duration = content.call('og:video:duration') || content.call('video:duration')
published = content.call('article:published_time') || content.call('date')
created_range = original_created_range(published)
platform_tags = platform_tags(uri)
{ title: metadata[:title],
thumbnail_base:
Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
original_created_from: serialise_time(created_range&.first),
original_created_before: serialise_time(created_range&.last),
duration: serialise_duration(duration),
tags: platform_tags.join(' ') }
end
def self.platform_tags uri
return ['動画', 'YouTube'] if Preview::KnownSiteExtractor.youtube_video_id(uri)
return ['動画', 'ニコニコ'] if Preview::KnownSiteExtractor.niconico_video_id(uri)
[]
end
def self.original_created_range value
return nil if value.blank?
raw = value.to_s.strip
from, before =
case raw
when /\A(\d{4})\z/
year = Regexp.last_match(1).to_i
from = Time.zone.local(year, 1, 1)
[from, from + 1.year]
when /\A(\d{4})-(\d{2})\z/
year = Regexp.last_match(1).to_i
month = Regexp.last_match(2).to_i
return nil unless month.between?(1, 12)
from = Time.zone.local(year, month, 1)
[from, from + 1.month]
when /\A(\d{4})-(\d{2})-(\d{2})\z/
year = Regexp.last_match(1).to_i
month = Regexp.last_match(2).to_i
day = Regexp.last_match(3).to_i
return nil unless Date.valid_date?(year, month, day)
from = Time.zone.local(year, month, day)
[from, from + 1.day]
else
parse_timestamp_range(raw)
end
return nil if from.nil? || before.nil?
[from, before]
rescue ArgumentError, TypeError
nil
end
def self.parse_timestamp_range raw
match = raw.match(TIMESTAMP_PATTERN)
return nil unless match
year = match[1].to_i
month = match[2].to_i
day = match[3].to_i
hour = match[4].to_i
minute = match[5]&.to_i || 0
second = match[6]&.to_i || 0
fraction = match[7]
offset = match[8]
return nil unless valid_timestamp_components?(year, month, day, hour, minute, second)
return nil unless valid_offset?(offset)
nanoseconds = parse_nanoseconds(fraction)
timestamp =
if offset.present?
Time.new(
year,
month,
day,
hour,
minute,
second + Rational(nanoseconds, 1_000_000_000),
parse_offset(offset)).in_time_zone
else
Time.zone.local(year, month, day, hour, minute, second).change(nsec: nanoseconds)
end
from = timestamp.change(sec: 0, nsec: 0)
before =
if match[5].nil?
from + 1.hour
else
from + 1.minute
end
[from, before]
end
def self.parse_nanoseconds value
return 0 if value.blank?
digits = value[0, 9].ljust(9, '0')
Integer(digits, 10)
end
def self.parse_offset value
return '+00:00' if value == 'Z'
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
end
def self.serialise_duration value
seconds = Float(value)
return nil unless seconds.positive?
milliseconds = (seconds * 1_000).round
seconds_string = (milliseconds / 1_000.0).to_s
seconds_string.end_with?('.0') ? seconds_string.delete_suffix('.0') : seconds_string
rescue ArgumentError, TypeError
nil
end
def self.valid_timestamp_components? year, month, day, hour, minute, second
return false unless Date.valid_date?(year, month, day)
return false unless hour.between?(0, 23)
return false unless minute.between?(0, 59)
return false unless second.between?(0, 59)
true
end
def self.valid_offset? value
return true if value.nil? || value == 'Z'
match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
return false if match.nil?
hours = match[2].to_i
minutes = match[3].to_i
hours.between?(0, 23) && minutes.between?(0, 59)
end
def self.serialise_time value
return nil if value.nil?
value.nsec.zero? ? value.iso8601 : value.iso8601(9)
end
private_class_method :platform_tags,
:original_created_range,
:parse_timestamp_range,
:parse_nanoseconds,
:parse_offset,
:serialise_duration,
:valid_timestamp_components?,
:valid_offset?,
:serialise_time
end
-13
ファイルの表示
@@ -1,13 +0,0 @@
class PostUrlNormaliser
def self.normalise raw_url
value = raw_url.to_s.strip
uri = URI.parse(value)
return nil unless uri.is_a?(URI::HTTP) && uri.host.present?
uri.host = uri.host.downcase
uri.path = uri.path.sub(/\/\z/, '') if uri.path.present?
PostUrlSanitisationRule.sanitise(uri.to_s)
rescue URI::InvalidURIError
nil
end
end
+1 -2
ファイルの表示
@@ -67,8 +67,7 @@ module Preview
log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
raise FetchTimeout, e.message
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
log_failure(:network_error, url: uri&.to_s || raw_url, error: e.class.name,
message: e.message)
log_failure(:network_error, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
raise FetchFailed, e.message
end
+8 -11
ファイルの表示
@@ -4,15 +4,6 @@ module Preview
youtube_thumbnail(uri)
end
def self.youtube_video_id(uri)
case uri.host&.downcase
when 'youtu.be'
uri.path.split('/').reject(&:blank?).first
when 'www.youtube.com', 'youtube.com', 'm.youtube.com'
uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil
end&.then { _1 if _1.match?(/\A[A-Za-z0-9_-]{6,20}\z/) }
end
def self.niconico_video_id(uri)
case uri.host&.downcase
when 'www.nicovideo.jp', 'nicovideo.jp'
@@ -23,8 +14,14 @@ module Preview
end
def self.youtube_thumbnail(uri)
id = youtube_video_id(uri)
return unless id
id =
case uri.host&.downcase
when 'youtu.be'
uri.path.split('/').reject(&:blank?).first
when 'www.youtube.com', 'youtube.com', 'm.youtube.com'
uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil
end
return unless id&.match?(/\A[A-Za-z0-9_-]{6,20}\z/)
"https://i.ytimg.com/vi/#{ id }/hqdefault.jpg"
end
+20 -29
ファイルの表示
@@ -25,22 +25,6 @@ module Preview
fetch_image!(metadata[:image_url])
end
def self.fetch_image_response(raw_url)
uri, = UrlSafety.validate(raw_url)
response = HttpFetcher.fetch(uri.to_s)
unless allowed_image_content_type?(response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
response
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::ResponseTooLarge
raise
rescue HttpFetcher::FetchFailed
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
end
def self.title(raw_url)
uri, = UrlSafety.validate(raw_url)
HtmlMetadataExtractor.extract(
@@ -61,7 +45,18 @@ module Preview
end
def self.fetch_image!(url)
fetch_image_response(url).body
response = HttpFetcher.fetch(url)
unless allowed_image_content_type?(response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
response.body
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::ResponseTooLarge
raise
rescue HttpFetcher::FetchFailed
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
end
def self.niconico_thumbnail_url(uri)
@@ -75,20 +70,16 @@ module Preview
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
payload = {
url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
nil
rescue Nokogiri::XML::SyntaxError => e
payload = {
url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
nil
end
+7 -6
ファイルの表示
@@ -1,3 +1,4 @@
require 'open-uri'
require 'set'
require 'time'
@@ -126,12 +127,12 @@ module Youtube
return if post.thumbnail.attached?
return if thumbnail_url.blank?
post.attach_thumbnail_from_url!(thumbnail_url)
rescue Post::RemoteThumbnailFetchFailed => e
Rails.logger.info("youtube_sync_thumbnail_fetch_failed #{ { post_id: post.id,
thumbnail_url:,
error: e.class.name,
message: e.message }.to_json }")
post.thumbnail.attach(
io: URI.open(thumbnail_url),
filename: File.basename(URI.parse(thumbnail_url).path),
content_type: 'image/jpeg')
post.resized_thumbnail!
end
def youtube_url_regexp id
-6
ファイルの表示
@@ -33,12 +33,6 @@ Rails.application.routes.draw do
get :thumbnail
end
scope 'posts/import', controller: :post_imports do
post :preview
post :validate
post '', action: :create
end
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
collection do
get :search
-61
ファイルの表示
@@ -1,61 +0,0 @@
class CreatePostUrlSanitisationRules < ActiveRecord::Migration[8.0]
class PostUrlSanitisationRule < ActiveRecord::Base
self.table_name = 'post_url_sanitisation_rules'
end
def up
create_table :post_url_sanitisation_rules, id: :integer, primary_key: :priority do |t|
t.string :source_pattern, null: false
t.string :replacement, null: false
t.timestamps
t.datetime :discarded_at
t.index :source_pattern, unique: true
t.index :discarded_at
end
now = Time.current
PostUrlSanitisationRule.insert_all!([
{ priority: 10,
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1',
created_at: now,
updated_at: now },
{ priority: 20,
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/live/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1',
created_at: now,
updated_at: now },
{ priority: 30,
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/shorts/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1',
created_at: now,
updated_at: now },
{ priority: 40,
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/embed/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1',
created_at: now,
updated_at: now },
{ priority: 50,
source_pattern:
'\Ahttps?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#&]+&)*v=([^&#]+)(?:[&#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1',
created_at: now,
updated_at: now },
{ priority: 60,
source_pattern: '\Ahttps?://nico\.ms/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.nicovideo.jp/watch/\1',
created_at: now,
updated_at: now },
{ priority: 70,
source_pattern: '\Ahttps?://(?:www\.)?nicovideo\.jp/watch/([^?#/]+)(?:[?#].*)?\z',
replacement: 'https://www.nicovideo.jp/watch/\1',
created_at: now,
updated_at: now }])
end
def down
drop_table :post_url_sanitisation_rules
end
end
生成ファイル
+1 -11
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -331,16 +331,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end
create_table "post_url_sanitisation_rules", primary_key: "priority", id: :integer, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "source_pattern", null: false
t.string "replacement", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.index ["discarded_at"], name: "index_post_url_sanitisation_rules_on_discarded_at"
t.index ["source_pattern"], name: "index_post_url_sanitisation_rules_on_source_pattern", unique: true
end
create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.integer "version_no", null: false
-37
ファイルの表示
@@ -8,43 +8,6 @@
# MovieGenre.find_or_create_by!(name: genre_name)
# end
post_url_sanitisation_rules = [
{ priority: 10,
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1' },
{ priority: 20,
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/live/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1' },
{ priority: 30,
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/shorts/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1' },
{ priority: 40,
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/embed/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1' },
{ priority: 50,
source_pattern:
'\Ahttps?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#&]+&)*v=([^&#]+)(?:[&#].*)?\z',
replacement: 'https://www.youtube.com/watch?v=\1' },
{ priority: 60,
source_pattern: '\Ahttps?://nico\.ms/([^/?#]+)(?:[?#].*)?\z',
replacement: 'https://www.nicovideo.jp/watch/\1' },
{ priority: 70,
source_pattern: '\Ahttps?://(?:www\.)?nicovideo\.jp/watch/([^?#/]+)(?:[?#].*)?\z',
replacement: 'https://www.nicovideo.jp/watch/\1' }
]
post_url_sanitisation_rule_scope = PostUrlSanitisationRule.unscoped
post_url_sanitisation_rules.each do |attributes|
priority = attributes.fetch(:priority)
source_pattern = attributes.fetch(:source_pattern)
next if post_url_sanitisation_rule_scope.exists?(priority:)
next if post_url_sanitisation_rule_scope.exists?(source_pattern:)
post_url_sanitisation_rule_scope.create!(attributes)
end
material_sync_source_uri = ENV['MATERIAL_SYNC_SOURCE_URI']
material_sync_source_file_id = ENV['MATERIAL_SYNC_SOURCE_FILE_ID']
+10 -20
ファイルの表示
@@ -70,17 +70,11 @@ namespace :nico do
unless post.thumbnail.attached?
thumbnail_base = fetch_thumbnail.(post.url) rescue nil
if thumbnail_base.present?
post.thumbnail.attach(
io: URI.open(thumbnail_base),
filename: File.basename(URI.parse(thumbnail_base).path),
content_type: 'image/jpeg')
attrs[:thumbnail_base] = thumbnail_base
begin
post.attach_thumbnail_from_url!(thumbnail_base)
rescue Post::RemoteThumbnailFetchFailed => e
payload = {
post_id: post.id,
thumbnail_base:,
error: e.class.name,
message: e.message }
Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
end
end
end
@@ -88,6 +82,7 @@ namespace :nico do
post_changed = post.changed?
if post_changed
post.save!
post.resized_thumbnail! if post.thumbnail.attached?
end
else
post_created = true
@@ -96,18 +91,13 @@ namespace :nico do
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
original_created_from:, original_created_before:)
if thumbnail_base.present?
begin
post.attach_thumbnail_from_url!(thumbnail_base)
rescue Post::RemoteThumbnailFetchFailed => e
payload = {
post_id: nil,
thumbnail_base:,
error: e.class.name,
message: e.message }
Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
end
post.thumbnail.attach(
io: URI.open(thumbnail_base),
filename: File.basename(URI.parse(thumbnail_base).path),
content_type: 'image/jpeg')
end
post.save!
post.resized_thumbnail!
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end
-65
ファイルの表示
@@ -1,65 +0,0 @@
require 'rails_helper'
RSpec.describe 'database seeds' do
before do
PostUrlSanitisationRule.unscoped.delete_all
end
it 'registers the initial post URL sanitisation rules' do
load_seeds
urls = {
'https://youtu.be/abc123?si=share' => youtube_url('abc123'),
'https://www.youtube.com/live/abc123?t=10' => youtube_url('abc123'),
'https://youtube.com/shorts/abc123?feature=share' => youtube_url('abc123'),
'https://m.youtube.com/embed/abc123' => youtube_url('abc123'),
'https://youtube.com/watch?feature=share&v=abc123&t=10' => youtube_url('abc123'),
'https://nico.ms/sm123?from=share#fragment' => nico_url('sm123'),
'https://www.nicovideo.jp/watch/sm123?ref=share#fragment' => nico_url('sm123')
}
expect(PostUrlSanitisationRule.count).to eq(7)
urls.each do |url, canonical_url|
expect(PostUrlSanitisationRule.sanitise(url)).to eq(canonical_url)
end
end
it 'does not overwrite or restore an existing rule' do
rule = PostUrlSanitisationRule.create!(
priority: 10,
source_pattern: '\Ahttps://example\.com/custom\z',
replacement: 'https://example.com/replacement'
)
rule.discard!
original_attributes = rule.reload.attributes
2.times { load_seeds }
persisted_rule = PostUrlSanitisationRule.unscoped.find(10)
expect(persisted_rule.attributes).to eq(original_attributes)
expect(PostUrlSanitisationRule.unscoped.count).to eq(7)
end
it 'does not duplicate a rule moved to another priority' do
source_pattern = '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z'
PostUrlSanitisationRule.create!(
priority: 80,
source_pattern:,
replacement: 'https://example.com/custom/\1'
)
load_seeds
rules = PostUrlSanitisationRule.unscoped
expect(rules.where(source_pattern:).count).to eq(1)
expect(rules.find(80).replacement).to eq('https://example.com/custom/\1')
end
def load_seeds
load Rails.root.join('db/seeds.rb')
end
def youtube_url(video_id) = "https://www.youtube.com/watch?v=#{ video_id }"
def nico_url(video_id) = "https://www.nicovideo.jp/watch/#{ video_id }"
end
-361
ファイルの表示
@@ -1,361 +0,0 @@
require 'rails_helper'
require 'tempfile'
RSpec.describe Post, type: :model do
before do
PostUrlSanitisationRule.unscoped.delete_all
end
describe 'URL normalisation' do
it 'normalises the HTTP URL before applying sanitisation rules' do
PostUrlSanitisationRule.create!(
priority: 10,
source_pattern: '\\Ahttps://example\\.com/videos/([^/]+)\\z',
replacement: 'https://example.com/watch/\\1'
)
post = described_class.create!(
title: 'normalised URL',
url: ' https://EXAMPLE.com/videos/123/ '
)
expect(post.url).to eq('https://example.com/watch/123')
end
it 'does not normalise an unchanged URL when another attribute changes' do
post = create(:post)
post.update_column(:url, 'https://EXAMPLE.com/unchanged/')
post.update!(title: 'updated title')
expect(post.reload.url).to eq('https://EXAMPLE.com/unchanged/')
end
it 'validates the sanitised URL length' do
path = 'a' * 375
PostUrlSanitisationRule.create!(
priority: 10,
source_pattern: '\\Ahttps://example\\.com/(a+)\\z',
replacement: 'https://example.com/\\1\\1'
)
post = described_class.new(
title: 'long URL',
url: "https://example.com/#{ path }"
)
expect(post).to be_invalid
expect(post.errors.details.fetch(:url)).to include(
error: :too_long,
count: 768
)
end
it 'validates uniqueness after sanitisation' do
PostUrlSanitisationRule.create!(
priority: 10,
source_pattern: '\\Ahttps://example\\.com/alias\\z',
replacement: 'https://example.com/canonical'
)
create(:post, url: 'https://example.com/canonical')
post = described_class.new(title: 'duplicate URL', url: 'https://example.com/alias')
expect(post).to be_invalid
expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
end
end
describe 'thumbnail processing' do
def image_blob(width:, height:, background:, draw: nil)
Tempfile.create(['post-thumbnail', '.png']) do |file|
MiniMagick::Tool::Convert.new do |convert|
convert.size "#{ width }x#{ height }"
convert.xc background
draw&.call(convert)
convert << file.path
end
File.binread(file.path)
end
end
def upload_for(blob)
StringIO.new(blob).tap(&:rewind)
end
def read_image(attachment)
blob =
attachment.is_a?(Hash) ? attachment.fetch(:io).read : attachment.download
MiniMagick::Image.read(blob)
end
def colour_at(image, x, y)
image.get_pixels.fetch(y).fetch(x)
end
def expect_green(pixel)
expect(pixel[1]).to be > pixel[0] + 40
expect(pixel[1]).to be > pixel[2] + 40
end
def expect_red(pixel)
expect(pixel[0]).to be > pixel[1] + 40
expect(pixel[0]).to be > pixel[2] + 40
end
def expect_blue(pixel)
expect(pixel[2]).to be > pixel[0] + 40
expect(pixel[2]).to be > pixel[1] + 40
end
describe '.resized_thumbnail_attachment' do
it 'centre-crops a wide image to 180x180 without distorting it' do
blob = image_blob(
width: 360,
height: 180,
background: 'red',
draw: -> convert {
convert.fill 'green'
convert.draw 'rectangle 90,0 269,179'
})
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
expect(image.dimensions).to eq([180, 180])
expect_green(colour_at(image, 0, 90))
expect_green(colour_at(image, 90, 90))
expect_green(colour_at(image, 179, 90))
end
it 'centre-crops a tall image to 180x180 without distorting it' do
blob = image_blob(
width: 180,
height: 360,
background: 'red',
draw: -> convert {
convert.fill 'green'
convert.draw 'rectangle 0,90 179,269'
})
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
expect(image.dimensions).to eq([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
blob = image_blob(
width: 180,
height: 180,
background: 'red',
draw: -> convert {
convert.fill 'blue'
convert.draw 'rectangle 90,0 179,179'
})
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
expect(image.dimensions).to eq([180, 180])
expect_red(colour_at(image, 20, 90))
expect_blue(colour_at(image, 160, 90))
end
end
describe '#attach_thumbnail_from_url!' do
it 'attaches a fetched remote image through the common resize path' do
blob = image_blob(
width: 240,
height: 180,
background: 'red',
draw: -> convert {
convert.fill 'green'
convert.draw 'rectangle 30,0 209,179'
})
response = Preview::HttpFetcher::Response.new(
blob,
'image/png',
'https://example.com/thumb.png')
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
.with('https://example.com/thumb.png')
.and_return(response)
post = described_class.create!(title: 'title', url: 'https://example.com/post')
expect(post.thumbnail).to receive(:attach).once.and_call_original
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
expect(post.thumbnail).to be_attached
image = read_image(post.thumbnail)
expect(image.dimensions).to eq([180, 180])
end
it 'does not attach anything when thumbnail conversion fails' do
response = Preview::HttpFetcher::Response.new(
'not-an-image',
'image/png',
'https://example.com/thumb.png')
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
.with('https://example.com/thumb.png')
.and_return(response)
allow(described_class).to receive(:resized_thumbnail_attachment)
.and_raise(MiniMagick::Error, 'convert failed')
post = described_class.create!(title: 'title', url: 'https://example.com/post')
expect {
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
expect(post.thumbnail).not_to be_attached
end
it 'keeps an existing thumbnail when remote conversion fails' do
existing = described_class.create!(title: 'title', url: 'https://example.com/post')
existing.thumbnail.attach(
io: StringIO.new('existing'),
filename: 'existing.jpg',
content_type: 'image/jpeg')
blob_id = existing.thumbnail.blob.id
response = Preview::HttpFetcher::Response.new(
'not-an-image',
'image/png',
'https://example.com/thumb.png')
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
.with('https://example.com/thumb.png')
.and_return(response)
allow(described_class).to receive(:resized_thumbnail_attachment)
.and_raise(MiniMagick::Error, 'convert failed')
expect {
existing.attach_thumbnail_from_url!('https://example.com/thumb.png')
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
existing.reload
expect(existing.thumbnail).to be_attached
expect(existing.thumbnail.blob.id).to eq(blob_id)
end
end
end
describe 'original created datetime validation' do
it 'allows unrelated updates on persisted posts with second-bearing datetimes' do
post = described_class.create!(title: 'title', url: 'https://example.com/post')
post.update_columns(
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
)
post.title = 'updated title'
expect(post).to be_valid
expect { post.save! }.not_to raise_error
end
it 'rejects second-bearing updates when the datetime field changes' do
post = described_class.create!(title: 'title', url: 'https://example.com/post')
post.original_created_from = '2024-01-01T12:34:30Z'
expect(post).to be_invalid
expect(post.errors[:original_created_from]).to eq(
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
)
end
it 'accepts fixing persisted datetimes to minute precision' do
post = described_class.create!(title: 'title', url: 'https://example.com/post')
post.update_columns(
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
)
post.original_created_from = '2024-01-01T12:34Z'
post.original_created_before = '2024-01-01T12:35Z'
expect(post).to be_valid
expect { post.save! }.not_to raise_error
end
it 'adds only the minute-precision error for second precision values' do
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: '2024-01-01T12:34:30',
original_created_before: '2024-01-01T12:35'
)
expect(post).to be_invalid
expect(post.errors[:original_created_from]).to eq(
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
)
expect(post.errors[:original_created_at]).to be_empty
end
it 'adds only the minute-precision error for fractional-second values' do
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: '2024-01-01T12:34:00.123',
original_created_before: '2024-01-01T12:35'
)
expect(post).to be_invalid
expect(post.errors[:original_created_from]).to eq(
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
)
expect(post.errors[:original_created_at]).to be_empty
end
it 'checks range rules only for valid minute-precision endpoints' do
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: '2024-01-01T12:34',
original_created_before: '2024-01-01T12:34'
)
expect(post).to be_invalid
expect(post.errors[:original_created_at]).to eq(
[described_class::ORIGINAL_CREATED_ORDER_MESSAGE]
)
end
it 'accepts a one-minute range at minute precision' do
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: '2024-01-01T12:34',
original_created_before: '2024-01-01T12:35'
)
expect(post).to be_valid
end
it 'rejects invalid calendar dates and invalid hours' do
invalid_dates = [
'2024-02-31T12:00',
'2023-02-29T12:00',
'2024-02-29T24:00'
]
invalid_dates.each do |value|
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: value
)
expect(post).to be_invalid
expect(post.errors[:original_created_from]).to eq(
[described_class::ORIGINAL_CREATED_INVALID_MESSAGE]
)
end
end
end
end
-265
ファイルの表示
@@ -1,265 +0,0 @@
require 'rails_helper'
RSpec.describe PostUrlSanitisationRule, type: :model do
before do
described_class.unscoped.delete_all
end
describe 'validations' do
it 'requires a source pattern' do
rule = described_class.new(priority: 10, source_pattern: nil, replacement: '')
expect(rule).to be_invalid
expect(rule.errors.details.fetch(:source_pattern)).to eq([{ error: :blank }])
end
it 'requires a unique source pattern' do
described_class.create!(priority: 10, source_pattern: 'source', replacement: 'first')
rule = described_class.new(
priority: 20,
source_pattern: 'source',
replacement: 'second'
)
expect(rule).to be_invalid
expect(rule.errors.details.fetch(:source_pattern)).to include(
error: :taken,
value: 'source'
)
end
it 'rejects an invalid regexp' do
rule = described_class.new(priority: 10, source_pattern: '[', replacement: '')
expect(rule).to be_invalid
expect(rule.errors[:source_pattern]).to include('変な正規表現だね〜(笑)')
end
end
describe '.sanitise' do
it 'applies each active rule once in priority order' do
described_class.create!(priority: 30, source_pattern: 'c', replacement: 'd')
described_class.create!(priority: 10, source_pattern: 'a', replacement: 'aa')
described_class.create!(priority: 20, source_pattern: 'aa', replacement: 'c')
discarded = described_class.create!(
priority: 5,
source_pattern: '.',
replacement: 'discarded'
)
discarded.discard!
expect(described_class.sanitise('a')).to eq('d')
end
it 'canonicalises the initial YouTube and Nico URL forms' do
create_initial_rules
urls = {
'https://youtu.be/abc123?si=share' => youtube_url('abc123'),
'https://www.youtube.com/live/abc123?t=10' => youtube_url('abc123'),
'https://youtube.com/shorts/abc123?feature=share' => youtube_url('abc123'),
'https://m.youtube.com/embed/abc123' => youtube_url('abc123'),
'https://youtube.com/watch?feature=share&v=abc123&t=10' => youtube_url('abc123'),
'https://nico.ms/sm123?from=share#fragment' => nico_url('sm123'),
'https://www.nicovideo.jp/watch/sm123?ref=share#fragment' => nico_url('sm123')
}
urls.each do |url, canonical_url|
expect(described_class.sanitise(url)).to eq(canonical_url)
end
end
end
describe '.apply!' do
it 'locks posts in ID order and updates through temporary URLs' do
first = create(:post, url: 'https://example.com/source/1')
second = create(:post, url: 'https://example.com/source/2')
create_source_rule
sql = capture_sql { described_class.apply! }
lock_sql = sql.find { _1.match?(/SELECT .*posts.*FOR UPDATE/i) }
expect(lock_sql).to match(/ORDER BY .*posts.*id.* ASC/i)
expect(sql.grep(/post-url-sanitising\.invalid/).size).to eq(2)
expect(first.reload.url).to eq('https://example.com/canonical/1')
expect(second.reload.url).to eq('https://example.com/canonical/2')
end
it 'loads and compiles rules only once' do
create(:post, url: 'https://example.com/source/1')
create(:post, url: 'https://example.com/source/2')
create_source_rule
expect(described_class).to receive(:rules).once.and_call_original
described_class.apply!
end
it 'does not change post versions, version_no, or updated_at' do
post = create(:post, url: 'https://example.com/source/1')
post.update_columns(version_no: 7, updated_at: 1.day.ago)
original_updated_at = post.reload.updated_at
create_source_rule
expect { described_class.apply! }.not_to change(PostVersion, :count)
post.reload
expect(post.url).to eq('https://example.com/canonical/1')
expect(post.version_no).to eq(7)
expect(post.updated_at).to eq(original_updated_at)
end
invalid_urls = {
'a blank URL' => '',
'an unparseable URL' => 'https://[',
'a non-HTTP URL' => 'ftp://example.com/file',
'an HTTP URL without a host' => 'https:/path'
}
invalid_urls.each do |description, sanitised_url|
it "rolls back every update when sanitisation produces #{ description }" do
valid_post = create(:post, url: 'https://example.com/source/1')
invalid_post = create(:post, url: 'https://example.com/invalid')
create_source_rule
described_class.create!(
priority: 20,
source_pattern: '\\Ahttps://example\\.com/invalid\\z',
replacement: sanitised_url
)
expect { described_class.apply! }
.to raise_error(described_class::InvalidUrlError) { |error|
expect(error.invalid_rows).to eq([
{ post_id: invalid_post.id,
original_url: 'https://example.com/invalid',
sanitised_url: }
])
}
expect(valid_post.reload.url).to eq('https://example.com/source/1')
expect(invalid_post.reload.url).to eq('https://example.com/invalid')
end
end
it 'rolls back every update when sanitisation produces a URL longer than 768 characters' do
path = 'a' * 375
original_url = "https://example.com/#{ path }"
sanitised_url = "https://example.com/#{ path }#{ path }"
valid_post = create(:post, url: 'https://example.com/source/1')
invalid_post = create(:post, url: original_url)
create_source_rule
described_class.create!(
priority: 20,
source_pattern: '\\Ahttps://example\\.com/(a+)\\z',
replacement: 'https://example.com/\\1\\1'
)
expect { described_class.apply! }
.to raise_error(described_class::InvalidUrlError) { |error|
expect(error.invalid_rows).to eq([
{ post_id: invalid_post.id,
original_url:,
sanitised_url: }
])
}
expect(valid_post.reload.url).to eq('https://example.com/source/1')
expect(invalid_post.reload.url).to eq(original_url)
end
it 'rejects sanitised URL collisions case-insensitively without changing posts' do
source = create(:post, url: 'https://example.com/source/Foo')
canonical = create(:post, url: 'https://example.com/canonical/foo')
create_source_rule
expect { described_class.apply! }
.to raise_error(described_class::UrlConflictError) { |error|
expect(error.conflicts).to contain_exactly(
{ url: 'https://example.com/canonical/Foo',
post_id: source.id,
original_url: 'https://example.com/source/Foo' },
{ url: 'https://example.com/canonical/foo',
post_id: canonical.id,
original_url: 'https://example.com/canonical/foo' }
)
}
expect(source.reload.url).to eq('https://example.com/source/Foo')
expect(canonical.reload.url).to eq('https://example.com/canonical/foo')
expect(Post.count).to eq(2)
end
it 'converts a persisted URL constraint race into UrlConflictError' do
post = create(:post, url: 'https://example.com/source/1')
create_source_rule
conflict = { url: 'https://example.com/canonical/1',
post_id: post.id,
original_url: post.url }
database_error = ActiveRecord::RecordNotUnique.new('duplicate URL')
allow_any_instance_of(ActiveRecord::Relation)
.to receive(:update_all).and_raise(database_error)
allow(described_class).to receive(:build_persisted_conflicts).and_return([conflict])
expect { described_class.apply! }
.to raise_error(described_class::UrlConflictError) { |error|
expect(error.conflicts).to eq([conflict])
expect(error.cause).to equal(database_error)
}
expect(post.reload.url).to eq('https://example.com/source/1')
end
it 're-raises an unidentified RecordNotUnique error' do
post = create(:post, url: 'https://example.com/source/1')
create_source_rule
database_error = ActiveRecord::RecordNotUnique.new('another unique constraint')
allow_any_instance_of(ActiveRecord::Relation)
.to receive(:update_all).and_raise(database_error)
allow(described_class).to receive(:build_persisted_conflicts).and_return([])
expect { described_class.apply! }.to raise_error(database_error)
expect(post.reload.url).to eq('https://example.com/source/1')
end
end
def capture_sql
statements = []
subscriber = lambda do |_name, _start, _finish, _id, payload|
binds = payload.fetch(:binds, []).map { _1.value_for_database.to_s }
statements << ([payload.fetch(:sql)] + binds).join(' ')
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
statements
end
def create_source_rule
described_class.create!(
priority: 10,
source_pattern: '\\Ahttps://example\\.com/source/(.+)\\z',
replacement: 'https://example.com/canonical/\\1'
)
end
def create_initial_rules
rules = [
['\\Ahttps?://youtu\\.be/([^/?#]+)(?:[?#].*)?\\z', youtube_url('\\1')],
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/live/([^/?#]+)(?:[?#].*)?\\z',
youtube_url('\\1')],
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/shorts/([^/?#]+)(?:[?#].*)?\\z',
youtube_url('\\1')],
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/embed/([^/?#]+)(?:[?#].*)?\\z',
youtube_url('\\1')],
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/watch\\?(?:[^#&]+&)*' \
'v=([^&#]+)(?:[&#].*)?\\z', youtube_url('\\1')],
['\\Ahttps?://nico\\.ms/([^/?#]+)(?:[?#].*)?\\z', nico_url('\\1')],
['\\Ahttps?://(?:www\\.)?nicovideo\\.jp/watch/([^?#/]+)(?:[?#].*)?\\z',
nico_url('\\1')]
]
rules.each_with_index do |(source_pattern, replacement), index|
described_class.create!(
priority: (index + 1) * 10,
source_pattern:,
replacement:
)
end
end
def youtube_url(video_id) = "https://www.youtube.com/watch?v=#{ video_id }"
def nico_url(video_id) = "https://www.nicovideo.jp/watch/#{ video_id }"
end
-184
ファイルの表示
@@ -1,184 +0,0 @@
require 'rails_helper'
RSpec.describe 'Post imports API', type: :request do
let(:member) { create(:user, :member) }
before do
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['8.8.8.8']]
end
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: nil,
tags: ''
)
end
describe 'POST /posts/import/preview' do
it 'requires a member' do
sign_out
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:unauthorized)
sign_in_as(create(:user, :guest))
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:forbidden)
end
it 'parses a URL list and returns preview rows' do
sign_in_as(member)
post '/posts/import/preview', params: {
source: " https://example.com/one \r\n\r\nhttps://example.com/two"
}
expect(response).to have_http_status(:ok)
expect(json.fetch('rows').map { _1.fetch('source_row') }).to eq([1, 3])
expect(json.fetch('rows').map { _1.fetch('url') }).to eq([
'https://example.com/one',
'https://example.com/two'
])
end
it 'returns a safe 400 response for an invalid source' do
sign_in_as(member)
post '/posts/import/preview', params: { source: '' }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('URL を入力してください.')
end
end
describe 'POST /posts/import/validate' do
it 'accepts camel-case row properties and returns their warnings' do
sign_in_as(member)
post '/posts/import/validate', params: {
rows: [{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'manual title' },
provenance: { url: 'manual', title: 'manual' },
tagSources: { automatic: '', manual: '' },
fieldWarnings: { title: ['old warning'] },
baseWarnings: ['base warning']
}],
changed_row: -1
}
expect(response).to have_http_status(:ok)
result = json.fetch('rows').first
expect(result.fetch('source_row')).to eq(1)
expect(result.fetch('tag_sources')).to eq('automatic' => '', 'manual' => '')
expect(result.fetch('field_warnings')).to eq('title' => ['old warning'])
expect(result.fetch('base_warnings')).to eq(['base warning'])
end
it 'rejects a non-array rows value with 400' do
sign_in_as(member)
post '/posts/import/validate', params: { rows: { sourceRow: 1 }, changed_row: -1 }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('取込行の形式が不正です.')
end
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
-85
ファイルの表示
@@ -2206,91 +2206,6 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects second-bearing original created timestamps on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid original created from',
url: 'https://example.com/invalid-original-created-from',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00:01Z')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
)
expect(json.fetch('errors')).not_to have_key('original_created_at')
end
it 'rejects fractional-second original created timestamps on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid original created before',
url: 'https://example.com/invalid-original-created-before',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_before: '2020-01-01T00:00:00.123Z')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.']
)
expect(json.fetch('errors')).not_to have_key('original_created_at')
end
it 'rejects non-increasing original created ranges on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'non-increasing original created range',
url: 'https://example.com/non-increasing-original-created-range',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00Z',
original_created_before: '2020-01-01T00:00Z')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_at' => ['オリジナルの作成日時の順番がをかしぃです.']
)
end
it 'accepts original created ranges that are exactly one minute on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'valid original created range',
url: 'https://example.com/valid-original-created-range',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00Z',
original_created_before: '2020-01-01T00:01Z')
expect(response).to have_http_status(:created)
expect(Time.iso8601(json.fetch('original_created_from')))
.to eq(Time.iso8601('2020-01-01T00:00Z'))
expect(Time.iso8601(json.fetch('original_created_before')))
.to eq(Time.iso8601('2020-01-01T00:01Z'))
end
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
sign_in_as(member)
base_version = create_post_version_for!(post_record)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title',
tags: 'spec_tag',
original_created_from: 'not-a-time')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_from' => ['オリジナルの作成日時の形式が不正です.']
)
end
end
describe 'tag versioning from post write actions' do
+83
ファイルの表示
@@ -226,6 +226,14 @@ RSpec.describe 'Tags deerjikists API', type: :request do
[platform2, code2],
)
end
it 'locks the tag before replacing the complete list' do
expect_any_instance_of(Tag).to receive(:lock!).and_call_original
do_request
expect(response).to have_http_status(:ok)
end
end
context 'when tag already has deerjikists' do
@@ -299,6 +307,81 @@ RSpec.describe 'Tags deerjikists API', type: :request do
end
end
context 'when platform is outside the enum' do
let(:payload) do
[
{ platform: 'invalid', code: code1 },
]
end
it 'returns 422 with an indexed platform error without changing the list' do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
expect {
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present],
)
end
end
context 'when a requested deerjikist belongs to another tag' do
let!(:other_tag) { create(:tag, category: :deerjikist) }
let!(:owned_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let!(:conflicting_deerjikist) do
Deerjikist.create!(platform: platform2, code: code2, tag: other_tag)
end
let(:payload) do
[
{ platform: 'nico', code: 'new-code' },
{ platform: platform2, code: code2 },
]
end
before do
other_tag.tag_name.update!(name: 'existing-deerjikist')
end
it 'returns an indexed 422 error and rolls back the complete replacement' do
expect {
do_request
}.not_to change { Deerjikist.order(:platform, :code).pluck(:platform, :code, :tag_id) }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.1.code' => [include('existing-deerjikist')],
)
expect(owned_deerjikist.reload.tag_id).to eq(tag.id)
expect(conflicting_deerjikist.reload.tag_id).to eq(other_tag.id)
end
end
context 'when a requested deerjikist already belongs to the same tag' do
let!(:existing_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let(:payload) do
[
{ platform: platform1, code: code1 },
]
end
it 'keeps the existing row' do
expect {
do_request
}.not_to change { existing_deerjikist.reload.created_at }
expect(response).to have_http_status(:ok)
expect(Deerjikist.where(tag: tag).pluck(:platform, :code))
.to eq([[platform1, code1]])
end
end
context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do
-82
ファイルの表示
@@ -1,82 +0,0 @@
require 'rails_helper'
require 'base64'
RSpec.describe PostCreator do
let(:actor) { create(:user, :member) }
def real_thumbnail_upload
gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
Rack::Test::UploadedFile.new(
StringIO.new(gif),
'image/gif',
original_filename: 'thumbnail.gif')
end
before do
allow(Tag).to receive(:normalise_tags!).and_return({ tags: [], sections: {} })
allow(TagVersioning).to receive(:record_tag_snapshots!)
allow(Tag).to receive(:expand_parent_tags).and_return([])
allow(PostVersionRecorder).to receive(:record!)
end
it 'prefers 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:,
attributes: {
title: 'title',
url: 'https://example.com/post',
thumbnail: real_thumbnail_upload,
thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' }).create!
expect(post.thumbnail).to be_attached
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
end
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
expect_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:,
attributes: {
title: 'title',
url: 'https://example.com/post',
thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' }).create!
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
expect(post.thumbnail).to be_attached
end
it '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:,
attributes: {
title: 'title',
url: 'https://example.com/post',
thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' })
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
-187
ファイルの表示
@@ -1,187 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportPreviewer do
before do
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['8.8.8.8']]
end
end
def row(source_row:, url:, attributes: { }, provenance: { }, **values)
{
source_row:,
url:,
attributes:,
provenance:
}.merge(values)
end
describe '#preview_rows' do
it 'marks an existing post for skipping without fetching metadata' do
existing = create(:post, url: 'https://example.com/existing')
expect(PostMetadataFetcher).not_to receive(:fetch)
result = described_class.new.preview_rows(rows: [
row(source_row: 1, url: 'https://EXAMPLE.com/existing/')
]).first
expect(result).to include(
url: 'https://example.com/existing',
skip_reason: 'existing',
existing_post_id: existing.id,
validation_errors: {}
)
expect(result.fetch(:field_warnings)).to eq({})
end
it 'reports list duplicates as URL errors instead of existing skips' do
create(:post, url: 'https://example.com/duplicate')
expect(PostMetadataFetcher).not_to receive(:fetch)
results = described_class.new.preview_rows(rows: [
row(source_row: 1, url: 'https://example.com/duplicate'),
row(source_row: 2, url: 'https://EXAMPLE.com/duplicate/')
])
expect(results.map { _1[:validation_errors] }).to all(
include(url: ['URL が重複しています.'])
)
expect(results).to all(include(skip_reason: nil, existing_post_id: nil))
end
it 'does not fetch metadata for an unsafe URL' do
allow(Preview::UrlSafety).to receive(:validate)
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
expect(PostMetadataFetcher).not_to receive(:fetch)
result = described_class.new.preview_rows(rows: [
row(source_row: 1, url: 'https://unsafe.example/post')
]).first
expect(result.fetch(:validation_errors)).to include(
url: ['安全でない接続先は使用できません.']
)
end
it 'applies metadata to automatic fields and recognises metadata tags' do
Tag.create!(name: 'known-tag', category: :general)
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'metadata title',
thumbnail_base: 'https://example.com/thumb.jpg',
duration: '2',
tags: 'known-tag'
)
result = described_class.new.preview_rows(rows: [
row(source_row: 1, url: 'https://example.com/new')
]).first
expect(result.fetch(:attributes)).to include(
'title' => 'metadata title',
'thumbnail_base' => 'https://example.com/thumb.jpg',
'duration' => '2',
'tags' => 'known-tag'
)
expect(result.fetch(:field_warnings)).not_to have_key('tags')
expect(PostMetadataFetcher).to have_received(:fetch).once
end
it 'preserves manual empty values when metadata is available' do
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'metadata title',
tags: 'metadata-tag'
)
result = described_class.new.preview_rows(rows: [
row(
source_row: 1,
url: 'https://example.com/manual',
attributes: { title: '', tags: '' },
provenance: { title: 'manual', tags: 'manual' },
tag_sources: { automatic: 'old-tag', manual: '' }
)
]).first
expect(result.fetch(:attributes)).to include('title' => '', 'tags' => '')
expect(result.fetch(:tag_sources)).to include('manual' => '')
end
it 'clears warnings derived from an old metadata URL' do
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'new title',
thumbnail_base: 'https://example.com/new-thumb.jpg',
tags: ''
)
result = described_class.new.preview_rows(rows: [
row(
source_row: 1,
url: 'https://example.com/new-url',
attributes: { title: 'old title', tags: 'old-tag' },
provenance: { title: 'automatic', tags: 'automatic' },
tag_sources: { automatic: 'old-tag', manual: '' },
metadata_url: 'https://example.com/old-url',
field_warnings: { tags: ['old tag warning'], url: ['old fetch warning'] },
base_warnings: ['old base warning']
)
]).first
expect(result.fetch(:attributes)).to include('title' => 'new title', 'tags' => '')
expect(result.fetch(:base_warnings)).to eq([])
expect(result.fetch(:field_warnings).values.flatten)
.not_to include('old tag warning', 'old fetch warning')
end
it 'isolates an unexpected metadata failure to the affected URL' do
allow(PostMetadataFetcher).to receive(:fetch) do |url|
raise 'fetch failure' if url.include?('failure')
{ title: 'successful title', tags: '' }
end
allow(Rails.logger).to receive(:error)
results = described_class.new.preview_rows(rows: [
row(source_row: 1, url: 'https://example.com/failure'),
row(source_row: 2, url: 'https://example.com/success')
])
expect(results[0].fetch(:field_warnings)).to include(
'url' => [described_class::METADATA_FETCH_WARNING]
)
expect(results[1].fetch(:attributes)).to include('title' => 'successful title')
end
it 'turns an unsafe URL detected during metadata fetching into a URL error' do
allow(PostMetadataFetcher).to receive(:fetch)
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先です.')
allow(Rails.logger).to receive(:info)
result = described_class.new.preview_rows(rows: [
row(source_row: 1, url: 'https://example.com/redirects-to-private')
]).first
expect(result.fetch(:validation_errors)).to include(
url: ['安全でない接続先です.']
)
end
it 'validates parent IDs from the preloaded set' do
parent = create(:post)
results = described_class.new.preview_rows(
rows: [
row(source_row: 1, url: 'https://example.com/valid-parent',
attributes: { parent_post_ids: parent.id.to_s }),
row(source_row: 2, url: 'https://example.com/missing-parent',
attributes: { parent_post_ids: '999999' })
],
fetch_metadata: false
)
expect(results[0].fetch(:validation_errors)).not_to have_key(:parent_post_ids)
expect(results[1].fetch(:validation_errors)).to include(
parent_post_ids: ['存在しない親投稿 Id. があります.']
)
end
end
end
-77
ファイルの表示
@@ -1,77 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportRowNormaliser do
def valid_row(overrides = { })
{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'title', duration: '1' },
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
ファイルの表示
@@ -1,152 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportRunner do
let(:actor) { create(:user, :member) }
def row(source_row: 1, url: 'https://example.com/post')
{
sourceRow: source_row,
url:,
attributes: { title: 'title', tags: '' },
provenance: { url: 'manual', title: 'manual', tags: 'manual' },
tagSources: { automatic: '', manual: '' }
}
end
def preview(source_row: 1, errors: { }, skip_reason: nil, existing_post_id: nil)
{
source_row:,
attributes: { 'title' => 'title', 'tags' => '' },
validation_errors: errors,
skip_reason:,
existing_post_id:
}
end
it 'previews the whole batch once before processing individual rows' do
rows = [row, row(source_row: 2, url: 'https://example.com/two')]
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
expect(previewer).to receive(:preview_rows)
.with(
rows: satisfy { _1.map { |row_value| row_value['source_row'] } == [1, 2] },
fetch_metadata: false
)
.and_return([preview, preview(source_row: 2)])
allow(PostCreator).to receive(:new).and_return(
instance_double(PostCreator, create!: create(:post))
)
result = described_class.new(actor:, rows:).run
expect(result).to include(created: 2, skipped: 0, failed: 0)
end
it 'treats validation errors as failures before an existing skip' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(errors: { url: ['URL が重複しています.'] },
skip_reason: 'existing', existing_post_id: existing.id)
])
expect(PostCreator).not_to receive(:new)
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to include(
status: 'failed',
errors: { url: ['URL が重複しています.'] },
recoverable: true
)
end
it 'returns the existing post ID for skipped rows' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(skip_reason: 'existing', existing_post_id: existing.id)
])
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to eq(
source_row: 1,
status: 'skipped',
existing_post_id: existing.id
)
end
it 'converts a URL uniqueness validation race into a skip' do
existing = create(:post, url: 'https://example.com/race')
invalid = Post.new(url: existing.url)
invalid.errors.add(:url, :taken)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(invalid))
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
it 're-raises RecordNotUnique errors unrelated to the posts URL index' do
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!)
.and_raise(ActiveRecord::RecordNotUnique, 'other_unique_index')
expect {
described_class.new(actor:, rows: [row]).run
}.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'converts a posts URL index race into a skip' do
existing = create(:post, url: 'https://example.com/index-race')
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(
ActiveRecord::RecordNotUnique,
'duplicate key index_posts_on_url'
)
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/index-race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
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
ファイルの表示
@@ -1,40 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportUrlListParser do
describe '.parse' do
it 'trims URLs, ignores blank lines, and preserves source line numbers' do
source = " https://example.com/one \r\n\r\nhttps://example.com/two\n"
expect(described_class.parse(source)).to eq([
{ source_row: 1, url: 'https://example.com/one' },
{ source_row: 3, url: 'https://example.com/two' }
])
end
it 'rejects an empty URL list' do
expect { described_class.parse(" \n\r\n") }
.to raise_error(ArgumentError, 'URL を入力してください.')
end
it 'rejects more than 100 non-empty rows' do
source = 101.times.map { |index| "https://example.com/#{ index }" }.join("\n")
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '取込件数は 100 件までです.')
end
it 'includes the original line number in an oversized URL error' do
source = "\n#{ 'a' * (described_class::MAX_URL_BYTES + 1) }"
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '2 行目: URL が長すぎます.')
end
it 'rejects an oversized request before parsing rows' do
source = 'a' * (described_class::MAX_BYTES + 1)
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '入力が大きすぎます.')
end
end
end
-123
ファイルの表示
@@ -1,123 +0,0 @@
require 'rails_helper'
RSpec.describe PostMetadataFetcher do
Response = Struct.new(:body)
def fetch_with_published_time(value, url: 'https://example.com/video')
html = <<~HTML
<html><head>
<meta property="article:published_time" content="#{ value }">
</head></html>
HTML
uri = URI.parse(url)
allow(Preview::UrlSafety).to receive(:validate).with(url).and_return([uri, ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch).and_return(Response.new(html))
allow(Preview::HtmlMetadataExtractor).to receive(:extract)
.and_return(title: 'title', image_url: nil)
described_class.fetch(url)
end
it 'builds ranges explicitly for year, month, and day precision' do
year = fetch_with_published_time('2024')
month = fetch_with_published_time('2024-02')
day = fetch_with_published_time('2024-02-03')
expect(year).to include(
original_created_from: Time.zone.local(2024, 1, 1).iso8601,
original_created_before: Time.zone.local(2025, 1, 1).iso8601
)
expect(month).to include(
original_created_from: Time.zone.local(2024, 2, 1).iso8601,
original_created_before: Time.zone.local(2024, 3, 1).iso8601
)
expect(day).to include(
original_created_from: Time.zone.local(2024, 2, 3).iso8601,
original_created_before: Time.zone.local(2024, 2, 4).iso8601
)
end
it 'rounds second and fractional-second timestamps down to one-minute ranges' do
result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30')
expect(Time.iso8601(result.fetch(:original_created_from)))
.to eq(Time.iso8601('2024-02-03T12:34:00+02:30'))
expect(
Time.iso8601(result.fetch(:original_created_before)) -
Time.iso8601(result.fetch(:original_created_from))
).to eq(60)
expect(result.fetch(:original_created_from)).not_to include('.')
end
it 'treats hour-precision timestamps as one-hour ranges with and without offsets' do
offset_result = fetch_with_published_time('2024-02-03T12+02:30')
local_result = fetch_with_published_time('2024-02-03T12')
expect(Time.iso8601(offset_result.fetch(:original_created_from)))
.to eq(Time.iso8601('2024-02-03T12:00:00+02:30'))
expect(
Time.iso8601(offset_result.fetch(:original_created_before)) -
Time.iso8601(offset_result.fetch(:original_created_from))
).to eq(3600)
expect(local_result.fetch(:original_created_from)).to eq('2024-02-03T12:00:00Z')
expect(local_result.fetch(:original_created_before)).to eq('2024-02-03T13:00:00Z')
end
it 'adds platform tags for known video URLs' do
result = fetch_with_published_time('2024', url: 'https://youtu.be/abc123')
expect(result.fetch(:tags)).to eq('動画 YouTube')
end
it 'returns nil dates for an invalid timestamp' do
result = fetch_with_published_time('not-a-time')
expect(result.fetch(:original_created_from)).to be_nil
expect(result.fetch(:original_created_before)).to be_nil
end
it 'returns nil dates for an invalid timestamp second value' do
result = fetch_with_published_time('2024-02-03T12:34:99Z')
expect(result.fetch(:original_created_from)).to be_nil
expect(result.fetch(:original_created_before)).to be_nil
end
it 'returns nil dates for invalid calendar dates and invalid hour values' do
invalid_day = fetch_with_published_time('2024-02-31T12:00Z')
invalid_leap = fetch_with_published_time('2023-02-29T12:00Z')
invalid_hour = fetch_with_published_time('2024-02-29T24:00Z')
[invalid_day, invalid_leap, invalid_hour].each do |result|
expect(result.fetch(:original_created_from)).to be_nil
expect(result.fetch(:original_created_before)).to be_nil
end
end
it 'accepts a valid leap-day timestamp at minute precision' do
result = fetch_with_published_time('2024-02-29T12:34Z')
expect(result.fetch(:original_created_from)).to eq('2024-02-29T12:34:00Z')
expect(result.fetch(:original_created_before)).to eq('2024-02-29T12:35:00Z')
end
it 'serialises metadata duration as the same seconds string contract used by forms' do
html = <<~HTML
<html><head>
<meta property="article:published_time" content="2024-02-03T12:34:56.123+02:30">
<meta property="og:video:duration" content="2.5">
</head></html>
HTML
uri = URI.parse('https://example.com/video')
allow(Preview::UrlSafety).to receive(:validate)
.with('https://example.com/video')
.and_return([uri, ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch).and_return(Response.new(html))
allow(Preview::HtmlMetadataExtractor).to receive(:extract)
.and_return(title: 'title', image_url: nil)
result = described_class.fetch('https://example.com/video')
expect(result.fetch(:duration)).to eq('2.5')
end
end
-27
ファイルの表示
@@ -1,27 +0,0 @@
require 'rails_helper'
RSpec.describe PostUrlNormaliser do
before do
PostUrlSanitisationRule.unscoped.delete_all
end
describe '.normalise' do
it 'normalises the host and trailing slash before applying sanitisation rules' do
PostUrlSanitisationRule.create!(
priority: 10,
source_pattern: '\\Ahttps://example\\.com/source\\z',
replacement: 'https://example.com/canonical'
)
result = described_class.normalise(' https://EXAMPLE.com/source/ ')
expect(result).to eq('https://example.com/canonical')
end
it 'returns nil for unsupported or malformed URLs' do
expect(described_class.normalise('ftp://example.com/file')).to be_nil
expect(described_class.normalise('https://[')).to be_nil
expect(described_class.normalise('https:/path')).to be_nil
end
end
end
-52
ファイルの表示
@@ -48,56 +48,4 @@ RSpec.describe Preview::ThumbnailFetcher do
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
end
end
describe '.fetch_image_response' do
it 'rejects an unsafe input URL before HTTP fetch' do
allow(Preview::UrlSafety).to receive(:validate)
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
expect(Preview::HttpFetcher).not_to receive(:fetch)
expect {
described_class.fetch_image_response('https://unsafe.example.com/thumb.jpg')
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
end
it 'rejects an unsafe redirect target' do
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.jpg')
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
expect {
described_class.fetch_image_response('https://example.com/thumb.jpg')
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
end
it 'rejects an oversized image response' do
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.jpg')
.and_raise(Preview::HttpFetcher::ResponseTooLarge, 'too large')
expect {
described_class.fetch_image_response('https://example.com/thumb.jpg')
}.to raise_error(Preview::HttpFetcher::ResponseTooLarge)
end
it 'rejects a non-image content type' do
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.jpg')
.and_return(
Preview::HttpFetcher::Response.new(
'<html></html>',
'text/html',
'https://example.com/thumb.jpg'))
expect {
described_class.fetch_image_response('https://example.com/thumb.jpg')
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
end
end
end
+1 -32
ファイルの表示
@@ -10,37 +10,6 @@ RSpec.describe Youtube::Sync do
allow(sync).to receive(:attach_thumbnail_if_needed!)
end
describe '#attach_thumbnail_if_needed!' do
it 'uses the common remote thumbnail attach path' do
post = create(:post, thumbnail_base: nil)
allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original
expect(post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg')
sync.send(
:attach_thumbnail_if_needed!,
post,
'https://example.com/thumb.jpg')
end
it 'retries on a later sync when the previous remote attach failed' do
post = create(:post, thumbnail_base: nil)
allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original
expect(post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg')
.twice
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
2.times do
sync.send(
:attach_thumbnail_if_needed!,
post,
'https://example.com/thumb.jpg')
end
end
end
describe '#sync!' do
it 'returns without fetching video details when no video ids are discovered' do
allow(sync).to receive(:query_terms).and_return([])
@@ -107,7 +76,7 @@ RSpec.describe Youtube::Sync do
sync.sync!
end
it 'creates a YouTube post with default tags when no deerjikist mapping exists' do
it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do
Tag.tagme
Tag.bot
Tag.youtube
+1 -1
ファイルの表示
@@ -62,7 +62,7 @@ RSpec.describe 'nico:export' do
it 'deduplicates video ids' do
create_post('https://www.nicovideo.jp/watch/sm12345')
create_post('https://sp.nicovideo.jp/watch/sm12345')
create_post('https://www.nicovideo.jp/watch/sm12345?from=1')
expect(Open3).to receive(:capture3) do |_env, *args, **_kwargs|
expect(args.drop(3)).to eq(['sm12345'])
+2 -52
ファイルの表示
@@ -18,11 +18,7 @@ RSpec.describe "nico:sync" do
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
# 既存 post(正規表現で拾われるURL)
post = Post.create!(
title: "old",
url: "https://www.nicovideo.jp/watch/sm9",
uploaded_user: nil
)
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
# 既存の非nicoタグ(kept_non_nico_ids)
kept_general = create_tag!("spec_kept", category: "general")
@@ -64,54 +60,8 @@ RSpec.describe "nico:sync" do
expect(active_tag_names).to include("bot操作")
end
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil)
Tag.bot
Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open)
.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')
end
it 'サムネール取得失敗後も次回同期で再試行できる' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil)
Tag.bot
Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open)
.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
end
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
post = Post.create!(
title: "old",
url: "https://www.nicovideo.jp/watch/sm9",
uploaded_user: nil
)
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
# 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_tag!("nico:OLD", category: "nico")
+10 -378
ファイルの表示
@@ -50,27 +50,16 @@ pass or the remaining failure is clearly blocked.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
- Never write a TypeScript or TSX line longer than 99 characters.
- Aim to keep TypeScript and TSX lines within 79 characters where practical.
- Use 2-space block indentation in TypeScript and TSX.
- Use 4-space continuation indentation for wrapped expressions, arguments,
ternary branches, method chains, object pairs, arrays, and JSX attributes.
- Treat the user's `PostImportSourcePage.tsx` and
`PostImportReviewPage.tsx` formatting as the local reference shape.
- Use 4-space logical indentation in TypeScript and TSX.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- In TypeScript and TSX only, use tabs for leading 8-column compression only.
- A tab does not represent one indentation level.
- Determine visible indentation with 2-space block indentation and 4-space
continuation indentation first, then compress only complete leading runs of
8 spaces into tabs.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab
to reduce bytes.
- Treat one leading tab as exactly equivalent to 8 leading spaces.
- Use tabs only for leading indentation. Never replace spaces that occur after
a non-space character on the same line.
- 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.
## React
@@ -108,23 +97,12 @@ pass or the remaining failure is clearly blocked.
third-party request outside the Rails API.
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
## Dialogues
- Dialogue work follows the shared-frontend reuse rules below.
## Imports and aliases
- The `@` alias points to `frontend/src`.
- Prefer `@/...` imports for app code instead of long relative paths.
- Keep type imports separate with `import type`.
- Do not mix runtime values and `type` specifiers in one named import
declaration.
- Do not write `import { value, type TypeName } from ...`.
- Keep short value imports from one module on one line when they fit within
99 characters.
- Order imports as four groups with a blank line between groups: external
value imports, `@/...` value imports, external type imports, `@/...`
type imports.
- Match existing import grouping: external packages, app modules, then type imports.
## Tailwind and UI
@@ -151,41 +129,6 @@ pass or the remaining failure is clearly blocked.
it is JSX- or React-specific.
- Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them.
- Block bodies for components, functions, callbacks, `if`, `try`, `catch`,
`finally`, loops, and JSX nesting use 2 spaces per level.
- Put the opening brace of `try`, `catch`, and `finally` blocks on the next
line at the same indentation as the keyword.
- Do not indent the opening `{` one level deeper than `try`, `catch`, or
`finally`.
- Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`.
- Wrapped expressions, arguments, ternary branches, method chains, and object
pairs use 4-space continuation indentation relative to the owning
expression. Do not confuse this with 2-space block indentation.
- Tabs are leading 8-column compression only. They do not represent one
nesting level. Decide visible indentation first, then compress only
complete leading runs of 8 spaces into tabs.
- Do not add braces around a single-line `if` body merely for formatting.
- Use braces for multi-line `if`, `else`, and loop bodies.
- Multi-stage ternary expressions must use explicit parentheses for each
condition group and nested branch. Do not rely on indentation alone to show
`?` / `:` pairing.
- Keep short inline props types local when they remain readable and within the
line limit; do not mechanically extract a named type with no reuse benefit.
- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
Use a meaningful callback parameter name such as `row`, `item`, `value`,
`entry`, or `result`.
- In multi-line object literals, keep the opening `{` with the first pair when
the line length allows it; do not mechanically explode short objects into
Prettier-style vertical blocks.
- Method chains should align as a continuation under the receiver expression;
do not indent chains more deeply than the normal continuation depth.
- `PostImportSourcePage.tsx` and `PostImportReviewPage.tsx` are the current
canonical examples for block indentation, continuation indentation, import
grouping, ternary grouping, method-chain placement, and local inline props
types.
- Treat TypeScript and TSX formatting rules as hard constraints, not
preferences. Before finishing a TypeScript or TSX edit, inspect the edited
hunks for closing `)`, `]`, and `}` placement and fix violations instead of
@@ -215,93 +158,14 @@ pass or the remaining failure is clearly blocked.
beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading block indentation uses
2 spaces per level, wrapped continuations use the repository's 4-space
continuation alignment, and complete leading runs of 8 spaces may be
compressed to tabs.
- The TypeScript/TSX self-review must confirm leading indentation follows
4-space logical indentation with tabs only as leading 8-space compression.
- For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the repository's 4-space
continuation rule while keeping block indentation at 2 spaces.
- Keep short value imports from one module on one line when they fit within
99 characters.
- Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression.
- In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing
`)`.
- Block indentation example:
```ts
const Component = () => {
const value = loadValue ()
useEffect (() => {
if (value != null)
useValue (value)
}, [value])
}
```
- `try` / `catch` / `finally` brace placement example:
```ts
try
{
doWork ()
}
catch
{
recover ()
}
finally
{
cleanUp ()
}
```
- Continuation indentation example:
```ts
const editingRow =
Number.isFinite (editingSourceRow)
? rows.find (row => row.sourceRow === editingSourceRow) ?? null
: null
```
- Import grouping example:
```ts
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { loadPostImportSession } from '@/lib/postImportSession'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
```
- Inline props type example:
```ts
const Footer = (
{ loading,
onSubmit }: { loading: boolean
onSubmit: () => void },
) => null
```
- Ternary grouping example:
```ts
const rows =
repairMode === 'failed'
? (
[...source].sort ((a, b) => {
const aFailed = a.failed ? 0 : 1
const bFailed = b.failed ? 0 : 1
return aFailed - bFailed
}))
: source
```
- If the parameter list itself is given its own multi-line block after the
function's opening `(`, put the closing parameter `)` at the beginning of its
own line before the return type or `=>`.
@@ -355,237 +219,6 @@ const rows =
`BehaviorSettingsSection.tsx`.
- Avoid reformatting unrelated JSX.
## Shared frontend systems
Before creating a new component, hook, helper, store, context, or other
frontend abstraction, search at least:
- `src/components/common`
- `src/components/layout`
- `src/components/ui`
- `src/components/dialogues`
- `src/lib`
- `src/lib/dialogues`
- `src/stores`
- `src/types.ts`
Also inspect the existing pages and components in the same feature.
Search by responsibility, not by filename alone. Check display, interaction,
state, communication, validation, and permission behaviour before deciding that
an existing implementation is unsuitable.
### Component placement and reuse order
When adding UI, use this order:
1. reuse an existing feature component
2. reuse an existing component from `components/common`
3. reuse an existing layout component from `components/layout`
4. use an existing primitive from `components/ui` through the established
common API
5. extend an existing component minimally
6. add a feature-local component in the feature area
7. add a new common component only when multiple features clearly share a
stable visual contract
Do not place a one-screen component in a common directory merely because its
name starts with `Common`.
### Low-level primitives
Treat `components/ui` as low-level primitives. If a higher-level common API
already exists for dialogues, toast, form validation, navigation, or similar
behaviour, feature code must use that API instead of assembling primitives
directly.
Examples of existing preferred entrypoints include:
- dialogue: `@/lib/dialogues/useDialogue`
- toast: the existing toast API
- internal navigation: `PrefetchLink`
- form errors: `FieldError`, `FieldWarning`, `FormField`
- buttons: `Button`
- conditional class merge: `cn`
Do not evade the rule with aliases or thin wrappers around the low-level
primitive.
### Dialogues
Feature-facing dialogue work must use `@/lib/dialogues/useDialogue`.
Reuse the existing common dialogue API and common dialogue component. Do not
import `@/components/ui/dialog` directly in feature code to assemble bespoke
dialogue shells, and do not evade this rule with aliases such as
`Dialog as Dialogue`.
Do not reimplement overlay, portal, close button, header, footer, focus
handling, Escape handling, outside-click handling, or confirmation flow in
feature code.
Keep business-specific form content in feature code, and keep the visual and
behavioural dialogue shell in common code.
Use British spelling `Dialogue` for project-defined dialogue identifiers. Keep
exact third-party spellings only at the external boundary where compatibility
requires them.
### API calls
Rails API calls must use `src/lib/api.ts`.
Do not create feature-local Axios instances, fetch wrappers, header injectors,
camelCase converters, or generic error converters. If blob or other special
transport behaviour is already supported by the common API, use the existing
options instead of bypassing the wrapper.
### Query keys, server state, and prefetch
Before adding query state, inspect:
- `src/lib/queryKeys.ts`
- existing domain helpers
- existing prefetchers
- the root query-key hierarchy
- current mutation invalidation patterns
- the app-wide `QueryClient`
Do not write ad hoc query-key arrays in feature code. Do not duplicate fetcher,
prefetcher, or invalidation helpers for the same resource.
### Domain helpers
For posts, tags, wiki, materials, and other domain work, inspect the existing
helpers in `src/lib/*.ts` before adding logic to a page component.
Do not accumulate these in page components when an existing helper layer should
own them:
- API request construction
- response-shape conversion
- query-key construction
- canonical URL generation
- permission calculation
- storage serialisation
- domain-specific parsing
Keep purely local one-screen display shaping local when that is the clearest
place for it.
### Permission helpers
Use the existing permission helpers such as `src/lib/users.ts` when deciding
editability, role checks, admin/member visibility, and similar UI behaviour.
Do not scatter `user?.role`, numeric role comparisons, or string comparisons
through components. Frontend visibility control should be consistent even though
backend authorization remains the final gate.
### Validation errors
Before adding feature-local validation-error handling, inspect:
- `useValidationErrors`
- `apiErrors`
- `FieldError`
- `FieldWarning`
- `FormField`
- `inputClass`
Do not create a new generic hook, field-error state shape, or error-rendering
component for a pattern the shared error stack already covers. Keep only
genuinely feature-specific business errors local.
### Forms and fields
Before creating a new input, textarea, date/time field, tag input, label, or
error layout, inspect at least:
- `Form`
- `FormField`
- `FieldError`
- `FieldWarning`
- `DateTimeField`
- `TagInput`
- `TextArea`
- `Label`
- `Button`
Do not create a same-function field component merely because the spacing or
surface styling is slightly different. Prefer feature-level composition over
bloated common-field option lists.
### Navigation and prefetch
Use `PrefetchLink` and existing router helpers for internal navigation. Do not
introduce feature-local `<a>`, `window.location`, or custom prefetch logic for
internal routes. Keep path-segment encoding aligned with the existing rules.
### State management
Before adding state, decide whether the source of truth should be:
- component-local state
- URL search params
- TanStack Query server state
- an existing Zustand store
- an existing event bus
- an existing storage helper
Do not create a new global store, context, or event bus for one screen when
local state or an existing mechanism is enough. Do not create a second store
for the same responsibility.
### Storage and settings
When touching localStorage, sessionStorage, or user settings, inspect existing
settings helpers, storage helpers, expiry handling, versioning, and sanitisers.
Do not reimplement per-component key naming, JSON parsing and serialisation,
expiry, or schema checks when a shared helper already owns the pattern.
### Hooks
Before creating a custom hook, search existing `src/lib/use*.ts` and
`src/lib/use*.tsx`.
Hooks are for shared stateful behaviour or React lifecycle integration. Do not
turn a pure function, one-off helper, or mere re-export shim into `useFoo`.
### Stores, contexts, and event buses
Add a new store, context, or event bus only when the current mechanisms cannot
express the requirement and there are multiple genuinely separate consumers.
Do not hold the same information redundantly across URL state, query cache,
local component state, Zustand, and an event bus. Keep one source of truth.
### Types
If a domain type already exists in `src/types.ts` or a domain helper, reuse it
instead of redefining the same shape in a feature file.
Small local props and draft types may stay local. Do not create giant
catch-all type files such as `CommonTypes.ts`.
### Styling utilities
Use existing styling utilities such as `cn` and `inputClass`.
Do not add feature-local class-merge helpers, generic status-colour mappers, or
responsive wrapper helpers when a shared utility already exists. Keep common
tone names visual only; feature-specific state names stay in feature code.
### Layout
Before adding page shells, padding rules, viewport-height handling, sidebar
offsets, or footer offsets, inspect existing layout components such as
`MainArea`, top navigation, sidebar, page title, and section-title patterns.
Do not create a second layout shell before checking whether the current layout
can be reused or minimally extended.
### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
@@ -930,9 +563,8 @@ hunks line by line:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading block indentation uses 2 spaces per level, wrapped continuations
use the repository's 4-space continuation alignment, and complete leading
runs of 8 spaces may be compressed to tabs.
9. Leading indentation is 4-space logical indentation with tabs used only as
leading 8-space compression.
10. No line has trailing whitespace.
## Lint and build constraints
-9
ファイルの表示
@@ -41,9 +41,6 @@ import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import PostListPage from '@/pages/posts/PostListPage'
import PostNewPage from '@/pages/posts/PostNewPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
@@ -78,9 +75,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
@@ -119,9 +113,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
+5 -29
ファイルの表示
@@ -19,8 +19,8 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => ({
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => ({
choice: vi.fn (),
}),
}))
@@ -78,39 +78,15 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
})
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
const post = buildPost ()
api.isApiError.mockReturnValue (true)
postsApi.updatePost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
errors: {
original_created_at: ['日時を確認してください.'],
original_created_from: ['日時を確認してください.'],
original_created_before: ['終了を確認してください.'] },
},
},
})
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
})
})
+42 -31
ファイルの表示
@@ -1,16 +1,15 @@
import { useEffect, useMemo, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import FormField from '@/components/common/FormField'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts'
import { msToTime } from '@/lib/utils'
import { inputClass, msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
@@ -18,11 +17,7 @@ import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types'
type PostFormField =
'parentPostIds' | 'tags' | 'videoMs'
| 'originalCreatedAt' | 'originalCreatedFrom' | 'originalCreatedBefore'
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt'
const videoMsToDurationValue = (videoMs: number | null): string =>
videoMs == null ? '' : String (videoMs / 1_000)
@@ -162,20 +157,32 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/>
<PostTextField
label="タイトル"
value={title ?? ''}
disabled={disabled}
onChange={setTitle}/>
{/* タイトル */}
<FormField label="タイトル">
{({ invalid }) => (
<input
type="text"
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''}
onChange={e => setTitle (e.target.value)}/>)}
</FormField>
<PostTextField
label="親投稿"
value={parentPostIds}
disabled={disabled}
errors={fieldErrors.parentPostIds}
onChange={setParentPostIds}/>
{/* 親投稿 */}
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<PostTagsField
{/* タグ */}
<PostFormTagsArea
disabled={disabled}
tags={tags}
setTags={setTags}
@@ -188,17 +195,21 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={groupedMessages (
fieldErrors.originalCreatedAt,
fieldErrors.originalCreatedFrom,
fieldErrors.originalCreatedBefore)}/>
errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{videoFlg && (
<PostDurationField
value={duration}
disabled={disabled}
errors={fieldErrors.videoMs}
onChange={setDuration}/>)}
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
disabled={disabled}
className={inputClass (invalid)}
value={duration}
onChange={e => setDuration (e.target.value)}/>)}
</FormField>)}
{/* 送信 */}
<Button type="submit" disabled={disabled}>
+3 -41
ファイルの表示
@@ -20,10 +20,8 @@ describe ('PostOriginalCreatedTimeField', () => {
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
expect (setFrom).toHaveBeenCalledWith (expect.stringMatching (
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
expect (setBefore).toHaveBeenCalledWith (expect.stringMatching (
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
expect (setFrom).toHaveBeenCalledWith (expect.any (String))
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
})
it ('infers an exclusive before value on blur', () => {
@@ -40,43 +38,7 @@ describe ('PostOriginalCreatedTimeField', () => {
const input = screen.getAllByDisplayValue ('')[0]
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
const value = setBefore.mock.calls.at (-1)?.[0]
expect (value).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
expect (new Date (value).getTime () - new Date ('2026-01-02T03:04').getTime ())
.toBe (60_000)
})
it ('does not rewrite mounted values that only differ by offset notation', () => {
const setFrom = vi.fn ()
const setBefore = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom="2024-01-01T12:34+09:00"
setOriginalCreatedFrom={setFrom}
originalCreatedBefore="2024-01-01T12:35+09:00"
setOriginalCreatedBefore={setBefore}/>,
)
expect (setFrom).not.toHaveBeenCalled ()
expect (setBefore).not.toHaveBeenCalled ()
})
it ('emits minute-precision UTC values only when the user edits the input', () => {
const setFrom = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom="2024-01-01T12:34+09:00"
setOriginalCreatedFrom={setFrom}
originalCreatedBefore={null}
setOriginalCreatedBefore={vi.fn ()}/>,
)
const input = screen.getDisplayValue ('2024-01-01T12:34')
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
})
it ('resets both values', () => {
+64 -68
ファイルの表示
@@ -1,4 +1,4 @@
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
@@ -19,74 +19,70 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props) => {
return (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<DateTimeField
className="w-full"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
errors }: Props) => (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<DateTimeField
className="w-full"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</>)}
</FormField>)
}
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</>)}
</FormField>)
export default PostOriginalCreatedTimeField
-37
ファイルの表示
@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest'
import { menuOutline } from '@/components/TopNav'
import { buildUser } from '@/test/factories'
const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item: string) => {
const menu = menuOutline ({
user: buildUser ({ role }),
wikiId: section === 'Wiki' ? 10 : null,
pathName: section === 'Wiki' ? '/wiki/page' : '/posts' })
return menu.find (entry => entry.name === section)?.subMenu.find (
subMenuItem => subMenuItem.name === item)
}
describe ('menuOutline', () => {
it ('uses content-edit permission for post, material, and Wiki actions', () => {
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 ('keeps material suppression admin-only', () => {
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
})
})
+6 -11
ファイルの表示
@@ -9,7 +9,6 @@ import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils'
@@ -27,11 +26,10 @@ export const menuOutline = (
tag?: Tag | null
material?: Material | null
wikiId: number | null
user: User | null
user: User | null,
pathName: string },
): Menu => {
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
const editable = canEditContent (user)
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? ''
@@ -44,8 +42,7 @@ export const menuOutline = (
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new', visible: editable },
{ name: '取込', to: '/posts/import', visible: editable },
{ name: '追加', to: '/posts/new' },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
@@ -61,9 +58,8 @@ export const menuOutline = (
visible: tagFlg && tag?.category !== 'nico' }] },
{ name: '素材', to: '/materials', visible: true, subMenu: [
{ name: '一覧', to: '/materials' },
{ name: '追加', to: '/materials/new', visible: editable },
{ name: '抑止', to: '/materials/suppressions',
visible: user?.role === 'admin' },
{ name: '追加', to: '/materials/new' },
{ name: '抑止', to: '/materials/suppressions' },
{ name: '全体履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
{ component: <Separator/>, visible: materialFlg },
@@ -74,15 +70,14 @@ export const menuOutline = (
visible: materialFlg }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new', visible: editable },
{ name: '新規', to: '/wiki/new' },
{ name: '全体履歴', to: '/wiki/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg },
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] },
+1 -2
ファイルの表示
@@ -21,8 +21,7 @@ describe ('DateTimeField', () => {
fireEvent.change (input, { target: { value: '' } })
const first = handleChange.mock.calls[0]?.[0]
expect (first).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
expect (new Date (first).getUTCSeconds ()).toBe (0)
expect (new Date (first).getFullYear ()).toBe (2026)
expect (handleChange).toHaveBeenLastCalledWith (null)
})
})
+8 -24
ファイルの表示
@@ -8,27 +8,13 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0')
const toDateTimeLocalValue = (value: Date) => {
const y = value.getFullYear ()
const m = pad (value.getMonth () + 1)
const day = pad (value.getDate ())
const h = pad (value.getHours ())
const min = pad (value.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }`
}
const toMinutePrecisionIsoUtc = (value: string) => {
const date = new Date (value)
if (Number.isNaN (date.getTime ()))
return value
const y = date.getUTCFullYear ()
const m = pad (date.getUTCMonth () + 1)
const day = pad (date.getUTCDate ())
const h = pad (date.getUTCHours ())
const min = pad (date.getUTCMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }Z`
const toDateTimeLocalValue = (d: Date) => {
const y = d.getFullYear ()
const m = pad (d.getMonth () + 1)
const day = pad (d.getDate ())
const h = pad (d.getHours ())
const min = pad (d.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }:00`
}
@@ -61,16 +47,14 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid,
'focus:ring-2 focus:ring-blue-200']),
className)}
type="datetime-local"
step={60}
value={local}
aria-invalid={invalid}
onChange={ev => {
const v = ev.target.value
setLocal (v)
onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
onChange?.(v ? (new Date (v)).toISOString () : null)
}}
onBlur={onBlur}/>)
}
export default DateTimeField
export { toMinutePrecisionIsoUtc }
-18
ファイルの表示
@@ -1,18 +0,0 @@
import type { FC } from 'react'
type Props = { id?: string
messages?: string[] }
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
if (messages == null || messages.length === 0)
return null
return (
<ul id={id} className="mt-1 space-y-1 text-amber-700 dark:text-amber-200">
{messages.map ((message, i) => <li key={i}>{message}</li>)}
</ul>)
}
export default FieldWarning
+4 -8
ファイルの表示
@@ -1,15 +1,11 @@
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type Props = {
children: ReactNode
className?: string }
type Props = { children: ReactNode }
const Form: FC<Props> = ({ children, className }) => (
<div className={cn ('mx-auto max-w-xl space-y-4 p-4', className)}>
const Form: FC<Props> = ({ children }) => (
<div className="max-w-xl mx-auto p-4 space-y-4">
{children}
</div>)
export default Form
export default Form
-34
ファイルの表示
@@ -1,34 +0,0 @@
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
export type StatusBadgeTone =
'success'
| 'neutral'
| 'warning'
type Props = {
children: ReactNode
tone: StatusBadgeTone }
const TONES: Record<StatusBadgeTone, string[]> = {
success: [
'border-emerald-300 bg-emerald-50 text-emerald-700',
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'],
warning: [
'border-amber-300 bg-amber-50 text-amber-700',
'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'],
neutral: [
'border-stone-300 bg-stone-50 text-stone-700',
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'] }
const StatusBadge: FC<Props> = ({ children, tone }) => (
<span
className={cn (
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium',
TONES[tone])}>
{children}
</span>)
export default StatusBadge
-114
ファイルの表示
@@ -1,114 +0,0 @@
import { useCallback, useEffect } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
const FormBody = (
{ controls,
onSelect }: { controls: DialogueFormControls
onSelect: () => Promise<boolean> | boolean },
) => {
useEffect (() => {
controls.setActions ([{
label: '左操作',
placement: 'start',
onSelect }, {
label: '保存',
onSelect: () => true }])
}, [controls, onSelect])
return <div></div>
}
const NestedConfirmFormBody = ({ controls }: { controls: DialogueFormControls }) => {
const reset = useCallback (async () => {
await controls.confirm ({
title: '変更をリセットしますか?',
confirmText: 'リセット' })
return false
}, [controls])
return <FormBody controls={controls} onSelect={reset}/>
}
describe ('DialogueProvider', () => {
it ('keeps ordinary dialogues in FIFO order', async () => {
const Launcher = () => {
const dialogue = useDialogue ()
return (
<button
onClick={() => {
void dialogue.confirm ({ title: '一件目' })
void dialogue.confirm ({ title: '二件目' })
}}>
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
expect (screen.getByText ('一件目')).toBeInTheDocument ()
expect (screen.queryByText ('二件目')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '確定' }))
expect (await screen.findByText ('二件目')).toBeInTheDocument ()
})
it ('keeps a large form open when an action returns false', async () => {
const action = vi.fn ().mockResolvedValue (false)
const Launcher = () => {
const dialogue = useDialogue ()
return (
<button
onClick={() => void dialogue.form ({
title: '投稿を編輯',
description: '説明',
size: 'large',
body: controls => <FormBody controls={controls} onSelect={action}/> })}>
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const dialogue = screen.getByRole ('dialog')
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
.toBeInTheDocument ())
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto')
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
expect (screen.getByText ('投稿を編輯')).toBeInTheDocument ()
})
it ('opens a nested confirmation over a form and returns to the same form', async () => {
const Launcher = () => {
const dialogue = useDialogue ()
return (
<button
onClick={() => void dialogue.form ({
title: '投稿を編輯',
body: controls => <NestedConfirmFormBody controls={controls}/> })}>
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const action = await screen.findByRole ('button', { name: '左操作' })
fireEvent.click (action)
expect (await screen.findByText ('変更をリセットしますか?')).toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: 'リセット' }))
await waitFor (() => {
expect (screen.queryByText ('変更をリセットしますか?')).not.toBeInTheDocument ()
})
expect (screen.getByText ('投稿を編輯')).toBeInTheDocument ()
})
})
+99 -266
ファイルの表示
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog,
@@ -7,16 +7,29 @@ import { Dialog,
DialogFooter,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { DialogueContext } from '@/lib/dialogues/useDialogue'
import type { FC, ReactNode } from 'react'
import type { AlertOptions,
ChoiceOptions,
ConfirmOptions,
DialogueAPI,
DialogueFormAction,
DialogueFormControls,
DialogueFormOptions } from '@/lib/dialogues/useDialogue'
type DialogueVariant = 'default' | 'danger'
type ConfirmOptions = { title: string
description?: ReactNode
confirmText?: string
cancelText?: string
variant?: DialogueVariant }
type AlertOptions = { title: string
description?: ReactNode
okText?: string }
type Choice<T extends string> = { value: T
label: string
variant?: DialogueVariant }
type ChoiceOptions<T extends string> = { title: string
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }
type DialogueRequest =
| { id: number
@@ -31,10 +44,13 @@ type DialogueRequest =
kind: 'choice'
options: ChoiceOptions<string>
resolve: (value: string | null) => void }
| { id: number
kind: 'form'
options: DialogueFormOptions
resolve: () => void }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
alert: (options: AlertOptions) => Promise<void>
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }
const DialogueContext = createContext<DialogueAPI | null> (null)
let nextDialogueId = 1
@@ -43,27 +59,20 @@ type Props = { children: ReactNode }
const DialogueProvider: FC<Props> = ({ children }) => {
const [queue, setQueue] = useState<DialogueRequest[]> ([])
const [pendingIds, setPendingIds] = useState<number[]> ([])
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
const formControls = useRef<Record<number, DialogueFormControls>> ({ })
const [nestedConfirm, setNestedConfirm] = useState<{
parentId: number
options: ConfirmOptions
resolve: (value: boolean) => void } | null> (null)
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
const id = nextDialogueId
++nextDialogueId
setQueue (current => [...current, { ...request, id } as DialogueRequest])
setQueue (q => [...q, { ...request, id } as DialogueRequest])
}, [])
const closeRequest = useCallback ((id: number, result?: unknown) => {
setQueue (current => {
const active = current.find (request => request.id === id)
const closeActive = useCallback ((result?: unknown) => {
setQueue (q => {
const [active, ...rest] = q
if (active == null)
return current
if (!(active))
return rest
switch (active.kind)
{
@@ -78,42 +87,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
case 'choice':
active.resolve ((result ?? null) as string | null)
break
case 'form':
active.resolve ()
break
}
return current.filter (request => request.id !== id)
})
setPendingIds (current => current.filter (pendingId => pendingId !== id))
setFormActions (current => {
const { [id]: _, ...rest } = current
return rest
})
delete formControls.current[id]
}, [])
const setRequestActions = useCallback (
(id: number, actions: DialogueFormAction[]) => {
setFormActions (current => ({ ...current, [id]: actions }))
},
[])
const openNestedConfirm = useCallback (
(parentId: number, options: ConfirmOptions) =>
new Promise<boolean> (resolve => {
setNestedConfirm ({ parentId, options, resolve })
}),
[])
const closeNestedConfirm = useCallback ((result: boolean) => {
setNestedConfirm (current => {
if (current == null)
return current
current.resolve (result)
return null
return rest
})
}, [])
@@ -127,229 +103,86 @@ const DialogueProvider: FC<Props> = ({ children }) => {
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })
}),
form: options => new Promise<void> (resolve => {
push ({ kind: 'form', options, resolve })
}) }), [push])
const handleFormAction = useCallback (
async (id: number, action: DialogueFormAction) => {
if (pendingIds.includes (id))
return
setPendingIds (current => [...current, id])
try
{
const shouldClose = await action.onSelect ()
if (shouldClose !== false)
closeRequest (id)
}
finally
{
setPendingIds (current => current.filter (pendingId => pendingId !== id))
}
},
[closeRequest, pendingIds])
resolve: resolve as (value: string | null) => void })}) }), [push])
const active = queue[0]
const startActions =
active?.kind === 'form'
? (formActions[active.id] ?? []).filter (action => action.placement === 'start')
: []
const endActions =
active?.kind === 'form'
? (formActions[active.id] ?? []).filter (action =>
action.placement == null || action.placement === 'end')
: []
return (
<DialogueContext.Provider value={api}>
{children}
{active && (
<Dialog
open
onOpenChange={open => {
const blocked =
nestedConfirm?.parentId === active.id
|| pendingIds.includes (active.id)
if (!(open) && !(blocked))
closeRequest (active.id, active.kind !== 'confirm' && null)
}}>
<DialogContent
className={
active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
? 'max-w-3xl'
: 'max-w-lg' } flex-col overflow-hidden gap-0`
: 'px-6 pb-6 pt-7'
}
onEscapeKeyDown={event => {
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
event.preventDefault ()
}}
onPointerDownOutside={event => {
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
event.preventDefault ()
}}>
{active.kind === 'form'
? (
<>
<DialogHeader className="shrink-0 pb-4 pl-8 pr-0 pt-1 text-left">
<DialogTitle>{active.options.title}</DialogTitle>
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{active.options.body (
formControls.current[active.id] ??= {
close: () => closeRequest (active.id),
setActions: actions => setRequestActions (active.id, actions),
confirm: options => openNestedConfirm (active.id, options) })}
</div>
<DialogFooter
className="shrink-0 flex-col gap-2 pt-4
sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
{startActions.map (action => (
<Button
key={action.label}
className="w-full sm:w-auto"
variant={action.variant === 'danger'
? 'destructive'
: 'default'}
onClick={() => void handleFormAction (active.id, action)}
disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id
|| action.disabled}>
{action.label}
</Button>))}
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
className="w-full sm:w-auto"
variant="outline"
onClick={() => closeRequest (active.id)}
disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id}>
{active.options.cancelText ?? '取消'}
</Button>
{endActions.map (action => (
<Button
key={action.label}
className="w-full sm:w-auto"
variant={action.variant === 'danger'
? 'destructive'
: 'default'}
onClick={() => void handleFormAction (active.id, action)}
disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id
|| action.disabled}>
{action.label}
</Button>))}
</div>
</DialogFooter>
</>)
: (
<>
<DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle>
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeRequest (active.id, false)}>
{active.options.cancelText ?? '取消'}
</Button>
<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeRequest (active.id, true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}
{active.kind === 'alert' && (
<Button onClick={() => closeRequest (active.id)}>
{active.options.okText ?? '確定'}
</Button>)}
{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeRequest (active.id, null)}>
{active.options.cancelText ?? '取消'}
</Button>
{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeRequest (active.id, choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</>)}
</DialogContent>
</Dialog>)}
{nestedConfirm && (
<Dialog
open
onOpenChange={open => {
if (!(open))
closeNestedConfirm (false)
}}>
<Dialog
open={Boolean (active)}
onOpenChange={open => {
if (!(open))
closeActive (active?.kind !== 'confirm' && null)
}}>
{active && (
<DialogContent className="px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle>{nestedConfirm.options.title}</DialogTitle>
<DialogTitle>{active.options.title}</DialogTitle>
{nestedConfirm.options.description && (
{active.options.description && (
<DialogDescription asChild>
<div>{nestedConfirm.options.description}</div>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => closeNestedConfirm (false)}>
{nestedConfirm.options.cancelText ?? '取消'}
</Button>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeActive (false)}>
{active.options.cancelText ?? '取消'}
</Button>
<Button
variant={nestedConfirm.options.variant === 'danger'
? 'destructive'
: 'default'}
onClick={() => closeNestedConfirm (true)}>
{nestedConfirm.options.confirmText ?? '確定'}
</Button>
<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}
{active.kind === 'alert' && (
<Button onClick={() => closeActive ()}>
{active.options.okText ?? '確定'}
</Button>)}
{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeActive (null)}>
{active.options.cancelText ?? '取消'}
</Button>
{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</DialogContent>
</Dialog>)}
</DialogContent>)}
</Dialog>
</DialogueContext.Provider>)
}
export { useDialogue } from '@/lib/dialogues/useDialogue'
export const useDialogue = () => {
const dialogue = useContext (DialogueContext)
if (!(dialogue))
throw new Error ('useDialogue must be used inside DialogueProvider')
return dialogue
}
export default DialogueProvider
-28
ファイルの表示
@@ -1,28 +0,0 @@
import PostTextField from '@/components/posts/PostTextField'
import type { FC } from 'react'
type Props = {
value: string
onChange: (value: string) => void
errors?: string[]
disabled?: boolean }
const PostDurationField: FC<Props> = (
{ value,
onChange,
errors,
disabled },
) => (
<PostTextField
label="動画時間"
value={value}
onChange={onChange}
errors={errors}
disabled={disabled}
type="text"
placeholder="例: 2 / 2.5 / 1:23"/>
)
export default PostDurationField
-29
ファイルの表示
@@ -1,29 +0,0 @@
import PostFormTagsArea from '@/components/PostFormTagsArea'
import FieldWarning from '@/components/common/FieldWarning'
import type { ComponentPropsWithoutRef, FC } from 'react'
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
tags: string
setTags: (tags: string) => void
warnings?: string[]
errors?: string[] }
const PostTagsField: FC<Props> = (
{ tags,
setTags,
warnings,
errors,
...rest },
) => (
<div className="space-y-2">
<PostFormTagsArea
{...rest}
tags={tags}
setTags={setTags}
errors={errors}/>
<FieldWarning messages={warnings}/>
</div>)
export default PostTagsField
-49
ファイルの表示
@@ -1,49 +0,0 @@
import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField'
import { inputClass } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type Props = {
label: string
value: string
onChange: (value: string) => void
warnings?: string[]
errors?: string[]
disabled?: boolean
type?: string
placeholder?: string
className?: string
after?: ReactNode }
const PostTextField: FC<Props> = (
{ label,
value,
onChange,
warnings,
errors,
disabled,
type = 'text',
placeholder,
className,
after },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<input
type={type}
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid, className)}/>
<FieldWarning messages={warnings}/>
{after}
</>)}
</FormField>)
export default PostTextField
-12
ファイルの表示
@@ -1,12 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
describe ('PostThumbnailPreview', () => {
it ('keeps an existing blob preview URL unchanged for normal post forms', () => {
render (<PostThumbnailPreview url="blob:preview" className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
})
})
-50
ファイルの表示
@@ -1,50 +0,0 @@
import { useEffect, useState } from 'react'
import type { FC } from 'react'
type Props = {
url: string
alt?: string
className?: string }
const PostThumbnailPreview: FC<Props> = (
{ url, alt = 'サムネール', className = 'h-16 w-16' },
) => {
const [failed, setFailed] = useState (false)
useEffect (() => {
setFailed (false)
}, [url])
if (!(url))
{
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-border bg-muted text-xs text-muted-foreground`}>
</div>)
}
if (failed)
{
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-amber-300 bg-amber-50 p-2 text-center text-xs
text-amber-700 dark:border-amber-900 dark:bg-amber-950
dark:text-amber-200`}>
</div>)
}
return (
<img
src={url}
alt={alt}
className={`${ className } rounded border border-border object-cover`}
onError={() => setFailed (true)}/>)
}
export default PostThumbnailPreview
-261
ファイルの表示
@@ -1,261 +0,0 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import { buildPostImportRow } from '@/test/postImportFactories'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostImportRowForm', () => {
beforeEach (() => {
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
})
it ('resets only the draft, then saves with resetRequested', async () => {
const row = buildPostImportRow ()
row.attributes.title = 'manual title'
row.provenance.title = 'manual'
const actions: DialogueFormAction[][] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn ().mockResolvedValue (true),
setActions: next => actions.push (next) }
const invalidRow = buildPostImportRow ({
validationErrors: { title: ['タイトルを確認してください.'] } })
const onSave = vi.fn ().mockResolvedValue ({ saved: false, row: invalidRow })
render (<PostImportRowForm row={row} controls={controls} onSave={onSave}/>)
const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
const reset = actions.at (-1)?.find (action => action.label === '変更をリセット')
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => {
await reset?.onSelect ()
})
expect (controls.confirm).toHaveBeenCalled ()
expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled ()
const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存')
await act (async () => {
await save?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({ title: '' }),
resetRequested: true })
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
})
it ('does not reset the draft when confirmation is cancelled', async () => {
const row = buildPostImportRow ({ attributes: { title: 'manual title' } })
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn ().mockResolvedValue (false),
setActions: next => {
actions = next
} }
render (
<PostImportRowForm
row={row}
controls={controls}
onSave={vi.fn ()}/>)
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
await actions.find (action => action.label === '変更をリセット')?.onSelect ()
})
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
})
it ('marks edited fields and areas invalid from field errors', () => {
const row = buildPostImportRow ({
validationErrors: { url: ['URL error'], tags: ['tag error'] },
importErrors: { duration: ['duration error'] },
fieldWarnings: { title: ['title warning'] } })
render (
<PostImportRowForm
row={row}
controls={{ close: vi.fn (), confirm: vi.fn (), setActions: vi.fn () }}
onSave={vi.fn ()}/>)
expect (screen.getByText ('URL error')).toBeInTheDocument ()
expect (screen.getByText ('tag error')).toBeInTheDocument ()
expect (screen.getByText ('duration error')).toBeInTheDocument ()
expect (screen.getByText ('title warning')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox').filter (
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
})
it ('keeps untouched original created values unchanged in the save payload', async () => {
let actions: DialogueFormAction[] = []
const row = buildPostImportRow ({
attributes: {
originalCreatedFrom: '2024-01-01T12:34+09:00',
originalCreatedBefore: '2024-01-01T12:35+09:00' } })
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null })
render (
<PostImportRowForm
row={row}
controls={controls}
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({
originalCreatedFrom: '2024-01-01T12:34+09:00',
originalCreatedBefore: '2024-01-01T12:35+09:00' }),
resetRequested: false })
})
it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null })
const { container } = render (
<PostImportRowForm
row={buildPostImportRow ({
attributes: { duration: '2', tags: 'tag1' } })}
controls={controls}
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
expect (container.querySelector ('input[type="file"]')).toBeNull ()
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2')
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({
duration: '2',
tags: 'tag1' }),
resetRequested: false })
})
it ('keeps reset enabled when the value matches but provenance still differs', async () => {
const row = buildPostImportRow ({
attributes: { title: 'same title' },
provenance: { title: 'manual' },
resetSnapshot: {
url: 'https://example.com/post',
attributes: {
title: 'same title',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
tags: '',
parentPostIds: '' },
provenance: {
url: 'manual',
title: 'automatic',
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
duration: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' },
tagSources: { automatic: '', manual: '' },
fieldWarnings: { },
baseWarnings: [] } })
let actions: DialogueFormAction[] = []
render (
<PostImportRowForm
row={row}
controls={{
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }}
onSave={vi.fn ()}/>)
await waitFor (() => expect (actions.length).toBe (2))
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
})
it ('disables every field while save validation is pending and re-enables them afterwards', async () => {
let actions: DialogueFormAction[] = []
let resolveSave:
((value: { saved: boolean
row: ReturnType<typeof buildPostImportRow> | null }) => void) | null
= null
const onSave = vi.fn (() =>
new Promise<{ saved: boolean
row: ReturnType<typeof buildPostImportRow> | null }> (resolve => {
resolveSave = resolve
}))
render (
<PostImportRowForm
row={buildPostImportRow ({ attributes: { title: 'draft title' } })}
controls={{
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }}
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
await waitFor (() => {
screen.getAllByRole ('textbox').forEach (textbox => {
expect (textbox).toBeDisabled ()
})
})
resolveSave?.({
saved: false,
row: buildPostImportRow ({
attributes: { title: 'draft title' },
validationErrors: { title: ['タイトルを確認してください.'] } }) })
await waitFor (() => {
screen.getAllByRole ('textbox').forEach (textbox => {
expect (textbox).not.toBeDisabled ()
})
})
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
})
})
-250
ファイルの表示
@@ -1,250 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import type { FC } from 'react'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
type Draft = PostImportEditableDraft
type Props = {
row: PostImportRow
controls: DialogueFormControls
onSave: (args: { draft: Draft
resetRequested: boolean }) => Promise<{
saved: boolean
row: PostImportRow | null }> }
const buildDraft = (row: PostImportRow): Draft => ({
url: row.url,
title: String (row.attributes.title ?? ''),
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
duration: String (row.attributes.duration ?? ''),
tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? '') })
const buildResetDraft = (row: PostImportRow): Draft => ({
url: row.resetSnapshot.url,
title: String (row.resetSnapshot.attributes.title ?? ''),
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
duration: String (row.resetSnapshot.attributes.duration ?? ''),
tags: String (row.resetSnapshot.attributes.tags ?? ''),
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const sameDraft = (left: Draft, right: Draft): boolean =>
left.url === right.url
&& left.title === right.title
&& left.thumbnailBase === right.thumbnailBase
&& left.originalCreatedFrom === right.originalCreatedFrom
&& left.originalCreatedBefore === right.originalCreatedBefore
&& left.duration === right.duration
&& left.tags === right.tags
&& left.parentPostIds === right.parentPostIds
const sameProvenance = (
current: PostImportRow['provenance'],
reset: PostImportRow['resetSnapshot']['provenance'],
): boolean =>
Object.keys (reset).every (field => current[field] === reset[field])
const sameTagSources = (
current: PostImportRow['tagSources'],
reset: PostImportRow['resetSnapshot']['tagSources'],
): boolean =>
(current?.automatic ?? '') === reset.automatic
&& (current?.manual ?? '') === reset.manual
const PostImportRowForm: FC<Props> = (
{ row,
controls,
onSave },
) => {
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
const [saving, setSaving] = useState (false)
const [resetRequested, setResetRequested] = useState (false)
useEffect (() => {
const nextDraft = buildDraft (row)
setDraft (nextDraft)
setMessageRow (null)
setResetRequested (false)
}, [row])
const displayRow = messageRow ?? row
const resetDraft = useMemo (
() => buildResetDraft (row),
[row])
const resetDisabled =
saving
|| (sameDraft (draft, resetDraft)
&& sameProvenance (row.provenance, row.resetSnapshot.provenance)
&& sameTagSources (row.tagSources, row.resetSnapshot.tagSources)
&& row.metadataUrl === row.resetSnapshot.metadataUrl)
const update = <Key extends keyof Draft,> (
key: Key,
value: Draft[Key],
) => {
if (messageRow != null)
setMessageRow (null)
setDraft (current => ({ ...current, [key]: value }))
}
const reset = useCallback (async (): Promise<boolean> => {
if (resetDisabled)
return false
const confirmed = await controls.confirm ({
title: '変更をリセットしますか?',
description: '現在の URL に対する自動取得直後の内容へ戻します.',
confirmText: 'リセット',
cancelText: '取消',
variant: 'danger' })
if (!(confirmed))
return false
setDraft (resetDraft)
setResetRequested (true)
setMessageRow (null)
return false
}, [controls, resetDisabled, resetDraft])
const save = useCallback (async (): Promise<boolean> => {
setSaving (true)
try
{
const result = await onSave ({ draft, resetRequested })
if (result.saved)
return true
if (result.row != null)
setMessageRow (result.row)
return false
}
finally
{
setSaving (false)
}
}, [draft, onSave, resetRequested])
useEffect (() => {
controls.setActions ([{
label: '変更をリセット',
placement: 'start',
variant: 'danger',
disabled: resetDisabled,
onSelect: reset },
{
label: '編輯内容を保存',
disabled: saving,
onSelect: save }])
}, [controls, resetDisabled, reset, save, saving])
return (
<>
<div className="px-6 pb-6">
<div className="space-y-4">
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<div className="space-y-3 md:sticky md:top-0 md:self-start">
<PostImportThumbnailPreview
url={draft.thumbnailBase}
className="h-28 w-28"/>
</div>
<div className="space-y-4">
<PostTextField
label="URL"
value={draft.url}
disabled={saving}
warnings={displayRow.fieldWarnings.url}
errors={groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url)}
onChange={value => update ('url', value)}/>
<PostTextField
label="タイトル"
value={draft.title}
disabled={saving}
warnings={displayRow.fieldWarnings.title}
errors={groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title)}
onChange={value => update ('title', value)}/>
<PostTextField
label="サムネール基底 URL"
value={draft.thumbnailBase}
disabled={saving}
warnings={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField
disabled={saving}
originalCreatedFrom={draft.originalCreatedFrom || null}
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
originalCreatedBefore={draft.originalCreatedBefore || null}
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
errors={groupedMessages (
displayRow.validationErrors.originalCreatedAt,
displayRow.validationErrors.originalCreatedFrom,
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedAt,
displayRow.importErrors?.originalCreatedFrom,
displayRow.importErrors?.originalCreatedBefore)}/>
<PostDurationField
value={draft.duration}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.duration,
displayRow.validationErrors.videoMs,
displayRow.importErrors?.duration,
displayRow.importErrors?.videoMs)}
onChange={value => update ('duration', value)}/>
<PostTagsField
tags={draft.tags}
disabled={saving}
setTags={value => update ('tags', value)}
warnings={displayRow.fieldWarnings.tags}
errors={groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags)}
rows={4}/>
<PostTextField
label="親投稿"
value={draft.parentPostIds}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds)}
onChange={value => update ('parentPostIds', value)}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
</div>
</div>
</div>
</div>
</>)
}
export default PostImportRowForm
export { buildDraft }
export type { Draft as PostImportRowDraft }
-120
ファイルの表示
@@ -1,120 +0,0 @@
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 } from '@/lib/postImportSession'
import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
type Props = {
row: PostImportRow
onEdit: () => void
editDisabled?: boolean }
const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0]
?? row.baseWarnings?.[0]
?? null
const summaryDate = (row: PostImportRow): string =>
originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)
const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
return (
<>
<div
className={cn (
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
'transition-shadow hover:shadow-sm')}>
<div className="space-y-1">
<div className="text-sm font-medium">#{row.sourceRow}</div>
</div>
<PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')}
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>
<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)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
</div>
<div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
</div>
</div>
<div
className={cn (
'space-y-3 rounded-lg border p-4 md:hidden',
'transition-shadow hover:shadow-sm')}>
<div className="flex items-start gap-3">
<PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')}
className="h-20 w-20 shrink-0"/>
<div className="min-w-0 flex-1 space-y-2">
<div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
</div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url}
</div>
<div className="flex flex-wrap gap-2">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '') || 'タグなし'}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
</div>
</div>
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
</div>
</>)
}
export default PostImportRowSummary
-27
ファイルの表示
@@ -1,27 +0,0 @@
import StatusBadge from '@/components/common/StatusBadge'
import type { FC } from 'react'
import type { StatusBadgeTone } from '@/components/common/StatusBadge'
import type { PostImportBadgeValue } from '@/components/posts/import/postImportRowStatus'
type Props = {
value: PostImportBadgeValue }
const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能',
warning: '警告',
skipped: 'スキップ' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'success',
warning: 'warning',
skipped: 'neutral' }
const PostImportStatusBadge: FC<Props> = ({ value }) => (
<StatusBadge tone={TONES[value]}>
{LABELS[value]}
</StatusBadge>)
export default PostImportStatusBadge
-78
ファイルの表示
@@ -1,78 +0,0 @@
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 ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
})
it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => {
api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
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 ('does not render the unsafe URL directly when preview fetching fails', async () => {
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
render (
<PostImportThumbnailPreview
url="http://127.0.0.1/private.png"
className="h-10 w-10"/>)
expect (await screen.findByText ('サムネールを表示できません')).toBeInTheDocument ()
expect (screen.queryByRole ('img')).toBeNull ()
})
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' }))
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/second.jpg"
className="h-10 w-10"/>)
await waitFor (() => {
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second')
})
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
})
})
-89
ファイルの表示
@@ -1,89 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { apiGet } from '@/lib/api'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = {
url: string
alt?: string
className?: string }
const PostImportThumbnailPreview: FC<Props> = (
{ url,
alt = 'サムネール',
className = 'h-16 w-16' },
) => {
const [previewUrl, setPreviewUrl] = useState ('')
const [unavailable, setUnavailable] = useState (false)
const previewUrlRef = useRef ('')
useEffect (() => {
if (previewUrlRef.current)
{
URL.revokeObjectURL (previewUrlRef.current)
previewUrlRef.current = ''
}
setPreviewUrl ('')
setUnavailable (false)
if (!(url))
return
let active = true
const loadPreview = async () => {
try
{
const blob = await apiGet<Blob> ('/preview/thumbnail', {
params: { url },
responseType: 'blob' })
if (!(active))
return
const nextPreviewUrl = URL.createObjectURL (blob)
previewUrlRef.current = nextPreviewUrl
setPreviewUrl (nextPreviewUrl)
}
catch
{
if (active)
setUnavailable (true)
}
}
void loadPreview ()
return () => {
active = false
if (previewUrlRef.current)
{
URL.revokeObjectURL (previewUrlRef.current)
previewUrlRef.current = ''
}
}
}, [url])
if (unavailable)
{
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-amber-300 bg-amber-50 p-2 text-center text-xs
text-amber-700 dark:border-amber-900 dark:bg-amber-950
dark:text-amber-200`}>
</div>)
}
return (
<PostThumbnailPreview
url={previewUrl}
alt={alt}
className={className}/>)
}
export default PostImportThumbnailPreview
-27
ファイルの表示
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('displayPostImportStatus', () => {
it ('shows only ready, warning, and skipped states', () => {
expect (displayPostImportStatus (buildPostImportRow ())).toBe ('ready')
expect (displayPostImportStatus (buildPostImportRow ({
status: 'warning',
fieldWarnings: { title: ['warning'] } }))).toBe ('warning')
expect (displayPostImportStatus (buildPostImportRow ({
skipReason: 'existing',
existingPostId: 2 }))).toBe ('skipped')
})
it ('does not expose validation, failure, or created states as badges', () => {
expect (displayPostImportStatus (buildPostImportRow ({
status: 'error',
validationErrors: { title: ['invalid'] } }))).toBeNull ()
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'failed' }))).toBeNull ()
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'created',
createdPostId: 3 }))).toBeNull ()
})
})
-22
ファイルの表示
@@ -1,22 +0,0 @@
import type { PostImportRow } from '@/lib/postImportSession'
export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
export type PostImportBadgeValue = PostImportDisplayStatus
const hasWarnings = (row: PostImportRow): boolean =>
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|| row.baseWarnings.length > 0
export const displayPostImportStatus = (
row: PostImportRow,
): PostImportDisplayStatus | null =>
(row.skipReason === 'existing' || row.importStatus === 'skipped')
? 'skipped'
: ((Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed'
|| row.importStatus === 'created')
? null
: ((hasWarnings (row) || row.status === 'warning')
? 'warning'
: 'ready'))
-5
ファイルの表示
@@ -239,11 +239,6 @@ body
background: var(--top-nav-submenu-bg);
}
.top-nav-mobile-menu .top-nav-submenu
{
background: var(--top-nav-mobile-active-bg);
}
.top-nav-mobile-menu
{
background: var(--top-nav-mobile-menu-bg);
-22
ファイルの表示
@@ -1,22 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const indexCss = readFileSync (
resolve (process.cwd (), 'src/index.css'),
'utf8')
const compactIndexCss = indexCss.replace (/\s+/g, ' ')
const mobileActiveRule = '.top-nav-mobile-active {'
+ ' background: var(--top-nav-mobile-active-bg); }'
const mobileSubmenuRule = '.top-nav-mobile-menu .top-nav-submenu {'
+ ' background: var(--top-nav-mobile-active-bg); }'
describe ('TopNav mobile menu colours', () => {
it ('uses one background variable for active rows and expanded submenus', () => {
expect (compactIndexCss).toContain (mobileActiveRule)
expect (compactIndexCss).toContain (mobileSubmenuRule)
})
})
-72
ファイルの表示
@@ -1,72 +0,0 @@
import { createContext, useContext } from 'react'
import type { ReactNode } from 'react'
type DialogueVariant = 'default' | 'danger'
type ConfirmOptions = { title: string
description?: ReactNode
confirmText?: string
cancelText?: string
variant?: DialogueVariant }
type AlertOptions = { title: string
description?: ReactNode
okText?: string }
type Choice<T extends string> = { value: T
label: string
variant?: DialogueVariant }
type ChoiceOptions<T extends string> = { title: string
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }
type DialogueFormAction = {
label: string
placement?: 'start' | 'end'
variant?: DialogueVariant
disabled?: boolean
onSelect: () => Promise<boolean | void> | boolean | void }
type DialogueFormControls = {
close: () => void
setActions: (actions: DialogueFormAction[]) => void
confirm: (options: ConfirmOptions) => Promise<boolean> }
type DialogueFormOptions = { title: string
description?: ReactNode
body: (controls: DialogueFormControls) => ReactNode
cancelText?: string
size?: 'default' | 'large' }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
alert: (options: AlertOptions) => Promise<void>
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null>
form: (options: DialogueFormOptions) => Promise<void> }
const DialogueContext = createContext<DialogueAPI | null> (null)
const useDialogue = () => {
const dialogue = useContext (DialogueContext)
if (dialogue == null)
throw new Error ('useDialogue must be used inside DialogueProvider')
return dialogue
}
export { DialogueContext, useDialogue }
export default useDialogue
export type {
AlertOptions,
Choice,
ChoiceOptions,
ConfirmOptions,
DialogueAPI,
DialogueFormAction,
DialogueFormControls,
DialogueFormOptions,
DialogueVariant }
-288
ファイルの表示
@@ -1,288 +0,0 @@
import { describe, expect, it } from 'vitest'
import { creatableImportRows,
buildNextEditedRow,
canEditResultRow,
canEditReviewRow,
canRetryResultRow,
hasExactSourceRows,
initialisePreviewRows,
mergeImportResults,
mergeValidatedImportRow,
mergeValidatedImportRows,
processableImportRows,
replaceImportRow,
resultRepairMode,
resultRowMessages,
resultSummaryCounts,
retryImportRow,
reviewSummaryCounts } from '@/lib/postImportSession'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import row state', () => {
it ('separates processable existing rows from creatable rows', () => {
const ready = buildPostImportRow ({ sourceRow: 1 })
const existing = buildPostImportRow ({
sourceRow: 2,
skipReason: 'existing',
existingPostId: 20 })
const invalid = buildPostImportRow ({
sourceRow: 3,
status: 'error',
validationErrors: { url: ['invalid'] } })
const created = buildPostImportRow ({
sourceRow: 4,
importStatus: 'created',
createdPostId: 40 })
const rows = [ready, existing, invalid, created]
expect (processableImportRows (rows)).toEqual ([ready, existing])
expect (creatableImportRows (rows)).toEqual ([ready])
expect (reviewSummaryCounts (rows)).toEqual ({
total: 4,
submittable: 1,
skipPlanned: 1 })
})
it ('preserves terminal rows while merging validation results', () => {
const created = buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 10,
attributes: { title: 'created title' } })
const pending = buildPostImportRow ({
sourceRow: 2,
fieldWarnings: { title: ['old warning'] },
provenance: { title: 'manual' },
attributes: { title: 'manual title' } })
const validated = [
buildPostImportRow ({ sourceRow: 1, attributes: { title: 'changed' } }),
buildPostImportRow ({
sourceRow: 2,
fieldWarnings: { title: ['fetch warning'], tags: ['tag warning'] },
provenance: { title: 'manual' },
attributes: { title: 'manual title' } })]
const result = mergeValidatedImportRows ([created, pending], validated)
expect (result[0]).toBe (created)
expect (result[1]?.fieldWarnings).toEqual ({ tags: ['tag warning'] })
})
it ('updates the reset snapshot only after metadata URL changes', () => {
const current = buildPostImportRow ({
attributes: { title: 'manual title' },
metadataUrl: 'https://example.com/old' })
const validated = buildPostImportRow ({
attributes: { title: 'new metadata title' },
metadataUrl: 'https://example.com/new',
fieldWarnings: { title: ['warning'] },
baseWarnings: ['base warning'] })
const result = mergeValidatedImportRows ([current], [validated])[0]
expect (result?.resetSnapshot).toMatchObject ({
attributes: { title: 'new metadata title' },
fieldWarnings: { title: ['warning'] },
baseWarnings: ['base warning'],
metadataUrl: 'https://example.com/new' })
})
it ('merges result states and clears incompatible post identifiers', () => {
const created = mergeImportResults ([buildPostImportRow ({
skipReason: 'existing',
existingPostId: 2,
recoverable: true,
importStatus: 'pending' })], [{
sourceRow: 1,
status: 'created',
post: { id: 3 } }])[0]
const skipped = mergeImportResults ([buildPostImportRow ({
createdPostId: 3,
importStatus: 'created',
recoverable: true })], [{
sourceRow: 1,
status: 'skipped',
existingPostId: 4 }])[0]
const failed = mergeImportResults ([buildPostImportRow ({
skipReason: 'existing',
existingPostId: 4 })], [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { base: ['failure'] } }])[0]
expect (created).toMatchObject ({
importStatus: 'created',
createdPostId: 3,
existingPostId: undefined,
recoverable: undefined,
skipReason: undefined })
expect (skipped).toMatchObject ({
importStatus: 'skipped',
existingPostId: 4,
createdPostId: undefined,
recoverable: undefined,
skipReason: 'existing' })
expect (failed).toMatchObject ({
importStatus: 'failed',
recoverable: true,
createdPostId: undefined,
existingPostId: undefined,
skipReason: undefined,
importErrors: { base: ['failure'] } })
})
it ('retries only the selected failed row and counts results exclusively', () => {
const rows = [
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 1 }),
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
existingPostId: 2, skipReason: 'existing' }),
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
recoverable: true, importErrors: { base: ['failed'] } }),
buildPostImportRow ({ sourceRow: 4, importStatus: 'pending',
recoverable: true, validationErrors: { base: ['failed'] } })]
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 2 })
expect (retryImportRow (rows, 3)[2]).toMatchObject ({
importStatus: 'pending',
importErrors: undefined })
expect (retryImportRow (rows, 4)[3]).toBe (rows[3])
})
it ('deduplicates messages and keeps repair mode only for repairable rows', () => {
const repairable = buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
validationErrors: { title: ['invalid'], base: ['duplicate'] },
importErrors: { base: ['duplicate'], url: ['network'] } })
const complete = buildPostImportRow ({
sourceRow: 2,
importStatus: 'created',
createdPostId: 2 })
expect (resultRowMessages (repairable)).toEqual (['invalid', 'duplicate', 'network'])
expect (resultRepairMode ([repairable, complete])).toBe ('failed')
expect (resultRepairMode ([complete])).toBe ('all')
})
it ('classifies editable and retryable rows by terminal and recoverable state', () => {
const ready = buildPostImportRow ()
const skipped = buildPostImportRow ({
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 2 })
const hardFailed = buildPostImportRow ({
importStatus: 'failed',
importErrors: { base: ['failed'] } })
const pendingInvalid = buildPostImportRow ({
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } })
const pendingValid = buildPostImportRow ({
importStatus: 'pending',
recoverable: true })
expect (canEditReviewRow (ready)).toBe (true)
expect (canEditReviewRow (skipped)).toBe (false)
expect (canEditReviewRow (hardFailed)).toBe (false)
expect (canEditResultRow (pendingInvalid)).toBe (true)
expect (canRetryResultRow (pendingInvalid)).toBe (false)
expect (canRetryResultRow (pendingValid)).toBe (true)
})
it ('detects missing, duplicate, and extra source rows exactly', () => {
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 2 }])).toBe (true)
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }])).toBe (false)
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 1 }])).toBe (false)
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 3 }])).toBe (false)
})
it ('merges only the validated source row and preserves other row edits', () => {
const edited = buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited row' },
provenance: { title: 'manual' } })
const other = buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'keep me' },
provenance: { title: 'manual' } })
const validated = buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'validated row' } })
const result = mergeValidatedImportRow ([edited, other], validated)
expect (result[0]?.attributes.title).toBe ('validated row')
expect (result[1]?.attributes.title).toBe ('keep me')
})
it ('replaces only the targeted source row and preserves the others', () => {
const original = buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })
const other = buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'keep edited row' } })
const restored = buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })
const result = replaceImportRow ([original, other], restored)
expect (result[0]).toEqual (restored)
expect (result[1]?.attributes.title).toBe ('keep edited row')
})
it ('copies reset snapshot values instead of sharing mutable records', () => {
const row = buildPostImportRow ({ fieldWarnings: { title: ['warning'] } })
const initialised = initialisePreviewRows ([row])[0]
expect (initialised).toBeDefined ()
if (initialised == null)
return
initialised.attributes.title = 'changed'
initialised.fieldWarnings.title?.push ('another')
expect (initialised.resetSnapshot.attributes.title).toBe ('')
expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning'])
})
it ('builds the next edited row with shared repair semantics', () => {
const row = buildPostImportRow ({
url: 'https://example.com/original',
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] },
attributes: { title: 'old title', tags: 'old-tag', duration: '2' },
provenance: { title: 'automatic', tags: 'automatic', url: 'manual' },
tagSources: { automatic: 'old-tag', manual: '' } })
const nextRow = buildNextEditedRow (
row,
{
url: 'https://example.com/edited',
title: 'edited title',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '2.5',
tags: 'edited-tag',
parentPostIds: '' },
true)
expect (nextRow.importStatus).toBe ('pending')
expect (nextRow.importErrors).toBeUndefined ()
expect (nextRow.url).toBe ('https://example.com/edited')
expect (nextRow.attributes.title).toBe ('edited title')
expect (nextRow.attributes.duration).toBe ('2.5')
expect (nextRow.attributes.tags).toBe ('edited-tag')
expect (nextRow.provenance.title).toBe ('manual')
expect (nextRow.provenance.url).toBe ('manual')
expect (nextRow.tagSources?.manual).toBe ('edited-tag')
})
})
-312
ファイルの表示
@@ -1,312 +0,0 @@
import type { PostImportEditableDraft,
PostImportResultRow,
PostImportRow } from '@/lib/postImportTypes'
const hasSkipReason = (row: PostImportRow): boolean =>
row.skipReason === 'existing'
const hasValidationErrors = (row: PostImportRow): boolean =>
Object.keys (row.validationErrors ?? { }).length > 0
const isRecoverableRow = (row: PostImportRow): boolean =>
row.recoverable === true
const isRepairableImportStatus = (row: PostImportRow): boolean =>
isRecoverableRow (row)
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
const buildResetSnapshot = (row: PostImportRow) => ({
url: row.url,
attributes: { ...row.attributes },
provenance: { ...row.provenance },
tagSources: {
automatic: row.tagSources?.automatic ?? '',
manual: row.tagSources?.manual ?? '' },
fieldWarnings: Object.fromEntries (
Object.entries (row.fieldWarnings).map (([key, values]) => [key, [...values]])),
baseWarnings: [...row.baseWarnings],
metadataUrl: row.metadataUrl })
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.filter (row => {
if (row.importStatus === 'created')
return false
if (row.importStatus === 'skipped')
return false
if (row.importStatus === 'failed')
return false
if (hasValidationErrors (row))
return false
return row.importStatus == null || row.importStatus === 'pending'
})
export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
processableImportRows (rows).filter (row => !(hasSkipReason (row)))
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
total: rows.length,
submittable: rows.filter (row =>
creatableImportRows ([row]).length > 0
&& !(hasValidationErrors (row))).length,
skipPlanned: rows.filter (row =>
processableImportRows ([row]).length > 0
&& hasSkipReason (row)).length })
export const resultSummaryCounts = (rows: PostImportRow[]) =>
rows.reduce (
(counts, row) => {
if (row.importStatus === 'created')
{
++counts.created
return counts
}
if (row.importStatus === 'skipped')
{
++counts.skipped
return counts
}
if (row.importStatus === 'failed'
|| (row.recoverable === true && row.importStatus === 'pending'))
++counts.failed
return counts
},
{ created: 0, skipped: 0, failed: 0 })
export const resultRepairMode = (
rows: PostImportRow[],
): 'all' | 'failed' =>
rows.some (row => hasValidationErrors (row) || isRepairableImportStatus (row))
? 'failed'
: 'all'
export const canEditReviewRow = (row: PostImportRow): boolean =>
!(row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| (row.importStatus === 'failed' && row.recoverable !== true))
export const canEditResultRow = (row: PostImportRow): boolean =>
row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && hasValidationErrors (row)))
export const canRetryResultRow = (row: PostImportRow): boolean =>
row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && !(hasValidationErrors (row))))
export const resultRowMessages = (row: PostImportRow): string[] =>
[...new Set ([
...Object.values (row.validationErrors ?? { }).flat (),
...Object.values (row.importErrors ?? { }).flat ()])]
export const resultRowWarnings = (row: PostImportRow): string[] =>
[...new Set ([
...Object.values (row.fieldWarnings ?? { }).flat (),
...row.baseWarnings])]
export const buildNextEditedRow = (
editingRow: PostImportRow,
draft: PostImportEditableDraft,
urlChanged: boolean,
): PostImportRow => {
const nextProvenance = { ...editingRow.provenance }
const nextAttributes = { ...editingRow.attributes }
const nextTagSources = {
automatic: editingRow.tagSources?.automatic ?? '',
manual: editingRow.tagSources?.manual ?? '' }
const draftFields = [
['title', draft.title],
['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => {
nextAttributes[field] = value
nextProvenance[field] =
value !== String (editingRow.attributes[field] ?? '')
? 'manual'
: (editingRow.provenance[field] ?? 'automatic')
})
nextAttributes.tags = draft.tags
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
{
nextProvenance.tags = 'manual'
nextTagSources.manual = draft.tags
}
else
{
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
}
return {
...editingRow,
url: draft.url,
attributes: nextAttributes,
provenance: {
...nextProvenance,
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
tagSources: nextTagSources,
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
importErrors: undefined }
}
export const hasExactSourceRows = (
expected: number[],
actual: Array<{ sourceRow: number }>,
): boolean => {
if (expected.length !== actual.length)
return false
const expectedSorted = [...expected].sort ((a, b) => a - b)
const actualSorted = actual.map (row => row.sourceRow).sort ((a, b) => a - b)
return expectedSorted.every ((value, index) => value === actualSorted[index])
}
export const replaceImportRow = (
rows: PostImportRow[],
nextRow: PostImportRow,
): PostImportRow[] =>
rows.map (row => row.sourceRow === nextRow.sourceRow ? nextRow : row)
export const mergeValidatedImportRow = (
rows: PostImportRow[],
validated: PostImportRow,
): PostImportRow[] => {
const current = rows.find (row => row.sourceRow === validated.sourceRow)
if (current == null)
return rows
const [merged] = mergeValidatedImportRows ([current], [validated])
return merged == null ? rows : replaceImportRow (rows, merged)
}
export const mergeValidatedImportRows = (
current: PostImportRow[],
validated: PostImportRow[],
): PostImportRow[] => {
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
return current.map (previous => {
if (
previous.importStatus === 'created'
|| previous.importStatus === 'skipped'
|| previous.importStatus === 'failed')
return previous
const row = validatedMap.get (previous.sourceRow)
if (row == null)
return previous
const fieldWarnings =
Object.keys (row.fieldWarnings).length > 0 || row.metadataUrl !== previous.metadataUrl
? { ...row.fieldWarnings }
: { ...previous.fieldWarnings }
for (const [field, origin] of Object.entries (row.provenance))
{
if (origin === 'manual')
delete fieldWarnings[field]
}
return {
...previous,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
fieldWarnings,
baseWarnings:
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
? row.baseWarnings
: previous.baseWarnings,
validationErrors: row.validationErrors,
status: row.status,
metadataUrl: row.metadataUrl,
resetSnapshot:
row.metadataUrl !== previous.metadataUrl
? buildResetSnapshot (row)
: previous.resetSnapshot }
})
}
export const mergeImportResults = (
rows: PostImportRow[],
results: PostImportResultRow[],
): PostImportRow[] => {
const resultMap = new Map (results.map (row => [row.sourceRow, row]))
return rows.map (row => {
const result = resultMap.get (row.sourceRow)
if (result == null)
return row
switch (result.status)
{
case 'created':
return {
...row,
importStatus: 'created',
recoverable: undefined,
skipReason: undefined,
createdPostId: result.post.id,
existingPostId: undefined,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors }
case 'skipped':
return {
...row,
importStatus: 'skipped',
recoverable: undefined,
skipReason: 'existing',
createdPostId: undefined,
existingPostId: result.existingPostId,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors }
case 'failed':
return {
...row,
importStatus: 'failed',
recoverable: result.recoverable === true ? true : undefined,
skipReason: undefined,
createdPostId: undefined,
existingPostId: undefined,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors }
}
})
}
export const retryImportRow = (
rows: PostImportRow[],
sourceRow: number,
): PostImportRow[] =>
rows.map (row =>
row.sourceRow === sourceRow
&& row.importStatus === 'failed'
&& row.recoverable === true
? { ...row, importStatus: 'pending', importErrors: undefined }
: row)
export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.map (row => ({
...row,
resetSnapshot: buildResetSnapshot (row) }))
-4
ファイルの表示
@@ -1,4 +0,0 @@
export * from '@/lib/postImportTypes'
export * from '@/lib/postImportStorage'
export * from '@/lib/postImportSourceValidation'
export * from '@/lib/postImportRows'
-49
ファイルの表示
@@ -1,49 +0,0 @@
import { describe, expect, it } from 'vitest'
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession'
describe ('post import source validation', () => {
it ('counts trimmed non-empty CRLF and LF rows', () => {
expect (countImportSourceLines (' one \r\n\r\n two\n')).toBe (2)
})
it ('reports invalid protocols and malformed URLs with original line numbers', () => {
const issues = validateImportSource (
'\nftp://example.com/file\nhttps://exa mple.com/path')
expect (issues).toEqual ([
{
sourceRow: 2,
message: 'HTTP または HTTPS の URL ではありません.',
url: 'ftp://example.com/file' },
{
sourceRow: 3,
message: 'URL の形式が不正です.',
url: 'https://exa mple.com/path' }])
})
it ('detects duplicates after frontend URL normalisation', () => {
const issues = validateImportSource (
'https://EXAMPLE.com/path/\nhttps://example.com/path')
expect (issues).toEqual ([{
sourceRow: 2,
message: '1 行目と同じ URL です.',
url: 'https://example.com/path' }])
})
it ('rejects oversized URLs and rows beyond the maximum count', () => {
const oversized = `https://example.com/${ 'a'.repeat (20 * 1024) }`
const tooMany = Array.from (
{ length: 101 },
(_, index) => `https://example.com/${ index }`).join ('\n')
expect (validateImportSource (oversized)[0]).toMatchObject ({
sourceRow: 1,
message: 'URL が長すぎます.' })
expect (validateImportSource (tooMany).at (-1)).toEqual ({
sourceRow: 101,
message: '取込件数は 100 件までです.',
url: 'https://example.com/100' })
})
})
-110
ファイルの表示
@@ -1,110 +0,0 @@
import type { PostImportSourceIssue } from '@/lib/postImportTypes'
const MAX_ROWS = 100
const MAX_URL_BYTES = 20 * 1024
const truncateUrl = (value: string): string =>
value.length > 120 ? `${ value.slice (0, 117) }` : value
const bytesize = (value: string): number =>
new TextEncoder ().encode (value).length
const normaliseImportUrl = (value: string): string | null => {
const trimmed = value.trim ()
if (!(trimmed))
return null
try
{
const url = new URL (trimmed)
if (!(url.protocol === 'http:' || url.protocol === 'https:'))
return null
if (!(url.host))
return null
url.hostname = url.hostname.toLowerCase ()
if (url.pathname.endsWith ('/'))
url.pathname = url.pathname.replace (/\/+$/, '')
return url.toString ()
}
catch
{
return null
}
}
export const countImportSourceLines = (source: string): number =>
source
.split (/\r\n|\n|\r/)
.map (line => line.trim ())
.filter (line => line !== '')
.length
export const validateImportSource = (
source: string,
): PostImportSourceIssue[] => {
const lines = source.split (/\r\n|\n|\r/)
const issues: PostImportSourceIssue[] = []
const seen = new Map<string, number> ()
let count = 0
lines.forEach ((rawLine, index) => {
const value = rawLine.trim ()
if (!(value))
return
++count
const sourceRow = index + 1
const displayUrl = truncateUrl (value)
const normalised = normaliseImportUrl (value)
if (count > MAX_ROWS)
{
issues.push ({
sourceRow,
message: `取込件数は ${ MAX_ROWS } 件までです.`,
url: displayUrl })
return
}
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
{
issues.push ({
sourceRow,
message: 'HTTP または HTTPS の URL ではありません.',
url: displayUrl })
return
}
if (bytesize (value) > MAX_URL_BYTES)
{
issues.push ({
sourceRow,
message: 'URL が長すぎます.',
url: displayUrl })
return
}
if (normalised == null)
{
issues.push ({
sourceRow,
message: 'URL の形式が不正です.',
url: displayUrl })
return
}
const duplicateRow = seen.get (normalised)
if (duplicateRow != null)
{
issues.push ({
sourceRow,
message: `${ duplicateRow } 行目と同じ URL です.`,
url: displayUrl })
return
}
seen.set (normalised, sourceRow)
})
return issues
}
-126
ファイルの表示
@@ -1,126 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanupExpiredPostImportSessions,
clearPostImportSourceDraft,
loadPostImportSession,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft } from '@/lib/postImportSession'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import storage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.useRealTimers ()
})
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 })
expect (savePostImportSession ('session', {
source: skipped.url,
rows: [row, skipped],
repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ('session')).toMatchObject ({
version: 2,
source: skipped.url,
rows: [{
attributes: { duration: '2.5' },
recoverable: true,
importStatus: 'pending' },
{
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 }] })
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
clearPostImportSourceDraft ()
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
})
it ('rejects inconsistent post IDs and terminal statuses', () => {
const session = {
version: 2,
savedAt: new Date ().toISOString (),
source: '',
repairMode: 'all',
rows: [buildPostImportRow ()] }
const invalidRows = [
{ ...session.rows[0], skipReason: 'existing', existingPostId: undefined },
{ ...session.rows[0], existingPostId: 2, skipReason: undefined },
{ ...session.rows[0], importStatus: 'created', createdPostId: undefined },
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 },
{ ...session.rows[0], importStatus: 'created', recoverable: true },
{ ...session.rows[0], importStatus: 'skipped', recoverable: true },
{ ...session.rows[0], recoverable: true }]
for (const [index, row] of invalidRows.entries ())
{
sessionStorage.setItem (`post-import-session:invalid-${ index }`, JSON.stringify ({
...session,
rows: [row] }))
expect (loadPostImportSession (`invalid-${ index }`)).toBeNull ()
}
})
it ('rejects invalid attribute, provenance, tag-source, and snapshot data', () => {
const row = buildPostImportRow ()
const invalidRows = [
{ ...row, attributes: { title: [] } },
{ ...row, attributes: { unknown: 'value' } },
{ ...row, provenance: { title: 'mapped' } },
{ ...row, tagSources: { mapped: 'tag' } },
{ ...row, resetSnapshot: { ...row.resetSnapshot,
fieldWarnings: { title: 'warning' } } }]
invalidRows.forEach ((invalidRow, index) => {
sessionStorage.setItem (`post-import-session:shape-${ index }`, JSON.stringify ({
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [invalidRow],
repairMode: 'all' }))
expect (loadPostImportSession (`shape-${ index }`)).toBeNull ()
})
})
it ('removes expired and malformed sessions without touching current sessions', () => {
const current = {
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [buildPostImportRow ()],
repairMode: 'all' }
const expired = {
...current,
savedAt: new Date (Date.now () - 25 * 60 * 60 * 1000).toISOString () }
sessionStorage.setItem ('post-import-session:current', JSON.stringify (current))
sessionStorage.setItem ('post-import-session:expired', JSON.stringify (expired))
sessionStorage.setItem ('post-import-session:malformed', '{')
cleanupExpiredPostImportSessions ()
expect (sessionStorage.getItem ('post-import-session:current')).not.toBeNull ()
expect (sessionStorage.getItem ('post-import-session:expired')).toBeNull ()
expect (sessionStorage.getItem ('post-import-session:malformed')).toBeNull ()
})
it ('reports storage access failures without throwing', () => {
const onError = vi.fn ()
vi.spyOn (Storage.prototype, 'setItem').mockImplementation (() => {
throw new DOMException ('quota')
})
expect (savePostImportSourceDraft ('source', onError)).toBe (false)
expect (onError).toHaveBeenCalledWith ('ブラウザへ保存できませんでした.')
})
})
-418
ファイルの表示
@@ -1,418 +0,0 @@
import type { PostImportOrigin,
PostImportResetSnapshot,
PostImportRow,
PostImportSession,
PostImportStatus,
PostImportSkipReason,
StorageErrorHandler } from '@/lib/postImportTypes'
const SESSION_VERSION = 2
const SESSION_PREFIX = 'post-import-session:'
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const ATTRIBUTE_KEYS = [
'title',
'thumbnailBase',
'originalCreatedFrom',
'originalCreatedBefore',
'duration',
'videoMs',
'tags',
'parentPostIds'] as const
const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const TAG_SOURCE_KEYS = ['automatic', 'manual'] as const
const WARNING_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
const readStorage = (
key: string,
onError?: StorageErrorHandler,
): string | null => {
if (typeof window === 'undefined')
return null
try
{
return sessionStorage.getItem (key)
}
catch
{
onError?.('保存済みデータを読み込めませんでした.')
return null
}
}
const writeStorage = (
key: string,
value: string,
onError?: StorageErrorHandler,
): boolean => {
if (typeof window === 'undefined')
return false
try
{
sessionStorage.setItem (key, value)
return true
}
catch
{
onError?.('ブラウザへ保存できませんでした.')
return false
}
}
const removeStorage = (
key: string,
onError?: StorageErrorHandler,
) => {
if (typeof window === 'undefined')
return
try
{
sessionStorage.removeItem (key)
}
catch
{
onError?.('保存済みデータを削除できませんでした.')
}
}
const ensureStringListRecord = (value: unknown): Record<string, string[]> | null => {
if (!(isPlainObject (value)))
return null
const result: Record<string, string[]> = { }
for (const [key, entry] of Object.entries (value))
{
if (!(Array.isArray (entry)) || !(entry.every (item => typeof item === 'string')))
return null
result[key] = entry
}
return result
}
const isValidStatus = (
value: unknown,
): value is PostImportRow['status'] =>
value === 'ready' || value === 'warning' || value === 'error'
const isValidImportStatus = (
value: unknown,
): value is PostImportStatus =>
value === 'pending'
|| value === 'created'
|| value === 'skipped'
|| value === 'failed'
const isValidOrigin = (
value: unknown,
): value is PostImportOrigin =>
value === 'automatic' || value === 'manual'
const isValidSkipReason = (
value: unknown,
): value is PostImportSkipReason =>
value === 'existing'
const isPositiveInteger = (value: unknown): value is number =>
Number.isInteger (value) && Number (value) > 0
const hasOnlyKeys = (
value: Record<string, unknown>,
allowedKeys: readonly string[],
): boolean =>
Object.keys (value).every (key => allowedKeys.includes (key))
const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => {
if (!(isPlainObject (value)))
return null
if (typeof value.url !== 'string')
return null
if (!(isPlainObject (value.attributes)))
return null
if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
return null
if (!(Object.values (value.attributes).every (entry =>
typeof entry === 'string' || typeof entry === 'number')))
return null
if (!(isPlainObject (value.provenance)))
return null
if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
return null
if (!(Object.values (value.provenance).every (origin => isValidOrigin (origin))))
return null
if (!(isPlainObject (value.tagSources)))
return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null
if (!(Object.values (value.tagSources).every (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 sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(isPlainObject (value)))
return null
if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
return null
if (typeof value.url !== 'string')
return null
if (!(isPlainObject (value.attributes)))
return null
if (!(isPlainObject (value.provenance)))
return null
if (!(isValidStatus (value.status)))
return null
if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
return null
if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
return null
if (value.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,
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 isExpiredSession = (savedAt: string): boolean => {
const value = Date.parse (savedAt)
return Number.isNaN (value) || Date.now () - value > SESSION_MAX_AGE_MS
}
export const createPostImportSessionId = (): string =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID ()
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
export const cleanupExpiredPostImportSessions = (
onError?: StorageErrorHandler,
) => {
if (typeof window === 'undefined')
return
try
{
for (let i = 0; i < sessionStorage.length; ++i)
{
const key = sessionStorage.key (i)
if (key == null || !(key.startsWith (SESSION_PREFIX)))
continue
const raw = sessionStorage.getItem (key)
if (raw == null)
continue
try
{
const value = JSON.parse (raw) as { savedAt?: string }
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{
sessionStorage.removeItem (key)
--i
}
}
catch
{
sessionStorage.removeItem (key)
--i
}
}
}
catch
{
onError?.('保存済みデータを整理できませんでした.')
}
}
export const loadPostImportSourceDraft = (
onError?: StorageErrorHandler,
): { source: string } => {
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: '' }
}
}
export const savePostImportSourceDraft = (
source: string,
onError?: StorageErrorHandler,
): boolean =>
writeStorage (SOURCE_DRAFT_KEY, JSON.stringify ({ source }), onError)
export const clearPostImportSourceDraft = (
onError?: StorageErrorHandler,
) => {
removeStorage (SOURCE_DRAFT_KEY, onError)
}
export const savePostImportSession = (
sessionId: string,
session: Omit<PostImportSession, 'version' | 'savedAt'>,
onError?: StorageErrorHandler,
): boolean =>
writeStorage (
sessionKey (sessionId),
JSON.stringify ({
...session,
version: SESSION_VERSION,
savedAt: new Date ().toISOString () }),
onError)
export const loadPostImportSession = (
sessionId: string,
onError?: StorageErrorHandler,
): PostImportSession | null => {
const raw = readStorage (sessionKey (sessionId), onError)
if (raw == null)
return null
try
{
const value = JSON.parse (raw) as Partial<PostImportSession>
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
return null
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{
removeStorage (sessionKey (sessionId), onError)
return null
}
const rows = value.rows.map (sanitiseRow)
if (rows.some (row => row == null))
return null
return {
version: SESSION_VERSION,
savedAt: value.savedAt,
source: typeof value.source === 'string' ? value.source : '',
rows: rows as PostImportRow[],
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
}
catch
{
return null
}
}
-85
ファイルの表示
@@ -1,85 +0,0 @@
export type PostImportOrigin = 'automatic' | 'manual'
export type PostImportRepairMode = 'all' | 'failed'
export type PostImportStatus =
'pending'
| 'created'
| 'skipped'
| 'failed'
export type PostImportSkipReason = 'existing'
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
export type PostImportAttributeValue = string | number
export type PostImportResetSnapshot = {
url: string
attributes: Record<string, PostImportAttributeValue>
provenance: Record<string, PostImportOrigin>
tagSources: Record<PostImportOrigin, string>
fieldWarnings: Record<string, string[]>
baseWarnings: string[]
metadataUrl?: string }
export type PostImportRow = {
sourceRow: number
url: string
attributes: Record<string, PostImportAttributeValue>
fieldWarnings: Record<string, string[]>
baseWarnings: string[]
validationErrors: Record<string, string[]>
importErrors?: Record<string, string[]>
provenance: Record<string, PostImportOrigin>
tagSources?: Record<PostImportOrigin, string>
status: 'ready' | 'warning' | 'error'
skipReason?: PostImportSkipReason
existingPostId?: number
metadataUrl?: string
resetSnapshot: PostImportResetSnapshot
createdPostId?: number
importStatus?: PostImportStatus
recoverable?: boolean }
export type PostImportResultRow =
| {
sourceRow: number
status: 'created'
post: { id: number }
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]> }
| {
sourceRow: number
status: 'skipped'
existingPostId: number
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]> }
| {
sourceRow: number
status: 'failed'
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]>
recoverable?: boolean }
export type PostImportSession = {
version: number
savedAt: string
source: string
rows: PostImportRow[]
repairMode: PostImportRepairMode }
export type PostImportSourceIssue = {
sourceRow: number
message: string
url: string }
export type PostImportEditableDraft = {
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
export type StorageErrorHandler = (message: string) => void
+1 -5
ファイルの表示
@@ -89,12 +89,8 @@ describe ('settings', () => {
})
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
const tokens = buildThemeTokens ('light', { })
expect (tokens.topNavMobileActiveBackground).toBe (
expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe (
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
expect (tokens.topNavMobileActiveBackground).not.toBe (
tokens.topNavRootBackgroundMobile)
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
DARK_THEME_TOKENS.topNavActiveBackground)
})
-402
ファイルの表示
@@ -1,402 +0,0 @@
import { act, 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 PostImportResultPage from '@/pages/posts/PostImportResultPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { originalCreatedAtString } from '@/lib/utils'
import type { PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
form: vi.fn (() => Promise.resolve ()),
}))
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
describe ('PostImportResultPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
})
it ('shows edit and retry only for recoverable failures and shows warnings', () => {
savePostImportSession ('result-session', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'recoverable row' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['recoverable failed'] } }),
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'hard failed row' },
importStatus: 'failed',
importErrors: { base: ['hard failed'] } }),
buildPostImportRow ({
sourceRow: 3,
attributes: {
title: 'created row',
originalCreatedFrom: '2024-01-01T00:00:00Z',
originalCreatedBefore: '2024-01-02T00:00:00Z' },
importStatus: 'created',
createdPostId: 3,
fieldWarnings: {
thumbnailBase: ['サムネール画像を取得できませんでした.'] } })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-session/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
.toBeInTheDocument ()
expect (screen.getByText (
originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'),
)).toBeInTheDocument ()
})
it ('shows hour-precision original-created ranges with the shared utility output', () => {
const from = '2024-01-01T12:00:00Z'
const before = '2024-01-01T13:00:00Z'
savePostImportSession ('result-hour-range', {
source: '',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1,
attributes: {
title: 'created row',
originalCreatedFrom: from,
originalCreatedBefore: before } })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-hour-range/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument ()
})
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
savePostImportSession ('result-pending', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'pending',
recoverable: true }),
buildPostImportRow ({
sourceRow: 3,
importStatus: 'failed',
importErrors: { base: ['hard failed'] } })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-pending/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('invalid')).toBeInTheDocument ()
expect (screen.getByText ('hard failed')).toBeInTheDocument ()
})
it ('disables editing, retry, and navigation while retry is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ('result-retry-busy', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-busy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getAllByRole ('button', { name: '再試行' })[0])
await waitFor (() => {
screen.getAllByRole ('button', { name: '編輯' }).forEach (button => {
expect (button).toBeDisabled ()
})
screen.getAllByRole ('button', { name: '再試行' }).forEach (button => {
expect (button).toBeDisabled ()
})
expect (screen.getByRole ('button', { name: '確認画面へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '新しい URL リストを入力' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('persists retry success to sessionStorage', async () => {
savePostImportSession ('result-retry-success', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 10 } }] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-success/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (loadPostImportSession ('result-retry-success')?.rows[0]?.importStatus)
.toBe ('created')
})
})
it ('keeps edited recoverable rows in session and retries with the edited values', async () => {
savePostImportSession ('result-edit-retry', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'old title' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 11 } }] })
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 act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
})
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-edit-retry/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-edit-retry')
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[2]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title')
})
})
it ('persists restored failed state when retry fails', async () => {
savePostImportSession ('result-retry-failure', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost.mockRejectedValueOnce (new Error ('network error'))
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-failure/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-failure')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (saved?.rows[0]?.recoverable).toBe (true)
})
})
it ('restores the failed row when the retry import response is incomplete', async () => {
savePostImportSession ('result-retry-missing-row', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-missing-row/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-missing-row')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
})
})
-544
ファイルの表示
@@ -1,544 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate, useParams } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import PageTitle from '@/components/common/PageTitle'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft,
buildNextEditedRow,
canEditResultRow,
canRetryResultRow,
hasExactSourceRows,
initialisePreviewRows,
loadPostImportSession,
mergeImportResults,
mergeValidatedImportRow,
replaceImportRow,
resultRepairMode,
resultRowMessages,
resultRowWarnings,
resultSummaryCounts,
retryImportRow,
savePostImportSession } from '@/lib/postImportSession'
import { originalCreatedAtString } from '@/lib/utils'
import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession'
import type { PostImportSession } from '@/lib/postImportSession'
import type { User } from '@/types'
type Props = { user: User | null }
const PostImportResultPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const dialogue = useDialogue ()
const navigate = useNavigate ()
const { sessionId } = useParams ()
const [session, setSession] = useState<PostImportSession | null> (null)
const [missing, setMissing] = useState (false)
const [loadingRow, setLoadingRow] = useState<number | null> (null)
const [, setEditingRow] = useState<PostImportRow | null> (null)
const sessionRef = useRef<PostImportSession | null> (null)
useEffect (() => {
if (sessionId == null)
return
const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
useEffect (() => {
if (sessionId == null || session == null)
return
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
useEffect (() => {
sessionRef.current = session
}, [session])
const counts = useMemo (
() => resultSummaryCounts (session?.rows ?? []),
[session])
const busy = loadingRow != null
const persistSession = (nextSession: PostImportSession) => {
if (sessionId == null)
return
savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}
const saveDraft = async (
row: PostImportRow,
{ draft, resetRequested }: {
draft: PostImportRowDraft
resetRequested: boolean },
): Promise<{ saved: boolean
row: PostImportRow | null }> => {
const currentSession = sessionRef.current
if (currentSession == null)
return { saved: false, row: null }
if (!(canEditResultRow (row)))
return { saved: false, row: null }
const baseRow =
resetRequested
? { ...row,
url: row.resetSnapshot.url,
attributes: { ...row.resetSnapshot.attributes },
provenance: { ...row.resetSnapshot.provenance },
tagSources: { ...row.resetSnapshot.tagSources },
fieldWarnings: Object.fromEntries (
Object.entries (row.resetSnapshot.fieldWarnings)
.map (([key, values]) => [key, [...values]])),
baseWarnings: [...row.resetSnapshot.baseWarnings],
metadataUrl: row.resetSnapshot.metadataUrl }
: row
const urlChanged = draft.url !== baseRow.url
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
const nextRows = currentSession.rows.map (row =>
row.sourceRow === baseRow.sourceRow
? nextRow
: row)
try
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows:
nextRows
.filter (row => row.importStatus !== 'created')
.map (row => ({ sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find (
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
const latestSession = sessionRef.current
if (latestSession == null)
return { saved: false, row: null }
if (target == null)
{
const restoredRows = replaceImportRow (latestSession.rows, row)
const restoredSession = {
...latestSession,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
toast ({ title: '行の再検証結果が不完全でした' })
return { saved: false, row: null }
}
const editedRows = replaceImportRow (latestSession.rows, nextRow)
const rows = mergeValidatedImportRow (editedRows, target)
const nextSession = {
...latestSession,
rows,
repairMode: resultRepairMode (rows) }
sessionRef.current = nextSession
setSession (nextSession)
persistSession (nextSession)
const mergedTarget =
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
?? target
if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget }
return { saved: true, row: null }
}
catch
{
toast ({ title: '行の再検証に失敗しました' })
return { saved: false, row: null }
}
}
const openEditingDialogue = async (row: PostImportRow) => {
const saveRowDraft = (
{ draft, resetRequested }: {
draft: PostImportRowDraft
resetRequested: boolean },
) =>
saveDraft (row, { draft, resetRequested })
await dialogue.form ({
title: '投稿を編輯',
description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
cancelText: '取消',
size: 'large',
body: controls => (
<PostImportRowForm
row={row}
controls={controls}
onSave={saveRowDraft}/>) })
}
const editRow = async (row: PostImportRow) => {
setEditingRow (row)
try
{
await openEditingDialogue (row)
}
finally
{
setEditingRow (current =>
current?.sourceRow === row.sourceRow
? null
: current)
}
}
const retry = async (sourceRow: number) => {
if (session == null || sessionId == null || loadingRow != null)
return
const initialSession = sessionRef.current
if (initialSession == null)
return
setLoadingRow (sourceRow)
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
if (originalRow == null)
{
setLoadingRow (null)
return
}
try
{
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
const pendingSession = { ...initialSession, rows: pendingRows }
sessionRef.current = pendingSession
setSession (pendingSession)
persistSession (pendingSession)
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: requestedRows.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
const validatedTarget = validatedRows.find (
row => row.sourceRow === sourceRow)
if (validatedTarget == null)
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
const latestAfterValidate = sessionRef.current ?? pendingSession
const mergedValidatedRows = mergeValidatedImportRow (
latestAfterValidate.rows,
validatedTarget)
const nextSession = {
...latestAfterValidate,
rows: mergedValidatedRows,
repairMode: resultRepairMode (mergedValidatedRows) }
sessionRef.current = nextSession
setSession (nextSession)
persistSession (nextSession)
const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
if (target == null)
{
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
const restoredSession = {
...latestAfterValidate,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
void editRow (target)
return
}
const result = await apiPost<{
created: number
skipped: number
failed: number
rows: PostImportResultRow[] }> ('/posts/import', {
rows: [{
sourceRow: target.sourceRow,
url: target.url,
attributes: target.attributes,
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] })
if (!(hasExactSourceRows ([sourceRow], result.rows)))
{
const latest = sessionRef.current ?? nextSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '登録結果が不完全でした' })
return
}
const latestAfterImport = sessionRef.current ?? nextSession
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (
recoverableRow => recoverableRow.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
recoverable: true,
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const recoverableTarget = nextRows.find (row => row.sourceRow === sourceRow)
const resultSession = {
...nextSession,
rows: nextRows,
repairMode: resultRepairMode (nextRows) }
sessionRef.current = resultSession
setSession (resultSession)
persistSession (resultSession)
if (recoverableTarget != null
&& Object.keys (recoverableTarget.validationErrors).length > 0)
{
const saved = savePostImportSession (sessionId, resultSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
void editRow (recoverableTarget)
return
}
}
catch
{
const latest = sessionRef.current
if (latest != null)
{
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
}
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
}
}
const openRepair = (sourceRow: number) => {
const currentSession = sessionRef.current
if (currentSession == null || sessionId == null || loadingRow != null)
return
const nextSession = { ...currentSession, repairMode: 'failed' as const }
sessionRef.current = nextSession
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
const row = nextSession.rows.find (rowValue => rowValue.sourceRow === sourceRow)
if (row != null)
void editRow (row)
}
if (!(editable))
return <Forbidden/>
if (missing || sessionId == null || session == null)
{
return (
<MainArea>
<div className="mx-auto max-w-4xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<FieldError messages={['取込状態が見つかりません.']}/>
<Button type="button" onClick={() => navigate ('/posts/import')}>
URL
</Button>
</div>
</MainArea>)
}
return (
<MainArea>
<Helmet>
<title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="mx-auto max-w-5xl space-y-4 p-4">
<PageTitle></PageTitle>
<div className="text-sm text-neutral-700 dark:text-neutral-200">
{counts.created}  {counts.skipped}  {counts.failed}
</div>
<div className="space-y-3">
{session.rows.map (row => {
const displayStatus = displayPostImportStatus (row)
const canEdit = canEditResultRow (row)
const canRetry = canRetryResultRow (row)
return (
<div
key={row.sourceRow}
className="rounded-lg border p-4 transition-shadow hover:shadow-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-start
md:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium"> {row.sourceRow}</span>
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-200">
{String (row.attributes.title ?? '') || row.url}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url}
</div>
<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">
{originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
<FieldWarning messages={resultRowWarnings (row)}/>
<FieldError messages={resultRowMessages (row)}/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{(row.createdPostId != null || row.existingPostId != null) && (
<Button type="button" variant="outline" asChild>
<PrefetchLink
to={`/posts/${ row.createdPostId ?? row.existingPostId }`}>
稿
</PrefetchLink>
</Button>)}
{canEdit && (
<Button
type="button"
variant="outline"
onClick={() => openRepair (row.sourceRow)}
disabled={busy}>
</Button>)}
{canRetry && (
<Button
type="button"
onClick={() => retry (row.sourceRow)}
disabled={busy}>
</Button>)}
</div>
</div>
</div>)})}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => {
const currentSession = sessionRef.current
if (currentSession == null)
return
const nextSession = { ...currentSession, repairMode: 'all' as const }
sessionRef.current = nextSession
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
}}>
</Button>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => {
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
navigate ('/posts/import')
}}>
URL
</Button>
</div>
</div>
</MainArea>)
}
export default PostImportResultPage
-295
ファイルの表示
@@ -1,295 +0,0 @@
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 { buildPostImportRow } from '@/test/postImportFactories'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportRow } from '@/lib/postImportSession'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
form: vi.fn (() => Promise.resolve ()),
}))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
describe ('PostImportReviewPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
})
it ('navigates to the result route after an initial recoverable failure', async () => {
savePostImportSession ('review-session', {
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'] } }] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-session/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByText ('RESULT ROUTE')).toBeInTheDocument ()
})
expect (dialogue.form).not.toHaveBeenCalled ()
})
it ('disables row editing and navigation while batch import is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ('review-loading', {
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: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-loading/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
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 ('review-missing-row', {
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 })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-missing-row/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
it ('does not navigate to result when import omits a requested source row', async () => {
savePostImportSession ('review-missing-import-row', {
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: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-missing-import-row/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument ()
})
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
savePostImportSession ('review-edit-retry', {
source: 'https://example.com/post',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'old title' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
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 ()
})
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-edit-retry/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ('review-edit-retry')
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')
})
})
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
savePostImportSession ('review-repair-sort', {
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 } = render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-repair-sort/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
node => node.textContent)
expect (titles[0]).toBe ('repair row')
})
})
-423
ファイルの表示
@@ -1,423 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate, useParams } from 'react-router-dom'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users'
import { loadPostImportSession,
buildNextEditedRow,
canEditReviewRow,
creatableImportRows,
hasExactSourceRows,
initialisePreviewRows,
mergeImportResults,
mergeValidatedImportRow,
mergeValidatedImportRows,
processableImportRows,
replaceImportRow,
resultRepairMode,
reviewSummaryCounts,
savePostImportSession } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
import type { PostImportResultRow,
PostImportRow,
PostImportSession } from '@/lib/postImportSession'
import type { User } from '@/types'
type Props = { user: User | null }
const isRepairRow = (row: PostImportRow): boolean =>
row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending'
&& Object.keys (row.validationErrors).length > 0))
const PostImportReviewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const dialogue = useDialogue ()
const navigate = useNavigate ()
const { sessionId } = useParams ()
const [session, setSession] = useState<PostImportSession | null> (null)
const [loading, setLoading] = useState (false)
const [missing, setMissing] = useState (false)
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const sessionRef = useRef<PostImportSession | null> (null)
useEffect (() => {
if (sessionId == null)
return
const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
useEffect (() => {
if (sessionId == null || session == null)
return
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
useEffect (() => {
sessionRef.current = session
}, [session])
const rows = session?.rows ?? []
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
const processable = useMemo (
() => processableImportRows (rows),
[rows])
const creatable = useMemo (
() => creatableImportRows (rows),
[rows])
const reviewRows =
session?.repairMode === 'failed'
? (
[...rows].sort ((a, b) => {
const aRepair = isRepairRow (a) ? 0 : 1
const bRepair = isRepairRow (b) ? 0 : 1
return aRepair - bRepair || a.sourceRow - b.sourceRow
}))
: rows
useEffect (() => {
if (editingRow == null || session?.repairMode !== 'failed')
return
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
}, [editingRow, session?.repairMode])
const saveDraft = async (
row: PostImportRow,
{ draft, resetRequested }: {
draft: PostImportRowDraft
resetRequested: boolean },
): Promise<{ saved: boolean
row: PostImportRow | null }> => {
const currentSession = sessionRef.current
if (currentSession == null)
return { saved: false, row: null }
if (!(canEditReviewRow (row)))
return { saved: false, row: null }
const baseRow =
resetRequested
? { ...row,
url: row.resetSnapshot.url,
attributes: { ...row.resetSnapshot.attributes },
provenance: { ...row.resetSnapshot.provenance },
tagSources: { ...row.resetSnapshot.tagSources },
fieldWarnings: Object.fromEntries (
Object.entries (row.resetSnapshot.fieldWarnings)
.map (([key, values]) => [key, [...values]])),
baseWarnings: [...row.resetSnapshot.baseWarnings],
metadataUrl: row.resetSnapshot.metadataUrl }
: row
const urlChanged = draft.url !== baseRow.url
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
const nextRows = currentSession.rows.map (row =>
row.sourceRow === baseRow.sourceRow
? nextRow
: row)
try
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows:
nextRows
.filter (row => row.importStatus !== 'created')
.map (row => ({ sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find (
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
const latestSession = sessionRef.current
if (latestSession == null)
return { saved: false, row: null }
if (target == null)
{
const restoredRows = replaceImportRow (latestSession.rows, row)
const restoredSession = {
...latestSession,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
toast ({ title: '行の再検証結果が不完全でした' })
return { saved: false, row: null }
}
const editedRows = replaceImportRow (latestSession.rows, nextRow)
const rows = mergeValidatedImportRow (editedRows, target)
const nextSession = {
...latestSession,
rows,
repairMode: resultRepairMode (rows) }
sessionRef.current = nextSession
setSession (nextSession)
const mergedTarget =
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
?? target
if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget }
return { saved: true, row: null }
}
catch
{
toast ({ title: '行の再検証に失敗しました' })
return { saved: false, row: null }
}
}
const openEditingDialogue = async (row: PostImportRow) => {
const saveRowDraft = (
{ draft, resetRequested }: {
draft: PostImportRowDraft
resetRequested: boolean },
) =>
saveDraft (row, { draft, resetRequested })
await dialogue.form ({
title: '投稿を編輯',
description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
cancelText: '取消',
size: 'large',
body: controls => (
<PostImportRowForm
row={row}
controls={controls}
onSave={saveRowDraft}/>) })
}
const editRow = async (row: PostImportRow) => {
setEditingRow (row)
try
{
await openEditingDialogue (row)
}
finally
{
setEditingRow (current =>
current?.sourceRow === row.sourceRow
? null
: current)
}
}
const submit = async () => {
const currentSession = sessionRef.current
if (sessionId == null || currentSession == null || processable.length === 0)
return
setLoading (true)
try
{
const validatableRows =
currentSession.rows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows:
validatableRows.map (row => ({ sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const expectedSourceRows = validatableRows.map (row => row.sourceRow)
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
{
toast ({ title: '再検証結果が不完全でした' })
return
}
const latestAfterValidate = sessionRef.current ?? currentSession
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
if (firstInvalid != null)
{
const nextSession = {
...latestAfterValidate,
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
sessionRef.current = nextSession
setSession (nextSession)
void editRow (firstInvalid)
return
}
const validatedSession = {
...latestAfterValidate,
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
sessionRef.current = validatedSession
setSession (validatedSession)
const savedValidated = savePostImportSession (sessionId, validatedSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(savedValidated))
return
const result = await apiPost<{
created: number
skipped: number
failed: number
rows: PostImportResultRow[] }> ('/posts/import', {
rows: processableImportRows (mergedRows).map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })) })
const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
{
toast ({ title: '登録結果が不完全でした' })
return
}
const latestAfterImport = sessionRef.current ?? validatedSession
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedResults.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (rr => rr.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
recoverable: true,
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const nextSession = {
...latestAfterImport,
rows: nextRows,
repairMode: resultRepairMode (nextRows) }
sessionRef.current = nextSession
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/result`)
}
catch
{
toast ({ title: '登録に失敗しました' })
}
finally
{
setLoading (false)
}
}
if (!(editable))
return <Forbidden/>
if (missing || sessionId == null || session == null)
{
return (
<MainArea>
<div className="mx-auto max-w-4xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<div className="text-red-700 dark:text-red-300"></div>
<Button type="button" onClick={() => navigate ('/posts/import')}>
URL
</Button>
</div>
</MainArea>)
}
return (
<>
<Helmet>
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
</Helmet>
<MainArea className="min-h-0">
<div className="mx-auto max-w-6xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<div className="space-y-3">
{reviewRows.map (row => (
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary
row={row}
editDisabled={loading}
onEdit={() => void editRow (row)}/>
</div>))}
</div>
</div>
</MainArea>
<PostImportFooter
loading={loading}
processableCount={processable.length}
creatableCount={creatable.length}
skipPlannedCount={counts.skipPlanned}
onBack={() => navigate ('/posts/import')}
onSubmit={submit}/>
</>)
}
const PostImportFooter = (
{ loading,
processableCount,
creatableCount,
skipPlannedCount,
onBack,
onSubmit }: { loading: boolean
processableCount: number
creatableCount: number
skipPlannedCount: number
onBack: () => void
onSubmit: () => void },
) => (
<div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
dark:border-neutral-700 dark:bg-neutral-950/95">
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> {processableCount}</span>
<span> {creatableCount}</span>
<span> {skipPlannedCount}</span>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
URL
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={loading || processableCount === 0}>
</Button>
</div>
</div>
</div>)
export default PostImportReviewPage
-93
ファイルの表示
@@ -1,93 +0,0 @@
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 }))
describe ('PostImportSourcePage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('shows no empty error initially and validates only after Next is pressed', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
expect (api.apiPost).not.toHaveBeenCalled ()
})
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 (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues')
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('shows backend URL errors against original input without a session', async () => {
api.apiPost.mockResolvedValue ({
rows: [buildPostImportRow ({
sourceRow: 2,
url: 'https://example.com/canonical',
status: 'error',
validationErrors: { url: ['URL が重複しています.'] } })] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: '\nhttps://example.com/original' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (await screen.findByText ('2 行目: URL が重複しています.')).toBeInTheDocument ()
expect (screen.getAllByText ('https://example.com/original')).toHaveLength (2)
expect (router.navigate).not.toHaveBeenCalled ()
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index))).not.toContainEqual(
expect.stringMatching (/^post-import-session:/))
})
it ('stores a successful preview before navigating to the review route', async () => {
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: 'https://example.com/post' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
await waitFor (() => {
expect (router.navigate).toHaveBeenCalledWith (
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
})
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index)).filter (
key => key?.startsWith ('post-import-session:'))
expect (sessionKeys).toHaveLength (1)
})
})
-208
ファイルの表示
@@ -1,208 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import TextArea from '@/components/common/TextArea'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost, isApiError } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { countImportSourceLines,
cleanupExpiredPostImportSessions,
createPostImportSessionId,
initialisePreviewRows,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft,
validateImportSource } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
import type { User } from '@/types'
type Props = { user: User | null }
const MAX_ROWS = 100
const SOURCE_ERROR_ID = 'post-import-source-error'
const SOURCE_ISSUES_ID = 'post-import-source-issues'
const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
const sourceLines = source.split (/\r\n|\n|\r/)
return rows.flatMap (row =>
(row.validationErrors.url ?? []).map (message => ({
sourceRow: row.sourceRow,
message,
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
}
const PostImportSourcePage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const navigate = useNavigate ()
const [source, setSource] = useState ('')
const [loading, setLoading] = useState (false)
const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
const [sourceError, setSourceError] = useState<string | null> (null)
const saveTimer = useRef<number | null> (null)
const editedRef = useRef (false)
const lineCount = countImportSourceLines (source)
const messages = sourceError != null ? [sourceError] : []
const sourceDescribedBy =
[sourceError != null ? SOURCE_ERROR_ID : null,
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
.filter (value => value != null)
.join (' ')
useEffect (() => {
cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
const draft = loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
if (!(editedRef.current))
setSource (current => current === '' ? draft.source : current)
}, [])
useEffect (() => {
if (saveTimer.current != null)
window.clearTimeout (saveTimer.current)
saveTimer.current = window.setTimeout (() => {
savePostImportSourceDraft (source, message =>
toast ({ title: '入力内容を保存できませんでした', description: message }))
}, 300)
return () => {
if (saveTimer.current != null)
window.clearTimeout (saveTimer.current)
}
}, [source])
const preview = async () => {
if (lineCount === 0)
{
setSourceIssues ([])
setSourceError ('URL を入力してください.')
return
}
const issues = validateImportSource (source)
if (issues.length > 0)
{
setSourceIssues (issues)
setSourceError (null)
return
}
setLoading (true)
setSourceIssues ([])
setSourceError (null)
try
{
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { source })
const urlIssues = urlIssuesFromRows (data.rows, source)
if (urlIssues.length > 0)
{
setSourceIssues (urlIssues)
return
}
const sessionId = createPostImportSessionId ()
const saved = savePostImportSession (
sessionId,
{ source,
rows: initialisePreviewRows (data.rows),
repairMode: 'all' },
message => toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
}
catch (requestError)
{
const message =
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
? (requestError.response?.data?.message
?? requestError.response?.data?.baseErrors?.[0])
: undefined
setSourceError (message ?? '入力を確認してください.')
toast ({ title: '投稿情報の取得に失敗しました',
description: message ?? '入力を確認してください.' })
}
finally
{
setLoading (false)
}
}
if (!(editable))
return <Forbidden/>
return (
<MainArea>
<Helmet>
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
</Helmet>
<Form className="max-w-4xl">
<PageTitle>稿</PageTitle>
<FormField label="URL リスト">
{() => (
<TextArea
value={source}
rows={14}
invalid={messages.length > 0 || sourceIssues.length > 0}
aria-describedby={sourceDescribedBy || undefined}
className="h-80 font-mono text-sm"
onBlur={() => {
savePostImportSourceDraft (source, message =>
toast ({ title: '入力内容を保存できませんでした',
description: message }))
}}
onChange={ev => {
editedRef.current = true
setSource (ev.target.value)
setSourceError (null)
setSourceIssues ([])
}}/>)}
</FormField>
<div id={messages.length > 0 ? SOURCE_ERROR_ID : undefined}>
<FieldError messages={messages}/>
</div>
<ul
id={SOURCE_ISSUES_ID}
className="space-y-2 text-sm text-red-700 dark:text-red-300">
{sourceIssues.map (issue => (
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
<div>{issue.sourceRow} : {issue.message}</div>
<div className="break-all font-mono text-xs">{issue.url}</div>
</li>))}
</ul>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-neutral-600 dark:text-neutral-300">
{lineCount} / {MAX_ROWS}
</div>
<Button
type="button"
className="shrink-0"
onClick={preview}
disabled={loading}>
</Button>
</div>
</Form>
</MainArea>)
}
export default PostImportSourcePage
+8 -64
ファイルの表示
@@ -1,7 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import PostNewPage from '@/pages/posts/PostNewPage'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -40,8 +39,8 @@ describe ('PostNewPage', () => {
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[3], { target: { value: '1 2' } })
fireEvent.change (textboxes[4], { target: { value: 'tag1 tag2' } })
fireEvent.change (textboxes[2], { target: { value: '1 2' } })
fireEvent.change (textboxes[3], { target: { value: 'tag1 tag2' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => {
@@ -64,19 +63,17 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const tags = screen.getAllByRole ('textbox')[4]
const tags = screen.getAllByRole ('textbox')[3]
fireEvent.change (tags, { target: { value: '動画' } })
fireEvent.change (
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
{ target: { value: '180.5' } })
fireEvent.change (screen.getByRole ('spinbutton'), { target: { value: '180.5' } })
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
})
it ('shows 422 validation errors for post fields', async () => {
@@ -99,64 +96,11 @@ describe ('PostNewPage', () => {
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[4], { target: { value: 'nico:nico_tag' } })
fireEvent.change (textboxes[3], { target: { value: 'nico:nico_tag' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true')
})
it ('uses the shared duration, tags, and datetime value contract for post submission', async () => {
api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([])
const { container } = renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[4], { target: { value: '動画 tag1 tag2' } })
fireEvent.change (
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
{ target: { value: '2.5' } })
const datetimeInputs = container.querySelectorAll ('input[type="datetime-local"]')
fireEvent.change (datetimeInputs[0] as HTMLInputElement, {
target: { value: '2024-01-01T12:34' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => expect (api.apiPost).toHaveBeenCalled ())
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (container.querySelector ('input[type="file"]')).not.toBeNull ()
expect (formData.get ('duration')).toBe ('2.5')
expect (formData.get ('tags')).toBe ('動画 tag1 tag2')
expect (formData.get ('original_created_from')).toBe (
toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
})
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
errors: {
original_created_at: ['日時を確認してください.'],
original_created_from: ['日時を確認してください.'],
original_created_before: ['終了を確認してください.'] },
},
},
})
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
expect (screen.getAllByRole ('textbox')[3]).toHaveAttribute ('aria-invalid', 'true')
})
})
+96 -84
ファイルの表示
@@ -2,20 +2,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden'
@@ -26,12 +25,7 @@ import type { User } from '@/types'
type Props = { user: User | null }
type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds'
| 'videoMs' | 'originalCreatedAt' | 'originalCreatedFrom'
| 'originalCreatedBefore' | 'thumbnail'
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => {
@@ -42,10 +36,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (null)
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('')
@@ -59,7 +51,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
[tags])
const handleSubmit = async () => {
clearValidationErrors ()
@@ -87,7 +79,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください' })
toast ({ title: '投稿失敗', description: '入力を確認してください' })
}
}
@@ -113,8 +105,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
try
{
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url },
responseType: 'blob' })
{ params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
@@ -143,22 +134,30 @@ const PostNewPage: FC<Props> = ({ user }) => {
<PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/>
<PostTextField
label="URL"
type="url"
value={url}
errors={fieldErrors.url}
placeholder="例:https://www.nicovideo.jp/watch/..."
onChange={setURL}/>
{/* URL */}
<FormField label="URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input type="url"
placeholder="例:https://www.nicovideo.jp/watch/..."
value={url}
onChange={e => setURL (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<PostTextField
label="タイトル"
value={title}
errors={fieldErrors.title}
disabled={titleLoading}
placeholder={titleLoading ? 'Loading...' : undefined}
onChange={setTitle}
after={
{/* タイトル */}
<FormField label="タイトル" messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<div className="space-y-2">
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
@@ -168,51 +167,59 @@ const PostNewPage: FC<Props> = ({ user }) => {
disabled={!(url) || titleLoading}>
</Button>
</div>}/>
</div>
</div>)}
</FormField>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<PostTextField
label="サムネール"
value={thumbnailFile?.name ?? ''}
errors={fieldErrors.thumbnail}
disabled
onChange={() => {}}/>
<input
type="file"
accept="image/*"
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
<PostThumbnailPreview
url={thumbnailPreview}
alt="preview"
className="h-28 w-28"/>
</div>
{/* サムネール */}
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
{({ describedBy, invalid }) => (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
{thumbnailPreview && (
<img src={thumbnailPreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>)}
</>)}
</FormField>
<PostTextField
label="親投稿"
value={parentPostIds}
errors={fieldErrors.parentPostIds}
onChange={setParentPostIds}/>
{/* 親投稿 */}
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<PostTagsField tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
{/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
@@ -220,16 +227,21 @@ const PostNewPage: FC<Props> = ({ user }) => {
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={groupedMessages (
fieldErrors.originalCreatedAt,
fieldErrors.originalCreatedFrom,
fieldErrors.originalCreatedBefore)}/>
errors={fieldErrors.originalCreatedAt}/>
{videoFlg && (
<PostDurationField
value={duration}
errors={fieldErrors.videoMs}
onChange={setDuration}/>)}
{/* 動画時間 */}
{(videoFlg &&
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
value={duration}
onChange={e => setDuration (e.target.value)}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>)}
{/* 送信 */}
<Button onClick={handleSubmit}
-53
ファイルの表示
@@ -1,53 +0,0 @@
import type { PostImportRow } from '@/lib/postImportSession'
export const buildPostImportRow = (
overrides: Partial<PostImportRow> = {},
): PostImportRow => {
const attributes = {
title: '',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
tags: '',
parentPostIds: '',
...overrides.attributes }
const provenance = {
url: 'manual' as const,
title: 'automatic' as const,
thumbnailBase: 'automatic' as const,
originalCreatedFrom: 'automatic' as const,
originalCreatedBefore: 'automatic' as const,
duration: 'automatic' as const,
tags: 'automatic' as const,
parentPostIds: 'automatic' as const,
...overrides.provenance }
const fieldWarnings = { ...overrides.fieldWarnings }
const baseWarnings = [...(overrides.baseWarnings ?? [])]
const tagSources = {
automatic: overrides.tagSources?.automatic ?? '',
manual: overrides.tagSources?.manual ?? '' }
const url = overrides.url ?? 'https://example.com/post'
return {
...overrides,
sourceRow: overrides.sourceRow ?? 1,
url,
attributes,
fieldWarnings,
baseWarnings,
validationErrors: overrides.validationErrors ?? {},
provenance,
tagSources,
status: overrides.status ?? 'ready',
recoverable: overrides.recoverable,
resetSnapshot: overrides.resetSnapshot ?? {
url,
attributes: { ...attributes },
provenance: { ...provenance },
tagSources: { ...tagSources },
fieldWarnings: Object.fromEntries (
Object.entries (fieldWarnings).map (([key, values]) => [key, [...values]])),
baseWarnings: [...baseWarnings],
metadataUrl: overrides.metadataUrl } }
}