このコミットが含まれているのは:
@@ -1,7 +1,7 @@
|
|||||||
class PostImportsController < ApplicationController
|
class PostImportsController < ApplicationController
|
||||||
before_action :require_member!
|
before_action :require_member!
|
||||||
|
|
||||||
def preview
|
def parse
|
||||||
source =
|
source =
|
||||||
if params[:format] == 'google_sheets'
|
if params[:format] == 'google_sheets'
|
||||||
PostImportGoogleSheetsFetcher.fetch!(params[:source], rate_key: current_user.id)
|
PostImportGoogleSheetsFetcher.fetch!(params[:source], rate_key: current_user.id)
|
||||||
@@ -11,6 +11,13 @@ class PostImportsController < ApplicationController
|
|||||||
format = params[:format] == 'google_sheets' ? 'csv' : params[:format]
|
format = params[:format] == 'google_sheets' ? 'csv' : params[:format]
|
||||||
parsed = PostImportSourceParser.new(source:, format:,
|
parsed = PostImportSourceParser.new(source:, format:,
|
||||||
has_header: params[:has_header], json_path: params[:json_path]).parse
|
has_header: params[:has_header], json_path: params[:json_path]).parse
|
||||||
|
render json: parsed
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render_bad_request(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview
|
||||||
|
parsed = parsed_params
|
||||||
url_column = params[:url_column].presence || (parsed[:columns].length == 1 ? 0 : nil)
|
url_column = params[:url_column].presence || (parsed[:columns].length == 1 ? 0 : nil)
|
||||||
return render_bad_request('URL 列を選択してください.') if url_column.nil?
|
return render_bad_request('URL 列を選択してください.') if url_column.nil?
|
||||||
|
|
||||||
@@ -21,9 +28,23 @@ class PostImportsController < ApplicationController
|
|||||||
render_bad_request(e.message)
|
render_bad_request(e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate
|
||||||
|
rows = validate_rows_params
|
||||||
|
changed_row = Integer(params[:changed_row], exception: false)
|
||||||
|
result = PostImportPreviewer.new(parsed: { columns: [], rows: [] }, url_column: 0,
|
||||||
|
mappings: {}, existing: params[:existing]).preview_rows(
|
||||||
|
rows:, fetch_metadata: changed_row,
|
||||||
|
metadata_cache: { })
|
||||||
|
render json: { rows: result }
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render_bad_request(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
render json: PostImportRunner.new(actor: current_user, rows: params[:rows].to_a).run,
|
result = PostImportRunner.new(actor: current_user, rows: params[:rows].to_a).run
|
||||||
status: :created
|
render json: result, status: result[:created].positive? ? :created : :ok
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render_bad_request(e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -34,4 +55,37 @@ class PostImportsController < ApplicationController
|
|||||||
|
|
||||||
head :forbidden
|
head :forbidden
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parsed_params
|
||||||
|
value = params.require(:parsed).to_unsafe_h.deep_transform_keys { _1.to_s.underscore }
|
||||||
|
columns = Array(value['columns'])
|
||||||
|
rows = Array(value['rows'])
|
||||||
|
raise ArgumentError, '解析結果の形式が不正です.' unless columns.all?(Hash) && rows.all?(Hash)
|
||||||
|
|
||||||
|
{ columns: columns.map { |column| column.slice('key', 'label') },
|
||||||
|
rows: rows.map { |row| { source_row: row['source_row'], values: Array(row['values']) } } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_rows_params
|
||||||
|
rows = Array(params[:rows])
|
||||||
|
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportSourceParser::MAX_ROWS
|
||||||
|
|
||||||
|
rows.map do |row|
|
||||||
|
value = ActionController::Parameters.new(row).permit(:source_row, :sourceRow, :url,
|
||||||
|
attributes: {}, provenance: {},
|
||||||
|
fetch_warnings: [], validation_warnings: [])
|
||||||
|
normalised = value.to_h.deep_transform_keys { _1.to_s.underscore }
|
||||||
|
attributes = normalised.fetch('attributes', { })
|
||||||
|
provenance = normalised.fetch('provenance', { })
|
||||||
|
allowed = PostImportPreviewer::FIELDS
|
||||||
|
unless attributes.is_a?(Hash) && provenance.is_a?(Hash) &&
|
||||||
|
(attributes.keys - allowed).empty? &&
|
||||||
|
(provenance.keys - (allowed + ['url'])).empty?
|
||||||
|
raise ArgumentError, '行の形式が不正です.'
|
||||||
|
end
|
||||||
|
raise ArgumentError, 'セルが大きすぎます.' if [normalised['url'], *attributes.values].any? { _1.to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES }
|
||||||
|
|
||||||
|
normalised.symbolize_keys
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -54,6 +54,6 @@ class PostImportGoogleSheetsFetcher
|
|||||||
|
|
||||||
def csv? response
|
def csv? response
|
||||||
type = response.content_type.to_s.downcase
|
type = response.content_type.to_s.downcase
|
||||||
type.include?('text/csv') || response.body.valid_encoding?
|
type.include?('text/csv') || type.include?('application/csv')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,30 +9,55 @@ class PostImportPreviewer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def preview
|
def preview
|
||||||
|
preview_rows(rows: @parsed[:rows])
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
|
||||||
urls = { }
|
urls = { }
|
||||||
@parsed[:rows].map do |row|
|
rows.map do |row|
|
||||||
values = row[:values]
|
row = row.symbolize_keys
|
||||||
attributes = FIELDS.to_h { |field| [field, mapped_value(field, values)] }
|
values = row[:values] || []
|
||||||
url = values[@url_column].to_s.strip
|
attributes = row[:attributes]&.stringify_keys || FIELDS.to_h { |field| [field, mapped_value(field, values)] }
|
||||||
metadata, warnings = fetch_metadata(url)
|
provenance = row[:provenance]&.stringify_keys || mapped_provenance(attributes)
|
||||||
attributes = metadata.merge(attributes) { |_key, automatic, input| input.presence || automatic }
|
url = row[:url].presence || values[@url_column].to_s.strip
|
||||||
|
provenance['url'] ||= row[:url].present? ? 'manual' : 'mapped'
|
||||||
|
attributes['url'] = url
|
||||||
|
fetch_warnings = Array(row[:fetch_warnings]).dup
|
||||||
|
should_fetch = fetch_metadata == true || fetch_metadata.to_i == row[:source_row].to_i
|
||||||
|
metadata, fetch_warnings = metadata_for(url, should_fetch, metadata_cache, fetch_warnings)
|
||||||
|
metadata.each do |field, value|
|
||||||
|
next unless provenance[field] == 'automatic' || attributes[field].blank?
|
||||||
|
|
||||||
|
attributes[field] = value
|
||||||
|
provenance[field] = 'automatic'
|
||||||
|
end
|
||||||
post = Post.new(url:)
|
post = Post.new(url:)
|
||||||
post.valid?
|
post.valid?
|
||||||
normal_url = post.url
|
normal_url = post.url
|
||||||
errors = post.errors.to_hash.transform_values(&:dup)
|
errors = post.errors.to_hash.transform_values(&:dup)
|
||||||
|
validation_warnings = []
|
||||||
errors[:url] = ['URL が重複しています.'] if normal_url.present? && urls.key?(normal_url)
|
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? && Post.exists?(url: normal_url)
|
if normal_url.present? && Post.exists?(url: normal_url)
|
||||||
@existing == 'error' ? errors[:url] = ['既存投稿です.'] : warnings << '既存投稿のためスキップします.'
|
@existing == 'error' ? errors[:url] = ['既存投稿です.'] : validation_warnings << '既存投稿のためスキップします.'
|
||||||
end
|
end
|
||||||
validate_preview_tags(attributes['tags'], errors)
|
validate_basic_data(attributes, errors)
|
||||||
|
validate_preview_tags(attributes['tags'], errors, validation_warnings)
|
||||||
validate_parents(attributes['parent_post_ids'], errors)
|
validate_parents(attributes['parent_post_ids'], errors)
|
||||||
{ source_row: row[:source_row], url: normal_url || url, attributes:, warnings:, errors:,
|
attributes.delete('url')
|
||||||
status: errors.present? ? 'error' : (warnings.present? ? 'warning' : 'ready'),
|
{ source_row: row[:source_row], url: normal_url || url, attributes:, provenance:,
|
||||||
|
fetch_warnings:, validation_warnings:, warnings: fetch_warnings + validation_warnings, errors:,
|
||||||
|
status: errors.present? ? 'error' : ((fetch_warnings + validation_warnings).present? ? 'warning' : 'ready'),
|
||||||
existing: normal_url.present? && Post.exists?(url: normal_url) }
|
existing: normal_url.present? && Post.exists?(url: normal_url) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def metadata_for url, fetch_metadata, cache, previous_warnings
|
||||||
|
return [{ }, previous_warnings] unless fetch_metadata
|
||||||
|
|
||||||
|
cache[url] ||= fetch_metadata(url)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def mapped_value field, values
|
def mapped_value field, values
|
||||||
@@ -43,33 +68,71 @@ class PostImportPreviewer
|
|||||||
Array(mapping['columns']).filter_map { |index| values[index.to_i].to_s.presence }.join(field == 'tags' ? ' ' : '')
|
Array(mapping['columns']).filter_map { |index| values[index.to_i].to_s.presence }.join(field == 'tags' ? ' ' : '')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def mapped_provenance attributes
|
||||||
|
attributes.to_h.to_h { |field, value|
|
||||||
|
mapping = @mappings[field.to_s]
|
||||||
|
origin =
|
||||||
|
if value.present? && mapping&.[]('kind') == 'fixed'
|
||||||
|
'fixed'
|
||||||
|
elsif value.present?
|
||||||
|
'mapped'
|
||||||
|
else
|
||||||
|
'automatic'
|
||||||
|
end
|
||||||
|
[field.to_s, origin]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def fetch_metadata url
|
def fetch_metadata url
|
||||||
return [{ }, ['URL が空です.']] if url.blank?
|
return [{ }, ['URL が空です.']] if url.blank?
|
||||||
|
|
||||||
data = PostMetadataFetcher.fetch(url)
|
data = PostMetadataFetcher.fetch(url).stringify_keys
|
||||||
warnings = []
|
warnings = []
|
||||||
warnings << 'タイトルを取得できませんでした.' if data[:title].blank?
|
warnings << 'タイトルを取得できませんでした.' if data['title'].blank?
|
||||||
warnings << 'サムネールを取得できませんでした.' if data[:thumbnail_base].blank?
|
warnings << 'サムネールを取得できませんでした.' if data['thumbnail_base'].blank?
|
||||||
[data.compact, warnings]
|
[data.compact, warnings]
|
||||||
rescue Preview::UrlSafety::UnsafeUrl, Preview::HttpFetcher::FetchFailed, Preview::HttpFetcher::ResponseTooLarge => e
|
rescue Preview::UrlSafety::UnsafeUrl, Preview::HttpFetcher::FetchFailed, Preview::HttpFetcher::ResponseTooLarge => e
|
||||||
[{ }, ["メタデータを取得できませんでした: #{ e.message }"]]
|
[{ }, ["メタデータを取得できませんでした: #{ e.message }"]]
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_preview_tags raw, errors
|
def validate_preview_tags raw, errors, 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:') }
|
||||||
errors[:tags] = ['ニコニコ・タグは直接指定できません.']
|
errors[:tags] = ['ニコニコ・タグは直接指定できません.']
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
canonical = names.map { TagName.canonicalise(_1.sub(/\[.*\]\z/, '')).first }
|
parsed = names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
|
||||||
if Tag.joins(:tag_name).where(tag_names: { name: canonical }, deprecated_at: nil).count < canonical.uniq.length
|
existing = Tag.joins(:tag_name).where(tag_names: { name: parsed }).includes(:tag_name).to_a
|
||||||
errors[:tags] = ['存在しないタグ又は廃止済みタグがあります.']
|
deprecated = existing.select(&:deprecated?).map(&:name)
|
||||||
end
|
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
|
||||||
|
known = existing.reject(&:deprecated?).map(&:name)
|
||||||
|
new_tags = parsed.uniq - known
|
||||||
|
warnings << "新規タグを作成します: #{ new_tags.join(' ') }" if new_tags.present?
|
||||||
rescue Tag::SectionLiteralParseError
|
rescue Tag::SectionLiteralParseError
|
||||||
errors[:tags] = ['タグ区間の記法が不正です.']
|
errors[:tags] = ['タグ区間の記法が不正です.']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_basic_data attributes, errors
|
||||||
|
post = Post.new(title: attributes['title'].presence,
|
||||||
|
url: attributes['url'],
|
||||||
|
thumbnail_base: attributes['thumbnail_base'].presence,
|
||||||
|
original_created_from: attributes['original_created_from'].presence,
|
||||||
|
original_created_before: attributes['original_created_before'].presence,
|
||||||
|
video_ms: parse_duration(attributes['duration'], errors))
|
||||||
|
post.valid?
|
||||||
|
post.errors.each { |error| (errors[error.attribute] ||= []) << error.message }
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_duration value, errors
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
value.is_a?(Numeric) ? value.to_i : Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
|
||||||
|
rescue Tag::SectionLiteralParseError
|
||||||
|
errors[:video_ms] = ['動画時間の記法が不正です.']
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def validate_parents raw, errors
|
def validate_parents raw, errors
|
||||||
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?
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
class PostImportRunner
|
class PostImportRunner
|
||||||
|
MAX_ROWS = PostImportSourceParser::MAX_ROWS
|
||||||
|
ATTRIBUTE_KEYS = %w[title thumbnail_base original_created_from original_created_before duration tags parent_post_ids video_ms].freeze
|
||||||
def initialize actor:, rows:
|
def initialize actor:, rows:
|
||||||
@actor = actor
|
@actor = actor
|
||||||
@rows = rows
|
@rows = rows
|
||||||
end
|
end
|
||||||
|
|
||||||
def run
|
def run
|
||||||
results = @rows.map { |row| run_row(row.to_h.stringify_keys) }
|
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if @rows.length > MAX_ROWS
|
||||||
|
|
||||||
|
results = @rows.map { |row| run_row(row.to_h.deep_transform_keys { _1.to_s.underscore }) }
|
||||||
{ created: results.count { _1[:status] == 'created' }, skipped: results.count { _1[:status] == 'skipped' },
|
{ created: results.count { _1[:status] == 'created' }, skipped: results.count { _1[:status] == 'skipped' },
|
||||||
failed: results.count { _1[:status] == 'failed' }, rows: results }
|
failed: results.count { _1[:status] == 'failed' }, rows: results }
|
||||||
end
|
end
|
||||||
@@ -15,10 +19,20 @@ class PostImportRunner
|
|||||||
def run_row row
|
def run_row row
|
||||||
return row.slice('source_row').merge(status: 'skipped') if row['status'] == 'skipped' || row['existing']
|
return row.slice('source_row').merge(status: 'skipped') if row['status'] == 'skipped' || row['existing']
|
||||||
attributes = row.fetch('attributes', { }).to_h.transform_keys { _1.to_s.underscore }
|
attributes = row.fetch('attributes', { }).to_h.transform_keys { _1.to_s.underscore }
|
||||||
|
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_KEYS).empty?
|
||||||
attributes['url'] = row['url']
|
attributes['url'] = row['url']
|
||||||
post = PostCreator.new(actor: @actor, attributes:).create!
|
post = PostCreator.new(actor: @actor, attributes:).create!
|
||||||
{ source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
|
{ source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
{ source_row: row['source_row'], status: 'failed', errors: e.record.errors.to_hash }
|
||||||
|
rescue Tag::NicoTagNormalisationError
|
||||||
|
{ source_row: row['source_row'], status: 'failed', errors: { tags: ['ニコニコ・タグは直接指定できません.'] } }
|
||||||
|
rescue Tag::DeprecatedTagNormalisationError
|
||||||
|
{ source_row: row['source_row'], status: 'failed', errors: { tags: ['廃止済みタグは付与できません.'] } }
|
||||||
|
rescue ArgumentError, PostCreator::VideoMsParseError
|
||||||
|
{ source_row: row['source_row'], status: 'failed', errors: { base: ['入力値が不正です.'] } }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
{ source_row: row['source_row'], status: 'failed', errors: { base: [e.message] } }
|
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name, message: e.message }.to_json }")
|
||||||
|
{ source_row: row['source_row'], status: 'failed', errors: { base: ['登録中にエラーが発生しました.'] } }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,9 +6,22 @@ class PostMetadataFetcher
|
|||||||
document = Nokogiri::HTML.parse(response.body)
|
document = Nokogiri::HTML.parse(response.body)
|
||||||
content = -> name { document.at_css("meta[property='#{ name }'], meta[name='#{ name }']")&.[]('content')&.strip.presence }
|
content = -> name { document.at_css("meta[property='#{ name }'], meta[name='#{ name }']")&.[]('content')&.strip.presence }
|
||||||
duration = content.call('og:video:duration') || content.call('video:duration')
|
duration = content.call('og:video:duration') || content.call('video:duration')
|
||||||
|
published = content.call('article:published_time') || content.call('date')
|
||||||
|
published_at = Time.zone.parse(published.to_s) if published.present?
|
||||||
|
platform_tags = platform_tags(uri)
|
||||||
{ title: metadata[:title],
|
{ title: metadata[:title],
|
||||||
thumbnail_base: Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
|
thumbnail_base: Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
|
||||||
original_created_from: content.call('article:published_time') || content.call('date'),
|
original_created_from: published_at&.iso8601,
|
||||||
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil } }
|
original_created_before: published_at && (published_at + 1.minute).iso8601,
|
||||||
|
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil },
|
||||||
|
tags: platform_tags.join(' ') }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.platform_tags uri
|
||||||
|
return ['動画', 'YouTube'] if Preview::KnownSiteExtractor.thumbnail_url(uri)
|
||||||
|
return ['動画', 'ニコニコ'] if Preview::KnownSiteExtractor.niconico_video_id(uri)
|
||||||
|
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
private_class_method :platform_tags
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
|
|
||||||
scope 'posts/import', controller: :post_imports do
|
scope 'posts/import', controller: :post_imports do
|
||||||
|
post :parse
|
||||||
post :preview
|
post :preview
|
||||||
|
post :validate
|
||||||
post '', action: :create
|
post '', action: :create
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
@@ -26,8 +26,10 @@ type ImportRow = {
|
|||||||
attributes: Record<string, string>
|
attributes: Record<string, string>
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
errors: Record<string, string[]>
|
errors: Record<string, string[]>
|
||||||
|
provenance: Record<string, 'automatic' | 'mapped' | 'fixed' | 'manual'>
|
||||||
status: 'ready' | 'warning' | 'error'
|
status: 'ready' | 'warning' | 'error'
|
||||||
existing: boolean }
|
existing: boolean }
|
||||||
|
type ParsedImport = { columns: Column[], rows: { sourceRow: number, values: string[] }[] }
|
||||||
|
|
||||||
|
|
||||||
const detectFormat = (source: string): 'csv' | 'tsv' =>
|
const detectFormat = (source: string): 'csv' | 'tsv' =>
|
||||||
@@ -43,28 +45,30 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
const [format, setFormat] = useState<'csv' | 'tsv' | 'json' | 'google_sheets'> ('tsv')
|
const [format, setFormat] = useState<'csv' | 'tsv' | 'json' | 'google_sheets'> ('tsv')
|
||||||
const [hasHeader, setHasHeader] = useState (false)
|
const [hasHeader, setHasHeader] = useState (false)
|
||||||
const [jsonPath, setJsonPath] = useState ('')
|
const [jsonPath, setJsonPath] = useState ('')
|
||||||
const [urlColumn, setUrlColumn] = useState ('0')
|
const [urlColumn, setUrlColumn] = useState ('')
|
||||||
|
const [parsed, setParsed] = useState<ParsedImport | null> (null)
|
||||||
const [columns, setColumns] = useState<Column[]> ([])
|
const [columns, setColumns] = useState<Column[]> ([])
|
||||||
const [rows, setRows] = useState<ImportRow[]> ([])
|
const [rows, setRows] = useState<ImportRow[]> ([])
|
||||||
const [mappings, setMappings] = useState<Record<string, { columns: string[] }>> ({ })
|
const [mappings, setMappings] = useState<Record<string, { columns: string[] }>> ({ })
|
||||||
const [existing, setExisting] = useState<'skip' | 'error'> ('skip')
|
const [existing, setExisting] = useState<'skip' | 'error'> ('skip')
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
|
const validationGeneration = useRef (0)
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
|
|
||||||
const preview = async () => {
|
const parseSource = async () => {
|
||||||
setLoading (true)
|
setLoading (true)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const data = await apiPost<{ columns: Column[], rows: ImportRow[] }> ('/posts/import/preview', {
|
const data = await apiPost<ParsedImport> ('/posts/import/parse', {
|
||||||
source,
|
source,
|
||||||
format,
|
format,
|
||||||
has_header: hasHeader,
|
has_header: hasHeader,
|
||||||
json_path: jsonPath,
|
json_path: jsonPath,
|
||||||
url_column: urlColumn,
|
|
||||||
mappings,
|
|
||||||
existing })
|
existing })
|
||||||
setColumns (data.columns)
|
setColumns (data.columns)
|
||||||
setRows (data.rows)
|
setParsed (data)
|
||||||
|
if (data.columns.length === 1)
|
||||||
|
setUrlColumn ('0')
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -76,9 +80,81 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAttribute = (index: number, field: string, value: string) => {
|
const preview = async () => {
|
||||||
setRows (current => current.map ((row, rowIndex) =>
|
if (!parsed || urlColumn === '')
|
||||||
rowIndex === index ? { ...row, attributes: { ...row.attributes, [field]: value } } : row))
|
return
|
||||||
|
setLoading (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const data = await apiPost<{ rows: ImportRow[] }> ('/posts/import/preview', {
|
||||||
|
parsed: { columns: parsed.columns, rows: parsed.rows },
|
||||||
|
url_column: urlColumn,
|
||||||
|
mappings,
|
||||||
|
existing })
|
||||||
|
setRows (data.rows)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
toast ({ title: '投稿プレビューに失敗しました' })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAttribute = async (index: number, field: string, value: string) => {
|
||||||
|
const row = rows[index]
|
||||||
|
const next = { ...row,
|
||||||
|
attributes: { ...row.attributes, [field]: value },
|
||||||
|
provenance: { ...row.provenance, [field]: 'manual' } }
|
||||||
|
const nextRows = rows.map ((currentRow, rowIndex) => rowIndex === index ? next : currentRow)
|
||||||
|
setRows (nextRows)
|
||||||
|
const generation = ++validationGeneration.current
|
||||||
|
setLoading (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate',
|
||||||
|
{ rows: nextRows, existing,
|
||||||
|
changed_row: -1 })
|
||||||
|
if (generation === validationGeneration.current)
|
||||||
|
setRows (validated.rows)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
toast ({ title: '行の再検証に失敗しました' })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (generation === validationGeneration.current)
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateURL = async (index: number, value: string) => {
|
||||||
|
const row = rows[index]
|
||||||
|
const next = { ...row, url: value, provenance: { ...row.provenance, url: 'manual' } }
|
||||||
|
const nextRows = rows.map ((currentRow, rowIndex) => rowIndex === index ? next : currentRow)
|
||||||
|
setRows (nextRows)
|
||||||
|
const generation = ++validationGeneration.current
|
||||||
|
setLoading (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate',
|
||||||
|
{ rows: nextRows, existing,
|
||||||
|
changed_row: row.sourceRow })
|
||||||
|
if (generation === validationGeneration.current)
|
||||||
|
setRows (validated.rows)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
toast ({ title: 'URL の再検証に失敗しました' })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (generation === validationGeneration.current)
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@@ -147,7 +223,22 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
{format === 'google_sheets' && <p className="text-sm text-neutral-600 dark:text-neutral-300">
|
{format === 'google_sheets' && <p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
非公開シートには対応していません。ウェブ公開 URL を使用するか、CSV を保存して選択、又は内容を貼り付けてください。
|
非公開シートには対応していません。ウェブ公開 URL を使用するか、CSV を保存して選択、又は内容を貼り付けてください。
|
||||||
</p>}
|
</p>}
|
||||||
<Button type="button" onClick={preview} disabled={loading || !(source)}>読込み・確認</Button>
|
<Button type="button" onClick={parseSource} disabled={loading || !(source)}>列を解析</Button>
|
||||||
|
</Form>) : parsed && rows.length === 0 ? (
|
||||||
|
<Form>
|
||||||
|
<PageTitle>列の割当て</PageTitle>
|
||||||
|
<p>URL 列と補助列を指定してから、投稿情報を取得します。</p>
|
||||||
|
<label>URL 列 <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
|
||||||
|
<option value="">選択してください</option>
|
||||||
|
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
|
||||||
|
</select></label>
|
||||||
|
{['title', 'thumbnail_base', 'original_created_from', 'original_created_before', 'duration', 'tags', 'parent_post_ids'].map (field =>
|
||||||
|
<label key={field}>{field}<select value={mappings[field]?.columns?.[0] ?? ''}
|
||||||
|
onChange={ev => setMappings (current => ({ ...current, [field]: { columns: ev.target.value === '' ? [] : [ev.target.value] } }))}>
|
||||||
|
<option value="">指定なし</option>
|
||||||
|
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
|
||||||
|
</select></label>)}
|
||||||
|
<Button type="button" onClick={preview} disabled={loading || urlColumn === ''}>投稿情報を取得して確認</Button>
|
||||||
</Form>) : (
|
</Form>) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
|
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
|
||||||
@@ -162,11 +253,11 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
onChange={ev => setMappings (current => ({ ...current, [field]: { columns: ev.target.value === '' ? [] : [ev.target.value] } }))}>
|
onChange={ev => setMappings (current => ({ ...current, [field]: { columns: ev.target.value === '' ? [] : [ev.target.value] } }))}>
|
||||||
<option value="">指定なし</option>{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
|
<option value="">指定なし</option>{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
|
||||||
</select></label>)}
|
</select></label>)}
|
||||||
<Button type="button" variant="outline" onClick={preview} disabled={loading}>再読込み</Button>
|
<Button type="button" variant="outline" onClick={preview} disabled={loading}>自動取得を更新</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 md:hidden">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute}/>)}</div>
|
<div className="space-y-3 md:hidden">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute}/>)}</div>
|
||||||
<div className="hidden overflow-x-auto md:block"><table className="w-full text-sm"><thead><tr><th>行</th><th>URL</th><th>タイトル</th><th>サムネール</th><th>日時</th><th>動画時間</th><th>タグ</th><th>親投稿</th><th>状態</th></tr></thead>
|
<div className="hidden overflow-x-auto md:block"><table className="w-full text-sm"><thead><tr><th>行</th><th>URL</th><th>タイトル</th><th>サムネール</th><th>日時</th><th>動画時間</th><th>タグ</th><th>親投稿</th><th>状態</th></tr></thead>
|
||||||
<tbody>{rows.map ((row, index) => <tr key={row.sourceRow} className="border-t dark:border-neutral-700"><td>{row.sourceRow}</td><td><input value={row.url} onChange={ev => { const next = [...rows]; next[index] = { ...row, url: ev.target.value }; setRows (next) }}/></td><td><input value={row.attributes.title ?? ''} onChange={ev => updateAttribute (index, 'title', ev.target.value)}/></td><td>{row.attributes.thumbnailBase && <img src={row.attributes.thumbnailBase} alt="サムネール" className="h-12"/>}</td><td>{row.attributes.originalCreatedFrom ?? ''}</td><td>{row.attributes.duration ?? ''}</td><td><input value={row.attributes.tags ?? ''} onChange={ev => updateAttribute (index, 'tags', ev.target.value)}/></td><td><input value={row.attributes.parentPostIds ?? ''} onChange={ev => updateAttribute (index, 'parent_post_ids', ev.target.value)}/></td><td>{row.status}<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/></td></tr>)}</tbody></table></div>
|
<tbody>{rows.map ((row, index) => <tr key={row.sourceRow} className="border-t dark:border-neutral-700"><td>{row.sourceRow}</td><td><input value={row.url} onChange={ev => updateURL (index, ev.target.value)}/></td><td><input value={row.attributes.title ?? ''} onChange={ev => updateAttribute (index, 'title', ev.target.value)}/></td><td><input value={row.attributes.thumbnailBase ?? ''} onChange={ev => updateAttribute (index, 'thumbnailBase', ev.target.value)}/></td><td><input value={row.attributes.originalCreatedFrom ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedFrom', ev.target.value)}/><input value={row.attributes.originalCreatedBefore ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedBefore', ev.target.value)}/></td><td><input value={String (row.attributes.duration ?? '')} onChange={ev => updateAttribute (index, 'duration', ev.target.value)}/></td><td><input value={row.attributes.tags ?? ''} onChange={ev => updateAttribute (index, 'tags', ev.target.value)}/></td><td><input value={row.attributes.parentPostIds ?? ''} onChange={ev => updateAttribute (index, 'parentPostIds', ev.target.value)}/></td><td>{row.status}<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/></td></tr>)}</tbody></table></div>
|
||||||
<Button type="button" onClick={submit} disabled={loading}>有効な投稿を一括登録</Button>
|
<Button type="button" onClick={submit} disabled={loading}>有効な投稿を一括登録</Button>
|
||||||
</div>)}
|
</div>)}
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする