コミットを比較

...

18 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 2e1b4449ba #399 2026-07-16 00:44:11 +09:00
みてるぞ 4eb483c049 #399 2026-07-16 00:24:24 +09:00
みてるぞ e197f27e9a #399 2026-07-15 23:59:01 +09:00
みてるぞ 34f81325d2 #399 2026-07-15 23:42:05 +09:00
みてるぞ 9af4670c6c #399 2026-07-15 23:29:19 +09:00
みてるぞ 5183b0b354 #399 2026-07-15 23:06:28 +09:00
みてるぞ d0914fce3b #399 2026-07-15 22:51:01 +09:00
みてるぞ 0ac7332458 #399 2026-07-15 22:41:06 +09:00
みてるぞ 07ce19e32d #399 2026-07-15 22:26:58 +09:00
みてるぞ 5f1d619139 #399 2026-07-15 22:04:47 +09:00
みてるぞ 3cd22855b3 #399 2026-07-15 21:42:00 +09:00
みてるぞ 55c69866a7 #399 2026-07-15 21:37:03 +09:00
みてるぞ 04d2f0f5a9 #399 2026-07-15 20:59:00 +09:00
みてるぞ 025f49cbcb #399 2026-07-15 20:51:24 +09:00
みてるぞ d2f1b1ed48 #399 2026-07-15 20:25:51 +09:00
みてるぞ 6e5aa1e30f #399 2026-07-15 19:57:09 +09:00
みてるぞ 21d6c1408a #399 2026-07-15 19:43:39 +09:00
みてるぞ eebbd5d069 #399 2026-07-15 19:33:18 +09:00
43個のファイルの変更3903行の追加1304行の削除
+204 -10
ファイルの表示
@@ -207,11 +207,42 @@ records.each {
syntactically valid. syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters. - Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
- Aim to keep Ruby, TypeScript, and TSX lines within 79 characters where practical. - Aim to keep Ruby, TypeScript, and TSX lines within 79 characters where practical.
- TypeScript and TSX use 4-space logical indentation. - In TypeScript and TSX, use 2-space block indentation.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab. - In TypeScript and TSX, block bodies for components, functions, callbacks,
- Tabs are only for leading indentation, never for spaces after non-space text. `if`, `try`, `catch`, `finally`, loops, and JSX nesting use 2 spaces per
level.
- 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 imports may stay on one line if they remain within the - TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically. 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 - 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 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, operator at end of line is unacceptable. This rule does not apply to Ruby,
@@ -266,6 +297,151 @@ const value =
value selection. Do not replace a clear ternary with `if` statements, and do value selection. Do not replace a clear ternary with `if` statements, and do
not introduce immediately invoked functions just to avoid or reformat a not introduce immediately invoked functions just to avoid or reformat a
ternary expression. 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 - 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 when the value can be expressed as a single `const` initializer. Prefer
`const` because it prevents accidental later reassignment. `const` because it prevents accidental later reassignment.
@@ -341,6 +517,20 @@ const value =
- For user-facing Japanese kanji spelling, do not normalize to - For user-facing Japanese kanji spelling, do not normalize to
《当用漢字による書きかえ》; prefer original forms such as `編輯`. 《当用漢字による書きかえ》; prefer original forms such as `編輯`.
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`. - 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 ### Frontend TypeScript and TSX style
@@ -379,14 +569,17 @@ const value =
beginning of a line. beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing - The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style. parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading indentation follows - The TypeScript/TSX self-review must confirm leading block indentation uses
4-space logical indentation with tabs only as leading 8-space compression. 2 spaces per level, wrapped continuations use the repository's 4-space
continuation alignment, and complete leading runs of 8 spaces may be
compressed to tabs.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations. - Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function - Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward. declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent with 4-space logical indentation. - In TSX, use 2-space block indentation and 4-space continuation
- In TypeScript and TSX, convert every leading run of 8 spaces to a tab indentation.
character. - In TypeScript and TSX, convert every complete leading run of 8 spaces to a
tab character.
- A leading tab is exactly equivalent to 8 leading spaces. - A leading tab is exactly equivalent to 8 leading spaces.
- In TypeScript and TSX function declarations, including `const` arrow - In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing function declarations, classify the parameter list before placing the closing
@@ -1014,8 +1207,9 @@ to `.ts` and `.tsx`:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves 7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise. otherwise.
8. JSX closing parentheses keep the compact local style. 8. JSX closing parentheses keep the compact local style.
9. Leading indentation is 4-space logical indentation with tabs used only as 9. Leading block indentation uses 2 spaces per level, wrapped continuations
leading 8-space compression. use the repository's 4-space continuation alignment, and complete leading
runs of 8 spaces may be compressed to tabs.
10. No line has trailing whitespace. 10. No line has trailing whitespace.
Preferred: Preferred:
+186
ファイルの表示
@@ -67,6 +67,192 @@ pass or the remaining failure is clearly blocked.
Before changing behavior, inspect the matching route, controller, model, Before changing behavior, inspect the matching route, controller, model,
service, representation, and spec. 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 ## Ruby style
- Prefer precise, minimal changes. - Prefer precise, minimal changes.
+77 -14
ファイルの表示
@@ -1,3 +1,4 @@
require 'time'
require 'timeout' require 'timeout'
class PostImportPreviewer class PostImportPreviewer
@@ -10,7 +11,6 @@ class PostImportPreviewer
'tags', 'tags',
'parent_post_ids'].freeze 'parent_post_ids'].freeze
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
EXISTING_SKIP_WARNING = '既存投稿のためスキップします.'.freeze
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze
@@ -55,7 +55,7 @@ class PostImportPreviewer
source = row.symbolize_keys source = row.symbolize_keys
url = source[:url].to_s.strip url = source[:url].to_s.strip
normal_url = normalised_url(url) normal_url = normalised_url(url)
source.merge(url_text: url, normal_url:) source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url))
end end
def preview_row row, def preview_row row,
@@ -78,17 +78,20 @@ class PostImportPreviewer
validation_errors = {} validation_errors = {}
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank? 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 if normal_url.present? && url_counts[normal_url].to_i > 1
validation_errors[:url] = ['URL が重複しています.'] validation_errors[:url] = ['URL が重複しています.']
end end
if row[:metadata_url].present? && row[:metadata_url] != url_for_metadata if row[:metadata_url].present? && row[:metadata_url] != url_for_metadata
clear_automatic_values!(attributes, provenance, tag_sources) clear_automatic_values!(attributes, provenance, tag_sources)
clear_fetch_warnings!(field_warnings) field_warnings = { }
base_warnings = [ ]
end end
if validation_errors.blank? && normal_url.present? && existing_post if validation_errors.blank? && normal_url.present? && existing_post
add_field_warning!(field_warnings, 'url', EXISTING_SKIP_WARNING)
attributes['tags'] = merged_tags(tag_sources, provenance['tags']) attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present? warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
return { source_row: row[:source_row], return { source_row: row[:source_row],
@@ -110,8 +113,12 @@ class PostImportPreviewer
if should_fetch if should_fetch
clear_fetch_warnings!(field_warnings) clear_fetch_warnings!(field_warnings)
metadata = metadata_for(url_for_metadata, metadata_cache) metadata = metadata_for(url_for_metadata, metadata_cache)
apply_metadata!(attributes, provenance, tag_sources, metadata[:data]) if metadata[:validation_errors].present?
apply_fetch_warnings!(field_warnings, metadata[:warnings]) validation_errors.merge!(metadata[:validation_errors])
else
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
apply_fetch_warnings!(field_warnings, metadata[:warnings])
end
end end
attributes['url'] = url attributes['url'] = url
@@ -126,7 +133,7 @@ class PostImportPreviewer
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present? warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
{ source_row: row[:source_row], { source_row: row[:source_row],
url: normal_url || url, url: validation_errors[:url].present? ? url : (normal_url || url),
attributes:, attributes:,
provenance:, provenance:,
tag_sources:, tag_sources:,
@@ -197,27 +204,68 @@ class PostImportPreviewer
end end
def fetch_metadata url def fetch_metadata url
return { data: { }, warnings: { 'url' => ['URL が空です.'] } } if url.blank? return { data: { }, warnings: { 'url' => ['URL が空です.'] },
validation_errors: { } } if url.blank?
data = PostMetadataFetcher.fetch(url).stringify_keys.compact data = sanitise_metadata(PostMetadataFetcher.fetch(url).stringify_keys.compact)
warnings = { } warnings = { }
add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank? add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank?
if data['thumbnail_base'].blank? if data['thumbnail_base'].blank?
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING) add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING)
end end
{ data:, warnings: } { data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl, rescue Preview::UrlSafety::UnsafeUrl => e
Preview::HttpFetcher::FetchFailed, 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 Preview::HttpFetcher::ResponseTooLarge => e
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_failure "\ "post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }") "#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } } { 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 = Time.iso8601(value)
time.nsec.zero? ? time.iso8601 : time.iso8601(9)
rescue ArgumentError, TypeError
nil
end
def sanitise_metadata_duration value
return nil unless value.is_a?(Numeric)
value.positive? ? value.to_i : nil
end end
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
urls = prepared_rows.filter_map { |row| urls = prepared_rows.filter_map { |row|
next unless row[:normal_url].present? 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 unless should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
next if url_counts[row[:normal_url]].to_i > 1 next if url_counts[row[:normal_url]].to_i > 1
next if existing_posts.key?(row[:normal_url]) next if existing_posts.key?(row[:normal_url])
@@ -258,11 +306,16 @@ class PostImportPreviewer
def safe_fetch_metadata url def safe_fetch_metadata url
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 rescue StandardError => e
Rails.logger.error( Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\ "post_import_metadata_fetch_unexpected_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }") "#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
@@ -305,12 +358,22 @@ class PostImportPreviewer
def should_apply_metadata_to_row? row, fetch_metadata, existing_posts, url_counts def should_apply_metadata_to_row? row, fetch_metadata, existing_posts, url_counts
normal_url = row[:normal_url] normal_url = row[:normal_url]
return false if normal_url.blank? 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 url_counts[normal_url].to_i > 1
return false if existing_posts.key?(normal_url) return false if existing_posts.key?(normal_url)
should_fetch_metadata?(fetch_metadata, row[:source_row].to_i) should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
end 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 def apply_metadata! attributes, provenance, tag_sources, metadata
metadata.each do |field, value| metadata.each do |field, value|
if field == 'tags' if field == 'tags'
+27 -9
ファイルの表示
@@ -78,8 +78,12 @@ class PostImportRowNormaliser
private_class_method :normalise_source_row! private_class_method :normalise_source_row!
def self.normalise_url! value def self.normalise_url! value
raise ArgumentError, 'URL の形式が不正です.' unless value.is_a?(String) unless value.is_a?(String)
raise ArgumentError, 'URL が長すぎます.' if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES raise ArgumentError, 'URL の形式が不正です.'
end
if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'URL が長すぎます.'
end
end end
private_class_method :normalise_url! private_class_method :normalise_url!
@@ -112,20 +116,32 @@ class PostImportRowNormaliser
private_class_method :normalise_attributes! private_class_method :normalise_attributes!
def self.normalise_provenance! provenance def self.normalise_provenance! provenance
raise ArgumentError, 'provenance の形式が不正です.' unless provenance.is_a?(Hash) unless provenance.is_a?(Hash)
raise ArgumentError, 'provenance の形式が不正です.'
end
allowed = ATTRIBUTE_FIELDS + ['url'] allowed = ATTRIBUTE_FIELDS + ['url']
raise ArgumentError, '値の由来が不正です.' unless (provenance.keys - allowed).empty? unless (provenance.keys - allowed).empty?
raise ArgumentError, '値の由来が不正です.' unless provenance.values.all? { ORIGINS.include?(_1) } raise ArgumentError, '値の由来が不正です.'
end
unless provenance.values.all? { ORIGINS.include?(_1) }
raise ArgumentError, '値の由来が不正です.'
end
end end
private_class_method :normalise_provenance! private_class_method :normalise_provenance!
def self.normalise_tag_sources! tag_sources def self.normalise_tag_sources! tag_sources
return if tag_sources.nil? return if tag_sources.nil?
raise ArgumentError, 'タグ由来の形式が不正です.' unless tag_sources.is_a?(Hash) unless tag_sources.is_a?(Hash)
raise ArgumentError, 'タグ由来の形式が不正です.' unless (tag_sources.keys - ORIGINS).empty? raise ArgumentError, 'タグ由来の形式が不正です.'
raise ArgumentError, 'タグ由来の形式が不正です.' unless tag_sources.values.all? { _1.is_a?(String) } 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 } if tag_sources.values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, 'タグ由来が大きすぎます.' raise ArgumentError, 'タグ由来が大きすぎます.'
end end
@@ -143,7 +159,9 @@ class PostImportRowNormaliser
raise ArgumentError, '警告の形式が不正です.' raise ArgumentError, '警告の形式が不正です.'
end end
field_warnings&.each do |key, values| field_warnings&.each do |key, values|
raise ArgumentError, '警告の形式が不正です.' unless ATTRIBUTE_FIELDS.include?(key) || key == 'url' unless ATTRIBUTE_FIELDS.include?(key) || key == 'url'
raise ArgumentError, '警告の形式が不正です.'
end
unless values.is_a?(Array) && values.all? { _1.is_a?(String) } unless values.is_a?(Array) && values.all? { _1.is_a?(String) }
raise ArgumentError, '警告の形式が不正です.' raise ArgumentError, '警告の形式が不正です.'
end end
+34 -7
ファイルの表示
@@ -26,7 +26,8 @@ class PostImportRunner
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore } attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
return { source_row: row['source_row'], return { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: preview[:validation_errors] } if preview[:validation_errors].present? errors: preview[:validation_errors],
recoverable: true } if preview[:validation_errors].present?
if preview[:skip_reason] == 'existing' if preview[:skip_reason] == 'existing'
return { source_row: row['source_row'], return { source_row: row['source_row'],
status: 'skipped', status: 'skipped',
@@ -45,19 +46,39 @@ class PostImportRunner
existing_post_id: existing_post.id } existing_post_id: existing_post.id }
end end
{ source_row: row['source_row'], status: 'failed', errors: e.record.errors.to_hash } { 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 rescue Tag::NicoTagNormalisationError
{ source_row: row['source_row'], { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] } } errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
recoverable: true }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
{ source_row: row['source_row'], { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: { tags: ['廃止済みタグは付与できません.'] } } errors: { tags: ['廃止済みタグは付与できません.'] },
recoverable: true }
rescue PostCreator::VideoMsParseError
{ source_row: row['source_row'],
status: 'failed',
errors: { duration: ['動画時間の記法が不正です.'] },
recoverable: true }
rescue ArgumentError rescue ArgumentError
{ source_row: row['source_row'], { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: { base: ['入力値が不正です.'] } } errors: { base: ['入力値が不正です.'] },
recoverable: true }
rescue StandardError => e rescue StandardError => e
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name, Rails.logger.error("post_import_runner_failure #{ { error: e.class.name,
message: e.message }.to_json }") message: e.message }.to_json }")
@@ -66,12 +87,18 @@ class PostImportRunner
errors: { base: ['登録中にエラーが発生しました.'] } } errors: { base: ['登録中にエラーが発生しました.'] } }
end end
def existing_post_for_race row, record def existing_post_for_race row, record = nil
return nil unless record.errors.of_kind?(:url, :taken) if record && !(record.errors.of_kind?(:url, :taken))
return nil
end
normal_url = PostUrlNormaliser.normalise(row['url']) normal_url = PostUrlNormaliser.normalise(row['url'])
return nil if normal_url.blank? return nil if normal_url.blank?
Post.find_by(url: normal_url) Post.find_by(url: normal_url)
end end
def url_record_not_unique? error
error.message.include?('index_posts_on_url')
end
end end
+12 -3
ファイルの表示
@@ -1,3 +1,5 @@
require 'time'
class PostMetadataFetcher class PostMetadataFetcher
TIMESTAMP_PATTERN = TIMESTAMP_PATTERN =
Regexp.new( Regexp.new(
@@ -26,8 +28,8 @@ class PostMetadataFetcher
{ title: metadata[:title], { title: metadata[:title],
thumbnail_base: thumbnail_base:
Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url], Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
original_created_from: created_range&.first&.iso8601, original_created_from: serialise_time(created_range&.first),
original_created_before: created_range&.last&.iso8601, original_created_before: serialise_time(created_range&.last),
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil }, duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil },
tags: platform_tags.join(' ') } tags: platform_tags.join(' ') }
end end
@@ -114,9 +116,16 @@ class PostMetadataFetcher
Rational(value.to_i, 10**value.length) Rational(value.to_i, 10**value.length)
end 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, private_class_method :platform_tags,
:original_created_range, :original_created_range,
:parse_timestamp_range, :parse_timestamp_range,
:parse_offset, :parse_offset,
:fractional_seconds :fractional_seconds,
:serialise_time
end end
+112
ファイルの表示
@@ -0,0 +1,112 @@
require 'rails_helper'
RSpec.describe 'Post imports API', type: :request do
let(:member) { create(:user, :member) }
before do
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['8.8.8.8']]
end
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: nil,
tags: ''
)
end
describe 'POST /posts/import/preview' do
it 'requires a member' do
sign_out
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:unauthorized)
sign_in_as(create(:user, :guest))
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:forbidden)
end
it 'parses a URL list and returns preview rows' do
sign_in_as(member)
post '/posts/import/preview', params: {
source: " https://example.com/one \r\n\r\nhttps://example.com/two"
}
expect(response).to have_http_status(:ok)
expect(json.fetch('rows').map { _1.fetch('source_row') }).to eq([1, 3])
expect(json.fetch('rows').map { _1.fetch('url') }).to eq([
'https://example.com/one',
'https://example.com/two'
])
end
it 'returns a safe 400 response for an invalid source' do
sign_in_as(member)
post '/posts/import/preview', params: { source: '' }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('URL を入力してください.')
end
end
describe 'POST /posts/import/validate' do
it 'accepts camel-case row properties and returns their warnings' do
sign_in_as(member)
post '/posts/import/validate', params: {
rows: [{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'manual title' },
provenance: { url: 'manual', title: 'manual' },
tagSources: { automatic: '', manual: '' },
fieldWarnings: { title: ['old warning'] },
baseWarnings: ['base warning']
}],
changed_row: -1
}
expect(response).to have_http_status(:ok)
result = json.fetch('rows').first
expect(result.fetch('source_row')).to eq(1)
expect(result.fetch('tag_sources')).to eq('automatic' => '', 'manual' => '')
expect(result.fetch('field_warnings')).to eq('title' => ['old warning'])
expect(result.fetch('base_warnings')).to eq(['base warning'])
end
it 'rejects a non-array rows value with 400' do
sign_in_as(member)
post '/posts/import/validate', params: { rows: { sourceRow: 1 }, changed_row: -1 }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('取込行の形式が不正です.')
end
end
describe 'POST /posts/import' do
it 'returns a formal skipped result for an existing post' do
existing = create(:post, url: 'https://example.com/existing')
sign_in_as(member)
post '/posts/import', params: {
rows: [{
sourceRow: 1,
url: existing.url,
attributes: { title: 'ignored' },
provenance: { url: 'manual', title: 'manual' },
tagSources: { automatic: '', manual: '' }
}]
}
expect(response).to have_http_status(:ok)
expect(json).to include('created' => 0, 'skipped' => 1, 'failed' => 0)
expect(json.fetch('rows').first).to include(
'status' => 'skipped',
'existing_post_id' => existing.id
)
end
end
end
+187
ファイルの表示
@@ -0,0 +1,187 @@
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_000,
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_000,
'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
ファイルの表示
@@ -0,0 +1,77 @@
require 'rails_helper'
RSpec.describe PostImportRowNormaliser do
def valid_row(overrides = { })
{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'title', duration: 1_000 },
provenance: { url: 'manual', title: 'automatic' },
tagSources: { automatic: 'tag', manual: '' }
}.deep_merge(overrides)
end
describe '.normalise!' do
it 'normalises aliases and source rows into a permitted plain hash' do
row = ActionController::Parameters.new(valid_row)
expect(described_class.normalise!([row])).to eq([
{
'source_row' => 1,
'url' => 'https://example.com/post',
'metadata_url' => 'https://example.com/post',
'attributes' => { 'title' => 'title', 'duration' => 1_000 },
'provenance' => { 'url' => 'manual', 'title' => 'automatic' },
'tag_sources' => { 'automatic' => 'tag', 'manual' => '' }
}
])
end
it 'normalises source rows before checking duplicates' do
rows = [valid_row, valid_row(sourceRow: 1, url: 'https://example.com/other')]
expect { described_class.normalise!(rows) }
.to raise_error(ArgumentError, '元行番号が重複しています.')
end
it 'rejects non-array batches and non-hash rows' do
expect { described_class.normalise!({}) }
.to raise_error(ArgumentError, '取込行の形式が不正です.')
expect { described_class.normalise!(['row']) }
.to raise_error(ArgumentError, '取込行の形式が不正です.')
end
it 'rejects unknown attributes and invalid field types' do
expect { described_class.normalise!([valid_row(attributes: { unknown: 'x' })]) }
.to raise_error(ArgumentError, '取込項目が不正です.')
expect { described_class.normalise!([valid_row(attributes: { title: [] })]) }
.to raise_error(ArgumentError, '取込項目の型が不正です.')
expect { described_class.normalise!([valid_row(attributes: { duration: false })]) }
.to raise_error(ArgumentError, '取込項目の型が不正です.')
end
it 'rejects unknown provenance and tag-source values' do
expect { described_class.normalise!([valid_row(provenance: { title: 'mapped' })]) }
.to raise_error(ArgumentError, '値の由来が不正です.')
expect { described_class.normalise!([valid_row(tagSources: { mapped: 'tag' })]) }
.to raise_error(ArgumentError, 'タグ由来の形式が不正です.')
end
it 'accepts warning fields only at the validation boundary' do
row = valid_row.merge(
fieldWarnings: { title: ['取得できませんでした.'] },
baseWarnings: ['確認してください.']
)
without_warnings = described_class.normalise!([row]).first
with_warnings = described_class.normalise!([row], allow_warning_fields: true).first
expect(without_warnings).not_to include('field_warnings', 'base_warnings')
expect(with_warnings).to include(
'field_warnings' => { 'title' => ['取得できませんでした.'] },
'base_warnings' => ['確認してください.']
)
end
end
end
+133
ファイルの表示
@@ -0,0 +1,133 @@
require 'rails_helper'
RSpec.describe PostImportRunner do
let(:actor) { create(:user, :member) }
def row(source_row: 1, url: 'https://example.com/post')
{
sourceRow: source_row,
url:,
attributes: { title: 'title', tags: '' },
provenance: { url: 'manual', title: 'manual', tags: 'manual' },
tagSources: { automatic: '', manual: '' }
}
end
def preview(source_row: 1, errors: { }, skip_reason: nil, existing_post_id: nil)
{
source_row:,
attributes: { 'title' => 'title', 'tags' => '' },
validation_errors: errors,
skip_reason:,
existing_post_id:
}
end
it 'previews the whole batch once before processing individual rows' do
rows = [row, row(source_row: 2, url: 'https://example.com/two')]
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
expect(previewer).to receive(:preview_rows)
.with(
rows: satisfy { _1.map { |row_value| row_value['source_row'] } == [1, 2] },
fetch_metadata: false
)
.and_return([preview, preview(source_row: 2)])
allow(PostCreator).to receive(:new).and_return(
instance_double(PostCreator, create!: create(:post))
)
result = described_class.new(actor:, rows:).run
expect(result).to include(created: 2, skipped: 0, failed: 0)
end
it 'treats validation errors as failures before an existing skip' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(errors: { url: ['URL が重複しています.'] },
skip_reason: 'existing', existing_post_id: existing.id)
])
expect(PostCreator).not_to receive(:new)
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to include(
status: 'failed',
errors: { url: ['URL が重複しています.'] },
recoverable: true
)
end
it 'returns the existing post ID for skipped rows' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(skip_reason: 'existing', existing_post_id: existing.id)
])
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to eq(
source_row: 1,
status: 'skipped',
existing_post_id: existing.id
)
end
it 'converts a URL uniqueness validation race into a skip' do
existing = create(:post, url: 'https://example.com/race')
invalid = Post.new(url: existing.url)
invalid.errors.add(:url, :taken)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(invalid))
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
it 're-raises RecordNotUnique errors unrelated to the posts URL index' do
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!)
.and_raise(ActiveRecord::RecordNotUnique, 'other_unique_index')
expect {
described_class.new(actor:, rows: [row]).run
}.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'converts a posts URL index race into a skip' do
existing = create(:post, url: 'https://example.com/index-race')
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(
ActiveRecord::RecordNotUnique,
'duplicate key index_posts_on_url'
)
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/index-race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
end
+40
ファイルの表示
@@ -0,0 +1,40 @@
require 'rails_helper'
RSpec.describe PostImportUrlListParser do
describe '.parse' do
it 'trims URLs, ignores blank lines, and preserves source line numbers' do
source = " https://example.com/one \r\n\r\nhttps://example.com/two\n"
expect(described_class.parse(source)).to eq([
{ source_row: 1, url: 'https://example.com/one' },
{ source_row: 3, url: 'https://example.com/two' }
])
end
it 'rejects an empty URL list' do
expect { described_class.parse(" \n\r\n") }
.to raise_error(ArgumentError, 'URL を入力してください.')
end
it 'rejects more than 100 non-empty rows' do
source = 101.times.map { |index| "https://example.com/#{ index }" }.join("\n")
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '取込件数は 100 件までです.')
end
it 'includes the original line number in an oversized URL error' do
source = "\n#{ 'a' * (described_class::MAX_URL_BYTES + 1) }"
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '2 行目: URL が長すぎます.')
end
it 'rejects an oversized request before parsing rows' do
source = 'a' * (described_class::MAX_BYTES + 1)
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '入力が大きすぎます.')
end
end
end
+64
ファイルの表示
@@ -0,0 +1,64 @@
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 'preserves an input offset and fractional-second precision' do
result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30')
expect(Time.iso8601(result.fetch(:original_created_from)))
.to eq(Time.iso8601('2024-02-03T12:34:56.123+02:30'))
expect(
Time.iso8601(result.fetch(:original_created_before)) -
Time.iso8601(result.fetch(:original_created_from))
).to eq(0.001)
expect(result.fetch(:original_created_from)).to include('.123000000')
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
end
+27
ファイルの表示
@@ -0,0 +1,27 @@
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
+350 -10
ファイルの表示
@@ -50,16 +50,27 @@ pass or the remaining failure is clearly blocked.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better. - Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
- Never write a TypeScript or TSX line longer than 99 characters. - Never write a TypeScript or TSX line longer than 99 characters.
- Aim to keep TypeScript and TSX lines within 79 characters where practical. - Aim to keep TypeScript and TSX lines within 79 characters where practical.
- Use 4-space logical indentation in TypeScript and TSX. - 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.
- For arrays, never put whitespace or a line break immediately before `]`. - For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default. - Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent - If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces. elements by 4 spaces.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab - In TypeScript and TSX only, use tabs for leading 8-column compression only.
to reduce bytes. - 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.
- Treat one leading tab as exactly equivalent to 8 leading spaces. - Treat one leading tab as exactly equivalent to 8 leading spaces.
- Use tabs only for leading indentation. Never replace spaces that occur after - Use tabs only for leading indentation. Never replace spaces that occur after
a non-space character on the same line. 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 ## React
@@ -97,12 +108,23 @@ pass or the remaining failure is clearly blocked.
third-party request outside the Rails API. third-party request outside the Rails API.
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body. - 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 ## Imports and aliases
- The `@` alias points to `frontend/src`. - The `@` alias points to `frontend/src`.
- Prefer `@/...` imports for app code instead of long relative paths. - Prefer `@/...` imports for app code instead of long relative paths.
- Keep type imports separate with `import type`. - Keep type imports separate with `import type`.
- Match existing import grouping: external packages, app modules, then type imports. - 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.
## Tailwind and UI ## Tailwind and UI
@@ -129,6 +151,30 @@ pass or the remaining failure is clearly blocked.
it is JSX- or React-specific. it is JSX- or React-specific.
- Preserve compact TSX expression shapes such as inline ternary branches and - Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them. 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.
- 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 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 - Treat TypeScript and TSX formatting rules as hard constraints, not
preferences. Before finishing a TypeScript or TSX edit, inspect the edited preferences. Before finishing a TypeScript or TSX edit, inspect the edited
hunks for closing `)`, `]`, and `}` placement and fix violations instead of hunks for closing `)`, `]`, and `}` placement and fix violations instead of
@@ -158,14 +204,76 @@ pass or the remaining failure is clearly blocked.
beginning of a line. beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing - The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style. parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading indentation follows - The TypeScript/TSX self-review must confirm leading block indentation uses
4-space logical indentation with tabs only as leading 8-space compression. 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.
- For long Tailwind `className` strings, wrap across lines only when needed. - For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation - Keep continuation indentation aligned with the repository's 4-space
rule, using tabs only as leading 8-space compression. 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.
- In TypeScript and TSX function declarations, including `const` arrow - In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing 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])
}
```
- Continuation indentation example:
```ts
const editingRow =
Number.isFinite (editingSourceRow)
? rows.find (_1 => _1.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 - 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 function's opening `(`, put the closing parameter `)` at the beginning of its
own line before the return type or `=>`. own line before the return type or `=>`.
@@ -219,6 +327,237 @@ pass or the remaining failure is clearly blocked.
`BehaviorSettingsSection.tsx`. `BehaviorSettingsSection.tsx`.
- Avoid reformatting unrelated JSX. - 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 ### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is Use this table before accepting any edited TypeScript or TSX hunk. The table is
@@ -563,8 +902,9 @@ hunks line by line:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves 7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise. otherwise.
8. JSX closing parentheses keep the compact local style. 8. JSX closing parentheses keep the compact local style.
9. Leading indentation is 4-space logical indentation with tabs used only as 9. Leading block indentation uses 2 spaces per level, wrapped continuations
leading 8-space compression. use the repository's 4-space continuation alignment, and complete leading
runs of 8 spaces may be compressed to tabs.
10. No line has trailing whitespace. 10. No line has trailing whitespace.
## Lint and build constraints ## Lint and build constraints
+2 -10
ファイルの表示
@@ -2,11 +2,10 @@ import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { FC, ReactNode } from 'react' import type { FC } from 'react'
type Props = { type Props = {
disabled?: boolean disabled?: boolean
labelAddon?: ReactNode
originalCreatedFrom: string | null originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null originalCreatedBefore: string | null
@@ -16,19 +15,12 @@ type Props = {
const PostOriginalCreatedTimeField: FC<Props> = ( const PostOriginalCreatedTimeField: FC<Props> = (
{ disabled, { disabled,
labelAddon,
originalCreatedFrom, originalCreatedFrom,
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore, setOriginalCreatedBefore,
errors }: Props) => ( errors }: Props) => (
<FormField <FormField label="オリジナルの作成日時" messages={errors}>
label={
<span className="flex items-center gap-2">
<span></span>
{labelAddon}
</span>}
messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start"> <div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
+36
ファイルの表示
@@ -0,0 +1,36 @@
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 (_1 => _1.name === section)?.subMenu.find (_1 => _1.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)
})
})
+11 -7
ファイルの表示
@@ -9,6 +9,7 @@ import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { fetchTag, fetchTagByName } from '@/lib/tags' import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials' import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -18,7 +19,7 @@ import type { FC, MouseEvent } from 'react'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null, }
export const menuOutline = ( export const menuOutline = (
@@ -30,6 +31,7 @@ export const menuOutline = (
pathName: string }, pathName: string },
): Menu => { ): Menu => {
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0 const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
const editable = canEditContent (user)
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId) const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? '' const wikiTitle = pathName.split ('/')[2] ?? ''
@@ -42,8 +44,8 @@ export const menuOutline = (
{ name: '広場', to: '/posts', subMenu: [ { name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' }, { name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new' }, { name: '追加', to: '/posts/new', visible: editable },
{ name: '取込', to: '/posts/import' }, { name: '取込', to: '/posts/import', visible: editable },
{ name: '全体履歴', to: '/posts/changes' }, { name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [ { name: 'タグ', to: '/tags', subMenu: [
@@ -59,8 +61,9 @@ export const menuOutline = (
visible: tagFlg && tag?.category !== 'nico' }] }, visible: tagFlg && tag?.category !== 'nico' }] },
{ name: '素材', to: '/materials', visible: true, subMenu: [ { name: '素材', to: '/materials', visible: true, subMenu: [
{ name: '一覧', to: '/materials' }, { name: '一覧', to: '/materials' },
{ name: '追加', to: '/materials/new' }, { name: '追加', to: '/materials/new', visible: editable },
{ name: '抑止', to: '/materials/suppressions' }, { name: '抑止', to: '/materials/suppressions',
visible: user?.role === 'admin' },
{ name: '全体履歴', to: '/materials/changes' }, { name: '全体履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
{ component: <Separator/>, visible: materialFlg }, { component: <Separator/>, visible: materialFlg },
@@ -71,14 +74,15 @@ export const menuOutline = (
visible: materialFlg }] }, visible: materialFlg }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' }, { name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' }, { name: '新規', to: '/wiki/new', visible: editable },
{ name: '全体履歴', to: '/wiki/changes' }, { name: '全体履歴', to: '/wiki/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg }, { component: <Separator/>, visible: wikiPageFlg },
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`, { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg }, visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] },
{ name: 'おたのしみ', visible: false, subMenu: [ { name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' }, { name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] }, { name: 'グカネータ (β)', to: '/gekanator' }] },
+8 -4
ファイルの表示
@@ -1,11 +1,15 @@
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
type Props = { children: ReactNode } type Props = {
children: ReactNode
className?: string }
const Form: FC<Props> = ({ children }) => ( const Form: FC<Props> = ({ children, className }) => (
<div className="max-w-xl mx-auto p-4 space-y-4"> <div className={cn ('mx-auto max-w-xl space-y-4 p-4', className)}>
{children} {children}
</div>) </div>)
export default Form export default Form
+34
ファイルの表示
@@ -0,0 +1,34 @@
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
ファイルの表示
@@ -0,0 +1,114 @@
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 ()
})
})
+266 -99
ファイルの表示
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, import { Dialog,
@@ -7,29 +7,16 @@ import { Dialog,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle } from '@/components/ui/dialog' DialogTitle } from '@/components/ui/dialog'
import { DialogueContext } from '@/lib/dialogues/useDialogue'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
import type { AlertOptions,
type DialogueVariant = 'default' | 'danger' ChoiceOptions,
ConfirmOptions,
type ConfirmOptions = { title: string DialogueAPI,
description?: ReactNode DialogueFormAction,
confirmText?: string DialogueFormControls,
cancelText?: string DialogueFormOptions } from '@/lib/dialogues/useDialogue'
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 = type DialogueRequest =
| { id: number | { id: number
@@ -44,13 +31,10 @@ type DialogueRequest =
kind: 'choice' kind: 'choice'
options: ChoiceOptions<string> options: ChoiceOptions<string>
resolve: (value: string | null) => void } resolve: (value: string | null) => void }
| { id: number
type DialogueAPI = kind: 'form'
{ confirm: (options: ConfirmOptions) => Promise<boolean> options: DialogueFormOptions
alert: (options: AlertOptions) => Promise<void> resolve: () => void }
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }
const DialogueContext = createContext<DialogueAPI | null> (null)
let nextDialogueId = 1 let nextDialogueId = 1
@@ -59,20 +43,27 @@ type Props = { children: ReactNode }
const DialogueProvider: FC<Props> = ({ children }) => { const DialogueProvider: FC<Props> = ({ children }) => {
const [queue, setQueue] = useState<DialogueRequest[]> ([]) 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 push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
const id = nextDialogueId const id = nextDialogueId
++nextDialogueId ++nextDialogueId
setQueue (q => [...q, { ...request, id } as DialogueRequest]) setQueue (current => [...current, { ...request, id } as DialogueRequest])
}, []) }, [])
const closeActive = useCallback ((result?: unknown) => { const closeRequest = useCallback ((id: number, result?: unknown) => {
setQueue (q => { setQueue (current => {
const [active, ...rest] = q const active = current.find (request => request.id === id)
if (!(active)) if (active == null)
return rest return current
switch (active.kind) switch (active.kind)
{ {
@@ -87,9 +78,42 @@ const DialogueProvider: FC<Props> = ({ children }) => {
case 'choice': case 'choice':
active.resolve ((result ?? null) as string | null) active.resolve ((result ?? null) as string | null)
break break
case 'form':
active.resolve ()
break
} }
return rest return current.filter (request => request.id !== id)
})
setPendingIds (current => current.filter (_1 => _1 !== 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
}) })
}, []) }, [])
@@ -103,86 +127,229 @@ const DialogueProvider: FC<Props> = ({ children }) => {
choice: options => new Promise (resolve => { choice: options => new Promise (resolve => {
push ({ kind: 'choice', push ({ kind: 'choice',
options: options as ChoiceOptions<string>, options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })}) }), [push]) 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 (_1 => _1 !== id))
}
},
[closeRequest, pendingIds])
const active = queue[0] 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 ( return (
<DialogueContext.Provider value={api}> <DialogueContext.Provider value={api}>
{children} {children}
<Dialog {active && (
open={Boolean (active)} <Dialog
onOpenChange={open => { open
if (!(open)) onOpenChange={open => {
closeActive (active?.kind !== 'confirm' && null) const blocked =
}}> nestedConfirm?.parentId === active.id
{active && ( || 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)
}}>
<DialogContent className="px-6 pb-6 pt-7"> <DialogContent className="px-6 pb-6 pt-7">
<DialogHeader className="pl-8"> <DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle> <DialogTitle>{nestedConfirm.options.title}</DialogTitle>
{active.options.description && ( {nestedConfirm.options.description && (
<DialogDescription asChild> <DialogDescription asChild>
<div>{active.options.description}</div> <div>{nestedConfirm.options.description}</div>
</DialogDescription>)} </DialogDescription>)}
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
{active.kind === 'confirm' && ( <Button
<> variant="outline"
<Button onClick={() => closeNestedConfirm (false)}>
variant="outline" {nestedConfirm.options.cancelText ?? '取消'}
onClick={() => closeActive (false)}> </Button>
{active.options.cancelText ?? '取消'}
</Button>
<Button <Button
variant={(active.options.variant === 'danger') variant={nestedConfirm.options.variant === 'danger'
? 'destructive' ? 'destructive'
: 'default'} : 'default'}
onClick={() => closeActive (true)}> onClick={() => closeNestedConfirm (true)}>
{active.options.confirmText ?? '確定'} {nestedConfirm.options.confirmText ?? '確定'}
</Button> </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> </DialogFooter>
</DialogContent>)} </DialogContent>
</Dialog> </Dialog>)}
</DialogueContext.Provider>) </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 export default DialogueProvider
-287
ファイルの表示
@@ -1,287 +0,0 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { inputClass } from '@/lib/utils'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import type { PostImportOrigin,
PostImportRow } from '@/lib/postImportSession'
type Draft = {
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
type Props = {
open: boolean
row: PostImportRow | null
saving: boolean
onOpenChange: (open: boolean) => void
onSave: (draft: Draft) => Promise<boolean> }
const originOf = (
row: PostImportRow,
field: string,
): PostImportOrigin =>
row.provenance[field] ?? 'automatic'
const changedOrigin = (
changed: boolean,
row: PostImportRow,
field: string,
): PostImportOrigin =>
changed ? 'manual' : originOf (row, field)
const originalCreatedOrigin = (
row: PostImportRow,
originalDraft: Draft,
draft: Draft,
): PostImportOrigin => {
const changed =
originalDraft.originalCreatedFrom !== draft.originalCreatedFrom
|| originalDraft.originalCreatedBefore !== draft.originalCreatedBefore
if (changed)
return 'manual'
const manualOrigin =
originOf (row, 'originalCreatedFrom') === 'manual'
|| originOf (row, 'originalCreatedBefore') === 'manual'
return manualOrigin ? 'manual' : 'automatic'
}
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 groupedMessages = (
...values: Array<string[] | undefined>
): string[] =>
values.flatMap (value => value ?? [])
const PostImportRowDialog: FC<Props> = (
{ open, row, saving, onOpenChange, onSave },
) => {
const [draft, setDraft] = useState<Draft | null> (null)
useEffect (() => {
if (open && row)
setDraft (buildDraft (row))
}, [open, row])
if (row == null || draft == null)
return null
const originalDraft = buildDraft (row)
const fieldOrigin = (field: keyof Draft): PostImportOrigin =>
changedOrigin (draft[field] !== originalDraft[field], row, field)
const update = <Key extends keyof Draft,> (
key: Key,
value: Draft[Key],
) => {
setDraft (current =>
current ? { ...current, [key]: value } : current)
}
const save = async () => {
if (await onSave (draft))
onOpenChange (false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100dvh-1rem)] max-w-3xl flex-col
overflow-hidden p-0">
<DialogHeader className="shrink-0 px-6 pb-4 pt-7">
<DialogTitle>稿</DialogTitle>
<DialogDescription>
稿 {row.sourceRow}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-6">
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<div className="space-y-3">
<ThumbnailPreview
url={draft.thumbnailBase}
className="h-28 w-28"/>
<div className="flex flex-wrap gap-2">
<PostImportStatusBadge value={effectivePostImportStatus (row)}/>
</div>
</div>
<div className="space-y-4">
<DialogTextField
label="URL"
value={draft.url}
origin={fieldOrigin ('url')}
warnings={row.fieldWarnings.url}
errors={groupedMessages (row.validationErrors.url, row.importErrors?.url)}
onChange={value => update ('url', value)}/>
<DialogTextField
label="タイトル"
value={draft.title}
origin={fieldOrigin ('title')}
warnings={row.fieldWarnings.title}
errors={groupedMessages (row.validationErrors.title, row.importErrors?.title)}
onChange={value => update ('title', value)}/>
<DialogTextField
label="サムネール基底 URL"
value={draft.thumbnailBase}
origin={fieldOrigin ('thumbnailBase')}
warnings={row.fieldWarnings.thumbnailBase}
errors={groupedMessages (
row.validationErrors.thumbnailBase,
row.importErrors?.thumbnailBase,
)}
onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField
labelAddon={
<PostImportStatusBadge
value={originalCreatedOrigin (row, originalDraft, draft)}/>
}
originalCreatedFrom={draft.originalCreatedFrom || null}
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
originalCreatedBefore={draft.originalCreatedBefore || null}
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
errors={groupedMessages (
row.validationErrors.originalCreatedAt,
row.validationErrors.originalCreatedFrom,
row.validationErrors.originalCreatedBefore,
row.importErrors?.originalCreatedAt,
row.importErrors?.originalCreatedFrom,
row.importErrors?.originalCreatedBefore,
)}/>
<DialogTextField
label="動画時間"
value={draft.duration}
origin={fieldOrigin ('duration')}
errors={groupedMessages (
row.validationErrors.duration,
row.validationErrors.videoMs,
row.importErrors?.duration,
row.importErrors?.videoMs,
)}
onChange={value => update ('duration', value)}/>
<DialogAreaField
label="タグ"
value={draft.tags}
origin={fieldOrigin ('tags')}
warnings={row.fieldWarnings.tags}
errors={groupedMessages (row.validationErrors.tags, row.importErrors?.tags)}
onChange={value => update ('tags', value)}/>
<DialogTextField
label="親投稿"
value={draft.parentPostIds}
origin={fieldOrigin ('parentPostIds')}
errors={groupedMessages (
row.validationErrors.parentPostIds,
row.importErrors?.parentPostIds,
)}
onChange={value => update ('parentPostIds', value)}/>
<FieldWarning messages={row.baseWarnings}/>
<FieldError messages={row.validationErrors.base}/>
<FieldError messages={row.importErrors?.base}/>
</div>
</div>
</div>
<DialogFooter className="shrink-0 px-6 pb-6 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange (false)}>
</Button>
<Button
type="button"
onClick={save}
disabled={saving}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>)
}
const DialogTextField = (
{ label, value, origin, warnings, errors, onChange }: {
label: string
value: string
origin: PostImportOrigin
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={<DialogLabel label={label} origin={origin}/>} messages={errors}>
{({ describedBy, invalid }) => (
<>
<input
value={value}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
<FieldWarning messages={warnings}/>
</>)}
</FormField>)
const DialogAreaField = (
{ label, value, origin, warnings, errors, onChange }: {
label: string
value: string
origin: PostImportOrigin
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={<DialogLabel label={label} origin={origin}/>} messages={errors}>
{({ describedBy, invalid }) => (
<>
<textarea
value={value}
rows={4}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
<FieldWarning messages={warnings}/>
</>)}
</FormField>)
const DialogLabel = (
{ label, origin }: {
label: string
origin: PostImportOrigin },
) => (
<span className="flex items-center gap-2">
<span>{label}</span>
<PostImportStatusBadge value={origin}/>
</span>)
export default PostImportRowDialog
+91
ファイルの表示
@@ -0,0 +1,91 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { 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'
describe ('PostImportRowForm', () => {
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 (_1 => _1.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 (_1 => _1.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 (_1 => _1.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 (
_1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
})
})
+277
ファイルの表示
@@ -0,0 +1,277 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { inputClass } from '@/lib/utils'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
type Draft = {
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
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[] =>
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 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)
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">
<ThumbnailPreview
url={draft.thumbnailBase}
className="h-28 w-28"/>
</div>
<div className="space-y-4">
<PostImportTextField
label="URL"
value={draft.url}
warnings={displayRow.fieldWarnings.url}
errors={groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url)}
onChange={value => update ('url', value)}/>
<PostImportTextField
label="タイトル"
value={draft.title}
warnings={displayRow.fieldWarnings.title}
errors={groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title)}
onChange={value => update ('title', value)}/>
<PostImportTextField
label="サムネール基底 URL"
value={draft.thumbnailBase}
warnings={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField
originalCreatedFrom={draft.originalCreatedFrom || null}
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
originalCreatedBefore={draft.originalCreatedBefore || null}
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
errors={groupedMessages (
displayRow.validationErrors.originalCreatedAt,
displayRow.validationErrors.originalCreatedFrom,
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedAt,
displayRow.importErrors?.originalCreatedFrom,
displayRow.importErrors?.originalCreatedBefore)}/>
<PostImportTextField
label="動画時間"
value={draft.duration}
errors={groupedMessages (
displayRow.validationErrors.duration,
displayRow.validationErrors.videoMs,
displayRow.importErrors?.duration,
displayRow.importErrors?.videoMs)}
onChange={value => update ('duration', value)}/>
<PostImportAreaField
label="タグ"
value={draft.tags}
warnings={displayRow.fieldWarnings.tags}
errors={groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags)}
onChange={value => update ('tags', value)}/>
<PostImportTextField
label="親投稿"
value={draft.parentPostIds}
errors={groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds)}
onChange={value => update ('parentPostIds', value)}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
</div>
</div>
</div>
</div>
</>)
}
const PostImportTextField = (
{ label, value, warnings, errors, onChange }: {
label: string
value: string
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<input
value={value}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
<FieldWarning messages={warnings}/>
</>)}
</FormField>)
const PostImportAreaField = (
{ label, value, warnings, errors, onChange }: {
label: string
value: string
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<TextArea
value={value}
rows={4}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
invalid={invalid}
className="h-auto"/>
<FieldWarning messages={warnings}/>
</>)}
</FormField>)
export default PostImportRowForm
export { buildDraft }
export type { Draft as PostImportRowDraft }
+7 -55
ファイルの表示
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import type { FC } from 'react' import type { FC } from 'react'
@@ -13,42 +13,6 @@ type Props = {
row: PostImportRow row: PostImportRow
onEdit: () => void } onEdit: () => void }
const toneClass = (row: PostImportRow): string[] => {
const value = effectivePostImportStatus (row)
switch (value)
{
case 'created':
return [
'border-sky-200 bg-sky-50',
'dark:border-sky-900 dark:bg-sky-950/30']
case 'skipped':
return [
'border-stone-200 bg-stone-50',
'dark:border-stone-800 dark:bg-stone-900/60']
case 'failed':
case 'error':
return [
'border-rose-200 bg-rose-50',
'dark:border-rose-900 dark:bg-rose-950/30']
case 'warning':
return [
'border-amber-200 bg-amber-50',
'dark:border-amber-900 dark:bg-amber-950/30']
default:
return [
'border-border bg-white',
'dark:border-neutral-700 dark:bg-neutral-900']
}
}
const summaryError = (row: PostImportRow): string | null => {
const validation = Object.values (row.validationErrors ?? { }).flat ()[0]
if (validation)
return validation
return Object.values (row.importErrors ?? { }).flat ()[0] ?? null
}
const summaryWarning = (row: PostImportRow): string | null => const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0] Object.values (row.fieldWarnings ?? { }).flat ()[0]
?? row.baseWarnings?.[0] ?? row.baseWarnings?.[0]
@@ -61,9 +25,8 @@ const summaryDate = (row: PostImportRow): string =>
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => { const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
const error = summaryError (row) const warning = summaryWarning (row)
const warning = error == null ? summaryWarning (row) : null const displayStatus = displayPostImportStatus (row)
const effectiveStatus = effectivePostImportStatus (row)
return ( return (
<> <>
@@ -71,9 +34,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
className={cn ( className={cn (
'hidden items-center gap-4 rounded-lg border p-4 md:grid', 'hidden items-center gap-4 rounded-lg border p-4 md:grid',
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]', 'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
toneClass (row), 'transition-shadow hover:shadow-sm')}>
'transition-shadow hover:shadow-sm',
)}>
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">#{row.sourceRow}</div> <div className="text-sm font-medium">#{row.sourceRow}</div>
</div> </div>
@@ -98,13 +59,9 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div className="text-xs text-amber-700 dark:text-amber-200"> <div className="text-xs text-amber-700 dark:text-amber-200">
{warning} {warning}
</div>)} </div>)}
{error && (
<div className="text-xs text-red-700 dark:text-red-200">
{error}
</div>)}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<PostImportStatusBadge value={effectiveStatus}/> {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
@@ -120,8 +77,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div <div
className={cn ( className={cn (
'space-y-3 rounded-lg border p-4 md:hidden', 'space-y-3 rounded-lg border p-4 md:hidden',
toneClass (row), 'transition-shadow hover:shadow-sm')}>
)}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<ThumbnailPreview <ThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
@@ -134,12 +90,8 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
{row.url} {row.url}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<PostImportStatusBadge value={effectiveStatus}/> {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
{error && (
<div className="text-xs text-red-700 dark:text-red-200">
{error}
</div>)}
{warning && ( {warning && (
<div className="text-xs text-amber-700 dark:text-amber-200"> <div className="text-xs text-amber-700 dark:text-amber-200">
{warning} {warning}
+11 -44
ファイルの表示
@@ -1,60 +1,27 @@
import { cn } from '@/lib/utils' import StatusBadge from '@/components/common/StatusBadge'
import type { FC } from 'react' import type { FC } from 'react'
import type { StatusBadgeTone } from '@/components/common/StatusBadge'
import type { PostImportBadgeValue } from '@/components/posts/import/postImportRowStatus' import type { PostImportBadgeValue } from '@/components/posts/import/postImportRowStatus'
type Props = { type Props = {
value: PostImportBadgeValue } value: PostImportBadgeValue }
const LABELS: Record<PostImportBadgeValue, string> = { const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能', ready: '登録可能',
warning: '警告', warning: '警告',
error: '要修正', skipped: 'スキップ' }
pending: '未登録',
created: '登録済み',
skipped: 'スキップ',
failed: '失敗',
automatic: '自動取得',
manual: '手修正' }
const STYLES: Record<PostImportBadgeValue, string[]> = { const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: [ ready: 'success',
'border-emerald-300 bg-emerald-50 text-emerald-700', warning: 'warning',
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'], skipped: 'neutral' }
warning: [
'border-amber-300 bg-amber-50 text-amber-700',
'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'],
error: [
'border-red-300 bg-red-50 text-red-700',
'dark:border-red-900 dark:bg-red-950 dark:text-red-200'],
pending: [
'border-slate-300 bg-slate-50 text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
created: [
'border-sky-300 bg-sky-50 text-sky-700',
'dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200'],
skipped: [
'border-stone-300 bg-stone-50 text-stone-700',
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'],
failed: [
'border-rose-300 bg-rose-50 text-rose-700',
'dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200'],
automatic: [
'border-slate-300 bg-slate-50 text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
manual: [
'border-purple-300 bg-purple-50 text-purple-700',
'dark:border-purple-900 dark:bg-purple-950 dark:text-purple-200'] }
const PostImportStatusBadge: FC<Props> = ({ value }) => ( const PostImportStatusBadge: FC<Props> = ({ value }) => (
<span <StatusBadge tone={TONES[value]}>
className={cn (
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium',
STYLES[value],
)}>
{LABELS[value]} {LABELS[value]}
</span>) </StatusBadge>)
export default PostImportStatusBadge export default PostImportStatusBadge
+27
ファイルの表示
@@ -0,0 +1,27 @@
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 ()
})
})
+17 -28
ファイルの表示
@@ -1,33 +1,22 @@
import type { PostImportOrigin, import type { PostImportRow } from '@/lib/postImportSession'
PostImportRow } from '@/lib/postImportSession'
export type PostImportEffectiveStatus = export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
'created'
| 'skipped'
| 'failed'
| 'error'
| 'warning'
| 'ready'
export type PostImportBadgeValue = export type PostImportBadgeValue = PostImportDisplayStatus
PostImportEffectiveStatus
| 'pending'
| PostImportOrigin
const hasWarnings = (row: PostImportRow): boolean =>
Object.values (row.fieldWarnings ?? { }).some (_1 => _1.length > 0)
|| row.baseWarnings.length > 0
export const effectivePostImportStatus = ( export const displayPostImportStatus = (
row: PostImportRow, row: PostImportRow,
): PostImportEffectiveStatus => { ): PostImportDisplayStatus | null =>
if (Object.keys (row.validationErrors ?? { }).length > 0) (row.skipReason === 'existing' || row.importStatus === 'skipped')
return 'error' ? 'skipped'
: ((Object.keys (row.validationErrors ?? { }).length > 0
switch (row.importStatus) || row.importStatus === 'failed'
{ || row.importStatus === 'created')
case 'created': ? null
case 'skipped': : ((hasWarnings (row) || row.status === 'warning')
case 'failed': ? 'warning'
return row.importStatus : 'ready'))
default:
return row.status
}
}
+72
ファイルの表示
@@ -0,0 +1,72 @@
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 }
+147
ファイルの表示
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import { creatableImportRows,
initialisePreviewRows,
mergeImportResults,
mergeValidatedImportRows,
processableImportRows,
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 })], [{
sourceRow: 1,
status: 'created',
post: { id: 3 } }])[0]
const skipped = mergeImportResults ([buildPostImportRow ({
createdPostId: 3,
importStatus: 'created' })], [{
sourceRow: 1,
status: 'skipped',
existingPostId: 4 }])[0]
const failed = mergeImportResults ([buildPostImportRow ({
skipReason: 'existing',
existingPostId: 4 })], [{
sourceRow: 1,
status: 'failed',
errors: { base: ['failure'] } }])[0]
expect (created).toMatchObject ({
importStatus: 'created',
createdPostId: 3,
existingPostId: undefined,
skipReason: undefined })
expect (skipped).toMatchObject ({
importStatus: 'skipped',
existingPostId: 4,
createdPostId: undefined,
skipReason: 'existing' })
expect (failed).toMatchObject ({
importStatus: 'failed',
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',
importErrors: { base: ['failed'] } })]
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 1 })
expect (retryImportRow (rows, 3)[2]).toMatchObject ({
importStatus: 'pending',
importErrors: undefined })
})
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'])
})
})
+32 -75
ファイルの表示
@@ -7,14 +7,17 @@ const hasSkipReason = (row: PostImportRow): boolean =>
const hasValidationErrors = (row: PostImportRow): boolean => const hasValidationErrors = (row: PostImportRow): boolean =>
Object.keys (row.validationErrors ?? { }).length > 0 Object.keys (row.validationErrors ?? { }).length > 0
const rowChanged = ( const buildResetSnapshot = (row: PostImportRow) => ({
previous: PostImportRow, url: row.url,
next: PostImportRow, attributes: { ...row.attributes },
): boolean => provenance: { ...row.provenance },
previous.url !== next.url tagSources: {
|| JSON.stringify (previous.attributes) !== JSON.stringify (next.attributes) automatic: row.tagSources?.automatic ?? '',
|| JSON.stringify (previous.provenance) !== JSON.stringify (next.provenance) manual: row.tagSources?.manual ?? '' },
|| JSON.stringify (previous.tagSources ?? { }) !== JSON.stringify (next.tagSources ?? { }) fieldWarnings: Object.fromEntries (
Object.entries (row.fieldWarnings).map (([key, values]) => [key, [...values]])),
baseWarnings: [...row.baseWarnings],
metadataUrl: row.metadataUrl })
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] => export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
@@ -39,23 +42,14 @@ export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
submittable: rows.filter (row => submittable: rows.filter (row =>
creatableImportRows ([row]).length > 0 creatableImportRows ([row]).length > 0
&& !(hasValidationErrors (row))).length, && !(hasValidationErrors (row))).length,
invalid: rows.filter (row => hasValidationErrors (row)).length,
skipPlanned: rows.filter (row => skipPlanned: rows.filter (row =>
hasSkipReason (row) processableImportRows ([row]).length > 0
&& !(hasValidationErrors (row)) && hasSkipReason (row)).length })
&& row.importStatus !== 'created').length,
created: rows.filter (row => row.importStatus === 'created').length,
failed: rows.filter (row => row.importStatus === 'failed').length })
export const resultSummaryCounts = (rows: PostImportRow[]) => export const resultSummaryCounts = (rows: PostImportRow[]) =>
rows.reduce ( rows.reduce (
(counts, row) => { (counts, row) => {
if (hasValidationErrors (row))
{
++counts.invalid
return counts
}
if (row.importStatus === 'created') if (row.importStatus === 'created')
{ {
++counts.created ++counts.created
@@ -70,7 +64,7 @@ export const resultSummaryCounts = (rows: PostImportRow[]) =>
++counts.failed ++counts.failed
return counts return counts
}, },
{ created: 0, skipped: 0, failed: 0, invalid: 0 }) { created: 0, skipped: 0, failed: 0 })
export const mergeValidatedImportRows = ( export const mergeValidatedImportRows = (
@@ -79,7 +73,10 @@ export const mergeValidatedImportRows = (
): PostImportRow[] => { ): PostImportRow[] => {
const validatedMap = new Map (validated.map (row => [row.sourceRow, row])) const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
return current.map (previous => { return current.map (previous => {
if (previous.importStatus === 'created') if (
previous.importStatus === 'created'
|| previous.importStatus === 'skipped'
|| previous.importStatus === 'failed')
return previous return previous
const row = validatedMap.get (previous.sourceRow) const row = validatedMap.get (previous.sourceRow)
@@ -111,59 +108,11 @@ export const mergeValidatedImportRows = (
: previous.baseWarnings, : previous.baseWarnings,
validationErrors: row.validationErrors, validationErrors: row.validationErrors,
status: row.status, status: row.status,
metadataUrl: row.metadataUrl } metadataUrl: row.metadataUrl,
}) resetSnapshot:
} row.metadataUrl !== previous.metadataUrl
? buildResetSnapshot (row)
: previous.resetSnapshot }
export const mergePreviewImportRows = (
current: PostImportRow[],
preview: PostImportRow[],
): PostImportRow[] => {
const currentMap = new Map (current.map (row => [row.sourceRow, row]))
return preview.map (row => {
const previous = currentMap.get (row.sourceRow)
if (previous == null)
return row
if (previous.importStatus === 'created')
return previous
const mergedAttributes = { ...row.attributes }
const mergedProvenance = { ...row.provenance }
for (const [field, origin] of Object.entries (previous.provenance))
{
if (origin !== 'manual' || field === 'url')
continue
if (field in previous.attributes)
mergedAttributes[field] = previous.attributes[field]
mergedProvenance[field] = 'manual'
}
const mergedTagSources =
previous.provenance.tags === 'manual'
? {
automatic: row.tagSources?.automatic ?? '',
manual: previous.tagSources?.manual ?? String (previous.attributes.tags ?? '') }
: row.tagSources
const nextRow = {
...row,
attributes: mergedAttributes,
provenance: mergedProvenance,
tagSources: mergedTagSources,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
createdPostId: previous.createdPostId,
importStatus: previous.importStatus,
importErrors: previous.importErrors }
if (rowChanged (previous, nextRow))
{
nextRow.importStatus = 'pending'
nextRow.importErrors = undefined
}
return nextRow
}) })
} }
@@ -184,13 +133,15 @@ export const mergeImportResults = (
return { return {
...row, ...row,
importStatus: 'created', importStatus: 'created',
createdPostId: result.post?.id, skipReason: undefined,
createdPostId: result.post.id,
existingPostId: undefined, existingPostId: undefined,
importErrors: result.errors } importErrors: result.errors }
case 'skipped': case 'skipped':
return { return {
...row, ...row,
importStatus: 'skipped', importStatus: 'skipped',
skipReason: 'existing',
createdPostId: undefined, createdPostId: undefined,
existingPostId: result.existingPostId, existingPostId: result.existingPostId,
importErrors: result.errors } importErrors: result.errors }
@@ -198,6 +149,7 @@ export const mergeImportResults = (
return { return {
...row, ...row,
importStatus: 'failed', importStatus: 'failed',
skipReason: undefined,
createdPostId: undefined, createdPostId: undefined,
existingPostId: undefined, existingPostId: undefined,
importErrors: result.errors } importErrors: result.errors }
@@ -214,3 +166,8 @@ export const retryImportRow = (
row.sourceRow === sourceRow && row.importStatus === 'failed' row.sourceRow === sourceRow && row.importStatus === 'failed'
? { ...row, importStatus: 'pending', importErrors: undefined } ? { ...row, importStatus: 'pending', importErrors: undefined }
: row) : row)
export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.map (row => ({
...row,
resetSnapshot: buildResetSnapshot (row) }))
+49
ファイルの表示
@@ -0,0 +1,49 @@
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' })
})
})
+114
ファイルの表示
@@ -0,0 +1,114 @@
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 ({
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 })
expect (savePostImportSession ('session', {
source: row.url,
rows: [row],
repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ('session')).toMatchObject ({
version: 2,
source: row.url,
rows: [{
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 }] })
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 }]
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 ('ブラウザへ保存できませんでした.')
})
})
+54 -3
ファイルの表示
@@ -1,4 +1,5 @@
import type { PostImportOrigin, import type { PostImportOrigin,
PostImportResetSnapshot,
PostImportRow, PostImportRow,
PostImportSession, PostImportSession,
PostImportStatus, PostImportStatus,
@@ -20,6 +21,7 @@ const ATTRIBUTE_KEYS = [
'parentPostIds'] as const 'parentPostIds'] as const
const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const TAG_SOURCE_KEYS = ['automatic', 'manual'] 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> => const isPlainObject = (value: unknown): value is Record<string, unknown> =>
@@ -137,6 +139,51 @@ const hasOnlyKeys = (
): boolean => ): boolean =>
Object.keys (value).every (key => allowedKeys.includes (key)) Object.keys (value).every (key => allowedKeys.includes (key))
const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => {
if (!(isPlainObject (value)))
return null
if (typeof value.url !== 'string')
return null
if (!(isPlainObject (value.attributes)))
return null
if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
return null
if (!(Object.values (value.attributes).every (entry =>
typeof entry === 'string' || typeof entry === 'number')))
return null
if (!(isPlainObject (value.provenance)))
return null
if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
return null
if (!(Object.values (value.provenance).every (origin => isValidOrigin (origin))))
return null
if (!(isPlainObject (value.tagSources)))
return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
return null
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
if (fieldWarnings == null)
return null
if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS)))
return null
if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
return null
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
return null
return {
url: value.url,
attributes: value.attributes as Record<string, string | number>,
provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string>,
fieldWarnings,
baseWarnings: value.baseWarnings,
metadataUrl: value.metadataUrl as string | undefined }
}
const sanitiseRow = (value: unknown): PostImportRow | null => { const sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(isPlainObject (value))) if (!(isPlainObject (value)))
@@ -166,14 +213,17 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
const validationErrors = ensureStringListRecord (value.validationErrors) const validationErrors = ensureStringListRecord (value.validationErrors)
const fieldWarnings = ensureStringListRecord (value.fieldWarnings) const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
const resetSnapshot = sanitiseResetSnapshot (value.resetSnapshot)
if (validationErrors == null || fieldWarnings == null) if (validationErrors == null || fieldWarnings == null)
return null return null
if (resetSnapshot == null)
return null
const importErrors = const importErrors =
value.importErrors == null value.importErrors == null
? undefined ? undefined
: ensureStringListRecord (value.importErrors) : ensureStringListRecord (value.importErrors)
if (value.importErrors != null && importErrors == null) if (importErrors === null)
return null return null
if (!(Array.isArray (value.baseWarnings)) if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string'))) || !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
@@ -211,13 +261,14 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
provenance: value.provenance as Record<string, PostImportOrigin>, provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined, tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
status: value.status, status: value.status,
skipReason: value.skipReason, skipReason: value.skipReason ?? undefined,
existingPostId: existingPostId:
isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined, isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined, metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
resetSnapshot,
createdPostId: createdPostId:
isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined, isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
importStatus: value.importStatus } importStatus: value.importStatus ?? undefined }
} }
+26 -6
ファイルの表示
@@ -9,6 +9,15 @@ export type PostImportSkipReason = 'existing'
export type PostImportResultStatus = 'created' | 'skipped' | 'failed' export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
export type PostImportAttributeValue = string | number 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 = { export type PostImportRow = {
sourceRow: number sourceRow: number
url: string url: string
@@ -23,15 +32,26 @@ export type PostImportRow = {
skipReason?: PostImportSkipReason skipReason?: PostImportSkipReason
existingPostId?: number existingPostId?: number
metadataUrl?: string metadataUrl?: string
resetSnapshot: PostImportResetSnapshot
createdPostId?: number createdPostId?: number
importStatus?: PostImportStatus } importStatus?: PostImportStatus }
export type PostImportResultRow = { export type PostImportResultRow =
sourceRow: number | {
status: PostImportResultStatus sourceRow: number
post?: { id: number } status: 'created'
existingPostId?: number post: { id: number }
errors?: Record<string, string[]> } errors?: Record<string, string[]> }
| {
sourceRow: number
status: 'skipped'
existingPostId: number
errors?: Record<string, string[]> }
| {
sourceRow: number
status: 'failed'
errors?: Record<string, string[]>
recoverable?: boolean }
export type PostImportSession = { export type PostImportSession = {
version: number version: number
+82
ファイルの表示
@@ -0,0 +1,82 @@
import { fireEvent, screen } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { savePostImportSession } from '@/lib/postImportSession'
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ apiPost: vi.fn () }))
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
const ReviewLocation = () => {
const location = useLocation ()
return <div>{`review route ${ location.search }`}</div>
}
const renderPage = () => renderWithProviders (
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
<Route path="/posts/import/:sessionId/review" element={<ReviewLocation/>}/>
</Routes>,
{ route: '/posts/import/session/result' })
describe ('PostImportResultPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
})
it ('shows exclusive result counts, post links, and failed-row actions', async () => {
const rows = [
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 11 }),
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
skipReason: 'existing', existingPostId: 22 }),
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
importErrors: { base: ['登録中に失敗しました.'] } })]
savePostImportSession ('session', { source: '', rows, repairMode: 'all' })
renderPage ()
expect (await screen.findByText (
/登録成功\s*1件.*スキップ\s*1件.*失敗\s*1件/)).toBeInTheDocument ()
expect (screen.getAllByRole ('link', { name: '投稿を開く' })
.map (_1 => _1.getAttribute ('href'))).toEqual (['/posts/11', '/posts/22'])
expect (screen.getByText ('登録中に失敗しました.')).toBeInTheDocument ()
expect (screen.getByRole ('button', { name: '編輯' })).toBeInTheDocument ()
expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument ()
})
it ('returns a retry validation error to the review dialogue route', async () => {
const failed = buildPostImportRow ({
importStatus: 'failed',
importErrors: { base: ['old error'] } })
const invalid = buildPostImportRow ({
importStatus: 'pending',
status: 'error',
validationErrors: { title: ['タイトルを確認してください.'] } })
savePostImportSession ('session', {
source: failed.url,
rows: [failed],
repairMode: 'all' })
api.apiPost.mockResolvedValue ({ rows: [invalid] })
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '再試行' }))
expect (await screen.findByText ('review route ?edit=1')).toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (1)
const saved = JSON.parse (
sessionStorage.getItem ('post-import-session:session') ?? '{}')
expect (saved.rows[0]).toMatchObject ({
importStatus: 'pending',
validationErrors: { title: ['タイトルを確認してください.'] } })
})
})
+178 -192
ファイルの表示
@@ -1,225 +1,225 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate, import { useNavigate, useParams } from 'react-router-dom'
useParams } from 'react-router-dom'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft, import { clearPostImportSourceDraft,
initialisePreviewRows,
loadPostImportSession, loadPostImportSession,
mergeImportResults, mergeImportResults,
mergeValidatedImportRows, mergeValidatedImportRows,
resultSummaryCounts, resultSummaryCounts,
retryImportRow, retryImportRow,
savePostImportSession, savePostImportSession } from '@/lib/postImportSession'
type PostImportResultRow,
type PostImportRow,
type PostImportSession } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession'
import type { PostImportSession } from '@/lib/postImportSession'
import type { User } from '@/types' import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
const resultToneClass = ( const rowMessages = (row: PostImportRow): string[] =>
status: ReturnType<typeof effectivePostImportStatus>, Object.values (row.importErrors ?? { }).flat ()
): string[] => {
switch (status)
{
case 'created':
return [
'border-sky-200 bg-sky-50',
'dark:border-sky-900 dark:bg-sky-950/30']
case 'skipped':
return [
'border-stone-200 bg-stone-50',
'dark:border-stone-800 dark:bg-stone-900/60']
case 'failed':
case 'error':
return [
'border-rose-200 bg-rose-50',
'dark:border-rose-900 dark:bg-rose-950/30']
case 'warning':
return [
'border-amber-200 bg-amber-50',
'dark:border-amber-900 dark:bg-amber-950/30']
default:
return [
'border-border bg-white',
'dark:border-neutral-700 dark:bg-neutral-900']
}
}
const rowMessages = (row: PostImportRow): string[] => [
...Object.values (row.validationErrors ?? { }).flat (),
...Object.values (row.importErrors ?? { }).flat ()]
const PostImportResultPage: FC<Props> = ({ user }) => { const PostImportResultPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const navigate = useNavigate () const navigate = useNavigate ()
const { sessionId } = useParams () const { sessionId } = useParams ()
const [session, setSession] = useState<PostImportSession | null> (null) const [session, setSession] = useState<PostImportSession | null> (null)
const [missing, setMissing] = useState (false) const [missing, setMissing] = useState (false)
const [loadingRow, setLoadingRow] = useState<number | null> (null) const [loadingRow, setLoadingRow] = useState<number | null> (null)
useEffect (() => { useEffect (() => {
if (sessionId == null) if (sessionId == null)
return return
const loaded = loadPostImportSession (sessionId, message => const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message })) toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded) setSession (loaded)
setMissing (loaded == null) setMissing (loaded == null)
}, [sessionId]) }, [sessionId])
useEffect (() => { useEffect (() => {
if (sessionId == null || session == null) if (sessionId == null || session == null)
return return
savePostImportSession (sessionId, session, message => savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId]) }, [session, sessionId])
const counts = useMemo ( const counts = useMemo (
() => resultSummaryCounts (session?.rows ?? []), () => resultSummaryCounts (session?.rows ?? []),
[session]) [session])
const retry = async (sourceRow: number) => { const retry = async (sourceRow: number) => {
if (session == null) if (session == null || sessionId == null)
return return
setLoadingRow (sourceRow) setLoadingRow (sourceRow)
try try
{
const pendingRows = retryImportRow (session.rows, sourceRow)
setSession ({ ...session, rows: pendingRows })
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: pendingRows
.filter (row => row.importStatus !== 'created')
.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = mergeValidatedImportRows (pendingRows, validated.rows)
setSession (current =>
current
? { ...current, rows: validatedRows }
: current)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null)
return
if (Object.keys (target.validationErrors ?? { }).length > 0)
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 }] })
setSession (current =>
current
? { ...current, rows: mergeImportResults (current.rows, result.rows) }
: current)
}
catch
{
setSession (session)
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
}
}
const openRepair = (sourceRow: number) => {
if (session == null || sessionId == null)
return
const nextSession = { ...session, repairMode: 'failed' as const }
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
}
if (!(editable))
return <Forbidden/>
if (missing || sessionId == null || session == null)
{ {
return ( const pendingRows = retryImportRow (session.rows, sourceRow)
<MainArea> setSession ({ ...session, rows: pendingRows })
<div className="mx-auto max-w-4xl space-y-4 p-4"> const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
<PageTitle>稿</PageTitle> rows: pendingRows
<FieldError messages={['取込状態が見つかりません.']}/> .filter (row => row.importStatus !== 'created')
<Button type="button" onClick={() => navigate ('/posts/import')}> .map (row => ({
URL sourceRow: row.sourceRow,
</Button> url: row.url,
</div> attributes: row.attributes,
</MainArea>) provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = mergeValidatedImportRows (
pendingRows,
initialisePreviewRows (validated.rows))
const nextSession = {
...session,
rows: validatedRows,
repairMode: 'failed' as const }
setSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null)
return
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
const result = await apiPost<{
created: number
skipped: number
failed: number
rows: PostImportResultRow[] }> ('/posts/import', {
rows: [{
sourceRow: target.sourceRow,
url: target.url,
attributes: target.attributes,
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] })
const mergedRows = mergeImportResults (nextSession.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow)
const resultSession = {
...nextSession,
rows: nextRows,
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const }
setSession (resultSession)
if (recoverableTarget != null
&& Object.keys (recoverableTarget.validationErrors).length > 0)
{
const saved = savePostImportSession (sessionId, resultSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
} }
catch
{
setSession (session)
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
}
}
return ( const openRepair = (sourceRow: number) => {
<MainArea> if (session == null || sessionId == null)
<Helmet> return
<title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="mx-auto max-w-5xl space-y-4 p-4"> const nextSession = { ...session, repairMode: 'failed' as const }
<PageTitle></PageTitle> setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
}
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700 if (!(editable))
dark:bg-neutral-900"> return <Forbidden/>
<div className="flex flex-wrap gap-2">
<SummaryChip label="登録成功" value={counts.created} badge="created"/> if (missing || sessionId == null || session == null)
<SummaryChip label="スキップ" value={counts.skipped} badge="skipped"/> {
<SummaryChip label="失敗" value={counts.failed} badge="failed"/> return (
<SummaryChip label="要修正" value={counts.invalid} badge="error"/> <MainArea>
</div> <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> </div>
</MainArea>)
}
<div className="space-y-3"> return (
{session.rows.map (row => { <MainArea>
const status = effectivePostImportStatus (row) <Helmet>
return ( <title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="mx-auto max-w-5xl space-y-4 p-4">
<PageTitle></PageTitle>
<div className="text-sm text-neutral-700 dark:text-neutral-200">
{counts.created}  {counts.skipped}  {counts.failed}
</div>
<div className="space-y-3">
{session.rows.map (row => {
const displayStatus = displayPostImportStatus (row)
const canEdit = row.importStatus === 'failed'
const canRetry =
row.importStatus === 'failed'
&& Object.keys (row.validationErrors).length === 0
return (
<div <div
key={row.sourceRow} key={row.sourceRow}
className={[ className="rounded-lg border p-4 transition-shadow hover:shadow-sm">
'rounded-lg border p-4',
...resultToneClass (status)].join (' ')}>
<div className="flex flex-col gap-3 md:flex-row md:items-start <div className="flex flex-col gap-3 md:flex-row md:items-start
md:justify-between"> md:justify-between">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium"> {row.sourceRow}</span> <span className="text-sm font-medium"> {row.sourceRow}</span>
<PostImportStatusBadge value={status}/> {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
<div className="text-sm text-neutral-700 dark:text-neutral-200"> <div className="text-sm text-neutral-700 dark:text-neutral-200">
{String (row.attributes.title ?? '') || row.url} {String (row.attributes.title ?? '') || row.url}
@@ -238,22 +238,20 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
稿 稿
</PrefetchLink> </PrefetchLink>
</Button>)} </Button>)}
{(status === 'error' || row.importStatus === 'failed') && ( {canEdit && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => openRepair (row.sourceRow)}> onClick={() => openRepair (row.sourceRow)}>
</Button>)} </Button>)}
{row.importStatus === 'failed' && status !== 'error' && ( {canRetry && (
<> <Button
<Button type="button"
type="button" onClick={() => retry (row.sourceRow)}
onClick={() => retry (row.sourceRow)} disabled={loadingRow === row.sourceRow}>
disabled={loadingRow === row.sourceRow}>
</Button>)}
</Button>
</>)}
</div> </div>
</div> </div>
</div>)})} </div>)})}
@@ -289,16 +287,4 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
</MainArea>) </MainArea>)
} }
const SummaryChip = (
{ label, value, badge }: {
label: string
value: number
badge: 'created' | 'skipped' | 'failed' | 'error' },
) => (
<div className="flex items-center gap-2 rounded-full border border-border
bg-background px-3 py-1 text-sm">
<PostImportStatusBadge value={badge}/>
<span>{label} {value}</span>
</div>)
export default PostImportResultPage export default PostImportResultPage
+78
ファイルの表示
@@ -0,0 +1,78 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { savePostImportSession } from '@/lib/postImportSession'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ apiPost: vi.fn () }))
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
const renderPage = () => renderWithProviders (
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route path="/posts/import/:sessionId/result" element={<div>result route</div>}/>
</Routes>,
{ route: '/posts/import/session/review' })
describe ('PostImportReviewPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
})
it ('keeps the review route and opens the first invalid row dialogue', async () => {
const row = buildPostImportRow ({ attributes: { title: 'title' } })
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ({
attributes: { title: 'title' },
status: 'error',
validationErrors: { title: ['タイトルを確認してください.'] } })] })
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (1)
expect (screen.queryByText ('result route')).not.toBeInTheDocument ()
})
it ('submits existing rows for a formal skipped result and opens result route', async () => {
const row = buildPostImportRow ({
skipReason: 'existing',
existingPostId: 10 })
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
api.apiPost
.mockResolvedValueOnce ({ rows: [row] })
.mockResolvedValueOnce ({
created: 0,
skipped: 1,
failed: 0,
rows: [{ sourceRow: 1, status: 'skipped', existingPostId: 10 }] })
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
expect (await screen.findByText ('result route')).toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (2)
expect (api.apiPost.mock.calls[1]?.[1]).toMatchObject ({
rows: [expect.objectContaining ({ sourceRow: 1, url: row.url })] })
await waitFor (() => {
const saved = JSON.parse (
sessionStorage.getItem ('post-import-session:session') ?? '{}')
expect (saved.rows[0]).toMatchObject ({
importStatus: 'skipped',
existingPostId: 10,
skipReason: 'existing' })
})
})
})
+331 -267
ファイルの表示
@@ -1,317 +1,344 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate, import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
useParams,
useSearchParams } from 'react-router-dom'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog' import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary' import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { loadPostImportSession, import { loadPostImportSession,
creatableImportRows, creatableImportRows,
initialisePreviewRows,
mergeImportResults, mergeImportResults,
mergeValidatedImportRows, mergeValidatedImportRows,
processableImportRows, processableImportRows,
reviewSummaryCounts, reviewSummaryCounts,
savePostImportSession, savePostImportSession } from '@/lib/postImportSession'
type PostImportResultRow,
type PostImportRow,
type PostImportSession } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' 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' import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
type Draft = {
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
const PostImportReviewPage: FC<Props> = ({ user }) => { const PostImportReviewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const navigate = useNavigate () const dialogue = useDialogue ()
const { sessionId } = useParams () const navigate = useNavigate ()
const [searchParams, setSearchParams] = useSearchParams () const { sessionId } = useParams ()
const [searchParams, setSearchParams] = useSearchParams ()
const [session, setSession] = useState<PostImportSession | null> (null) const [session, setSession] = useState<PostImportSession | null> (null)
const [loading, setLoading] = useState (false) const [loading, setLoading] = useState (false)
const [missing, setMissing] = useState (false) const [missing, setMissing] = useState (false)
const [savingRow, setSavingRow] = useState<number | null> (null)
useEffect (() => { useEffect (() => {
if (sessionId == null) if (sessionId == null)
return return
const loaded = loadPostImportSession (sessionId, message => const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message })) toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded) setSession (loaded)
setMissing (loaded == null) setMissing (loaded == null)
}, [sessionId]) }, [sessionId])
useEffect (() => { useEffect (() => {
if (sessionId == null || session == null) if (sessionId == null || session == null)
return return
savePostImportSession (sessionId, session, message => savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId]) }, [session, sessionId])
const rows = session?.rows ?? [] const rows = session?.rows ?? []
const editingSourceRow = Number (searchParams.get ('edit') ?? '') const editingSourceRow = Number (searchParams.get ('edit') ?? '')
const editingRow = const editingRow =
Number.isFinite (editingSourceRow) Number.isFinite (editingSourceRow)
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null ? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
: null : null
const counts = useMemo (() => reviewSummaryCounts (rows), [rows]) const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
const processable = useMemo ( const processable = useMemo (
() => processableImportRows (rows), () => processableImportRows (rows),
[rows]) [rows])
const creatable = useMemo ( const creatable = useMemo (
() => creatableImportRows (rows), () => creatableImportRows (rows),
[rows]) [rows])
const reviewRows = const reviewRows =
session?.repairMode === 'failed' session?.repairMode === 'failed'
? [...rows].sort ((a, b) => { ? (
[...rows].sort ((a, b) => {
const aFailed = a.importStatus === 'failed' ? 0 : 1 const aFailed = a.importStatus === 'failed' ? 0 : 1
const bFailed = b.importStatus === 'failed' ? 0 : 1 const bFailed = b.importStatus === 'failed' ? 0 : 1
return aFailed - bFailed || a.sourceRow - b.sourceRow return aFailed - bFailed || a.sourceRow - b.sourceRow
}) }))
: rows : rows
useEffect (() => { useEffect (() => {
if (editingRow == null || session?.repairMode !== 'failed') if (editingRow == null || session?.repairMode !== 'failed')
return return
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`) const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' }) element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
}, [editingRow, session?.repairMode]) }, [editingRow, session?.repairMode])
const updateSessionRows = (nextRows: PostImportRow[]) => const updateSessionRows = (nextRows: PostImportRow[]) =>
setSession (current => setSession (current =>
current ? { ...current, rows: nextRows } : current) current != null ? { ...current, rows: nextRows } : current)
const saveDraft = async (draft: Draft): Promise<boolean> => { const saveDraft = async (
if (session == null) { draft, resetRequested, resetSnapshot }: {
return false draft: PostImportRowDraft
if (editingRow == null) resetRequested: boolean
return false resetSnapshot: PostImportRow['resetSnapshot'] },
): Promise<{ saved: boolean
row: PostImportRow | null }> => {
if (session == null)
return { saved: false, row: null }
const urlChanged = draft.url !== editingRow.url if (editingRow == null)
const nextProvenance = { ...editingRow.provenance } return { saved: false, row: null }
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 ?? ''
}
setSavingRow (editingRow.sourceRow) const baseRow =
const nextRows = session.rows.map (row => resetRequested
row.sourceRow === editingRow.sourceRow ? { ...editingRow,
? { url: resetSnapshot.url,
...row, attributes: { ...resetSnapshot.attributes },
url: draft.url, provenance: { ...resetSnapshot.provenance },
attributes: nextAttributes, tagSources: { ...resetSnapshot.tagSources },
provenance: { fieldWarnings: Object.fromEntries (
...nextProvenance, Object.entries (resetSnapshot.fieldWarnings)
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') }, .map (([key, values]) => [key, [...values]])),
tagSources: nextTagSources, baseWarnings: [...resetSnapshot.baseWarnings],
importStatus: row.importStatus === 'created' ? 'created' : 'pending', metadataUrl: resetSnapshot.metadataUrl }
importErrors: undefined } : editingRow
: row) const urlChanged = draft.url !== baseRow.url
try const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { const nextRows = session.rows.map (row =>
rows: nextRows row.sourceRow === editingRow.sourceRow
.filter (row => row.importStatus !== 'created') ? nextRow
.map (row => ({ : row)
sourceRow: row.sourceRow, try
url: row.url, {
attributes: row.attributes, const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
provenance: row.provenance, rows:
tagSources: row.tagSources, nextRows
metadataUrl: row.metadataUrl })), .filter (row => row.importStatus !== 'created')
changed_row: urlChanged ? editingRow.sourceRow : -1 }) .map (row => ({ sourceRow: row.sourceRow,
updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows)) url: row.url,
setSearchParams ({ }) attributes: row.attributes,
return true provenance: row.provenance,
} tagSources: row.tagSources,
catch metadataUrl: row.metadataUrl })),
{ changed_row: urlChanged ? editingRow.sourceRow : -1 })
toast ({ title: '行の再検証に失敗しました' }) const validatedRows = initialisePreviewRows (validated.rows)
return false const target = validatedRows.find (row => row.sourceRow === editingRow.sourceRow)
} if (target != null && Object.keys (target.validationErrors).length > 0)
finally return { saved: false, row: target }
{ updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
setSavingRow (null) setSearchParams ({ })
} return { saved: true, row: null }
} }
catch
const submit = async () => { {
if (sessionId == null || session == null || processable.length === 0) toast ({ title: '行の再検証に失敗しました' })
return return { saved: false, row: null }
setLoading (true)
try
{
const result = await apiPost<{
created: number
skipped: number
failed: number
rows: PostImportResultRow[] }> ('/posts/import', {
rows: processable.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })) })
const nextRows = mergeImportResults (session.rows, result.rows)
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
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)) const openEditingDialogue = async (row: PostImportRow) => {
return <Forbidden/> const saveRowDraft = (
{ draft, resetRequested }: {
draft: PostImportRowDraft
resetRequested: boolean },
) =>
saveDraft ({
draft,
resetRequested,
resetSnapshot: row.resetSnapshot })
if (missing || sessionId == null || session == null) await dialogue.form ({
{ title: '投稿を編輯',
return ( description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
<MainArea> cancelText: '取消',
<div className="mx-auto max-w-4xl space-y-4 p-4"> size: 'large',
<PageTitle>稿</PageTitle> body: controls => (
<div className="text-red-700 dark:text-red-300"></div> <PostImportRowForm
<Button type="button" onClick={() => navigate ('/posts/import')}> row={row}
URL controls={controls}
</Button> onSave={saveRowDraft}/>) })
</div> setSearchParams ({ })
</MainArea>) }
}
return ( useEffect (() => {
<> if (editingRow == null)
<Helmet> return
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
</Helmet>
<MainArea className="min-h-0"> void openEditingDialogue (editingRow)
<div className="mx-auto max-w-6xl space-y-4 p-4"> }, [editingRow])
<PageTitle>稿</PageTitle>
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700 const submit = async () => {
dark:bg-neutral-900"> if (sessionId == null || session == null || processable.length === 0)
<div className="flex flex-wrap items-center gap-2"> return
<SummaryChip label="全件" value={counts.total}/>
<SummaryChip label="登録対象" value={counts.submittable}/>
<SummaryChip label="要修正" value={counts.invalid}/>
<SummaryChip label="スキップ予定" value={counts.skipPlanned}/>
<SummaryChip label="登録済み" value={counts.created}/>
<SummaryChip label="失敗" value={counts.failed}/>
</div>
</div>
<div className="space-y-3"> setLoading (true)
{reviewRows.map (row => ( try
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}> {
<PostImportRowSummary const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
row={row} rows:
onEdit={() => { session.rows
setSearchParams ({ edit: String (row.sourceRow) }) .filter (row => row.importStatus !== 'created')
}}/> .map (row => ({ sourceRow: row.sourceRow,
</div>))} url: row.url,
</div> attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const mergedRows = mergeValidatedImportRows (session.rows, validatedRows)
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
if (firstInvalid != null)
{
setSession ({ ...session, rows: mergedRows })
setSearchParams ({ edit: String (firstInvalid.sourceRow) })
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 mergedResults = mergeImportResults (mergedRows, 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',
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const firstRecoverable = nextRows.find (row =>
Object.keys (row.validationErrors).length > 0
&& row.importStatus !== 'created')
if (firstRecoverable != null)
{
const repairSession = { ...session,
rows: nextRows,
repairMode: 'failed' as const }
setSession (repairSession)
const saved = savePostImportSession (sessionId, repairSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
setSearchParams ({ edit: String (firstRecoverable.sourceRow) })
return
}
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
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> </div>
</MainArea> </MainArea>)
}
<PostImportFooter return (
loading={loading} <>
invalidCount={counts.invalid} <Helmet>
processableCount={processable.length} <title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
creatableCount={creatable.length} </Helmet>
skipPlannedCount={counts.skipPlanned}
onBack={() => navigate ('/posts/import')}
onSubmit={submit}/>
<PostImportRowDialog <MainArea className="min-h-0">
open={editingRow != null} <div className="mx-auto max-w-6xl space-y-4 p-4">
row={editingRow} <PageTitle>稿</PageTitle>
saving={savingRow != null}
onOpenChange={open => { <div className="space-y-3">
if (!(open)) {reviewRows.map (row => (
setSearchParams ({ }) <div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
}} <PostImportRowSummary
onSave={saveDraft}/> row={row}
</>) onEdit={() => {
setSearchParams ({ edit: String (row.sourceRow) })
}}/>
</div>))}
</div>
</div>
</MainArea>
<PostImportFooter
loading={loading}
processableCount={processable.length}
creatableCount={creatable.length}
skipPlannedCount={counts.skipPlanned}
onBack={() => navigate ('/posts/import')}
onSubmit={submit}/>
</>)
} }
const PostImportFooter = ( const PostImportFooter = (
{ loading, { loading,
invalidCount,
processableCount, processableCount,
creatableCount, creatableCount,
skipPlannedCount, skipPlannedCount,
onBack, onBack,
onSubmit }: { onSubmit }: { loading: boolean
loading: boolean processableCount: number
invalidCount: number creatableCount: number
processableCount: number skipPlannedCount: number
creatableCount: number onBack: () => void
skipPlannedCount: number onSubmit: () => void },
onBack: () => void
onSubmit: () => void },
) => ( ) => (
<div <div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
@@ -319,14 +346,9 @@ const PostImportFooter = (
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row <div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between"> md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<PostImportStatusBadge value="pending"/> <span> {processableCount}</span>
<span> {processableCount} </span> <span> {creatableCount}</span>
<PostImportStatusBadge value="ready"/> <span> {skipPlannedCount}</span>
<span> {creatableCount} </span>
<PostImportStatusBadge value="skipped"/>
<span> {skipPlannedCount} </span>
<PostImportStatusBadge value="error"/>
<span> {invalidCount} </span>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={onBack}> <Button type="button" variant="outline" onClick={onBack}>
@@ -336,16 +358,58 @@ const PostImportFooter = (
type="button" type="button"
onClick={onSubmit} onClick={onSubmit}
disabled={loading || processableCount === 0}> disabled={loading || processableCount === 0}>
</Button> </Button>
</div> </div>
</div> </div>
</div>) </div>)
const SummaryChip = ({ label, value }: { label: string const buildNextEditedRow = (
value: number }) => ( editingRow: PostImportRow,
<div className="rounded-full border border-border bg-background px-3 py-1 text-sm"> draft: PostImportRowDraft,
{label} {value} urlChanged: boolean,
</div>) ): 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 default PostImportReviewPage export default PostImportReviewPage
+92
ファイルの表示
@@ -0,0 +1,92 @@
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 (_1 => _1?.startsWith ('post-import-session:'))
expect (sessionKeys).toHaveLength (1)
})
})
+158 -162
ファイルの表示
@@ -3,27 +3,29 @@ import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import TextArea from '@/components/common/TextArea'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { countImportSourceLines, import { countImportSourceLines,
cleanupExpiredPostImportSessions, cleanupExpiredPostImportSessions,
createPostImportSessionId, createPostImportSessionId,
initialisePreviewRows,
loadPostImportSourceDraft, loadPostImportSourceDraft,
savePostImportSession, savePostImportSession,
savePostImportSourceDraft, savePostImportSourceDraft,
validateImportSource, validateImportSource } from '@/lib/postImportSession'
type PostImportRow } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
import type { User } from '@/types' import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
@@ -32,181 +34,175 @@ const MAX_ROWS = 100
const SOURCE_ERROR_ID = 'post-import-source-error' const SOURCE_ERROR_ID = 'post-import-source-error'
const SOURCE_ISSUES_ID = 'post-import-source-issues' const SOURCE_ISSUES_ID = 'post-import-source-issues'
const urlIssuesFromRows = (rows: PostImportRow[]) => const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
rows.flatMap (row => const sourceLines = source.split (/\r\n|\n|\r/)
(row.validationErrors.url ?? []).map (message => ({
sourceRow: row.sourceRow, return rows.flatMap (row =>
message, (row.validationErrors.url ?? []).map (message => ({
url: row.url }))) sourceRow: row.sourceRow,
message,
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
}
const PostImportSourcePage: FC<Props> = ({ user }) => { const PostImportSourcePage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const navigate = useNavigate () const navigate = useNavigate ()
const [source, setSource] = useState ('') const [source, setSource] = useState ('')
const [loading, setLoading] = useState (false) const [loading, setLoading] = useState (false)
const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([]) const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
const [sourceError, setSourceError] = useState<string | null> (null) const [sourceError, setSourceError] = useState<string | null> (null)
const saveTimer = useRef<number | null> (null) const saveTimer = useRef<number | null> (null)
const editedRef = useRef (false) const editedRef = useRef (false)
const lineCount = countImportSourceLines (source) const lineCount = countImportSourceLines (source)
const messages = sourceError ? [sourceError] : [] const messages = sourceError != null ? [sourceError] : []
const sourceDescribedBy = [ const sourceDescribedBy =
sourceError ? SOURCE_ERROR_ID : null, [sourceError != null ? SOURCE_ERROR_ID : null,
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null] sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
.filter (_1 => _1 != null) .filter (_1 => _1 != null)
.join (' ') .join (' ')
useEffect (() => { useEffect (() => {
cleanupExpiredPostImportSessions (message => cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message })) toast ({ title: '保存済みデータを整理できませんでした', description: message }))
const draft = loadPostImportSourceDraft (message => const draft = loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message })) toast ({ title: '保存済み入力を復元できませんでした', description: message }))
if (!(editedRef.current))
{
setSource (current => current === '' ? draft.source : current)
}
}, [])
useEffect (() => { if (!(editedRef.current))
if (saveTimer.current != null) setSource (current => current === '' ? draft.source : current)
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 () => { useEffect (() => {
if (lineCount === 0) if (saveTimer.current != null)
{ window.clearTimeout (saveTimer.current)
setSourceIssues ([])
setSourceError ('URL を入力してください.')
return
}
const issues = validateImportSource (source) saveTimer.current = window.setTimeout (() => {
if (issues.length > 0) savePostImportSourceDraft (source, message =>
{ toast ({ title: '入力内容を保存できませんでした', description: message }))
setSourceIssues (issues) }, 300)
setSourceError (null)
return
}
setLoading (true) return () => {
setSourceIssues ([]) if (saveTimer.current != null)
setSourceError (null) window.clearTimeout (saveTimer.current)
try
{
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
source })
const urlIssues = urlIssuesFromRows (data.rows)
if (urlIssues.length > 0)
{
setSourceIssues (urlIssues)
return
}
const sessionId = createPostImportSessionId ()
const saved = savePostImportSession (sessionId, {
source,
rows: 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)
}
} }
}, [source])
if (!(editable)) const preview = async () => {
return <Forbidden/> if (lineCount === 0)
{
setSourceIssues ([])
setSourceError ('URL を入力してください.')
return
}
return ( const issues = validateImportSource (source)
<MainArea> if (issues.length > 0)
<Helmet> {
<title>{`投稿インポート | ${ SITE_TITLE }`}</title> setSourceIssues (issues)
</Helmet> setSourceError (null)
return
}
<div className="mx-auto max-w-4xl space-y-4 p-4"> setLoading (true)
<PageTitle>稿</PageTitle> setSourceIssues ([])
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700 setSourceError (null)
dark:bg-neutral-900 md:p-6"> try
<div className="space-y-4"> {
<FormField label="URL リスト"> const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { source })
{() => ( const urlIssues = urlIssuesFromRows (data.rows, source)
<textarea if (urlIssues.length > 0)
value={source} {
rows={14} setSourceIssues (urlIssues)
aria-invalid={messages.length > 0 || sourceIssues.length > 0} return
aria-describedby={sourceDescribedBy || undefined} }
className={inputClass ( const sessionId = createPostImportSessionId ()
messages.length > 0 || sourceIssues.length > 0, const saved = savePostImportSession (
'font-mono text-sm', sessionId,
)} { source,
onBlur={() => { rows: initialisePreviewRows (data.rows),
savePostImportSourceDraft (source, message => repairMode: 'all' },
toast ({ message => toast ({ title: '取込状態を保存できませんでした', description: message }))
title: '入力内容を保存できませんでした', if (!(saved))
description: message })) return
}} navigate (`/posts/import/${ sessionId }/review`)
onChange={ev => { }
editedRef.current = true catch (requestError)
setSource (ev.target.value) {
setSourceError (null) const message =
setSourceIssues ([]) isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
}}/>)} ? (requestError.response?.data?.message
</FormField> ?? requestError.response?.data?.baseErrors?.[0])
<div id={messages.length > 0 ? SOURCE_ERROR_ID : undefined}> : undefined
<FieldError messages={messages}/> setSourceError (message ?? '入力を確認してください.')
</div> toast ({ title: '投稿情報の取得に失敗しました',
<ul description: message ?? '入力を確認してください.' })
id={SOURCE_ISSUES_ID} }
className="space-y-2 text-sm text-red-700 dark:text-red-300"> finally
{sourceIssues.map (issue => ( {
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}> setLoading (false)
<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"> if (!(editable))
<div className="text-sm text-neutral-600 dark:text-neutral-300"> return <Forbidden/>
{lineCount} / {MAX_ROWS}
</div> return (
<Button <MainArea>
type="button" <Helmet>
className="shrink-0" <title>{`投稿インポート | ${ SITE_TITLE }`}</title>
onClick={preview} </Helmet>
disabled={loading}>
<Form className="max-w-4xl">
</Button> <PageTitle>稿</PageTitle>
</div> <FormField label="URL リスト">
</div> {() => (
</div> <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> </div>
</MainArea>) <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 export default PostImportSourcePage
+7 -12
ファイルの表示
@@ -25,13 +25,8 @@ import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
type PostFormField = type PostFormField =
'url' 'url' | 'title' | 'tags' | 'parentPostIds'
| 'title' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
| 'tags'
| 'parentPostIds'
| 'videoMs'
| 'originalCreatedAt'
| 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => { const PostNewPage: FC<Props> = ({ user }) => {
@@ -42,8 +37,10 @@ const PostNewPage: FC<Props> = ({ user }) => {
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> () useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) const [originalCreatedBefore, setOriginalCreatedBefore] =
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('') const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('') const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('') const [duration, setDuration] = useState ('')
@@ -56,9 +53,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
const thumbnailPreviewRef = useRef ('') const thumbnailPreviewRef = useRef ('')
const videoFlg = const videoFlg =
useMemo (() => useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
tags.split (/\s+/).some (
tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags]) [tags])
const handleSubmit = async () => { const handleSubmit = async () => {
+52
ファイルの表示
@@ -0,0 +1,52 @@
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',
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 } }
}