このコミットが含まれているのは:
@@ -1,3 +1,5 @@
|
|||||||
|
require 'timeout'
|
||||||
|
|
||||||
class PostImportPreviewer
|
class PostImportPreviewer
|
||||||
FIELDS = [
|
FIELDS = [
|
||||||
'title',
|
'title',
|
||||||
@@ -6,8 +8,7 @@ class PostImportPreviewer
|
|||||||
'original_created_before',
|
'original_created_before',
|
||||||
'duration',
|
'duration',
|
||||||
'tags',
|
'tags',
|
||||||
'parent_post_ids',
|
'parent_post_ids'].freeze
|
||||||
].freeze
|
|
||||||
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
|
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
|
||||||
EXISTING_SKIP_WARNING = '既存投稿のためスキップします.'.freeze
|
EXISTING_SKIP_WARNING = '既存投稿のためスキップします.'.freeze
|
||||||
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
|
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
|
||||||
@@ -15,15 +16,26 @@ class PostImportPreviewer
|
|||||||
METADATA_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 = {}
|
|
||||||
prepared_rows = rows.map { prepare_row(_1) }
|
prepared_rows = rows.map { prepare_row(_1) }
|
||||||
|
url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally
|
||||||
existing_posts =
|
existing_posts =
|
||||||
Post.where(url: prepared_rows.map { _1[:normal_url] }.compact.uniq).index_by(&:url)
|
Post.where(url: prepared_rows.map { _1[:normal_url] }.compact.uniq).index_by(&:url)
|
||||||
|
known_tags = preload_known_tags(prepared_rows)
|
||||||
|
existing_parent_ids = preload_parent_ids(prepared_rows)
|
||||||
|
preload_metadata!(
|
||||||
|
prepared_rows,
|
||||||
|
fetch_metadata,
|
||||||
|
metadata_cache,
|
||||||
|
existing_posts,
|
||||||
|
url_counts)
|
||||||
prepared_rows.map { |row|
|
prepared_rows.map { |row|
|
||||||
preview_row(row, urls:,
|
preview_row(row,
|
||||||
fetch_metadata:,
|
fetch_metadata:,
|
||||||
metadata_cache:,
|
metadata_cache:,
|
||||||
existing_posts:)
|
existing_posts:,
|
||||||
|
url_counts:,
|
||||||
|
known_tags:,
|
||||||
|
existing_parent_ids:)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -40,7 +52,13 @@ class PostImportPreviewer
|
|||||||
source.merge(url_text: url, normal_url:)
|
source.merge(url_text: url, normal_url:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def preview_row row, urls:, fetch_metadata:, metadata_cache:, existing_posts:
|
def preview_row row,
|
||||||
|
fetch_metadata:,
|
||||||
|
metadata_cache:,
|
||||||
|
existing_posts:,
|
||||||
|
url_counts:,
|
||||||
|
known_tags:,
|
||||||
|
existing_parent_ids:
|
||||||
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)
|
||||||
@@ -54,10 +72,9 @@ class PostImportPreviewer
|
|||||||
|
|
||||||
validation_errors = {}
|
validation_errors = {}
|
||||||
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
|
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
|
||||||
if normal_url.present? && urls.key?(normal_url)
|
if normal_url.present? && url_counts[normal_url].to_i > 1
|
||||||
validation_errors[:url] = ['URL が重複しています.']
|
validation_errors[:url] = ['URL が重複しています.']
|
||||||
end
|
end
|
||||||
urls[normal_url] = true if normal_url.present?
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -89,7 +106,8 @@ class PostImportPreviewer
|
|||||||
end }
|
end }
|
||||||
end
|
end
|
||||||
|
|
||||||
should_fetch = should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
should_fetch = validation_errors.blank?
|
||||||
|
should_fetch &&= should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
||||||
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)
|
||||||
@@ -101,8 +119,9 @@ class PostImportPreviewer
|
|||||||
validate_basic_data(attributes, validation_errors)
|
validate_basic_data(attributes, validation_errors)
|
||||||
validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
|
validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
|
||||||
validation_errors,
|
validation_errors,
|
||||||
field_warnings)
|
field_warnings,
|
||||||
validate_parents(attributes['parent_post_ids'], validation_errors)
|
known_tags)
|
||||||
|
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
|
||||||
attributes.delete('url')
|
attributes.delete('url')
|
||||||
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
|
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
|
||||||
|
|
||||||
@@ -197,6 +216,64 @@ class PostImportPreviewer
|
|||||||
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } }
|
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
|
||||||
|
urls = prepared_rows.filter_map { |row|
|
||||||
|
next unless row[:normal_url].present?
|
||||||
|
next unless should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
||||||
|
next if url_counts[row[:normal_url]].to_i > 1
|
||||||
|
next if existing_posts.key?(row[:normal_url])
|
||||||
|
|
||||||
|
row[:normal_url]
|
||||||
|
}.uniq
|
||||||
|
urls = urls.reject { metadata_cache.key?(_1) }
|
||||||
|
return if urls.empty?
|
||||||
|
|
||||||
|
queue = Queue.new
|
||||||
|
urls.each { queue << _1 }
|
||||||
|
workers = [urls.length, 4].min.times.map {
|
||||||
|
Thread.new {
|
||||||
|
loop do
|
||||||
|
url = queue.pop(true)
|
||||||
|
metadata_cache[url] = fetch_metadata(url)
|
||||||
|
rescue ThreadError
|
||||||
|
break
|
||||||
|
end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timeout.timeout(15) { workers.each(&:join) }
|
||||||
|
rescue Timeout::Error
|
||||||
|
workers&.each(&:kill)
|
||||||
|
urls.each do |url|
|
||||||
|
metadata_cache[url] ||= { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def preload_known_tags prepared_rows
|
||||||
|
names = prepared_rows.flat_map { |row|
|
||||||
|
attributes = initial_attributes(row)
|
||||||
|
provenance = initial_provenance(row)
|
||||||
|
tag_sources = initial_tag_sources(row, attributes, provenance)
|
||||||
|
preview_tag_names(merged_tags(tag_sources, provenance['tags']))
|
||||||
|
}.compact.uniq
|
||||||
|
return { } if names.empty?
|
||||||
|
|
||||||
|
Tag.joins(:tag_name)
|
||||||
|
.where(tag_names: { name: names })
|
||||||
|
.includes(:tag_name)
|
||||||
|
.to_a
|
||||||
|
.index_by(&:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def preload_parent_ids prepared_rows
|
||||||
|
ids = prepared_rows.flat_map { |row|
|
||||||
|
attributes = initial_attributes(row)
|
||||||
|
preview_parent_ids(attributes['parent_post_ids'])
|
||||||
|
}.uniq
|
||||||
|
return { } if ids.empty?
|
||||||
|
|
||||||
|
Post.where(id: ids).pluck(:id).to_h { [_1, true] }
|
||||||
|
end
|
||||||
|
|
||||||
def 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'
|
||||||
@@ -232,7 +309,19 @@ class PostImportPreviewer
|
|||||||
sources['automatic'].to_s
|
sources['automatic'].to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_preview_tags raw, errors, field_warnings
|
def preview_tag_names raw
|
||||||
|
names = raw.to_s.split
|
||||||
|
return [] if names.empty?
|
||||||
|
if names.any? { _1.downcase.start_with?('nico:') }
|
||||||
|
return []
|
||||||
|
end
|
||||||
|
|
||||||
|
names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
|
||||||
|
rescue Tag::SectionLiteralParseError
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_preview_tags raw, errors, field_warnings, known_tags
|
||||||
names = raw.to_s.split
|
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:') }
|
||||||
@@ -241,7 +330,7 @@ class PostImportPreviewer
|
|||||||
end
|
end
|
||||||
|
|
||||||
parsed = names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
|
parsed = names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
|
||||||
existing = Tag.joins(:tag_name).where(tag_names: { name: parsed }).includes(:tag_name).to_a
|
existing = parsed.filter_map { known_tags[_1] }
|
||||||
deprecated = existing.select(&:deprecated?).map(&:name)
|
deprecated = existing.select(&:deprecated?).map(&:name)
|
||||||
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)
|
||||||
@@ -277,7 +366,11 @@ class PostImportPreviewer
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_parents raw, errors
|
def preview_parent_ids raw
|
||||||
|
raw.to_s.split.map { Integer(_1, exception: false) }.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_parents raw, errors, existing_parent_ids
|
||||||
ids = raw.to_s.split.map { Integer(_1, exception: false) }
|
ids = raw.to_s.split.map { Integer(_1, exception: false) }
|
||||||
return if ids.compact.empty? && raw.to_s.blank?
|
return if ids.compact.empty? && raw.to_s.blank?
|
||||||
|
|
||||||
@@ -285,10 +378,8 @@ class PostImportPreviewer
|
|||||||
errors[:parent_post_ids] = ['親投稿 Id. が不正です.']
|
errors[:parent_post_ids] = ['親投稿 Id. が不正です.']
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Post.where(id: ids).count != ids.uniq.length
|
if ids.uniq.any? { !existing_parent_ids[_1] }
|
||||||
errors[:parent_post_ids] = ['存在しない親投稿 Id. があります.']
|
errors[:parent_post_ids] = ['存在しない親投稿 Id. があります.']
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private :prepare_row
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ class PostImportRowNormaliser
|
|||||||
'original_created_from',
|
'original_created_from',
|
||||||
'original_created_before',
|
'original_created_before',
|
||||||
'tags',
|
'tags',
|
||||||
'parent_post_ids',
|
'parent_post_ids'].freeze
|
||||||
].freeze
|
|
||||||
FLEXIBLE_FIELDS = ['duration', 'video_ms'].freeze
|
FLEXIBLE_FIELDS = ['duration', 'video_ms'].freeze
|
||||||
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
|
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
|
||||||
|
|
||||||
@@ -59,16 +58,14 @@ class PostImportRowNormaliser
|
|||||||
{ attributes: {} },
|
{ attributes: {} },
|
||||||
{ provenance: {} },
|
{ provenance: {} },
|
||||||
{ tag_sources: {} },
|
{ tag_sources: {} },
|
||||||
{ tagSources: {} },
|
{ tagSources: {} }]
|
||||||
]
|
|
||||||
return keys unless allow_warning_fields
|
return keys unless allow_warning_fields
|
||||||
|
|
||||||
keys + [
|
keys + [
|
||||||
{ field_warnings: {} },
|
{ field_warnings: {} },
|
||||||
{ fieldWarnings: {} },
|
{ fieldWarnings: {} },
|
||||||
{ base_warnings: [] },
|
{ base_warnings: [] },
|
||||||
{ baseWarnings: [] },
|
{ baseWarnings: [] }]
|
||||||
]
|
|
||||||
end
|
end
|
||||||
private_class_method :permitted_keys
|
private_class_method :permitted_keys
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
class PostMetadataFetcher
|
class PostMetadataFetcher
|
||||||
TIMESTAMP_PATTERN =
|
TIMESTAMP_PATTERN =
|
||||||
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2})/ \
|
Regexp.new(
|
||||||
/(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?/ \
|
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
|
||||||
/(Z|[+-]\d{2}:?\d{2})?\z/
|
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
|
||||||
|
'(Z|[+-]\d{2}:?\d{2})?\z')
|
||||||
|
|
||||||
def self.fetch raw_url
|
def self.fetch raw_url
|
||||||
uri, = Preview::UrlSafety.validate(raw_url)
|
uri, = Preview::UrlSafety.validate(raw_url)
|
||||||
@@ -80,9 +81,14 @@ class PostMetadataFetcher
|
|||||||
minute = match[5]&.to_i || 0
|
minute = match[5]&.to_i || 0
|
||||||
second = match[6]&.to_i || 0
|
second = match[6]&.to_i || 0
|
||||||
fraction = match[7]
|
fraction = match[7]
|
||||||
offset = parse_offset(match[8])
|
offset = match[8]
|
||||||
from = Time.new(year, month, day, hour, minute, second + fractional_seconds(fraction), offset)
|
whole_second = second + fractional_seconds(fraction)
|
||||||
.in_time_zone
|
from =
|
||||||
|
if offset.present?
|
||||||
|
Time.new(year, month, day, hour, minute, whole_second, parse_offset(offset)).in_time_zone
|
||||||
|
else
|
||||||
|
Time.zone.local(year, month, day, hour, minute, whole_second)
|
||||||
|
end
|
||||||
before =
|
before =
|
||||||
if fraction.present?
|
if fraction.present?
|
||||||
from + (10**(-fraction.length))
|
from + (10**(-fraction.length))
|
||||||
@@ -97,7 +103,6 @@ class PostMetadataFetcher
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.parse_offset value
|
def self.parse_offset value
|
||||||
return Time.zone.formatted_offset if value.blank?
|
|
||||||
return '+00:00' if value == 'Z'
|
return '+00:00' if value == 'Z'
|
||||||
|
|
||||||
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
|
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
|
||||||
|
|||||||
@@ -54,16 +54,18 @@ const originalCreatedOrigin = (
|
|||||||
row: PostImportRow,
|
row: PostImportRow,
|
||||||
originalDraft: Draft,
|
originalDraft: Draft,
|
||||||
draft: Draft,
|
draft: Draft,
|
||||||
): PostImportOrigin =>
|
): PostImportOrigin => {
|
||||||
originalDraft.originalCreatedFrom !== draft.originalCreatedFrom
|
const changed =
|
||||||
|| originalDraft.originalCreatedBefore !== draft.originalCreatedBefore
|
originalDraft.originalCreatedFrom !== draft.originalCreatedFrom
|
||||||
? 'manual'
|
|| originalDraft.originalCreatedBefore !== draft.originalCreatedBefore
|
||||||
: (
|
if (changed)
|
||||||
|
return 'manual'
|
||||||
|
|
||||||
|
const manualOrigin =
|
||||||
originOf (row, 'originalCreatedFrom') === 'manual'
|
originOf (row, 'originalCreatedFrom') === 'manual'
|
||||||
|| originOf (row, 'originalCreatedBefore') === 'manual'
|
|| originOf (row, 'originalCreatedBefore') === 'manual'
|
||||||
? 'manual'
|
return manualOrigin ? 'manual' : 'automatic'
|
||||||
: 'automatic'
|
}
|
||||||
)
|
|
||||||
|
|
||||||
const buildDraft = (row: PostImportRow): Draft => ({
|
const buildDraft = (row: PostImportRow): Draft => ({
|
||||||
url: row.url,
|
url: row.url,
|
||||||
@@ -91,7 +93,7 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
setDraft (buildDraft (row))
|
setDraft (buildDraft (row))
|
||||||
}, [open, row])
|
}, [open, row])
|
||||||
|
|
||||||
if (!(row) || !(draft))
|
if (row == null || draft == null)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const originalDraft = buildDraft (row)
|
const originalDraft = buildDraft (row)
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { PostImportResultRow,
|
||||||
|
PostImportRow } from '@/lib/postImportTypes'
|
||||||
|
|
||||||
|
const hasSkipReason = (row: PostImportRow): boolean =>
|
||||||
|
row.skipReason === 'existing'
|
||||||
|
|
||||||
|
const hasValidationErrors = (row: PostImportRow): boolean =>
|
||||||
|
Object.keys (row.validationErrors ?? { }).length > 0
|
||||||
|
|
||||||
|
const 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 processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
|
rows.filter (row => {
|
||||||
|
if (row.importStatus === 'created')
|
||||||
|
return false
|
||||||
|
if (row.importStatus === 'skipped')
|
||||||
|
return false
|
||||||
|
if (row.importStatus === 'failed')
|
||||||
|
return false
|
||||||
|
if (hasValidationErrors (row))
|
||||||
|
return false
|
||||||
|
return row.importStatus == null || row.importStatus === 'pending'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
|
processableImportRows (rows).filter (row => !(hasSkipReason (row)))
|
||||||
|
|
||||||
|
|
||||||
|
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
||||||
|
total: rows.length,
|
||||||
|
submittable: rows.filter (row =>
|
||||||
|
creatableImportRows ([row]).length > 0
|
||||||
|
&& !(hasValidationErrors (row))).length,
|
||||||
|
invalid: rows.filter (row => hasValidationErrors (row)).length,
|
||||||
|
skipPlanned: rows.filter (row =>
|
||||||
|
hasSkipReason (row)
|
||||||
|
&& !(hasValidationErrors (row))
|
||||||
|
&& 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[]) =>
|
||||||
|
rows.reduce (
|
||||||
|
(counts, row) => {
|
||||||
|
if (hasValidationErrors (row))
|
||||||
|
{
|
||||||
|
++counts.invalid
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
if (row.importStatus === 'created')
|
||||||
|
{
|
||||||
|
++counts.created
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
if (row.importStatus === 'skipped')
|
||||||
|
{
|
||||||
|
++counts.skipped
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
if (row.importStatus === 'failed')
|
||||||
|
++counts.failed
|
||||||
|
return counts
|
||||||
|
},
|
||||||
|
{ created: 0, skipped: 0, failed: 0, invalid: 0 })
|
||||||
|
|
||||||
|
|
||||||
|
export const mergeValidatedImportRows = (
|
||||||
|
current: PostImportRow[],
|
||||||
|
validated: PostImportRow[],
|
||||||
|
): PostImportRow[] => {
|
||||||
|
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
|
||||||
|
return current.map (previous => {
|
||||||
|
if (previous.importStatus === 'created')
|
||||||
|
return previous
|
||||||
|
|
||||||
|
const row = validatedMap.get (previous.sourceRow)
|
||||||
|
if (row == null)
|
||||||
|
return previous
|
||||||
|
|
||||||
|
const fieldWarnings =
|
||||||
|
Object.keys (row.fieldWarnings).length > 0 || row.metadataUrl !== previous.metadataUrl
|
||||||
|
? { ...row.fieldWarnings }
|
||||||
|
: { ...previous.fieldWarnings }
|
||||||
|
for (const [field, origin] of Object.entries (row.provenance))
|
||||||
|
{
|
||||||
|
if (origin === 'manual')
|
||||||
|
delete fieldWarnings[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
url: row.url,
|
||||||
|
attributes: row.attributes,
|
||||||
|
provenance: row.provenance,
|
||||||
|
tagSources: row.tagSources,
|
||||||
|
skipReason: row.skipReason,
|
||||||
|
existingPostId: row.existingPostId,
|
||||||
|
fieldWarnings,
|
||||||
|
baseWarnings:
|
||||||
|
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
|
||||||
|
? row.baseWarnings
|
||||||
|
: previous.baseWarnings,
|
||||||
|
validationErrors: row.validationErrors,
|
||||||
|
status: row.status,
|
||||||
|
metadataUrl: row.metadataUrl }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const mergeImportResults = (
|
||||||
|
rows: PostImportRow[],
|
||||||
|
results: PostImportResultRow[],
|
||||||
|
): PostImportRow[] => {
|
||||||
|
const resultMap = new Map (results.map (row => [row.sourceRow, row]))
|
||||||
|
return rows.map (row => {
|
||||||
|
const result = resultMap.get (row.sourceRow)
|
||||||
|
return result
|
||||||
|
? {
|
||||||
|
...row,
|
||||||
|
importStatus: result.status,
|
||||||
|
createdPostId: result.post?.id,
|
||||||
|
importErrors: result.errors }
|
||||||
|
: row
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const retryImportRow = (
|
||||||
|
rows: PostImportRow[],
|
||||||
|
sourceRow: number,
|
||||||
|
): PostImportRow[] =>
|
||||||
|
rows.map (row =>
|
||||||
|
row.sourceRow === sourceRow && row.importStatus === 'failed'
|
||||||
|
? { ...row, importStatus: 'pending', importErrors: undefined }
|
||||||
|
: row)
|
||||||
@@ -1,653 +1,4 @@
|
|||||||
export type PostImportOrigin = 'automatic' | 'manual'
|
export * from '@/lib/postImportTypes'
|
||||||
export type PostImportRepairMode = 'all' | 'failed'
|
export * from '@/lib/postImportStorage'
|
||||||
export type PostImportStatus =
|
export * from '@/lib/postImportSourceValidation'
|
||||||
'pending'
|
export * from '@/lib/postImportRows'
|
||||||
| 'created'
|
|
||||||
| 'skipped'
|
|
||||||
| 'failed'
|
|
||||||
export type PostImportSkipReason = 'existing'
|
|
||||||
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
|
||||||
export type PostImportAttributeValue = string | number
|
|
||||||
|
|
||||||
export type PostImportRow = {
|
|
||||||
sourceRow: number
|
|
||||||
url: string
|
|
||||||
attributes: Record<string, PostImportAttributeValue>
|
|
||||||
fieldWarnings: Record<string, string[]>
|
|
||||||
baseWarnings: string[]
|
|
||||||
validationErrors: Record<string, string[]>
|
|
||||||
importErrors?: Record<string, string[]>
|
|
||||||
provenance: Record<string, PostImportOrigin>
|
|
||||||
tagSources?: Record<PostImportOrigin, string>
|
|
||||||
status: 'ready' | 'warning' | 'error'
|
|
||||||
skipReason?: PostImportSkipReason
|
|
||||||
existingPostId?: number
|
|
||||||
metadataUrl?: string
|
|
||||||
createdPostId?: number
|
|
||||||
importStatus?: PostImportStatus }
|
|
||||||
|
|
||||||
export type PostImportResultRow = {
|
|
||||||
sourceRow: number
|
|
||||||
status: PostImportResultStatus
|
|
||||||
post?: { id: number }
|
|
||||||
errors?: Record<string, string[]> }
|
|
||||||
|
|
||||||
export type PostImportSession = {
|
|
||||||
version: number
|
|
||||||
savedAt: string
|
|
||||||
source: string
|
|
||||||
rows: PostImportRow[]
|
|
||||||
repairMode: PostImportRepairMode }
|
|
||||||
|
|
||||||
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 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 isValidSkipReason = (
|
|
||||||
value: unknown,
|
|
||||||
): value is PostImportSkipReason =>
|
|
||||||
value === 'existing'
|
|
||||||
|
|
||||||
const isPositiveInteger = (value: unknown): value is number =>
|
|
||||||
Number.isInteger (value) && Number (value) > 0
|
|
||||||
|
|
||||||
|
|
||||||
const sanitiseRow = (value: unknown): PostImportRow | null => {
|
|
||||||
if (!(isPlainObject (value)))
|
|
||||||
return null
|
|
||||||
if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
|
|
||||||
return null
|
|
||||||
if (typeof value.url !== 'string')
|
|
||||||
return null
|
|
||||||
if (!(isPlainObject (value.attributes)))
|
|
||||||
return null
|
|
||||||
if (!(isPlainObject (value.provenance)))
|
|
||||||
return null
|
|
||||||
if (!(isValidStatus (value.status)))
|
|
||||||
return null
|
|
||||||
if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
|
|
||||||
return null
|
|
||||||
if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
|
|
||||||
return null
|
|
||||||
if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId)))
|
|
||||||
return null
|
|
||||||
if (value.skipReason !== 'existing' && value.existingPostId != null)
|
|
||||||
return null
|
|
||||||
if (value.importStatus === 'created' && !(isPositiveInteger (value.createdPostId)))
|
|
||||||
return null
|
|
||||||
if (value.importStatus !== 'created' && value.createdPostId != null)
|
|
||||||
return null
|
|
||||||
if (value.createdPostId != null && !(isPositiveInteger (value.createdPostId)))
|
|
||||||
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,
|
|
||||||
skipReason: value.skipReason,
|
|
||||||
existingPostId:
|
|
||||||
isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
|
|
||||||
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
|
|
||||||
createdPostId:
|
|
||||||
isPositiveInteger (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 =>
|
|
||||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
||||||
? crypto.randomUUID ()
|
|
||||||
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
|
|
||||||
|
|
||||||
|
|
||||||
export const cleanupExpiredPostImportSessions = (
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
) => {
|
|
||||||
if (typeof window === 'undefined')
|
|
||||||
return
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
for (let i = 0; i < sessionStorage.length; ++i)
|
|
||||||
{
|
|
||||||
const key = sessionStorage.key (i)
|
|
||||||
if (key == null || !(key.startsWith (SESSION_PREFIX)))
|
|
||||||
continue
|
|
||||||
|
|
||||||
const raw = sessionStorage.getItem (key)
|
|
||||||
if (raw == null)
|
|
||||||
continue
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const value = JSON.parse (raw) as { savedAt?: string }
|
|
||||||
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
|
||||||
{
|
|
||||||
sessionStorage.removeItem (key)
|
|
||||||
--i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
sessionStorage.removeItem (key)
|
|
||||||
--i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
onError?.('保存済みデータを整理できませんでした.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const loadPostImportSourceDraft = (
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
): { source: string } => {
|
|
||||||
const raw = readStorage (SOURCE_DRAFT_KEY, onError)
|
|
||||||
if (raw == null)
|
|
||||||
return { source: '' }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const value = JSON.parse (raw) as { source?: string }
|
|
||||||
return { source: typeof value.source === 'string' ? value.source : '' }
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return { source: '' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const savePostImportSourceDraft = (
|
|
||||||
source: string,
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
): boolean =>
|
|
||||||
writeStorage (SOURCE_DRAFT_KEY, JSON.stringify ({ source }), onError)
|
|
||||||
|
|
||||||
|
|
||||||
export const clearPostImportSourceDraft = (
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
) => {
|
|
||||||
removeStorage (SOURCE_DRAFT_KEY, onError)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const savePostImportSession = (
|
|
||||||
sessionId: string,
|
|
||||||
session: Omit<PostImportSession, 'version' | 'savedAt'>,
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
): boolean =>
|
|
||||||
writeStorage (
|
|
||||||
sessionKey (sessionId),
|
|
||||||
JSON.stringify ({
|
|
||||||
...session,
|
|
||||||
version: SESSION_VERSION,
|
|
||||||
savedAt: new Date ().toISOString () }),
|
|
||||||
onError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export const loadPostImportSession = (
|
|
||||||
sessionId: string,
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
): PostImportSession | null => {
|
|
||||||
const raw = readStorage (sessionKey (sessionId), onError)
|
|
||||||
if (raw == null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const value = JSON.parse (raw) as Partial<PostImportSession>
|
|
||||||
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
|
|
||||||
return null
|
|
||||||
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
|
||||||
{
|
|
||||||
removeStorage (sessionKey (sessionId), onError)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = value.rows.map (sanitiseRow)
|
|
||||||
if (rows.some (_1 => _1 == null))
|
|
||||||
return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: SESSION_VERSION,
|
|
||||||
savedAt: value.savedAt,
|
|
||||||
source: typeof value.source === 'string' ? value.source : '',
|
|
||||||
rows: rows as PostImportRow[],
|
|
||||||
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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 = (
|
|
||||||
current: PostImportRow[],
|
|
||||||
validated: PostImportRow[],
|
|
||||||
): PostImportRow[] => {
|
|
||||||
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
|
|
||||||
return current.map (previous => {
|
|
||||||
if (previous.importStatus === 'created')
|
|
||||||
return previous
|
|
||||||
|
|
||||||
const row = validatedMap.get (previous.sourceRow)
|
|
||||||
if (row == null)
|
|
||||||
return previous
|
|
||||||
|
|
||||||
const fieldWarnings =
|
|
||||||
Object.keys (row.fieldWarnings).length > 0 || row.metadataUrl !== previous.metadataUrl
|
|
||||||
? { ...row.fieldWarnings }
|
|
||||||
: { ...previous.fieldWarnings }
|
|
||||||
for (const [field, origin] of Object.entries (row.provenance))
|
|
||||||
{
|
|
||||||
if (origin === 'manual')
|
|
||||||
delete fieldWarnings[field]
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
url: row.url,
|
|
||||||
attributes: row.attributes,
|
|
||||||
provenance: row.provenance,
|
|
||||||
tagSources: row.tagSources,
|
|
||||||
skipReason: row.skipReason,
|
|
||||||
existingPostId: row.existingPostId,
|
|
||||||
fieldWarnings,
|
|
||||||
baseWarnings:
|
|
||||||
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
|
|
||||||
? row.baseWarnings
|
|
||||||
: previous.baseWarnings,
|
|
||||||
validationErrors: row.validationErrors,
|
|
||||||
status: row.status,
|
|
||||||
metadataUrl: row.metadataUrl }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const mergeImportResults = (
|
|
||||||
rows: PostImportRow[],
|
|
||||||
results: PostImportResultRow[],
|
|
||||||
): PostImportRow[] => {
|
|
||||||
const resultMap = new Map (results.map (row => [row.sourceRow, row]))
|
|
||||||
return rows.map (row => {
|
|
||||||
const result = resultMap.get (row.sourceRow)
|
|
||||||
return result
|
|
||||||
? {
|
|
||||||
...row,
|
|
||||||
importStatus: result.status,
|
|
||||||
createdPostId: result.post?.id,
|
|
||||||
importErrors: result.errors }
|
|
||||||
: row
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const retryImportRow = (
|
|
||||||
rows: PostImportRow[],
|
|
||||||
sourceRow: number,
|
|
||||||
): PostImportRow[] =>
|
|
||||||
rows.map (row =>
|
|
||||||
row.sourceRow === sourceRow && row.importStatus === 'failed'
|
|
||||||
? { ...row, importStatus: 'pending', importErrors: undefined }
|
|
||||||
: row)
|
|
||||||
|
|
||||||
|
|
||||||
export const countImportSourceLines = (source: string): number =>
|
|
||||||
source
|
|
||||||
.split (/\r\n|\n|\r/)
|
|
||||||
.map (_1 => _1.trim ())
|
|
||||||
.filter (_1 => _1 !== '')
|
|
||||||
.length
|
|
||||||
|
|
||||||
|
|
||||||
export const validateImportSource = (
|
|
||||||
source: string,
|
|
||||||
): PostImportSourceIssue[] => {
|
|
||||||
const lines = source.split (/\r\n|\n|\r/)
|
|
||||||
const issues: PostImportSourceIssue[] = []
|
|
||||||
const seen = new Map<string, number> ()
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
lines.forEach ((rawLine, index) => {
|
|
||||||
const value = rawLine.trim ()
|
|
||||||
if (!(value))
|
|
||||||
return
|
|
||||||
|
|
||||||
++count
|
|
||||||
const sourceRow = index + 1
|
|
||||||
const displayUrl = truncateUrl (value)
|
|
||||||
const normalised = normaliseImportUrl (value)
|
|
||||||
if (count > MAX_ROWS)
|
|
||||||
{
|
|
||||||
issues.push ({
|
|
||||||
sourceRow,
|
|
||||||
message: `取込件数は ${ MAX_ROWS } 件までです.`,
|
|
||||||
url: displayUrl })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
|
|
||||||
{
|
|
||||||
issues.push ({
|
|
||||||
sourceRow,
|
|
||||||
message: 'HTTP または HTTPS の URL ではありません.',
|
|
||||||
url: displayUrl })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (bytesize (value) > MAX_URL_BYTES)
|
|
||||||
{
|
|
||||||
issues.push ({
|
|
||||||
sourceRow,
|
|
||||||
message: 'URL が長すぎます.',
|
|
||||||
url: displayUrl })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalised == null)
|
|
||||||
{
|
|
||||||
issues.push ({
|
|
||||||
sourceRow,
|
|
||||||
message: 'URL の形式が不正です.',
|
|
||||||
url: displayUrl })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const duplicateRow = seen.get (normalised)
|
|
||||||
if (duplicateRow != null)
|
|
||||||
{
|
|
||||||
issues.push ({
|
|
||||||
sourceRow,
|
|
||||||
message: `${ duplicateRow } 行目と同じ URL です.`,
|
|
||||||
url: displayUrl })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen.set (normalised, sourceRow)
|
|
||||||
})
|
|
||||||
|
|
||||||
return issues
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const hasSkipReason = (row: PostImportRow): boolean =>
|
|
||||||
row.skipReason === 'existing'
|
|
||||||
|
|
||||||
const hasValidationErrors = (row: PostImportRow): boolean =>
|
|
||||||
Object.keys (row.validationErrors ?? { }).length > 0
|
|
||||||
|
|
||||||
|
|
||||||
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
|
||||||
rows.filter (row => {
|
|
||||||
if (row.importStatus === 'created')
|
|
||||||
return false
|
|
||||||
if (row.importStatus === 'skipped')
|
|
||||||
return false
|
|
||||||
if (row.importStatus === 'failed')
|
|
||||||
return false
|
|
||||||
if (hasValidationErrors (row))
|
|
||||||
return false
|
|
||||||
return row.importStatus == null || row.importStatus === 'pending'
|
|
||||||
})
|
|
||||||
|
|
||||||
export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
|
||||||
processableImportRows (rows).filter (row => !(hasSkipReason (row)))
|
|
||||||
|
|
||||||
|
|
||||||
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
|
||||||
total: rows.length,
|
|
||||||
submittable: rows.filter (row =>
|
|
||||||
creatableImportRows ([row]).length > 0
|
|
||||||
&& !(hasValidationErrors (row))).length,
|
|
||||||
invalid: rows.filter (row => hasValidationErrors (row)).length,
|
|
||||||
skipPlanned: rows.filter (row =>
|
|
||||||
hasSkipReason (row)
|
|
||||||
&& !(hasValidationErrors (row))
|
|
||||||
&& row.importStatus !== 'created').length,
|
|
||||||
created: rows.filter (row => row.importStatus === 'created').length,
|
|
||||||
failed: rows.filter (row => row.importStatus === 'failed').length })
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { PostImportSourceIssue } from '@/lib/postImportTypes'
|
||||||
|
|
||||||
|
const MAX_ROWS = 100
|
||||||
|
const MAX_URL_BYTES = 20 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
const truncateUrl = (value: string): string =>
|
||||||
|
value.length > 120 ? `${ value.slice (0, 117) }…` : value
|
||||||
|
|
||||||
|
|
||||||
|
const bytesize = (value: string): number =>
|
||||||
|
new TextEncoder ().encode (value).length
|
||||||
|
|
||||||
|
|
||||||
|
const normaliseImportUrl = (value: string): string | null => {
|
||||||
|
const trimmed = value.trim ()
|
||||||
|
if (!(trimmed))
|
||||||
|
return null
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const url = new URL (trimmed)
|
||||||
|
if (!(url.protocol === 'http:' || url.protocol === 'https:'))
|
||||||
|
return null
|
||||||
|
if (!(url.host))
|
||||||
|
return null
|
||||||
|
|
||||||
|
url.hostname = url.hostname.toLowerCase ()
|
||||||
|
if (url.pathname.endsWith ('/'))
|
||||||
|
url.pathname = url.pathname.replace (/\/+$/, '')
|
||||||
|
return url.toString ()
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const countImportSourceLines = (source: string): number =>
|
||||||
|
source
|
||||||
|
.split (/\r\n|\n|\r/)
|
||||||
|
.map (_1 => _1.trim ())
|
||||||
|
.filter (_1 => _1 !== '')
|
||||||
|
.length
|
||||||
|
|
||||||
|
|
||||||
|
export const validateImportSource = (
|
||||||
|
source: string,
|
||||||
|
): PostImportSourceIssue[] => {
|
||||||
|
const lines = source.split (/\r\n|\n|\r/)
|
||||||
|
const issues: PostImportSourceIssue[] = []
|
||||||
|
const seen = new Map<string, number> ()
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
lines.forEach ((rawLine, index) => {
|
||||||
|
const value = rawLine.trim ()
|
||||||
|
if (!(value))
|
||||||
|
return
|
||||||
|
|
||||||
|
++count
|
||||||
|
const sourceRow = index + 1
|
||||||
|
const displayUrl = truncateUrl (value)
|
||||||
|
const normalised = normaliseImportUrl (value)
|
||||||
|
if (count > MAX_ROWS)
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: `取込件数は ${ MAX_ROWS } 件までです.`,
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: 'HTTP または HTTPS の URL ではありません.',
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (bytesize (value) > MAX_URL_BYTES)
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: 'URL が長すぎます.',
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalised == null)
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: 'URL の形式が不正です.',
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const duplicateRow = seen.get (normalised)
|
||||||
|
if (duplicateRow != null)
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: `${ duplicateRow } 行目と同じ URL です.`,
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.set (normalised, sourceRow)
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import type { PostImportOrigin,
|
||||||
|
PostImportRow,
|
||||||
|
PostImportSession,
|
||||||
|
PostImportStatus,
|
||||||
|
PostImportSkipReason,
|
||||||
|
StorageErrorHandler } from '@/lib/postImportTypes'
|
||||||
|
|
||||||
|
const SESSION_VERSION = 2
|
||||||
|
const SESSION_PREFIX = 'post-import-session:'
|
||||||
|
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
|
||||||
|
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value != null && !(Array.isArray (value))
|
||||||
|
|
||||||
|
|
||||||
|
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
|
||||||
|
|
||||||
|
|
||||||
|
const readStorage = (
|
||||||
|
key: string,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
): string | null => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return null
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return sessionStorage.getItem (key)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
onError?.('保存済みデータを読み込めませんでした.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const writeStorage = (
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
): boolean => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return false
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessionStorage.setItem (key, value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
onError?.('ブラウザへ保存できませんでした.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const removeStorage = (
|
||||||
|
key: string,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
) => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessionStorage.removeItem (key)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
onError?.('保存済みデータを削除できませんでした.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ensureStringListRecord = (value: unknown): Record<string, string[]> | null => {
|
||||||
|
if (!(isPlainObject (value)))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const result: Record<string, string[]> = { }
|
||||||
|
for (const [key, entry] of Object.entries (value))
|
||||||
|
{
|
||||||
|
if (!(Array.isArray (entry)) || !(entry.every (_1 => typeof _1 === 'string')))
|
||||||
|
return null
|
||||||
|
result[key] = entry
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const isValidStatus = (
|
||||||
|
value: unknown,
|
||||||
|
): value is PostImportRow['status'] =>
|
||||||
|
value === 'ready' || value === 'warning' || value === 'error'
|
||||||
|
|
||||||
|
|
||||||
|
const isValidImportStatus = (
|
||||||
|
value: unknown,
|
||||||
|
): value is PostImportStatus =>
|
||||||
|
value === 'pending'
|
||||||
|
|| value === 'created'
|
||||||
|
|| value === 'skipped'
|
||||||
|
|| value === 'failed'
|
||||||
|
|
||||||
|
|
||||||
|
const isValidOrigin = (
|
||||||
|
value: unknown,
|
||||||
|
): value is PostImportOrigin =>
|
||||||
|
value === 'automatic' || value === 'manual'
|
||||||
|
|
||||||
|
|
||||||
|
const isValidSkipReason = (
|
||||||
|
value: unknown,
|
||||||
|
): value is PostImportSkipReason =>
|
||||||
|
value === 'existing'
|
||||||
|
|
||||||
|
const isPositiveInteger = (value: unknown): value is number =>
|
||||||
|
Number.isInteger (value) && Number (value) > 0
|
||||||
|
|
||||||
|
|
||||||
|
const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||||
|
if (!(isPlainObject (value)))
|
||||||
|
return null
|
||||||
|
if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
|
||||||
|
return null
|
||||||
|
if (typeof value.url !== 'string')
|
||||||
|
return null
|
||||||
|
if (!(isPlainObject (value.attributes)))
|
||||||
|
return null
|
||||||
|
if (!(isPlainObject (value.provenance)))
|
||||||
|
return null
|
||||||
|
if (!(isValidStatus (value.status)))
|
||||||
|
return null
|
||||||
|
if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
|
||||||
|
return null
|
||||||
|
if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
|
||||||
|
return null
|
||||||
|
if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId)))
|
||||||
|
return null
|
||||||
|
if (value.skipReason !== 'existing' && value.existingPostId != null)
|
||||||
|
return null
|
||||||
|
if (value.importStatus === 'created' && !(isPositiveInteger (value.createdPostId)))
|
||||||
|
return null
|
||||||
|
if (value.importStatus !== 'created' && value.createdPostId != null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const validationErrors = ensureStringListRecord (value.validationErrors)
|
||||||
|
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
||||||
|
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, string | number>,
|
||||||
|
fieldWarnings,
|
||||||
|
baseWarnings: value.baseWarnings,
|
||||||
|
validationErrors,
|
||||||
|
importErrors,
|
||||||
|
provenance: value.provenance as Record<string, PostImportOrigin>,
|
||||||
|
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
|
||||||
|
status: value.status,
|
||||||
|
skipReason: value.skipReason,
|
||||||
|
existingPostId:
|
||||||
|
isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
|
||||||
|
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
|
||||||
|
createdPostId:
|
||||||
|
isPositiveInteger (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 =>
|
||||||
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID ()
|
||||||
|
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
|
||||||
|
|
||||||
|
|
||||||
|
export const cleanupExpiredPostImportSessions = (
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
) => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (let i = 0; i < sessionStorage.length; ++i)
|
||||||
|
{
|
||||||
|
const key = sessionStorage.key (i)
|
||||||
|
if (key == null || !(key.startsWith (SESSION_PREFIX)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
const raw = sessionStorage.getItem (key)
|
||||||
|
if (raw == null)
|
||||||
|
continue
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const value = JSON.parse (raw) as { savedAt?: string }
|
||||||
|
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||||
|
{
|
||||||
|
sessionStorage.removeItem (key)
|
||||||
|
--i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
sessionStorage.removeItem (key)
|
||||||
|
--i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
onError?.('保存済みデータを整理できませんでした.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const loadPostImportSourceDraft = (
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
): { source: string } => {
|
||||||
|
const raw = readStorage (SOURCE_DRAFT_KEY, onError)
|
||||||
|
if (raw == null)
|
||||||
|
return { source: '' }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const value = JSON.parse (raw) as { source?: string }
|
||||||
|
return { source: typeof value.source === 'string' ? value.source : '' }
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return { source: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const savePostImportSourceDraft = (
|
||||||
|
source: string,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
): boolean =>
|
||||||
|
writeStorage (SOURCE_DRAFT_KEY, JSON.stringify ({ source }), onError)
|
||||||
|
|
||||||
|
|
||||||
|
export const clearPostImportSourceDraft = (
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
) => {
|
||||||
|
removeStorage (SOURCE_DRAFT_KEY, onError)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const savePostImportSession = (
|
||||||
|
sessionId: string,
|
||||||
|
session: Omit<PostImportSession, 'version' | 'savedAt'>,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
): boolean =>
|
||||||
|
writeStorage (
|
||||||
|
sessionKey (sessionId),
|
||||||
|
JSON.stringify ({
|
||||||
|
...session,
|
||||||
|
version: SESSION_VERSION,
|
||||||
|
savedAt: new Date ().toISOString () }),
|
||||||
|
onError)
|
||||||
|
|
||||||
|
|
||||||
|
export const loadPostImportSession = (
|
||||||
|
sessionId: string,
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
): PostImportSession | null => {
|
||||||
|
const raw = readStorage (sessionKey (sessionId), onError)
|
||||||
|
if (raw == null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const value = JSON.parse (raw) as Partial<PostImportSession>
|
||||||
|
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
|
||||||
|
return null
|
||||||
|
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||||
|
{
|
||||||
|
removeStorage (sessionKey (sessionId), onError)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = value.rows.map (sanitiseRow)
|
||||||
|
if (rows.some (_1 => _1 == null))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: SESSION_VERSION,
|
||||||
|
savedAt: value.savedAt,
|
||||||
|
source: typeof value.source === 'string' ? value.source : '',
|
||||||
|
rows: rows as PostImportRow[],
|
||||||
|
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export type PostImportOrigin = 'automatic' | 'manual'
|
||||||
|
export type PostImportRepairMode = 'all' | 'failed'
|
||||||
|
export type PostImportStatus =
|
||||||
|
'pending'
|
||||||
|
| 'created'
|
||||||
|
| 'skipped'
|
||||||
|
| 'failed'
|
||||||
|
export type PostImportSkipReason = 'existing'
|
||||||
|
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||||
|
export type PostImportAttributeValue = string | number
|
||||||
|
|
||||||
|
export type PostImportRow = {
|
||||||
|
sourceRow: number
|
||||||
|
url: string
|
||||||
|
attributes: Record<string, PostImportAttributeValue>
|
||||||
|
fieldWarnings: Record<string, string[]>
|
||||||
|
baseWarnings: string[]
|
||||||
|
validationErrors: Record<string, string[]>
|
||||||
|
importErrors?: Record<string, string[]>
|
||||||
|
provenance: Record<string, PostImportOrigin>
|
||||||
|
tagSources?: Record<PostImportOrigin, string>
|
||||||
|
status: 'ready' | 'warning' | 'error'
|
||||||
|
skipReason?: PostImportSkipReason
|
||||||
|
existingPostId?: number
|
||||||
|
metadataUrl?: string
|
||||||
|
createdPostId?: number
|
||||||
|
importStatus?: PostImportStatus }
|
||||||
|
|
||||||
|
export type PostImportResultRow = {
|
||||||
|
sourceRow: number
|
||||||
|
status: PostImportResultStatus
|
||||||
|
post?: { id: number }
|
||||||
|
errors?: Record<string, string[]> }
|
||||||
|
|
||||||
|
export type PostImportSession = {
|
||||||
|
version: number
|
||||||
|
savedAt: string
|
||||||
|
source: string
|
||||||
|
rows: PostImportRow[]
|
||||||
|
repairMode: PostImportRepairMode }
|
||||||
|
|
||||||
|
export type PostImportSourceIssue = {
|
||||||
|
sourceRow: number
|
||||||
|
message: string
|
||||||
|
url: string }
|
||||||
|
|
||||||
|
export type StorageErrorHandler = (message: string) => void
|
||||||
@@ -18,6 +18,7 @@ import { clearPostImportSourceDraft,
|
|||||||
loadPostImportSession,
|
loadPostImportSession,
|
||||||
mergeImportResults,
|
mergeImportResults,
|
||||||
mergeValidatedImportRows,
|
mergeValidatedImportRows,
|
||||||
|
resultSummaryCounts,
|
||||||
retryImportRow,
|
retryImportRow,
|
||||||
savePostImportSession,
|
savePostImportSession,
|
||||||
type PostImportResultRow,
|
type PostImportResultRow,
|
||||||
@@ -92,13 +93,9 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
}, [session, sessionId])
|
}, [session, sessionId])
|
||||||
|
|
||||||
const counts = useMemo (() => ({
|
const counts = useMemo (
|
||||||
created: session?.rows.filter (_1 => _1.importStatus === 'created').length ?? 0,
|
() => resultSummaryCounts (session?.rows ?? []),
|
||||||
skipped: session?.rows.filter (_1 => _1.importStatus === 'skipped').length ?? 0,
|
[session])
|
||||||
failed: session?.rows.filter (_1 => _1.importStatus === 'failed').length ?? 0,
|
|
||||||
invalid:
|
|
||||||
session?.rows.filter (_1 => effectivePostImportStatus (_1) === 'error').length ?? 0,
|
|
||||||
}), [session])
|
|
||||||
|
|
||||||
const retry = async (sourceRow: number) => {
|
const retry = async (sourceRow: number) => {
|
||||||
if (session == null)
|
if (session == null)
|
||||||
@@ -120,11 +117,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
metadataUrl: row.metadataUrl })),
|
metadataUrl: row.metadataUrl })),
|
||||||
changed_row: -1 })
|
changed_row: -1 })
|
||||||
const validatedRows = mergeValidatedImportRows (pendingRows, validated.rows).map (row =>
|
const validatedRows = mergeValidatedImportRows (pendingRows, validated.rows)
|
||||||
row.sourceRow === sourceRow
|
|
||||||
&& Object.keys (row.validationErrors ?? { }).length > 0
|
|
||||||
? { ...row, importStatus: 'failed' as const }
|
|
||||||
: row)
|
|
||||||
setSession (current =>
|
setSession (current =>
|
||||||
current
|
current
|
||||||
? { ...current, rows: validatedRows }
|
? { ...current, rows: validatedRows }
|
||||||
@@ -238,20 +231,21 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
{row.createdPostId && (
|
{(row.createdPostId != null || row.existingPostId != null) && (
|
||||||
<Button type="button" variant="outline" asChild>
|
<Button type="button" variant="outline" asChild>
|
||||||
<PrefetchLink to={`/posts/${ row.createdPostId }`}>
|
<PrefetchLink
|
||||||
|
to={`/posts/${ row.createdPostId ?? row.existingPostId }`}>
|
||||||
投稿を開く
|
投稿を開く
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{status === 'error' && (
|
{(status === 'error' || row.importStatus === 'failed') && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openRepair (row.sourceRow)}>
|
onClick={() => openRepair (row.sourceRow)}>
|
||||||
編輯
|
編輯
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{row.importStatus === 'failed' && (
|
{row.importStatus === 'failed' && status !== 'error' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -81,12 +81,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
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) => {
|
||||||
@@ -126,8 +124,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
['originalCreatedFrom', draft.originalCreatedFrom],
|
['originalCreatedFrom', draft.originalCreatedFrom],
|
||||||
['originalCreatedBefore', draft.originalCreatedBefore],
|
['originalCreatedBefore', draft.originalCreatedBefore],
|
||||||
['duration', draft.duration],
|
['duration', draft.duration],
|
||||||
['parentPostIds', draft.parentPostIds],
|
['parentPostIds', draft.parentPostIds]] as const
|
||||||
] as const
|
|
||||||
draftFields.forEach (([field, value]) => {
|
draftFields.forEach (([field, value]) => {
|
||||||
nextAttributes[field] = value
|
nextAttributes[field] = value
|
||||||
nextProvenance[field] =
|
nextProvenance[field] =
|
||||||
@@ -284,6 +281,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
invalidCount={counts.invalid}
|
invalidCount={counts.invalid}
|
||||||
processableCount={processable.length}
|
processableCount={processable.length}
|
||||||
creatableCount={creatable.length}
|
creatableCount={creatable.length}
|
||||||
|
skipPlannedCount={counts.skipPlanned}
|
||||||
onBack={() => navigate ('/posts/import')}
|
onBack={() => navigate ('/posts/import')}
|
||||||
onSubmit={submit}/>
|
onSubmit={submit}/>
|
||||||
|
|
||||||
@@ -300,11 +298,18 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PostImportFooter = (
|
const PostImportFooter = (
|
||||||
{ loading, invalidCount, processableCount, creatableCount, onBack, onSubmit }: {
|
{ loading,
|
||||||
|
invalidCount,
|
||||||
|
processableCount,
|
||||||
|
creatableCount,
|
||||||
|
skipPlannedCount,
|
||||||
|
onBack,
|
||||||
|
onSubmit }: {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
invalidCount: number
|
invalidCount: number
|
||||||
processableCount: number
|
processableCount: number
|
||||||
creatableCount: number
|
creatableCount: number
|
||||||
|
skipPlannedCount: number
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onSubmit: () => void },
|
onSubmit: () => void },
|
||||||
) => (
|
) => (
|
||||||
@@ -315,7 +320,11 @@ const PostImportFooter = (
|
|||||||
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"/>
|
<PostImportStatusBadge value="pending"/>
|
||||||
|
<span>処理対象 {processableCount} 件</span>
|
||||||
|
<PostImportStatusBadge value="ready"/>
|
||||||
<span>登録対象 {creatableCount} 件</span>
|
<span>登録対象 {creatableCount} 件</span>
|
||||||
|
<PostImportStatusBadge value="skipped"/>
|
||||||
|
<span>スキップ予定 {skipPlannedCount} 件</span>
|
||||||
<PostImportStatusBadge value="error"/>
|
<PostImportStatusBadge value="error"/>
|
||||||
<span>要修正 {invalidCount} 件</span>
|
<span>要修正 {invalidCount} 件</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +336,7 @@ const PostImportFooter = (
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={loading || processableCount === 0}>
|
disabled={loading || processableCount === 0}>
|
||||||
有効な投稿を一括登録
|
取込を実行
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする