このコミットが含まれているのは:
@@ -2,7 +2,7 @@ class PostImportsController < ApplicationController
|
|||||||
before_action :require_member!
|
before_action :require_member!
|
||||||
|
|
||||||
def preview
|
def preview
|
||||||
rows = PostImportPreviewer.new(existing: params[:existing]).preview_rows(
|
rows = PostImportPreviewer.new.preview_rows(
|
||||||
rows: PostImportUrlListParser.parse(params[:source]))
|
rows: PostImportUrlListParser.parse(params[:source]))
|
||||||
render json: { rows: }
|
render json: { rows: }
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
@@ -13,18 +13,17 @@ class PostImportsController < ApplicationController
|
|||||||
rows = normalised_import_rows(allow_warning_fields: true)
|
rows = normalised_import_rows(allow_warning_fields: true)
|
||||||
changed_row = Integer(params[:changed_row], exception: false)
|
changed_row = Integer(params[:changed_row], exception: false)
|
||||||
result =
|
result =
|
||||||
PostImportPreviewer.new(existing: params[:existing])
|
PostImportPreviewer.new.preview_rows(rows:,
|
||||||
.preview_rows(rows:, fetch_metadata: changed_row, metadata_cache: {})
|
fetch_metadata: changed_row,
|
||||||
|
metadata_cache: { })
|
||||||
render json: { rows: result }
|
render json: { rows: result }
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_bad_request(e.message)
|
render_bad_request(e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
result = PostImportRunner.new(
|
result = PostImportRunner.new(actor: current_user,
|
||||||
actor: current_user,
|
rows: normalised_import_rows).run
|
||||||
rows: normalised_import_rows,
|
|
||||||
existing: params[:existing]).run
|
|
||||||
render json: result, status: result[:created].positive? ? :created : :ok
|
render json: result, status: result[:created].positive? ? :created : :ok
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_bad_request(e.message)
|
render_bad_request(e.message)
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ class PostImportPreviewer
|
|||||||
'tags',
|
'tags',
|
||||||
'parent_post_ids',
|
'parent_post_ids',
|
||||||
].freeze
|
].freeze
|
||||||
def initialize existing: 'skip'
|
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
|
||||||
@existing = existing == 'error' ? 'error' : 'skip'
|
EXISTING_SKIP_WARNING = '既存投稿のためスキップします.'.freeze
|
||||||
end
|
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
|
||||||
|
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
|
||||||
|
METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze
|
||||||
|
|
||||||
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
|
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
|
||||||
urls = {}
|
urls = {}
|
||||||
@@ -30,48 +32,60 @@ class PostImportPreviewer
|
|||||||
attributes = initial_attributes(row)
|
attributes = initial_attributes(row)
|
||||||
provenance = initial_provenance(row)
|
provenance = initial_provenance(row)
|
||||||
tag_sources = initial_tag_sources(row, attributes, provenance)
|
tag_sources = initial_tag_sources(row, attributes, provenance)
|
||||||
|
field_warnings = initial_field_warnings(row)
|
||||||
|
base_warnings = initial_base_warnings(row)
|
||||||
url = row[:url].to_s.strip
|
url = row[:url].to_s.strip
|
||||||
provenance['url'] = 'manual'
|
provenance['url'] = 'manual'
|
||||||
normal_url = normalised_url(url)
|
normal_url = normalised_url(url)
|
||||||
url_for_metadata = normal_url || url
|
url_for_metadata = normal_url || url
|
||||||
existing_post = normal_url.present? && Post.exists?(url: normal_url)
|
existing_post = normal_url.present? && Post.exists?(url: normal_url)
|
||||||
|
|
||||||
validation_warnings = []
|
|
||||||
validation_errors = {}
|
validation_errors = {}
|
||||||
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
|
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
|
||||||
validation_errors[:url] = ['URL が重複しています.'] if normal_url.present? && urls.key?(normal_url)
|
validation_errors[:url] = ['URL が重複しています.'] if normal_url.present? && urls.key?(normal_url)
|
||||||
urls[normal_url] = true if normal_url.present?
|
urls[normal_url] = true if normal_url.present?
|
||||||
if normal_url.present? && existing_post
|
|
||||||
if @existing == 'error'
|
|
||||||
validation_errors[:url] = ['既存投稿です.']
|
|
||||||
else
|
|
||||||
validation_warnings << '既存投稿のためスキップします.'
|
|
||||||
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
if normal_url.present? && existing_post
|
||||||
|
add_field_warning!(field_warnings, 'url', EXISTING_SKIP_WARNING)
|
||||||
|
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
|
||||||
|
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
|
||||||
|
return {
|
||||||
|
source_row: row[:source_row],
|
||||||
|
url: normal_url,
|
||||||
|
attributes:,
|
||||||
|
provenance:,
|
||||||
|
tag_sources:,
|
||||||
|
metadata_url: url_for_metadata,
|
||||||
|
field_warnings:,
|
||||||
|
base_warnings:,
|
||||||
|
validation_errors:,
|
||||||
|
status: validation_errors.present? ? 'error' : (warnings_present ? 'warning' : 'ready'),
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
fetch_warnings = Array(row[:fetch_warnings]).dup
|
|
||||||
should_fetch = should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
should_fetch = should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
||||||
if should_fetch && !(existing_post && @existing == 'skip')
|
if should_fetch
|
||||||
metadata, fetch_warnings =
|
clear_fetch_warnings!(field_warnings)
|
||||||
metadata_for(url_for_metadata, metadata_cache, fetch_warnings)
|
metadata = metadata_for(url_for_metadata, metadata_cache)
|
||||||
apply_metadata!(attributes, provenance, tag_sources, metadata)
|
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
|
||||||
|
apply_fetch_warnings!(field_warnings, metadata[:warnings])
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes['url'] = url
|
attributes['url'] = url
|
||||||
validate_basic_data(attributes, validation_errors)
|
validate_basic_data(attributes, validation_errors)
|
||||||
validate_preview_tags(
|
validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
|
||||||
merged_tags(tag_sources, provenance['tags']),
|
validation_errors,
|
||||||
validation_errors,
|
field_warnings)
|
||||||
validation_warnings,
|
|
||||||
)
|
|
||||||
validate_parents(attributes['parent_post_ids'], validation_errors)
|
validate_parents(attributes['parent_post_ids'], validation_errors)
|
||||||
attributes.delete('url')
|
attributes.delete('url')
|
||||||
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?
|
||||||
{
|
{
|
||||||
source_row: row[:source_row],
|
source_row: row[:source_row],
|
||||||
url: normal_url || url,
|
url: normal_url || url,
|
||||||
@@ -79,14 +93,10 @@ class PostImportPreviewer
|
|||||||
provenance:,
|
provenance:,
|
||||||
tag_sources:,
|
tag_sources:,
|
||||||
metadata_url: url_for_metadata,
|
metadata_url: url_for_metadata,
|
||||||
fetch_warnings:,
|
field_warnings:,
|
||||||
validation_warnings:,
|
base_warnings:,
|
||||||
warnings: fetch_warnings + validation_warnings,
|
|
||||||
validation_errors:,
|
validation_errors:,
|
||||||
status: validation_errors.present? \
|
status: validation_errors.present? ? 'error' : (warnings_present ? 'warning' : 'ready'),
|
||||||
? 'error' \
|
|
||||||
: ((fetch_warnings + validation_warnings).present? ? 'warning' : 'ready'),
|
|
||||||
existing: existing_post,
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -100,12 +110,12 @@ class PostImportPreviewer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def initial_attributes row
|
def initial_attributes row
|
||||||
attributes = row[:attributes]&.stringify_keys || {}
|
attributes = row[:attributes]&.stringify_keys || { }
|
||||||
FIELDS.to_h { |field| [field, attributes[field].to_s] }
|
FIELDS.to_h { |field| [field, attributes[field].to_s] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def initial_provenance row
|
def initial_provenance row
|
||||||
provenance = row[:provenance]&.stringify_keys || {}
|
provenance = row[:provenance]&.stringify_keys || { }
|
||||||
FIELDS.to_h { |field| [field, provenance[field].presence || 'automatic'] }
|
FIELDS.to_h { |field| [field, provenance[field].presence || 'automatic'] }
|
||||||
.merge('url' => 'manual')
|
.merge('url' => 'manual')
|
||||||
end
|
end
|
||||||
@@ -118,6 +128,14 @@ class PostImportPreviewer
|
|||||||
sources
|
sources
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def initial_field_warnings row
|
||||||
|
(row[:field_warnings] || { }).stringify_keys.transform_values { |value| Array(value).map(&:to_s) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def initial_base_warnings row
|
||||||
|
Array(row[:base_warnings]).map(&:to_s)
|
||||||
|
end
|
||||||
|
|
||||||
def clear_automatic_values! attributes, provenance, tag_sources
|
def clear_automatic_values! attributes, provenance, tag_sources
|
||||||
['title', 'thumbnail_base', 'original_created_from',
|
['title', 'thumbnail_base', 'original_created_from',
|
||||||
'original_created_before', 'duration'].each do |field|
|
'original_created_before', 'duration'].each do |field|
|
||||||
@@ -127,18 +145,24 @@ class PostImportPreviewer
|
|||||||
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
|
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
|
||||||
end
|
end
|
||||||
|
|
||||||
def metadata_for url, cache, previous_warnings
|
def clear_fetch_warnings! field_warnings
|
||||||
|
FETCH_WARNING_FIELDS.each do |field|
|
||||||
|
field_warnings.delete(field)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def metadata_for url, cache
|
||||||
cache[url] ||= fetch_metadata(url)
|
cache[url] ||= fetch_metadata(url)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_metadata url
|
def fetch_metadata url
|
||||||
return [{}, ['URL が空です.']] if url.blank?
|
return { data: { }, warnings: { 'url' => ['URL が空です.'] } } if url.blank?
|
||||||
|
|
||||||
data = PostMetadataFetcher.fetch(url).stringify_keys
|
data = PostMetadataFetcher.fetch(url).stringify_keys.compact
|
||||||
warnings = []
|
warnings = { }
|
||||||
warnings << 'タイトルを取得できませんでした.' if data['title'].blank?
|
add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank?
|
||||||
warnings << 'サムネールを取得できませんでした.' if data['thumbnail_base'].blank?
|
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING) if data['thumbnail_base'].blank?
|
||||||
[data.compact, warnings]
|
{ data:, warnings: }
|
||||||
rescue Preview::UrlSafety::UnsafeUrl,
|
rescue Preview::UrlSafety::UnsafeUrl,
|
||||||
Preview::HttpFetcher::FetchFailed,
|
Preview::HttpFetcher::FetchFailed,
|
||||||
Preview::HttpFetcher::ResponseTooLarge => e
|
Preview::HttpFetcher::ResponseTooLarge => e
|
||||||
@@ -146,7 +170,7 @@ class PostImportPreviewer
|
|||||||
"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] } }
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_metadata! attributes, provenance, tag_sources, metadata
|
def apply_metadata! attributes, provenance, tag_sources, metadata
|
||||||
@@ -165,13 +189,26 @@ class PostImportPreviewer
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def apply_fetch_warnings! field_warnings, warnings
|
||||||
|
warnings.each do |field, values|
|
||||||
|
values.each do |value|
|
||||||
|
add_field_warning!(field_warnings, field, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_field_warning! field_warnings, field, message
|
||||||
|
field_warnings[field] ||= []
|
||||||
|
field_warnings[field] << message unless field_warnings[field].include?(message)
|
||||||
|
end
|
||||||
|
|
||||||
def merged_tags sources, origin = nil
|
def merged_tags sources, origin = nil
|
||||||
return sources['manual'].to_s if origin == 'manual'
|
return sources['manual'].to_s if origin == 'manual'
|
||||||
|
|
||||||
sources['automatic'].to_s
|
sources['automatic'].to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_preview_tags raw, errors, warnings
|
def validate_preview_tags raw, errors, field_warnings
|
||||||
names = raw.to_s.split
|
names = raw.to_s.split
|
||||||
return if names.empty?
|
return if names.empty?
|
||||||
if names.any? { _1.downcase.start_with?('nico:') }
|
if names.any? { _1.downcase.start_with?('nico:') }
|
||||||
@@ -185,7 +222,9 @@ class PostImportPreviewer
|
|||||||
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
|
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
|
||||||
known = existing.reject(&:deprecated?).map(&:name)
|
known = existing.reject(&:deprecated?).map(&:name)
|
||||||
new_tags = parsed.uniq - known
|
new_tags = parsed.uniq - known
|
||||||
warnings << "新規タグを作成します: #{ new_tags.join(' ') }" if new_tags.present?
|
if new_tags.present?
|
||||||
|
add_field_warning!(field_warnings, 'tags', "新規タグを作成します: #{ new_tags.join(' ') }")
|
||||||
|
end
|
||||||
rescue Tag::SectionLiteralParseError
|
rescue Tag::SectionLiteralParseError
|
||||||
errors[:tags] = ['タグ区間の記法が不正です.']
|
errors[:tags] = ['タグ区間の記法が不正です.']
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ class PostImportRowNormaliser
|
|||||||
return keys unless allow_warning_fields
|
return keys unless allow_warning_fields
|
||||||
|
|
||||||
keys + [
|
keys + [
|
||||||
{ fetch_warnings: [] },
|
{ field_warnings: {} },
|
||||||
{ fetchWarnings: [] },
|
{ fieldWarnings: {} },
|
||||||
{ validation_warnings: [] },
|
{ base_warnings: [] },
|
||||||
{ validationWarnings: [] },
|
{ baseWarnings: [] },
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
private_class_method :permitted_keys
|
private_class_method :permitted_keys
|
||||||
@@ -141,10 +141,12 @@ class PostImportRowNormaliser
|
|||||||
def self.normalise_warning_values! normalised, allow_warning_fields:
|
def self.normalise_warning_values! normalised, allow_warning_fields:
|
||||||
return unless allow_warning_fields
|
return unless allow_warning_fields
|
||||||
|
|
||||||
%w[fetch_warnings validation_warnings].each do |field|
|
field_warnings = normalised['field_warnings']
|
||||||
values = normalised[field]
|
unless field_warnings.nil? || field_warnings.is_a?(Hash)
|
||||||
next if values.nil?
|
raise ArgumentError, '警告の形式が不正です.'
|
||||||
|
end
|
||||||
|
field_warnings&.each do |key, values|
|
||||||
|
raise ArgumentError, '警告の形式が不正です.' unless ATTRIBUTE_FIELDS.include?(key) || key == 'url'
|
||||||
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
|
||||||
@@ -152,6 +154,14 @@ class PostImportRowNormaliser
|
|||||||
raise ArgumentError, '警告が大きすぎます.'
|
raise ArgumentError, '警告が大きすぎます.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
base_warnings = normalised['base_warnings']
|
||||||
|
return if base_warnings.nil?
|
||||||
|
|
||||||
|
unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) }
|
||||||
|
raise ArgumentError, '警告の形式が不正です.'
|
||||||
|
end
|
||||||
|
raise ArgumentError, '警告が大きすぎます.' if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
||||||
end
|
end
|
||||||
private_class_method :normalise_warning_values!
|
private_class_method :normalise_warning_values!
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
class PostImportRunner
|
class PostImportRunner
|
||||||
def initialize actor:, rows:, existing: 'skip'
|
def initialize actor:, rows:
|
||||||
@actor = actor
|
@actor = actor
|
||||||
@rows = rows
|
@rows = rows
|
||||||
@existing = existing == 'error' ? 'error' : 'skip'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def run
|
def run
|
||||||
@@ -21,8 +20,7 @@ class PostImportRunner
|
|||||||
|
|
||||||
def run_row row
|
def run_row row
|
||||||
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
|
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
|
||||||
preview = PostImportPreviewer.new(existing: @existing)
|
preview = PostImportPreviewer.new.preview_rows(
|
||||||
.preview_rows(
|
|
||||||
rows: [{
|
rows: [{
|
||||||
source_row: row['source_row'],
|
source_row: row['source_row'],
|
||||||
url: row['url'],
|
url: row['url'],
|
||||||
@@ -31,7 +29,7 @@ class PostImportRunner
|
|||||||
tag_sources: row['tag_sources'],
|
tag_sources: row['tag_sources'],
|
||||||
}],
|
}],
|
||||||
fetch_metadata: false).first
|
fetch_metadata: false).first
|
||||||
if @existing == 'skip' && preview[:existing]
|
if Array(preview.dig(:field_warnings, 'url')).include?(PostImportPreviewer::EXISTING_SKIP_WARNING)
|
||||||
return row.slice('source_row').merge(status: 'skipped')
|
return row.slice('source_row').merge(status: 'skipped')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PostImportUrlListParser
|
|||||||
url = line.strip
|
url = line.strip
|
||||||
next if url.blank?
|
next if url.blank?
|
||||||
|
|
||||||
raise ArgumentError, 'URL が長すぎます.' if url.bytesize > MAX_URL_BYTES
|
raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます." if url.bytesize > MAX_URL_BYTES
|
||||||
|
|
||||||
{ source_row: index + 1, url: }
|
{ source_row: index + 1, url: }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const buildDraft = (row: PostImportRow): Draft => ({
|
|||||||
tags: String (row.attributes.tags ?? ''),
|
tags: String (row.attributes.tags ?? ''),
|
||||||
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
||||||
|
|
||||||
|
const warningClass =
|
||||||
|
'mt-1 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm '
|
||||||
|
+ 'text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'
|
||||||
|
|
||||||
|
|
||||||
const PostImportRowDialog: FC<Props> = (
|
const PostImportRowDialog: FC<Props> = (
|
||||||
{ open, row, saving, onOpenChange, onSave },
|
{ open, row, saving, onOpenChange, onSave },
|
||||||
@@ -102,46 +106,81 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
label="URL"
|
label="URL"
|
||||||
value={draft.url}
|
value={draft.url}
|
||||||
origin={originOf (row, 'url')}
|
origin={originOf (row, 'url')}
|
||||||
|
warnings={row.fieldWarnings.url}
|
||||||
|
errors={[
|
||||||
|
...(row.validationErrors.url ?? []),
|
||||||
|
...(row.importErrors?.url ?? [])]}
|
||||||
onChange={value => update ('url', value)}/>
|
onChange={value => update ('url', value)}/>
|
||||||
<DialogField
|
<DialogField
|
||||||
label="タイトル"
|
label="タイトル"
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
origin={originOf (row, 'title')}
|
origin={originOf (row, 'title')}
|
||||||
|
warnings={row.fieldWarnings.title}
|
||||||
|
errors={[
|
||||||
|
...(row.validationErrors.title ?? []),
|
||||||
|
...(row.importErrors?.title ?? [])]}
|
||||||
onChange={value => update ('title', value)}/>
|
onChange={value => update ('title', value)}/>
|
||||||
<DialogField
|
<DialogField
|
||||||
label="サムネール基底 URL"
|
label="サムネール基底 URL"
|
||||||
value={draft.thumbnailBase}
|
value={draft.thumbnailBase}
|
||||||
origin={originOf (row, 'thumbnailBase')}
|
origin={originOf (row, 'thumbnailBase')}
|
||||||
|
warnings={row.fieldWarnings.thumbnailBase}
|
||||||
|
errors={[
|
||||||
|
...(row.validationErrors.thumbnailBase ?? []),
|
||||||
|
...(row.importErrors?.thumbnailBase ?? [])]}
|
||||||
onChange={value => update ('thumbnailBase', value)}/>
|
onChange={value => update ('thumbnailBase', value)}/>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<DialogField
|
<DialogField
|
||||||
label="作成日時 From"
|
label="作成日時 From"
|
||||||
value={draft.originalCreatedFrom}
|
value={draft.originalCreatedFrom}
|
||||||
origin={originOf (row, 'originalCreatedFrom')}
|
origin={originOf (row, 'originalCreatedFrom')}
|
||||||
|
errors={[
|
||||||
|
...(row.validationErrors.originalCreatedFrom ?? []),
|
||||||
|
...(row.importErrors?.originalCreatedFrom ?? [])]}
|
||||||
onChange={value => update ('originalCreatedFrom', value)}/>
|
onChange={value => update ('originalCreatedFrom', value)}/>
|
||||||
<DialogField
|
<DialogField
|
||||||
label="作成日時 Before"
|
label="作成日時 Before"
|
||||||
value={draft.originalCreatedBefore}
|
value={draft.originalCreatedBefore}
|
||||||
origin={originOf (row, 'originalCreatedBefore')}
|
origin={originOf (row, 'originalCreatedBefore')}
|
||||||
|
errors={[
|
||||||
|
...(row.validationErrors.originalCreatedBefore ?? []),
|
||||||
|
...(row.importErrors?.originalCreatedBefore ?? [])]}
|
||||||
onChange={value => update ('originalCreatedBefore', value)}/>
|
onChange={value => update ('originalCreatedBefore', value)}/>
|
||||||
</div>
|
</div>
|
||||||
<DialogField
|
<DialogField
|
||||||
label="動画時間"
|
label="動画時間"
|
||||||
value={draft.duration}
|
value={draft.duration}
|
||||||
origin={originOf (row, 'duration')}
|
origin={originOf (row, 'duration')}
|
||||||
|
errors={[
|
||||||
|
...(row.validationErrors.duration ?? []),
|
||||||
|
...(row.validationErrors.videoMs ?? []),
|
||||||
|
...(row.importErrors?.duration ?? []),
|
||||||
|
...(row.importErrors?.videoMs ?? [])]}
|
||||||
onChange={value => update ('duration', value)}/>
|
onChange={value => update ('duration', value)}/>
|
||||||
<DialogArea
|
<DialogArea
|
||||||
label="タグ"
|
label="タグ"
|
||||||
value={draft.tags}
|
value={draft.tags}
|
||||||
origin={originOf (row, 'tags')}
|
origin={originOf (row, 'tags')}
|
||||||
|
warnings={row.fieldWarnings.tags}
|
||||||
|
errors={[
|
||||||
|
...(row.validationErrors.tags ?? []),
|
||||||
|
...(row.importErrors?.tags ?? [])]}
|
||||||
onChange={value => update ('tags', value)}/>
|
onChange={value => update ('tags', value)}/>
|
||||||
<DialogField
|
<DialogField
|
||||||
label="親投稿"
|
label="親投稿"
|
||||||
value={draft.parentPostIds}
|
value={draft.parentPostIds}
|
||||||
origin={originOf (row, 'parentPostIds')}
|
origin={originOf (row, 'parentPostIds')}
|
||||||
|
errors={[
|
||||||
|
...(row.validationErrors.parentPostIds ?? []),
|
||||||
|
...(row.importErrors?.parentPostIds ?? [])]}
|
||||||
onChange={value => update ('parentPostIds', value)}/>
|
onChange={value => update ('parentPostIds', value)}/>
|
||||||
<FieldError messages={Object.values (row.validationErrors ?? { }).flat ()}/>
|
{row.baseWarnings.length > 0 && (
|
||||||
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
<div className={warningClass}>
|
||||||
|
{row.baseWarnings.map ((message, index) => (
|
||||||
|
<div key={`${ message }-${ index }`}>{message}</div>))}
|
||||||
|
</div>)}
|
||||||
|
<FieldError messages={row.validationErrors.base}/>
|
||||||
|
<FieldError messages={row.importErrors?.base}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,10 +203,12 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DialogField = (
|
const DialogField = (
|
||||||
{ label, value, origin, onChange }: {
|
{ label, value, origin, warnings, errors, onChange }: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
origin: PostImportOrigin
|
origin: PostImportOrigin
|
||||||
|
warnings?: string[]
|
||||||
|
errors?: string[]
|
||||||
onChange: (value: string) => void },
|
onChange: (value: string) => void },
|
||||||
) => (
|
) => (
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
@@ -179,13 +220,21 @@ const DialogField = (
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={ev => onChange (ev.target.value)}
|
onChange={ev => onChange (ev.target.value)}
|
||||||
className={inputClass (false)}/>
|
className={inputClass (false)}/>
|
||||||
|
{warnings && warnings.length > 0 && (
|
||||||
|
<div className={warningClass}>
|
||||||
|
{warnings.map ((message, index) => (
|
||||||
|
<div key={`${ message }-${ index }`}>{message}</div>))}
|
||||||
|
</div>)}
|
||||||
|
<FieldError messages={errors}/>
|
||||||
</label>)
|
</label>)
|
||||||
|
|
||||||
const DialogArea = (
|
const DialogArea = (
|
||||||
{ label, value, origin, onChange }: {
|
{ label, value, origin, warnings, errors, onChange }: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
origin: PostImportOrigin
|
origin: PostImportOrigin
|
||||||
|
warnings?: string[]
|
||||||
|
errors?: string[]
|
||||||
onChange: (value: string) => void },
|
onChange: (value: string) => void },
|
||||||
) => (
|
) => (
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
@@ -198,6 +247,12 @@ const DialogArea = (
|
|||||||
rows={4}
|
rows={4}
|
||||||
onChange={ev => onChange (ev.target.value)}
|
onChange={ev => onChange (ev.target.value)}
|
||||||
className={inputClass (false)}/>
|
className={inputClass (false)}/>
|
||||||
|
{warnings && warnings.length > 0 && (
|
||||||
|
<div className={warningClass}>
|
||||||
|
{warnings.map ((message, index) => (
|
||||||
|
<div key={`${ message }-${ index }`}>{message}</div>))}
|
||||||
|
</div>)}
|
||||||
|
<FieldError messages={errors}/>
|
||||||
</label>)
|
</label>)
|
||||||
|
|
||||||
export default PostImportRowDialog
|
export default PostImportRowDialog
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
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'
|
||||||
|
|
||||||
@@ -48,6 +49,11 @@ const summaryError = (row: PostImportRow): string | null => {
|
|||||||
return Object.values (row.importErrors ?? { }).flat ()[0] ?? null
|
return Object.values (row.importErrors ?? { }).flat ()[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const summaryWarning = (row: PostImportRow): string | null =>
|
||||||
|
Object.values (row.fieldWarnings ?? { }).flat ()[0]
|
||||||
|
?? row.baseWarnings?.[0]
|
||||||
|
?? null
|
||||||
|
|
||||||
const summaryDate = (row: PostImportRow): string =>
|
const summaryDate = (row: PostImportRow): string =>
|
||||||
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
|
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
|
||||||
.filter (_1 => typeof _1 === 'string' && _1 !== '')
|
.filter (_1 => typeof _1 === 'string' && _1 !== '')
|
||||||
@@ -56,6 +62,7 @@ const summaryDate = (row: PostImportRow): string =>
|
|||||||
|
|
||||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||||
const error = summaryError (row)
|
const error = summaryError (row)
|
||||||
|
const warning = error == null ? summaryWarning (row) : null
|
||||||
const importStatus = row.importStatus ?? 'pending'
|
const importStatus = row.importStatus ?? 'pending'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,6 +96,21 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
{summaryDate (row) || '日時未取得'}
|
{summaryDate (row) || '日時未取得'}
|
||||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
{row.createdPostId && (
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/posts/${ row.createdPostId }`}
|
||||||
|
className="inline-block text-xs text-sky-700 underline
|
||||||
|
dark:text-sky-300">
|
||||||
|
投稿 #{row.createdPostId}
|
||||||
|
</PrefetchLink>)}
|
||||||
|
{warning && (
|
||||||
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
|
{warning}
|
||||||
|
</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={importStatus}/>
|
<PostImportStatusBadge value={importStatus}/>
|
||||||
@@ -96,7 +118,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{error
|
{error
|
||||||
? <PostImportStatusBadge value="error"/>
|
? <PostImportStatusBadge value="error"/>
|
||||||
: <PostImportStatusBadge value="ready"/>}
|
: (warning ? <PostImportStatusBadge value="warning"/> : <PostImportStatusBadge value="ready"/>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
@@ -128,11 +150,23 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<PostImportStatusBadge value={row.status}/>
|
<PostImportStatusBadge value={row.status}/>
|
||||||
<PostImportStatusBadge value={importStatus}/>
|
<PostImportStatusBadge value={importStatus}/>
|
||||||
|
{warning && <PostImportStatusBadge value="warning"/>}
|
||||||
</div>
|
</div>
|
||||||
|
{row.createdPostId && (
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/posts/${ row.createdPostId }`}
|
||||||
|
className="inline-block text-xs text-sky-700 underline
|
||||||
|
dark:text-sky-300">
|
||||||
|
投稿 #{row.createdPostId}
|
||||||
|
</PrefetchLink>)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-700 dark:text-red-200">
|
<div className="text-xs text-red-700 dark:text-red-200">
|
||||||
{error}
|
{error}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
{warning && (
|
||||||
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
|
{warning}
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className =
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${ className } flex items-center justify-center rounded border
|
className={`${ className } flex items-center justify-center rounded border
|
||||||
border-red-300 bg-red-50 p-2 text-center text-xs text-red-700
|
border-amber-300 bg-amber-50 p-2 text-center text-xs
|
||||||
dark:border-red-900 dark:bg-red-950 dark:text-red-200`}>
|
text-amber-700 dark:border-amber-900 dark:bg-amber-950
|
||||||
表示不可
|
dark:text-amber-200`}>
|
||||||
|
サムネールを表示できません
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export type PostImportRow = {
|
|||||||
sourceRow: number
|
sourceRow: number
|
||||||
url: string
|
url: string
|
||||||
attributes: Record<string, PostImportAttributeValue>
|
attributes: Record<string, PostImportAttributeValue>
|
||||||
warnings: string[]
|
fieldWarnings: Record<string, string[]>
|
||||||
|
baseWarnings: string[]
|
||||||
validationErrors: Record<string, string[]>
|
validationErrors: Record<string, string[]>
|
||||||
importErrors?: Record<string, string[]>
|
importErrors?: Record<string, string[]>
|
||||||
provenance: Record<string, PostImportOrigin>
|
provenance: Record<string, PostImportOrigin>
|
||||||
tagSources?: Record<PostImportOrigin, string>
|
tagSources?: Record<PostImportOrigin, string>
|
||||||
status: 'ready' | 'warning' | 'error'
|
status: 'ready' | 'warning' | 'error'
|
||||||
existing: boolean
|
|
||||||
metadataUrl?: string
|
metadataUrl?: string
|
||||||
createdPostId?: number
|
createdPostId?: number
|
||||||
importStatus?: PostImportStatus }
|
importStatus?: PostImportStatus }
|
||||||
@@ -33,13 +33,223 @@ export type PostImportSession = {
|
|||||||
version: number
|
version: number
|
||||||
savedAt: string
|
savedAt: string
|
||||||
source: string
|
source: string
|
||||||
existing: 'skip' | 'error'
|
|
||||||
rows: PostImportRow[]
|
rows: PostImportRow[]
|
||||||
repairMode: PostImportRepairMode }
|
repairMode: PostImportRepairMode }
|
||||||
|
|
||||||
const SESSION_VERSION = 1
|
export type PostImportSourceIssue = {
|
||||||
|
sourceRow: number
|
||||||
|
message: string
|
||||||
|
url: string }
|
||||||
|
|
||||||
|
type StorageErrorHandler = (message: string) => void
|
||||||
|
|
||||||
|
const SESSION_VERSION = 2
|
||||||
const SESSION_PREFIX = 'post-import-session:'
|
const SESSION_PREFIX = 'post-import-session:'
|
||||||
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
|
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
|
||||||
|
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||||
|
const MAX_ROWS = 100
|
||||||
|
const MAX_URL_BYTES = 20 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value != null && !(Array.isArray (value))
|
||||||
|
|
||||||
|
|
||||||
|
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
|
||||||
|
|
||||||
|
|
||||||
|
const readStorage = (
|
||||||
|
key: string,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
): string | null => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return null
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return sessionStorage.getItem (key)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
onError?.('保存済みデータを読み込めませんでした.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const writeStorage = (
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
): boolean => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return false
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessionStorage.setItem (key, value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
onError?.('ブラウザへ保存できませんでした.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const removeStorage = (
|
||||||
|
key: string,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
) => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessionStorage.removeItem (key)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
onError?.('保存済みデータを削除できませんでした.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const truncateUrl = (value: string): string =>
|
||||||
|
value.length > 120 ? `${ value.slice (0, 117) }…` : value
|
||||||
|
|
||||||
|
|
||||||
|
const bytesize = (value: string): number =>
|
||||||
|
new TextEncoder ().encode (value).length
|
||||||
|
|
||||||
|
|
||||||
|
const normaliseImportUrl = (value: string): string | null => {
|
||||||
|
const trimmed = value.trim ()
|
||||||
|
if (!(trimmed))
|
||||||
|
return null
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const url = new URL (trimmed)
|
||||||
|
if (!(url.protocol === 'http:' || url.protocol === 'https:'))
|
||||||
|
return null
|
||||||
|
if (!(url.host))
|
||||||
|
return null
|
||||||
|
|
||||||
|
url.hostname = url.hostname.toLowerCase ()
|
||||||
|
if (url.pathname.endsWith ('/'))
|
||||||
|
url.pathname = url.pathname.replace (/\/+$/, '')
|
||||||
|
return url.toString ()
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ensureStringListRecord = (value: unknown): Record<string, string[]> | null => {
|
||||||
|
if (!(isPlainObject (value)))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const result: Record<string, string[]> = { }
|
||||||
|
for (const [key, entry] of Object.entries (value))
|
||||||
|
{
|
||||||
|
if (!(Array.isArray (entry)) || !(entry.every (_1 => typeof _1 === 'string')))
|
||||||
|
return null
|
||||||
|
result[key] = entry
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const isValidStatus = (
|
||||||
|
value: unknown,
|
||||||
|
): value is PostImportRow['status'] =>
|
||||||
|
value === 'ready' || value === 'warning' || value === 'error'
|
||||||
|
|
||||||
|
|
||||||
|
const isValidImportStatus = (
|
||||||
|
value: unknown,
|
||||||
|
): value is PostImportStatus =>
|
||||||
|
value === 'pending'
|
||||||
|
|| value === 'created'
|
||||||
|
|| value === 'skipped'
|
||||||
|
|| value === 'failed'
|
||||||
|
|
||||||
|
|
||||||
|
const isValidOrigin = (
|
||||||
|
value: unknown,
|
||||||
|
): value is PostImportOrigin =>
|
||||||
|
value === 'automatic' || value === 'manual'
|
||||||
|
|
||||||
|
|
||||||
|
const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||||
|
if (!(isPlainObject (value)))
|
||||||
|
return null
|
||||||
|
if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
|
||||||
|
return null
|
||||||
|
if (typeof value.url !== 'string')
|
||||||
|
return null
|
||||||
|
if (!(isPlainObject (value.attributes)))
|
||||||
|
return null
|
||||||
|
if (!(isPlainObject (value.provenance)))
|
||||||
|
return null
|
||||||
|
if (!(isValidStatus (value.status)))
|
||||||
|
return null
|
||||||
|
if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const validationErrors = ensureStringListRecord (value.validationErrors)
|
||||||
|
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
||||||
|
if (validationErrors == null || fieldWarnings == null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const importErrors =
|
||||||
|
value.importErrors == null
|
||||||
|
? undefined
|
||||||
|
: ensureStringListRecord (value.importErrors)
|
||||||
|
if (value.importErrors != null && importErrors == null)
|
||||||
|
return null
|
||||||
|
if (!(Array.isArray (value.baseWarnings))
|
||||||
|
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const provenanceEntries = Object.entries (value.provenance)
|
||||||
|
if (!(provenanceEntries.every (([, origin]) => isValidOrigin (origin))))
|
||||||
|
return null
|
||||||
|
|
||||||
|
if (value.tagSources != null)
|
||||||
|
{
|
||||||
|
if (!(isPlainObject (value.tagSources)))
|
||||||
|
return null
|
||||||
|
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceRow: Number (value.sourceRow),
|
||||||
|
url: value.url,
|
||||||
|
attributes: value.attributes as Record<string, PostImportAttributeValue>,
|
||||||
|
fieldWarnings,
|
||||||
|
baseWarnings: value.baseWarnings,
|
||||||
|
validationErrors,
|
||||||
|
importErrors,
|
||||||
|
provenance: value.provenance as Record<string, PostImportOrigin>,
|
||||||
|
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
|
||||||
|
status: value.status,
|
||||||
|
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
|
||||||
|
createdPostId:
|
||||||
|
Number.isInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
|
||||||
|
importStatus: value.importStatus }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const isExpiredSession = (savedAt: string): boolean => {
|
||||||
|
const value = Date.parse (savedAt)
|
||||||
|
return Number.isNaN (value) || Date.now () - value > SESSION_MAX_AGE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const createPostImportSessionId = (): string =>
|
export const createPostImportSessionId = (): string =>
|
||||||
@@ -48,82 +258,122 @@ export const createPostImportSessionId = (): string =>
|
|||||||
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
|
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
|
||||||
|
|
||||||
|
|
||||||
export const loadPostImportSourceDraft = (): {
|
export const cleanupExpiredPostImportSessions = (
|
||||||
source: string
|
onError?: StorageErrorHandler,
|
||||||
existing: 'skip' | 'error' } => {
|
) => {
|
||||||
if (typeof window === 'undefined')
|
if (typeof window === 'undefined')
|
||||||
return { source: '', existing: 'skip' }
|
return
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const raw = sessionStorage.getItem (SOURCE_DRAFT_KEY)
|
for (let i = 0; i < sessionStorage.length; ++i)
|
||||||
if (raw == null)
|
{
|
||||||
return { source: '', existing: 'skip' }
|
const key = sessionStorage.key (i)
|
||||||
|
if (key == null || !(key.startsWith (SESSION_PREFIX)))
|
||||||
|
continue
|
||||||
|
|
||||||
const value = JSON.parse (raw) as {
|
const raw = sessionStorage.getItem (key)
|
||||||
source?: string
|
if (raw == null)
|
||||||
existing?: 'skip' | 'error' }
|
continue
|
||||||
return {
|
try
|
||||||
source: typeof value.source === 'string' ? value.source : '',
|
{
|
||||||
existing: value.existing === 'error' ? 'error' : 'skip' }
|
const value = JSON.parse (raw) as { savedAt?: string }
|
||||||
|
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||||
|
{
|
||||||
|
sessionStorage.removeItem (key)
|
||||||
|
--i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
sessionStorage.removeItem (key)
|
||||||
|
--i
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return { source: '', existing: 'skip' }
|
onError?.('保存済みデータを整理できませんでした.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const loadPostImportSourceDraft = (
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
): { source: string } => {
|
||||||
|
const raw = readStorage (SOURCE_DRAFT_KEY, onError)
|
||||||
|
if (raw == null)
|
||||||
|
return { source: '' }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const value = JSON.parse (raw) as { source?: string }
|
||||||
|
return { source: typeof value.source === 'string' ? value.source : '' }
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return { source: '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const savePostImportSourceDraft = (
|
export const savePostImportSourceDraft = (
|
||||||
source: string,
|
source: string,
|
||||||
existing: 'skip' | 'error',
|
onError?: StorageErrorHandler,
|
||||||
|
): boolean =>
|
||||||
|
writeStorage (SOURCE_DRAFT_KEY, JSON.stringify ({ source }), onError)
|
||||||
|
|
||||||
|
|
||||||
|
export const clearPostImportSourceDraft = (
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
) => {
|
) => {
|
||||||
if (typeof window === 'undefined')
|
removeStorage (SOURCE_DRAFT_KEY, onError)
|
||||||
return
|
|
||||||
|
|
||||||
sessionStorage.setItem (SOURCE_DRAFT_KEY, JSON.stringify ({ source, existing }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
|
|
||||||
|
|
||||||
|
|
||||||
export const savePostImportSession = (
|
export const savePostImportSession = (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
session: Omit<PostImportSession, 'version' | 'savedAt'>,
|
session: Omit<PostImportSession, 'version' | 'savedAt'>,
|
||||||
) => {
|
onError?: StorageErrorHandler,
|
||||||
if (typeof window === 'undefined')
|
): boolean =>
|
||||||
return
|
writeStorage (
|
||||||
|
sessionKey (sessionId),
|
||||||
sessionStorage.setItem (sessionKey (sessionId), JSON.stringify ({
|
JSON.stringify ({
|
||||||
...session,
|
...session,
|
||||||
version: SESSION_VERSION,
|
version: SESSION_VERSION,
|
||||||
savedAt: new Date ().toISOString () }))
|
savedAt: new Date ().toISOString () }),
|
||||||
}
|
onError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
export const loadPostImportSession = (
|
export const loadPostImportSession = (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
): PostImportSession | null => {
|
): PostImportSession | null => {
|
||||||
if (typeof window === 'undefined')
|
const raw = readStorage (sessionKey (sessionId), onError)
|
||||||
|
if (raw == null)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const raw = sessionStorage.getItem (sessionKey (sessionId))
|
|
||||||
if (raw == null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const value = JSON.parse (raw) as Partial<PostImportSession>
|
const value = JSON.parse (raw) as Partial<PostImportSession>
|
||||||
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
|
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
|
||||||
return null
|
return null
|
||||||
|
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||||
|
{
|
||||||
|
removeStorage (sessionKey (sessionId), onError)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = value.rows.map (sanitiseRow)
|
||||||
|
if (rows.some (_1 => _1 == null))
|
||||||
|
return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: SESSION_VERSION,
|
version: SESSION_VERSION,
|
||||||
savedAt: typeof value.savedAt === 'string' ? value.savedAt : '',
|
savedAt: value.savedAt,
|
||||||
source: typeof value.source === 'string' ? value.source : '',
|
source: typeof value.source === 'string' ? value.source : '',
|
||||||
existing: value.existing === 'error' ? 'error' : 'skip',
|
rows: rows as PostImportRow[],
|
||||||
rows: value.rows as PostImportRow[],
|
|
||||||
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
|
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -133,44 +383,122 @@ export const loadPostImportSession = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const rowChanged = (
|
||||||
|
previous: PostImportRow,
|
||||||
|
next: PostImportRow,
|
||||||
|
): boolean =>
|
||||||
|
previous.url !== next.url
|
||||||
|
|| JSON.stringify (previous.attributes) !== JSON.stringify (next.attributes)
|
||||||
|
|| JSON.stringify (previous.provenance) !== JSON.stringify (next.provenance)
|
||||||
|
|| JSON.stringify (previous.tagSources ?? { }) !== JSON.stringify (next.tagSources ?? { })
|
||||||
|
|
||||||
|
|
||||||
export const mergeValidatedImportRows = (
|
export const mergeValidatedImportRows = (
|
||||||
current: PostImportRow[],
|
current: PostImportRow[],
|
||||||
validated: PostImportRow[],
|
validated: PostImportRow[],
|
||||||
): PostImportRow[] =>
|
): PostImportRow[] => {
|
||||||
current
|
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
|
||||||
.map (previous => {
|
return current.map (previous => {
|
||||||
if (previous.importStatus === 'created')
|
if (previous.importStatus === 'created')
|
||||||
return previous
|
return previous
|
||||||
|
|
||||||
const row = validated.find (_1 => _1.sourceRow === previous.sourceRow)
|
const row = validatedMap.get (previous.sourceRow)
|
||||||
return row
|
if (row == null)
|
||||||
? {
|
return previous
|
||||||
...row,
|
|
||||||
importStatus: previous.importStatus,
|
const fieldWarnings =
|
||||||
createdPostId: previous.createdPostId,
|
Object.keys (row.fieldWarnings).length > 0 || row.metadataUrl !== previous.metadataUrl
|
||||||
importErrors: previous.importErrors,
|
? { ...row.fieldWarnings }
|
||||||
validationErrors: row.validationErrors ?? { } }
|
: { ...previous.fieldWarnings }
|
||||||
: previous
|
for (const [field, origin] of Object.entries (row.provenance))
|
||||||
})
|
{
|
||||||
.concat (validated.filter (row =>
|
if (origin === 'manual' && String (row.attributes[field] ?? '') !== '')
|
||||||
!(current.some (_1 => _1.sourceRow === row.sourceRow))))
|
delete fieldWarnings[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
url: row.url,
|
||||||
|
attributes: row.attributes,
|
||||||
|
provenance: row.provenance,
|
||||||
|
tagSources: row.tagSources,
|
||||||
|
fieldWarnings,
|
||||||
|
baseWarnings:
|
||||||
|
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
|
||||||
|
? row.baseWarnings
|
||||||
|
: previous.baseWarnings,
|
||||||
|
validationErrors: row.validationErrors,
|
||||||
|
status: row.status,
|
||||||
|
metadataUrl: row.metadataUrl }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
createdPostId: previous.createdPostId,
|
||||||
|
importStatus: previous.importStatus,
|
||||||
|
importErrors: previous.importErrors }
|
||||||
|
|
||||||
|
if (rowChanged (previous, nextRow))
|
||||||
|
{
|
||||||
|
nextRow.importStatus = 'pending'
|
||||||
|
nextRow.importErrors = undefined
|
||||||
|
}
|
||||||
|
return nextRow
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const mergeImportResults = (
|
export const mergeImportResults = (
|
||||||
rows: PostImportRow[],
|
rows: PostImportRow[],
|
||||||
results: PostImportResultRow[],
|
results: PostImportResultRow[],
|
||||||
): PostImportRow[] =>
|
): PostImportRow[] => {
|
||||||
rows.map (row => {
|
const resultMap = new Map (results.map (row => [row.sourceRow, row]))
|
||||||
const result = results.find (_1 => _1.sourceRow === row.sourceRow)
|
return rows.map (row => {
|
||||||
|
const result = resultMap.get (row.sourceRow)
|
||||||
return result
|
return result
|
||||||
? {
|
? {
|
||||||
...row,
|
...row,
|
||||||
importStatus: result.status,
|
importStatus: result.status,
|
||||||
createdPostId: result.post?.id,
|
createdPostId: result.post?.id,
|
||||||
importErrors: result.errors,
|
importErrors: result.errors }
|
||||||
validationErrors: row.validationErrors }
|
|
||||||
: row
|
: row
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const retryImportRow = (
|
export const retryImportRow = (
|
||||||
@@ -183,12 +511,6 @@ export const retryImportRow = (
|
|||||||
: row)
|
: row)
|
||||||
|
|
||||||
|
|
||||||
export const rowMessages = (row: PostImportRow): string[] => [
|
|
||||||
...row.warnings,
|
|
||||||
...Object.values (row.validationErrors ?? { }).flat (),
|
|
||||||
...Object.values (row.importErrors ?? { }).flat ()]
|
|
||||||
|
|
||||||
|
|
||||||
export const countImportSourceLines = (source: string): number =>
|
export const countImportSourceLines = (source: string): number =>
|
||||||
source
|
source
|
||||||
.split (/\r\n|\n|\r/)
|
.split (/\r\n|\n|\r/)
|
||||||
@@ -197,25 +519,96 @@ export const countImportSourceLines = (source: string): number =>
|
|||||||
.length
|
.length
|
||||||
|
|
||||||
|
|
||||||
export const countImportSourceValidLines = (source: string): number =>
|
export const validateImportSource = (
|
||||||
source
|
source: string,
|
||||||
.split (/\r\n|\n|\r/)
|
): PostImportSourceIssue[] => {
|
||||||
.map (_1 => _1.trim ())
|
const lines = source.split (/\r\n|\n|\r/)
|
||||||
.filter (_1 => /^https?:\/\//.test (_1))
|
const issues: PostImportSourceIssue[] = []
|
||||||
.length
|
const seen = new Map<string, number> ()
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
lines.forEach ((rawLine, index) => {
|
||||||
|
const value = rawLine.trim ()
|
||||||
|
if (!(value))
|
||||||
|
return
|
||||||
|
|
||||||
|
++count
|
||||||
|
const sourceRow = index + 1
|
||||||
|
const displayUrl = truncateUrl (value)
|
||||||
|
const normalised = normaliseImportUrl (value)
|
||||||
|
if (count > MAX_ROWS)
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: `取込件数は ${ MAX_ROWS } 件までです.`,
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: 'HTTP または HTTPS の URL ではありません.',
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (bytesize (value) > MAX_URL_BYTES)
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: 'URL が長すぎます.',
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalised == null)
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: 'URL の形式が不正です.',
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const duplicateRow = seen.get (normalised)
|
||||||
|
if (duplicateRow != null)
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: `${ duplicateRow } 行目と同じ URL です.`,
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.set (normalised, sourceRow)
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const hasSkipWarning = (row: PostImportRow): boolean =>
|
||||||
|
(row.fieldWarnings.url ?? []).includes ('既存投稿のためスキップします.')
|
||||||
|
|
||||||
|
|
||||||
export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
rows.filter (row => row.importStatus == null || row.importStatus === 'pending')
|
rows.filter (row => {
|
||||||
|
if (row.importStatus === 'created')
|
||||||
|
return false
|
||||||
|
if (row.importStatus === 'skipped')
|
||||||
|
return false
|
||||||
|
if (hasSkipWarning (row))
|
||||||
|
return false
|
||||||
|
if (row.importStatus === 'failed')
|
||||||
|
return false
|
||||||
|
return row.importStatus == null || row.importStatus === 'pending'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
submittable: rows.filter (row =>
|
submittable: rows.filter (row =>
|
||||||
(row.importStatus == null || row.importStatus === 'pending')
|
submittableImportRows ([row]).length > 0
|
||||||
&& Object.keys (row.validationErrors ?? { }).length === 0).length,
|
&& Object.keys (row.validationErrors ?? { }).length === 0).length,
|
||||||
invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length,
|
invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length,
|
||||||
skipPlanned: rows.filter (row =>
|
skipPlanned: rows.filter (row =>
|
||||||
row.existing && row.importStatus !== 'created' && row.status !== 'error').length,
|
hasSkipWarning (row) && row.importStatus !== 'created').length,
|
||||||
created: rows.filter (row => row.importStatus === 'created').length,
|
created: rows.filter (row => row.importStatus === 'created').length,
|
||||||
failed: rows.filter (row => row.importStatus === 'failed').length })
|
failed: rows.filter (row => row.importStatus === 'failed').length })
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ 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 { loadPostImportSession,
|
import { clearPostImportSourceDraft,
|
||||||
|
loadPostImportSession,
|
||||||
mergeImportResults,
|
mergeImportResults,
|
||||||
retryImportRow,
|
retryImportRow,
|
||||||
savePostImportSession,
|
savePostImportSession,
|
||||||
@@ -63,7 +64,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
if (!(sessionId))
|
if (!(sessionId))
|
||||||
return
|
return
|
||||||
|
|
||||||
const loaded = loadPostImportSession (sessionId)
|
const loaded = loadPostImportSession (sessionId, message =>
|
||||||
|
toast ({ title: '取込状態を復元できませんでした', description: message }))
|
||||||
setSession (loaded)
|
setSession (loaded)
|
||||||
setMissing (loaded == null)
|
setMissing (loaded == null)
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
@@ -72,7 +74,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
if (!(sessionId) || !(session))
|
if (!(sessionId) || !(session))
|
||||||
return
|
return
|
||||||
|
|
||||||
savePostImportSession (sessionId, session)
|
savePostImportSession (sessionId, session, message =>
|
||||||
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
}, [session, sessionId])
|
}, [session, sessionId])
|
||||||
|
|
||||||
const counts = useMemo (() => ({
|
const counts = useMemo (() => ({
|
||||||
@@ -104,8 +107,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
attributes: target.attributes,
|
attributes: target.attributes,
|
||||||
provenance: target.provenance,
|
provenance: target.provenance,
|
||||||
tagSources: target.tagSources,
|
tagSources: target.tagSources,
|
||||||
metadataUrl: target.metadataUrl }],
|
metadataUrl: target.metadataUrl }] })
|
||||||
existing: session.existing })
|
|
||||||
setSession (current =>
|
setSession (current =>
|
||||||
current
|
current
|
||||||
? { ...current, rows: mergeImportResults (current.rows, result.rows) }
|
? { ...current, rows: mergeImportResults (current.rows, result.rows) }
|
||||||
@@ -126,7 +128,12 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
const nextSession = { ...session, repairMode: 'failed' as const }
|
const nextSession = { ...session, repairMode: 'failed' as const }
|
||||||
setSession (nextSession)
|
setSession (nextSession)
|
||||||
if (sessionId)
|
if (sessionId)
|
||||||
savePostImportSession (sessionId, nextSession)
|
{
|
||||||
|
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||||
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
|
if (!(saved))
|
||||||
|
return
|
||||||
|
}
|
||||||
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +192,13 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{row.url}
|
{row.url}
|
||||||
</div>
|
</div>
|
||||||
|
{row.createdPostId && (
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/posts/${ row.createdPostId }`}
|
||||||
|
className="inline-block text-xs text-sky-700 underline
|
||||||
|
dark:text-sky-300">
|
||||||
|
投稿 #{row.createdPostId}
|
||||||
|
</PrefetchLink>)}
|
||||||
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,7 +236,10 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const nextSession = { ...session, repairMode: 'all' as const }
|
const nextSession = { ...session, repairMode: 'all' as const }
|
||||||
setSession (nextSession)
|
setSession (nextSession)
|
||||||
savePostImportSession (sessionId, nextSession)
|
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||||
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
|
if (!(saved))
|
||||||
|
return
|
||||||
navigate (`/posts/import/${ sessionId }/review`)
|
navigate (`/posts/import/${ sessionId }/review`)
|
||||||
}}>
|
}}>
|
||||||
確認画面へ戻る
|
確認画面へ戻る
|
||||||
@@ -230,7 +247,11 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate ('/posts/import')}>
|
onClick={() => {
|
||||||
|
clearPostImportSourceDraft (message =>
|
||||||
|
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||||||
|
navigate ('/posts/import')
|
||||||
|
}}>
|
||||||
新しい URL リストを入力
|
新しい URL リストを入力
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { loadPostImportSession,
|
|||||||
mergeImportResults,
|
mergeImportResults,
|
||||||
mergeValidatedImportRows,
|
mergeValidatedImportRows,
|
||||||
reviewSummaryCounts,
|
reviewSummaryCounts,
|
||||||
rowMessages,
|
|
||||||
savePostImportSession,
|
savePostImportSession,
|
||||||
submittableImportRows,
|
submittableImportRows,
|
||||||
type PostImportResultRow,
|
type PostImportResultRow,
|
||||||
@@ -59,7 +58,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (!(sessionId))
|
if (!(sessionId))
|
||||||
return
|
return
|
||||||
|
|
||||||
const loaded = loadPostImportSession (sessionId)
|
const loaded = loadPostImportSession (sessionId, message =>
|
||||||
|
toast ({ title: '取込状態を復元できませんでした', description: message }))
|
||||||
setSession (loaded)
|
setSession (loaded)
|
||||||
setMissing (loaded == null)
|
setMissing (loaded == null)
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
@@ -68,7 +68,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (!(sessionId) || !(session))
|
if (!(sessionId) || !(session))
|
||||||
return
|
return
|
||||||
|
|
||||||
savePostImportSession (sessionId, session)
|
savePostImportSession (sessionId, session, message =>
|
||||||
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
}, [session, sessionId])
|
}, [session, sessionId])
|
||||||
|
|
||||||
const rows = session?.rows ?? []
|
const rows = session?.rows ?? []
|
||||||
@@ -160,8 +161,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||||
rows: nextRows.filter (row => row.importStatus !== 'created'),
|
rows: nextRows
|
||||||
existing: session.existing,
|
.filter (row => row.importStatus !== 'created')
|
||||||
|
.map (row => ({
|
||||||
|
sourceRow: row.sourceRow,
|
||||||
|
url: row.url,
|
||||||
|
attributes: row.attributes,
|
||||||
|
provenance: row.provenance,
|
||||||
|
tagSources: row.tagSources,
|
||||||
|
metadataUrl: row.metadataUrl })),
|
||||||
changed_row: urlChanged ? editingRow.sourceRow : -1 })
|
changed_row: urlChanged ? editingRow.sourceRow : -1 })
|
||||||
updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows))
|
updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows))
|
||||||
setSearchParams ({ })
|
setSearchParams ({ })
|
||||||
@@ -178,33 +186,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateExisting = async (value: 'skip' | 'error') => {
|
|
||||||
if (!(session))
|
|
||||||
return
|
|
||||||
|
|
||||||
const pendingRows = session.rows.map (row =>
|
|
||||||
row.importStatus === 'created'
|
|
||||||
? row
|
|
||||||
: {
|
|
||||||
...row,
|
|
||||||
importStatus: 'pending' as const,
|
|
||||||
importErrors: undefined })
|
|
||||||
setSession ({ ...session, existing: value, rows: pendingRows })
|
|
||||||
setLoading (true)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
|
||||||
rows: pendingRows.filter (row => row.importStatus !== 'created'),
|
|
||||||
existing: value,
|
|
||||||
changed_row: -1 })
|
|
||||||
updateSessionRows (mergeValidatedImportRows (pendingRows, validated.rows))
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setLoading (false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!(sessionId) || !(session) || submittable.length === 0)
|
if (!(sessionId) || !(session) || submittable.length === 0)
|
||||||
return
|
return
|
||||||
@@ -223,12 +204,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
attributes: row.attributes,
|
attributes: row.attributes,
|
||||||
provenance: row.provenance,
|
provenance: row.provenance,
|
||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
metadataUrl: row.metadataUrl })),
|
metadataUrl: row.metadataUrl })) })
|
||||||
existing: session.existing })
|
|
||||||
const nextRows = mergeImportResults (session.rows, result.rows)
|
const nextRows = mergeImportResults (session.rows, result.rows)
|
||||||
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
|
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
|
||||||
setSession (nextSession)
|
setSession (nextSession)
|
||||||
savePostImportSession (sessionId, nextSession)
|
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||||
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
|
if (!(saved))
|
||||||
|
return
|
||||||
navigate (`/posts/import/${ sessionId }/result`)
|
navigate (`/posts/import/${ sessionId }/result`)
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -264,7 +247,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
|
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div className="mx-auto max-w-6xl space-y-4 p-4 pb-28">
|
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4 pb-8">
|
||||||
<PageTitle>投稿情報の確認</PageTitle>
|
<PageTitle>投稿情報の確認</PageTitle>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||||
@@ -279,26 +262,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
<div className="flex-1 space-y-3 pb-24">
|
||||||
dark:bg-neutral-900">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
|
||||||
URL {rows.length} 件
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<span>既存投稿</span>
|
|
||||||
<select
|
|
||||||
value={session.existing}
|
|
||||||
onChange={ev => updateExisting (ev.target.value as 'skip' | 'error')}
|
|
||||||
className="rounded border border-border bg-background px-3 py-2">
|
|
||||||
<option value="skip">スキップ</option>
|
|
||||||
<option value="error">エラー</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{reviewRows.map (row => (
|
{reviewRows.map (row => (
|
||||||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||||||
<PostImportRowSummary
|
<PostImportRowSummary
|
||||||
@@ -306,16 +270,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setSearchParams ({ edit: String (row.sourceRow) })
|
setSearchParams ({ edit: String (row.sourceRow) })
|
||||||
}}/>
|
}}/>
|
||||||
{rowMessages (row).length > 0 && (
|
|
||||||
<div className="mt-2 md:max-w-3xl">
|
|
||||||
<FieldError messages={rowMessages (row)}/>
|
|
||||||
</div>)}
|
|
||||||
</div>))}
|
</div>))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-x-0 bottom-0 border-t bg-white/95 p-4 backdrop-blur
|
className="sticky bottom-0 border-t bg-white/95 p-4 backdrop-blur
|
||||||
dark:border-neutral-700 dark:bg-neutral-950/95">
|
dark:border-neutral-700 dark:bg-neutral-950/95">
|
||||||
<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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -13,11 +13,12 @@ import { apiPost, isApiError } from '@/lib/api'
|
|||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { countImportSourceLines,
|
import { countImportSourceLines,
|
||||||
countImportSourceValidLines,
|
cleanupExpiredPostImportSessions,
|
||||||
createPostImportSessionId,
|
createPostImportSessionId,
|
||||||
loadPostImportSourceDraft,
|
loadPostImportSourceDraft,
|
||||||
savePostImportSession,
|
savePostImportSession,
|
||||||
savePostImportSourceDraft,
|
savePostImportSourceDraft,
|
||||||
|
validateImportSource,
|
||||||
type PostImportRow } from '@/lib/postImportSession'
|
type PostImportRow } from '@/lib/postImportSession'
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
@@ -33,38 +34,58 @@ const MAX_ROWS = 100
|
|||||||
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 draft = useMemo (() => loadPostImportSourceDraft (), [])
|
const draft = useMemo (() => loadPostImportSourceDraft (message =>
|
||||||
|
toast ({ title: '保存済み入力を復元できませんでした', description: message })), [])
|
||||||
|
|
||||||
const [source, setSource] = useState (draft.source)
|
const [source, setSource] = useState (draft.source)
|
||||||
const [existing, setExisting] = useState<'skip' | 'error'> (draft.existing)
|
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
const [error, setError] = useState<string | null> (null)
|
const [error, setError] = useState<string | null> (null)
|
||||||
|
const [touched, setTouched] = useState (false)
|
||||||
|
const saveTimer = useRef<number | null> (null)
|
||||||
|
|
||||||
const lineCount = countImportSourceLines (source)
|
const lineCount = countImportSourceLines (source)
|
||||||
const validLineCount = countImportSourceValidLines (source)
|
const issues = useMemo (() => validateImportSource (source), [source])
|
||||||
const messages = [
|
const messages = [
|
||||||
...(lineCount === 0 ? ['URL を入力してください.'] : []),
|
...(((touched) && lineCount === 0) ? ['URL を入力してください.'] : []),
|
||||||
...(lineCount > MAX_ROWS ? [`最大 ${ MAX_ROWS } 件までです.`] : []),
|
|
||||||
...(error ? [error] : [])]
|
...(error ? [error] : [])]
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
savePostImportSourceDraft (source, existing)
|
cleanupExpiredPostImportSessions (message =>
|
||||||
}, [existing, source])
|
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (saveTimer.current != null)
|
||||||
|
window.clearTimeout (saveTimer.current)
|
||||||
|
saveTimer.current = window.setTimeout (() => {
|
||||||
|
savePostImportSourceDraft (source, message =>
|
||||||
|
toast ({ title: '入力内容を保存できませんでした', description: message }))
|
||||||
|
}, 300)
|
||||||
|
return () => {
|
||||||
|
if (saveTimer.current != null)
|
||||||
|
window.clearTimeout (saveTimer.current)
|
||||||
|
}
|
||||||
|
}, [source])
|
||||||
|
|
||||||
const preview = async () => {
|
const preview = async () => {
|
||||||
|
setTouched (true)
|
||||||
|
if (lineCount === 0 || issues.length > 0)
|
||||||
|
return
|
||||||
|
|
||||||
setLoading (true)
|
setLoading (true)
|
||||||
setError (null)
|
setError (null)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
||||||
source,
|
source })
|
||||||
existing })
|
|
||||||
const sessionId = createPostImportSessionId ()
|
const sessionId = createPostImportSessionId ()
|
||||||
savePostImportSession (sessionId, {
|
const saved = savePostImportSession (sessionId, {
|
||||||
source,
|
source,
|
||||||
existing,
|
|
||||||
rows: data.rows,
|
rows: data.rows,
|
||||||
repairMode: 'all' })
|
repairMode: 'all' }, message =>
|
||||||
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
|
if (!(saved))
|
||||||
|
return
|
||||||
navigate (`/posts/import/${ sessionId }/review`)
|
navigate (`/posts/import/${ sessionId }/review`)
|
||||||
}
|
}
|
||||||
catch (requestError)
|
catch (requestError)
|
||||||
@@ -99,48 +120,50 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||||
dark:bg-neutral-900 md:p-6">
|
dark:bg-neutral-900 md:p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-lg font-semibold">URL リストを入力</h2>
|
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
|
||||||
一行につき一つの URL を入力してください.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField label="URL リスト">
|
<FormField label="URL リスト">
|
||||||
{() => (
|
{() => (
|
||||||
<textarea
|
<textarea
|
||||||
value={source}
|
value={source}
|
||||||
rows={14}
|
rows={14}
|
||||||
className={inputClass (
|
className={inputClass (
|
||||||
messages.length > 0,
|
messages.length > 0 || issues.length > 0,
|
||||||
'font-mono text-sm',
|
'font-mono text-sm',
|
||||||
)}
|
)}
|
||||||
onChange={ev => setSource (ev.target.value)}/>)}
|
onBlur={() => {
|
||||||
|
setTouched (true)
|
||||||
|
savePostImportSourceDraft (source, message =>
|
||||||
|
toast ({
|
||||||
|
title: '入力内容を保存できませんでした',
|
||||||
|
description: message }))
|
||||||
|
}}
|
||||||
|
onChange={ev => {
|
||||||
|
setSource (ev.target.value)
|
||||||
|
setError (null)
|
||||||
|
}}/>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
<FieldError messages={messages}/>
|
<FieldError messages={messages}/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{issues.map (issue => (
|
||||||
|
<div
|
||||||
|
key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}
|
||||||
|
className="rounded-md border border-red-300 bg-red-50 p-3 text-sm
|
||||||
|
text-red-700 dark:border-red-900 dark:bg-red-950
|
||||||
|
dark:text-red-200">
|
||||||
|
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
||||||
|
<div className="mt-1 break-all font-mono text-xs">{issue.url}</div>
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm
|
||||||
text-neutral-600 dark:text-neutral-300">
|
text-neutral-600 dark:text-neutral-300">
|
||||||
<div>有効行数: {validLineCount}</div>
|
<div>入力件数 {lineCount} / {MAX_ROWS}</div>
|
||||||
<div>最大 {MAX_ROWS} 件</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block space-y-1">
|
|
||||||
<span className="text-sm font-medium">既存投稿</span>
|
|
||||||
<select
|
|
||||||
value={existing}
|
|
||||||
onChange={ev => setExisting (ev.target.value as typeof existing)}
|
|
||||||
className={inputClass (false, 'w-full md:w-auto')}>
|
|
||||||
<option value="skip">スキップ</option>
|
|
||||||
<option value="error">エラー</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={preview}
|
onClick={preview}
|
||||||
disabled={loading || lineCount === 0 || lineCount > MAX_ROWS}>
|
disabled={loading || lineCount === 0 || issues.length > 0}>
|
||||||
投稿情報を取得
|
投稿情報を取得
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする