コミットを比較
22 コミット
main
...
aa96ec95d1
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| aa96ec95d1 | |||
| df17f20907 | |||
| 769966648b | |||
| 7bcb76516c | |||
| 8970edc59f | |||
| 9b1ea56e36 | |||
| 155edfe018 | |||
| 23f1ffb04b | |||
| ccfe65a6a4 | |||
| 06d6e512e4 | |||
| 5bd097bcfe | |||
| f636d2a177 | |||
| 10dc776313 | |||
| a9e16735f8 | |||
| 440a6c9961 | |||
| 4535a9d260 | |||
| 440d3d38be | |||
| cb33d9ca25 | |||
| 19bf24432a | |||
| ac41385962 | |||
| 08bf92ff79 | |||
| c51d7b98ba |
@@ -114,6 +114,9 @@ npm run preview
|
|||||||
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
|
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
|
||||||
- Ruby: never put a line break immediately before `)`.
|
- Ruby: never put a line break immediately before `)`.
|
||||||
- Ruby: do not use `%w` or `%i`.
|
- Ruby: do not use `%w` or `%i`.
|
||||||
|
- Ruby: TypeScript / TSX の associative syntax に近い感覚で読むこと。
|
||||||
|
Hash の `}` は block の `}` ではない。call の `)` も function 定義の
|
||||||
|
parameter-list `)` ではない。delimiter の役割を見誤らないこと。
|
||||||
- In Ruby, when an `if` condition is split across multiple lines and combines
|
- In Ruby, when an `if` condition is split across multiple lines and combines
|
||||||
clauses with `&&` or `||`, wrap the whole condition in parentheses.
|
clauses with `&&` or `||`, wrap the whole condition in parentheses.
|
||||||
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
|
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
|
||||||
@@ -127,6 +130,152 @@ npm run preview
|
|||||||
- Keep the first element on the same line as `[` by default.
|
- Keep the first element on the same line as `[` by default.
|
||||||
- If an array would exceed the line limit, break after `[` and indent
|
- If an array would exceed the line limit, break after `[` and indent
|
||||||
elements by 4 spaces.
|
elements by 4 spaces.
|
||||||
|
|
||||||
|
### Ruby delimiter and wrapping rules
|
||||||
|
|
||||||
|
- Ruby でも、closing delimiter は glyph ではなく syntax role で判定する。
|
||||||
|
`}` が block close なのか hash close なのか、`)` が method call なのか
|
||||||
|
grouping なのかを区別してから配置すること。
|
||||||
|
- Ruby の multi-line method call では、receiver / method と opening `(` を
|
||||||
|
同じ行に保ち、closing `)` を単独行へ落とさない。
|
||||||
|
- Ruby の multi-line hash literal / keyword-like argument hash では、opening
|
||||||
|
`{` を最初の pair と同じ行に置き、closing `}` を最後の pair と同じ行に
|
||||||
|
置く。Prettier 的な縦開き・縦閉じをしない。
|
||||||
|
- Ruby の `if` / `unless` / `case` 条件で、複数行に分けるだけで安易に
|
||||||
|
`if ... end` へ展開しない。局所の既存コードが modifier 形式ならそれに
|
||||||
|
そろえる。
|
||||||
|
- Ruby の guard 条件は、1 行で収まるなら modifier 形式を優先する。2 行以上に
|
||||||
|
なるなら通常の block 形式へ切り替へてよい。
|
||||||
|
- Ruby の method chain や call argument を折り返す際、call-site の `)`
|
||||||
|
直前で行を空けたり、closing delimiter を block のやうに独立させない。
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
source =
|
||||||
|
if params[:format] == 'google_sheets'
|
||||||
|
PostImportGoogleSheetsFetcher.fetch!(params[:source],
|
||||||
|
rate_key: current_user.id)
|
||||||
|
else
|
||||||
|
params[:source]
|
||||||
|
end
|
||||||
|
parsed = PostImportSourceParser.new(
|
||||||
|
source:,
|
||||||
|
format:,
|
||||||
|
has_header: params[:has_header],
|
||||||
|
json_path: params[:json_path],
|
||||||
|
).parse
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
source =
|
||||||
|
if params[:format] == 'google_sheets'
|
||||||
|
PostImportGoogleSheetsFetcher.fetch!(params[:source],
|
||||||
|
rate_key: current_user.id)
|
||||||
|
else
|
||||||
|
params[:source]
|
||||||
|
end
|
||||||
|
parsed = PostImportSourceParser.new(
|
||||||
|
source:,
|
||||||
|
format:,
|
||||||
|
has_header: params[:has_header],
|
||||||
|
json_path: params[:json_path]).parse
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
result[field] = {
|
||||||
|
'kind' => kind,
|
||||||
|
'value' => value['value'].to_s,
|
||||||
|
'columns' => Array(columns).map { Integer(_1) },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
result[field] = {
|
||||||
|
'kind' => kind,
|
||||||
|
'value' => value['value'].to_s,
|
||||||
|
'columns' => Array(columns).map { Integer(_1) } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
unless rows.all? { |row|
|
||||||
|
values = Array(row['values'])
|
||||||
|
values.length <= columns.length &&
|
||||||
|
values.all? { |value|
|
||||||
|
value.is_a?(String) &&
|
||||||
|
value.bytesize <= PostImportSourceParser::MAX_CELL_BYTES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raise ArgumentError, '解析結果のセルが不正です.'
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
raise ArgumentError, '解析結果のセルが不正です.' unless rows.all? { |row|
|
||||||
|
values = Array(row['values'])
|
||||||
|
values.length <= columns.length && values.all? { |value|
|
||||||
|
value.is_a?(String) && value.bytesize <= PostImportSourceParser::MAX_CELL_BYTES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
if row['url'].to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES
|
||||||
|
raise ArgumentError, 'URL が長すぎます.'
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
raise ArgumentError, 'URL が長すぎます.' if row['url'].to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
parameters = row.is_a?(ActionController::Parameters) ? row :
|
||||||
|
ActionController::Parameters.new(row)
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
parameters =
|
||||||
|
row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row)
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
PostTagSection.create!(
|
||||||
|
post_id: post.id,
|
||||||
|
tag_id:,
|
||||||
|
begin_ms:,
|
||||||
|
end_ms:,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
PostTagSection.create!(post_id: post.id,
|
||||||
|
tag_id:,
|
||||||
|
begin_ms:,
|
||||||
|
end_ms:)
|
||||||
|
```
|
||||||
- TypeScript and Python: use GNU-style spacing before parentheses where
|
- TypeScript and Python: use GNU-style spacing before parentheses where
|
||||||
syntactically valid.
|
syntactically valid.
|
||||||
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
|
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
class PostImportsController < ApplicationController
|
||||||
|
before_action :require_member!
|
||||||
|
|
||||||
|
def preview
|
||||||
|
rows = PostImportPreviewer.new(existing: params[:existing]).preview_rows(
|
||||||
|
rows: PostImportUrlListParser.parse(params[:source]))
|
||||||
|
render json: { rows: }
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render_bad_request(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate
|
||||||
|
rows = normalised_import_rows(allow_warning_fields: true)
|
||||||
|
changed_row = Integer(params[:changed_row], exception: false)
|
||||||
|
result =
|
||||||
|
PostImportPreviewer.new(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
|
||||||
|
result = PostImportRunner.new(
|
||||||
|
actor: current_user,
|
||||||
|
rows: normalised_import_rows,
|
||||||
|
existing: params[:existing]).run
|
||||||
|
render json: result, status: result[:created].positive? ? :created : :ok
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render_bad_request(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def require_member!
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return if current_user.gte_member?
|
||||||
|
|
||||||
|
head :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalised_import_rows allow_warning_fields: false
|
||||||
|
PostImportRowNormaliser.normalise!(params[:rows], allow_warning_fields:)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -142,37 +142,13 @@ class PostsController < ApplicationController
|
|||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
# TODO: サイトに応じて thumbnail_base 設定
|
post = PostCreator.new(actor: current_user,
|
||||||
title = params[:title].presence
|
attributes: { title: params[:title], url: params[:url],
|
||||||
url = params[:url]
|
thumbnail: params[:thumbnail], tags: params[:tags],
|
||||||
thumbnail = params[:thumbnail]
|
original_created_from: params[:original_created_from],
|
||||||
tag_names = params[:tags].to_s.split
|
original_created_before: params[:original_created_before],
|
||||||
original_created_from = params[:original_created_from]
|
parent_post_ids: parse_parent_post_ids,
|
||||||
original_created_before = params[:original_created_before]
|
video_ms: params[:video_ms], duration: params[:duration] }).create!
|
||||||
parent_post_ids = parse_parent_post_ids
|
|
||||||
resized_thumbnail = thumbnail.present? ? Post.resized_thumbnail_attachment(thumbnail) : nil
|
|
||||||
|
|
||||||
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
|
|
||||||
original_created_from:, original_created_before:)
|
|
||||||
post.thumbnail.attach(resized_thumbnail) if resized_thumbnail
|
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
|
||||||
post.save!
|
|
||||||
|
|
||||||
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
|
|
||||||
{ tags:, sections: }
|
|
||||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
|
|
||||||
|
|
||||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
|
||||||
post.video_ms = normalise_video_ms(tags)
|
|
||||||
validate_video_sections!(post.video_ms, sections)
|
|
||||||
post.save!
|
|
||||||
sync_post_tags!(post, tags, sections)
|
|
||||||
|
|
||||||
sync_parent_posts!(post, parent_post_ids)
|
|
||||||
|
|
||||||
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
|
|
||||||
end
|
|
||||||
|
|
||||||
post.reload
|
post.reload
|
||||||
render json: PostRepr.base(post), status: :created
|
render json: PostRepr.base(post), status: :created
|
||||||
@@ -182,7 +158,7 @@ class PostsController < ApplicationController
|
|||||||
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
||||||
rescue Tag::SectionLiteralParseError
|
rescue Tag::SectionLiteralParseError
|
||||||
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
|
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
|
||||||
rescue VideoMsParseError
|
rescue PostCreator::VideoMsParseError
|
||||||
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
|
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
|
||||||
rescue MiniMagick::Error
|
rescue MiniMagick::Error
|
||||||
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
|
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
|
||||||
|
|||||||
@@ -173,15 +173,6 @@ class Post < ApplicationRecord
|
|||||||
def normalise_url
|
def normalise_url
|
||||||
return if url.blank?
|
return if url.blank?
|
||||||
|
|
||||||
self.url = url.strip
|
self.url = PostUrlNormaliser.normalise(url) || url.strip
|
||||||
|
|
||||||
u = URI.parse(url)
|
|
||||||
return unless u in URI::HTTP
|
|
||||||
|
|
||||||
u.host = u.host.downcase if u.host
|
|
||||||
u.path = u.path.sub(/\/\Z/, '') if u.path.present?
|
|
||||||
self.url = u.to_s
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
;
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
class PostCreator
|
||||||
|
class VideoMsParseError < ArgumentError; end
|
||||||
|
|
||||||
|
def initialize actor:, attributes:
|
||||||
|
@actor = actor
|
||||||
|
@attributes = attributes.symbolize_keys
|
||||||
|
end
|
||||||
|
|
||||||
|
def create!
|
||||||
|
post = Post.new(title: @attributes[:title].presence,
|
||||||
|
url: @attributes[:url],
|
||||||
|
thumbnail_base: @attributes[:thumbnail_base].presence,
|
||||||
|
uploaded_user: @actor,
|
||||||
|
original_created_from: @attributes[:original_created_from].presence,
|
||||||
|
original_created_before: @attributes[:original_created_before].presence)
|
||||||
|
thumbnail = @attributes[:thumbnail]
|
||||||
|
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) if thumbnail.present?
|
||||||
|
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
post.save!
|
||||||
|
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
|
||||||
|
{ tags:, sections: }
|
||||||
|
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor)
|
||||||
|
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||||
|
post.video_ms = normalise_video_ms(tags)
|
||||||
|
validate_video_sections!(post.video_ms, sections)
|
||||||
|
post.save!
|
||||||
|
sync_post_tags!(post, tags, sections)
|
||||||
|
sync_parent_posts!(post, parent_post_ids)
|
||||||
|
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
|
||||||
|
end
|
||||||
|
post
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def tag_names = @attributes[:tags].to_s.split
|
||||||
|
|
||||||
|
def parent_post_ids
|
||||||
|
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
|
||||||
|
id = Integer(token, exception: false)
|
||||||
|
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
|
||||||
|
|
||||||
|
id
|
||||||
|
}.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalise_video_ms tags
|
||||||
|
return nil unless tags.any? { _1.id == Tag.video.id }
|
||||||
|
|
||||||
|
video_ms = @attributes[:video_ms]
|
||||||
|
if video_ms.present?
|
||||||
|
value = Integer(video_ms, exception: false)
|
||||||
|
raise VideoMsParseError unless value&.positive?
|
||||||
|
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
duration = @attributes[:duration]
|
||||||
|
return nil if duration.blank?
|
||||||
|
|
||||||
|
return duration.to_i if duration.is_a?(Numeric) && duration.to_i.positive?
|
||||||
|
|
||||||
|
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
|
||||||
|
raise VideoMsParseError unless value.positive?
|
||||||
|
|
||||||
|
value
|
||||||
|
rescue Tag::SectionLiteralParseError
|
||||||
|
raise VideoMsParseError
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_video_sections! video_ms, sections
|
||||||
|
return unless video_ms
|
||||||
|
|
||||||
|
sections.each_value do |ranges|
|
||||||
|
ranges.each do |begin_ms, end_ms|
|
||||||
|
next if begin_ms < video_ms && (!end_ms || end_ms <= video_ms)
|
||||||
|
|
||||||
|
post = Post.new
|
||||||
|
post.errors.add :video_ms, 'タグ区間が動画時間の範囲外です.'
|
||||||
|
raise ActiveRecord::RecordInvalid, post
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_post_tags! post, desired_tags, sections
|
||||||
|
desired_ids = desired_tags.map(&:id).to_set
|
||||||
|
current_ids = post.tags.pluck(:id).to_set
|
||||||
|
Tag.where(id: desired_ids - current_ids).find_each do |tag|
|
||||||
|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
|
||||||
|
end
|
||||||
|
PostTagSection.where(post_id: post.id).destroy_all
|
||||||
|
sections.each do |tag_id, ranges|
|
||||||
|
ranges.each do |begin_ms, end_ms|
|
||||||
|
PostTagSection.create!(post_id: post.id,
|
||||||
|
tag_id:,
|
||||||
|
begin_ms:,
|
||||||
|
end_ms:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
PostTag.where(post_id: post.id,
|
||||||
|
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag|
|
||||||
|
post_tag.discard_by!(@actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_parent_posts! post, ids
|
||||||
|
if ids.include?(post.id)
|
||||||
|
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
|
||||||
|
raise ActiveRecord::RecordInvalid, post
|
||||||
|
end
|
||||||
|
missing = ids - Post.where(id: ids).pluck(:id)
|
||||||
|
if missing.present?
|
||||||
|
post.errors.add :parent_post_ids, "存在しない親投稿 Id. があります: #{ missing.join(' ') }"
|
||||||
|
raise ActiveRecord::RecordInvalid, post
|
||||||
|
end
|
||||||
|
ids.each { |parent_post_id| PostImplication.create_or_find_by!(post:, parent_post_id:) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
class PostImportPreviewer
|
||||||
|
FIELDS = [
|
||||||
|
'title',
|
||||||
|
'thumbnail_base',
|
||||||
|
'original_created_from',
|
||||||
|
'original_created_before',
|
||||||
|
'duration',
|
||||||
|
'tags',
|
||||||
|
'parent_post_ids',
|
||||||
|
].freeze
|
||||||
|
def initialize existing: 'skip'
|
||||||
|
@existing = existing == 'error' ? 'error' : 'skip'
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
|
||||||
|
urls = {}
|
||||||
|
rows.map { |row|
|
||||||
|
preview_row(row, urls:, fetch_metadata:, metadata_cache:)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalised_url value
|
||||||
|
PostUrlNormaliser.normalise(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def preview_row row, urls:, fetch_metadata:, metadata_cache:
|
||||||
|
row = row.symbolize_keys
|
||||||
|
attributes = initial_attributes(row)
|
||||||
|
provenance = initial_provenance(row)
|
||||||
|
tag_sources = initial_tag_sources(row, attributes, provenance)
|
||||||
|
url = row[:url].to_s.strip
|
||||||
|
provenance['url'] = 'manual'
|
||||||
|
normal_url = normalised_url(url)
|
||||||
|
url_for_metadata = normal_url || url
|
||||||
|
existing_post = normal_url.present? && Post.exists?(url: normal_url)
|
||||||
|
|
||||||
|
validation_warnings = []
|
||||||
|
validation_errors = {}
|
||||||
|
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
|
||||||
|
validation_errors[:url] = ['URL が重複しています.'] if normal_url.present? && urls.key?(normal_url)
|
||||||
|
urls[normal_url] = true if normal_url.present?
|
||||||
|
if normal_url.present? && existing_post
|
||||||
|
if @existing == 'error'
|
||||||
|
validation_errors[:url] = ['既存投稿です.']
|
||||||
|
else
|
||||||
|
validation_warnings << '既存投稿のためスキップします.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if row[:metadata_url].present? && row[:metadata_url] != url_for_metadata
|
||||||
|
clear_automatic_values!(attributes, provenance, tag_sources)
|
||||||
|
end
|
||||||
|
|
||||||
|
fetch_warnings = Array(row[:fetch_warnings]).dup
|
||||||
|
should_fetch = should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
||||||
|
if should_fetch && !(existing_post && @existing == 'skip')
|
||||||
|
metadata, fetch_warnings =
|
||||||
|
metadata_for(url_for_metadata, metadata_cache, fetch_warnings)
|
||||||
|
apply_metadata!(attributes, provenance, tag_sources, metadata)
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes['url'] = url
|
||||||
|
validate_basic_data(attributes, validation_errors)
|
||||||
|
validate_preview_tags(
|
||||||
|
merged_tags(tag_sources, provenance['tags']),
|
||||||
|
validation_errors,
|
||||||
|
validation_warnings,
|
||||||
|
)
|
||||||
|
validate_parents(attributes['parent_post_ids'], validation_errors)
|
||||||
|
attributes.delete('url')
|
||||||
|
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
|
||||||
|
|
||||||
|
{
|
||||||
|
source_row: row[:source_row],
|
||||||
|
url: normal_url || url,
|
||||||
|
attributes:,
|
||||||
|
provenance:,
|
||||||
|
tag_sources:,
|
||||||
|
metadata_url: url_for_metadata,
|
||||||
|
fetch_warnings:,
|
||||||
|
validation_warnings:,
|
||||||
|
warnings: fetch_warnings + validation_warnings,
|
||||||
|
validation_errors:,
|
||||||
|
status: validation_errors.present? \
|
||||||
|
? 'error' \
|
||||||
|
: ((fetch_warnings + validation_warnings).present? ? 'warning' : 'ready'),
|
||||||
|
existing: existing_post,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_fetch_metadata? fetch_metadata, source_row
|
||||||
|
case fetch_metadata
|
||||||
|
when true then true
|
||||||
|
when Integer then fetch_metadata == source_row
|
||||||
|
when false, nil then false
|
||||||
|
else raise ArgumentError, 'メタデータ取得対象が不正です.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initial_attributes row
|
||||||
|
attributes = row[:attributes]&.stringify_keys || {}
|
||||||
|
FIELDS.to_h { |field| [field, attributes[field].to_s] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def initial_provenance row
|
||||||
|
provenance = row[:provenance]&.stringify_keys || {}
|
||||||
|
FIELDS.to_h { |field| [field, provenance[field].presence || 'automatic'] }
|
||||||
|
.merge('url' => 'manual')
|
||||||
|
end
|
||||||
|
|
||||||
|
def initial_tag_sources row, attributes, provenance
|
||||||
|
sources = row[:tag_sources]&.stringify_keys || { 'automatic' => '', 'manual' => '' }
|
||||||
|
sources['automatic'] = sources['automatic'].to_s
|
||||||
|
sources['manual'] =
|
||||||
|
provenance['tags'] == 'manual' ? attributes['tags'].to_s : sources['manual'].to_s
|
||||||
|
sources
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_automatic_values! attributes, provenance, tag_sources
|
||||||
|
['title', 'thumbnail_base', 'original_created_from',
|
||||||
|
'original_created_before', 'duration'].each do |field|
|
||||||
|
attributes[field] = '' if provenance[field] == 'automatic'
|
||||||
|
end
|
||||||
|
tag_sources['automatic'] = ''
|
||||||
|
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def metadata_for url, cache, previous_warnings
|
||||||
|
cache[url] ||= fetch_metadata(url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_metadata url
|
||||||
|
return [{}, ['URL が空です.']] if url.blank?
|
||||||
|
|
||||||
|
data = PostMetadataFetcher.fetch(url).stringify_keys
|
||||||
|
warnings = []
|
||||||
|
warnings << 'タイトルを取得できませんでした.' if data['title'].blank?
|
||||||
|
warnings << 'サムネールを取得できませんでした.' if data['thumbnail_base'].blank?
|
||||||
|
[data.compact, warnings]
|
||||||
|
rescue Preview::UrlSafety::UnsafeUrl,
|
||||||
|
Preview::HttpFetcher::FetchFailed,
|
||||||
|
Preview::HttpFetcher::ResponseTooLarge => e
|
||||||
|
Rails.logger.info(
|
||||||
|
"post_import_metadata_fetch_failure "\
|
||||||
|
"#{ { error: e.class.name, message: e.message }.to_json }",
|
||||||
|
)
|
||||||
|
[{}, ['メタデータを取得できませんでした.']]
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_metadata! attributes, provenance, tag_sources, metadata
|
||||||
|
metadata.each do |field, value|
|
||||||
|
if field == 'tags'
|
||||||
|
next if provenance['tags'] == 'manual'
|
||||||
|
|
||||||
|
tag_sources['automatic'] = value.to_s
|
||||||
|
attributes['tags'] = merged_tags(tag_sources)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
next unless provenance[field] == 'automatic' || attributes[field].blank?
|
||||||
|
|
||||||
|
attributes[field] = value
|
||||||
|
provenance[field] = 'automatic'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def merged_tags sources, origin = nil
|
||||||
|
return sources['manual'].to_s if origin == 'manual'
|
||||||
|
|
||||||
|
sources['automatic'].to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_preview_tags raw, errors, warnings
|
||||||
|
names = raw.to_s.split
|
||||||
|
return if names.empty?
|
||||||
|
if names.any? { _1.downcase.start_with?('nico:') }
|
||||||
|
errors[:tags] = ['ニコニコ・タグは直接指定できません.']
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
parsed = names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
|
||||||
|
existing = Tag.joins(:tag_name).where(tag_names: { name: parsed }).includes(:tag_name).to_a
|
||||||
|
deprecated = existing.select(&:deprecated?).map(&:name)
|
||||||
|
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
|
||||||
|
errors[:tags] = ['タグ区間の記法が不正です.']
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_basic_data attributes, errors
|
||||||
|
post = Post.new(title: attributes['title'].presence,
|
||||||
|
url: attributes['url'],
|
||||||
|
thumbnail_base: attributes['thumbnail_base'].presence,
|
||||||
|
original_created_from: attributes['original_created_from'].presence,
|
||||||
|
original_created_before: attributes['original_created_before'].presence,
|
||||||
|
video_ms: parse_duration(attributes['duration'], errors))
|
||||||
|
post.valid?
|
||||||
|
post.errors.each do |error|
|
||||||
|
next if error.attribute == :url && error.type == :taken
|
||||||
|
|
||||||
|
(errors[error.attribute] ||= []) << error.message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_duration value, errors
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
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
|
||||||
|
ids = raw.to_s.split.map { Integer(_1, exception: false) }
|
||||||
|
return if ids.compact.empty? && raw.to_s.blank?
|
||||||
|
|
||||||
|
errors[:parent_post_ids] = ['親投稿 Id. が不正です.'] and return if ids.any? { _1.nil? || _1 <= 0 }
|
||||||
|
if Post.where(id: ids).count != ids.uniq.length
|
||||||
|
errors[:parent_post_ids] = ['存在しない親投稿 Id. があります.']
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
class PostImportRowNormaliser
|
||||||
|
ORIGINS = ['automatic', 'manual'].freeze
|
||||||
|
STRING_FIELDS = [
|
||||||
|
'title',
|
||||||
|
'thumbnail_base',
|
||||||
|
'original_created_from',
|
||||||
|
'original_created_before',
|
||||||
|
'tags',
|
||||||
|
'parent_post_ids',
|
||||||
|
].freeze
|
||||||
|
FLEXIBLE_FIELDS = ['duration', 'video_ms'].freeze
|
||||||
|
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
|
||||||
|
|
||||||
|
def self.normalise! rows, allow_warning_fields: false
|
||||||
|
raise ArgumentError, '取込行の形式が不正です.' unless rows.is_a?(Array)
|
||||||
|
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportUrlListParser::MAX_ROWS
|
||||||
|
|
||||||
|
normalised_rows = rows.map { normalise_row!(_1, allow_warning_fields:) }
|
||||||
|
source_rows = normalised_rows.map { _1['source_row'] }
|
||||||
|
raise ArgumentError, '元行番号が重複しています.' if source_rows.uniq.length != source_rows.length
|
||||||
|
if normalised_rows.sum { row_bytesize(_1) } > PostImportUrlListParser::MAX_BYTES
|
||||||
|
raise ArgumentError, '取込データが大きすぎます.'
|
||||||
|
end
|
||||||
|
|
||||||
|
normalised_rows
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.normalise_row! row, allow_warning_fields:
|
||||||
|
unless row.is_a?(Hash) || row.is_a?(ActionController::Parameters)
|
||||||
|
raise ArgumentError, '取込行の形式が不正です.'
|
||||||
|
end
|
||||||
|
|
||||||
|
parameters =
|
||||||
|
row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row)
|
||||||
|
permitted = parameters.permit(*permitted_keys(allow_warning_fields))
|
||||||
|
normalised = permitted.to_h.deep_transform_keys { _1.to_s.underscore }
|
||||||
|
|
||||||
|
normalised['source_row'] = normalise_source_row!(normalised['source_row'])
|
||||||
|
normalise_url!(normalised['url'])
|
||||||
|
normalise_metadata_url!(normalised['metadata_url'])
|
||||||
|
normalise_attributes!(normalised.fetch('attributes', { }))
|
||||||
|
normalise_provenance!(normalised.fetch('provenance', { }))
|
||||||
|
normalise_tag_sources!(normalised['tag_sources'])
|
||||||
|
normalise_warning_values!(normalised, allow_warning_fields:)
|
||||||
|
if row_bytesize(normalised) > PostImportUrlListParser::MAX_BYTES
|
||||||
|
raise ArgumentError, '取込行が大きすぎます.'
|
||||||
|
end
|
||||||
|
|
||||||
|
normalised
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.permitted_keys allow_warning_fields
|
||||||
|
keys = [
|
||||||
|
:source_row,
|
||||||
|
:sourceRow,
|
||||||
|
:url,
|
||||||
|
:metadata_url,
|
||||||
|
:metadataUrl,
|
||||||
|
{ attributes: {} },
|
||||||
|
{ provenance: {} },
|
||||||
|
{ tag_sources: {} },
|
||||||
|
{ tagSources: {} },
|
||||||
|
]
|
||||||
|
return keys unless allow_warning_fields
|
||||||
|
|
||||||
|
keys + [
|
||||||
|
{ fetch_warnings: [] },
|
||||||
|
{ fetchWarnings: [] },
|
||||||
|
{ validation_warnings: [] },
|
||||||
|
{ validationWarnings: [] },
|
||||||
|
]
|
||||||
|
end
|
||||||
|
private_class_method :permitted_keys
|
||||||
|
|
||||||
|
def self.normalise_source_row! value
|
||||||
|
source_row = Integer(value, exception: false)
|
||||||
|
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
|
||||||
|
|
||||||
|
source_row
|
||||||
|
end
|
||||||
|
private_class_method :normalise_source_row!
|
||||||
|
|
||||||
|
def self.normalise_url! value
|
||||||
|
raise ArgumentError, 'URL の形式が不正です.' unless value.is_a?(String)
|
||||||
|
raise ArgumentError, 'URL が長すぎます.' if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES
|
||||||
|
end
|
||||||
|
private_class_method :normalise_url!
|
||||||
|
|
||||||
|
def self.normalise_metadata_url! value
|
||||||
|
raise ArgumentError, 'metadata_url の形式が不正です.' unless value.nil? || value.is_a?(String)
|
||||||
|
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
|
||||||
|
raise ArgumentError, 'metadata_url が長すぎます.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :normalise_metadata_url!
|
||||||
|
|
||||||
|
def self.normalise_attributes! attributes
|
||||||
|
raise ArgumentError, 'attributes の形式が不正です.' unless attributes.is_a?(Hash)
|
||||||
|
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_FIELDS).empty?
|
||||||
|
|
||||||
|
attributes.each do |key, value|
|
||||||
|
case key
|
||||||
|
when *STRING_FIELDS
|
||||||
|
raise ArgumentError, '取込項目の型が不正です.' unless value.nil? || value.is_a?(String)
|
||||||
|
when *FLEXIBLE_FIELDS
|
||||||
|
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric)
|
||||||
|
raise ArgumentError, '取込項目の型が不正です.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
|
||||||
|
raise ArgumentError, '取込項目が大きすぎます.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :normalise_attributes!
|
||||||
|
|
||||||
|
def self.normalise_provenance! provenance
|
||||||
|
raise ArgumentError, 'provenance の形式が不正です.' unless provenance.is_a?(Hash)
|
||||||
|
|
||||||
|
allowed = ATTRIBUTE_FIELDS + ['url']
|
||||||
|
raise ArgumentError, '値の由来が不正です.' unless (provenance.keys - allowed).empty?
|
||||||
|
raise ArgumentError, '値の由来が不正です.' unless provenance.values.all? { ORIGINS.include?(_1) }
|
||||||
|
end
|
||||||
|
private_class_method :normalise_provenance!
|
||||||
|
|
||||||
|
def self.normalise_tag_sources! tag_sources
|
||||||
|
return if tag_sources.nil?
|
||||||
|
|
||||||
|
raise ArgumentError, 'タグ由来の形式が不正です.' unless tag_sources.is_a?(Hash)
|
||||||
|
raise ArgumentError, 'タグ由来の形式が不正です.' unless (tag_sources.keys - ORIGINS).empty?
|
||||||
|
raise ArgumentError, 'タグ由来の形式が不正です.' unless tag_sources.values.all? { _1.is_a?(String) }
|
||||||
|
if tag_sources.values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
||||||
|
raise ArgumentError, 'タグ由来が大きすぎます.'
|
||||||
|
end
|
||||||
|
if tag_sources.values.sum(&:bytesize) > PostImportUrlListParser::MAX_URL_BYTES
|
||||||
|
raise ArgumentError, 'タグ由来が大きすぎます.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :normalise_tag_sources!
|
||||||
|
|
||||||
|
def self.normalise_warning_values! normalised, allow_warning_fields:
|
||||||
|
return unless allow_warning_fields
|
||||||
|
|
||||||
|
%w[fetch_warnings validation_warnings].each do |field|
|
||||||
|
values = normalised[field]
|
||||||
|
next if values.nil?
|
||||||
|
|
||||||
|
unless values.is_a?(Array) && values.all? { _1.is_a?(String) }
|
||||||
|
raise ArgumentError, '警告の形式が不正です.'
|
||||||
|
end
|
||||||
|
if values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
||||||
|
raise ArgumentError, '警告が大きすぎます.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :normalise_warning_values!
|
||||||
|
|
||||||
|
def self.row_bytesize row
|
||||||
|
row.to_json.bytesize
|
||||||
|
end
|
||||||
|
private_class_method :row_bytesize
|
||||||
|
end
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
class PostImportRunner
|
||||||
|
def initialize actor:, rows:, existing: 'skip'
|
||||||
|
@actor = actor
|
||||||
|
@rows = rows
|
||||||
|
@existing = existing == 'error' ? 'error' : 'skip'
|
||||||
|
end
|
||||||
|
|
||||||
|
def run
|
||||||
|
normalised_rows = PostImportRowNormaliser.normalise!(@rows)
|
||||||
|
|
||||||
|
results = normalised_rows.map { |row| run_row(row) }
|
||||||
|
{
|
||||||
|
created: results.count { _1[:status] == 'created' },
|
||||||
|
skipped: results.count { _1[:status] == 'skipped' },
|
||||||
|
failed: results.count { _1[:status] == 'failed' },
|
||||||
|
rows: results,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def run_row row
|
||||||
|
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
|
||||||
|
preview = PostImportPreviewer.new(existing: @existing)
|
||||||
|
.preview_rows(
|
||||||
|
rows: [{
|
||||||
|
source_row: row['source_row'],
|
||||||
|
url: row['url'],
|
||||||
|
attributes:,
|
||||||
|
provenance: row['provenance'],
|
||||||
|
tag_sources: row['tag_sources'],
|
||||||
|
}],
|
||||||
|
fetch_metadata: false).first
|
||||||
|
if @existing == 'skip' && preview[:existing]
|
||||||
|
return row.slice('source_row').merge(status: 'skipped')
|
||||||
|
end
|
||||||
|
|
||||||
|
if preview[:validation_errors].present?
|
||||||
|
return {
|
||||||
|
source_row: row['source_row'],
|
||||||
|
status: 'failed',
|
||||||
|
errors: preview[:validation_errors],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes['tags'] = preview[:attributes]['tags']
|
||||||
|
attributes['url'] = row['url']
|
||||||
|
post = PostCreator.new(actor: @actor, attributes:).create!
|
||||||
|
{ 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
|
||||||
|
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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
class PostImportUrlListParser
|
||||||
|
MAX_ROWS = 100
|
||||||
|
MAX_BYTES = 1.megabyte
|
||||||
|
MAX_URL_BYTES = 20.kilobytes
|
||||||
|
|
||||||
|
def self.parse source
|
||||||
|
raw = source.to_s
|
||||||
|
raise ArgumentError, '入力が大きすぎます.' if raw.bytesize > MAX_BYTES
|
||||||
|
|
||||||
|
rows = raw.split(/\r\n|\n|\r/).each_with_index.filter_map { |line, index|
|
||||||
|
url = line.strip
|
||||||
|
next if url.blank?
|
||||||
|
|
||||||
|
raise ArgumentError, 'URL が長すぎます.' if url.bytesize > MAX_URL_BYTES
|
||||||
|
|
||||||
|
{ source_row: index + 1, url: }
|
||||||
|
}
|
||||||
|
raise ArgumentError, 'URL を入力してください.' if rows.empty?
|
||||||
|
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
|
||||||
|
|
||||||
|
rows
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
class PostMetadataFetcher
|
||||||
|
def self.fetch raw_url
|
||||||
|
uri, = Preview::UrlSafety.validate(raw_url)
|
||||||
|
response = Preview::HttpFetcher.fetch(
|
||||||
|
uri.to_s,
|
||||||
|
max_bytes: Preview::ThumbnailFetcher::HTML_MAX_BYTES,
|
||||||
|
)
|
||||||
|
metadata = Preview::HtmlMetadataExtractor.extract(response)
|
||||||
|
document = Nokogiri::HTML.parse(response.body)
|
||||||
|
content = lambda { |name|
|
||||||
|
document
|
||||||
|
.at_css("meta[property='#{ name }'], meta[name='#{ name }']")
|
||||||
|
&.[]('content')
|
||||||
|
&.strip
|
||||||
|
&.presence
|
||||||
|
}
|
||||||
|
duration = content.call('og:video:duration') || content.call('video:duration')
|
||||||
|
published = content.call('article:published_time') || content.call('date')
|
||||||
|
created_range = original_created_range(published)
|
||||||
|
platform_tags = platform_tags(uri)
|
||||||
|
{ title: metadata[:title],
|
||||||
|
thumbnail_base: Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
|
||||||
|
original_created_from: created_range&.first&.iso8601,
|
||||||
|
original_created_before: created_range&.last&.iso8601,
|
||||||
|
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil },
|
||||||
|
tags: platform_tags.join(' ') }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.platform_tags uri
|
||||||
|
return ['動画', 'YouTube'] if Preview::KnownSiteExtractor.youtube_video_id(uri)
|
||||||
|
return ['動画', 'ニコニコ'] if Preview::KnownSiteExtractor.niconico_video_id(uri)
|
||||||
|
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.original_created_range value
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
raw = value.to_s.strip
|
||||||
|
from = Time.zone.parse(raw)
|
||||||
|
zone = '(?:Z|[+-]\d{2}:?\d{2})?'
|
||||||
|
before =
|
||||||
|
case raw
|
||||||
|
when /\A\d{4}\z/ then from + 1.year
|
||||||
|
when /\A\d{4}-\d{2}\z/ then from + 1.month
|
||||||
|
when /\A\d{4}-\d{2}-\d{2}\z/ then from + 1.day
|
||||||
|
when /\A\d{4}-\d{2}-\d{2}T\d{2}#{ zone }\z/ then from + 1.hour
|
||||||
|
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}#{ zone }\z/ then from + 1.minute
|
||||||
|
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}#{ zone }\z/ then from + 1.second
|
||||||
|
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.(\d+)#{ zone }\z/
|
||||||
|
from + (10**(-Regexp.last_match(1).length))
|
||||||
|
else
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
[from, before]
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
private_class_method :platform_tags, :original_created_range
|
||||||
|
end
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
class PostUrlNormaliser
|
||||||
|
def self.normalise raw_url
|
||||||
|
value = raw_url.to_s.strip
|
||||||
|
uri = URI.parse(value)
|
||||||
|
return nil unless uri.is_a?(URI::HTTP) && uri.host.present?
|
||||||
|
|
||||||
|
uri.host = uri.host.downcase
|
||||||
|
uri.path = uri.path.sub(/\/\z/, '') if uri.path.present?
|
||||||
|
uri.to_s
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -12,8 +12,12 @@ module Preview
|
|||||||
|
|
||||||
Response = Data.define(:body, :content_type, :url)
|
Response = Data.define(:body, :content_type, :url)
|
||||||
|
|
||||||
def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS)
|
def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS,
|
||||||
|
allowed_hosts: nil)
|
||||||
uri, addresses = UrlSafety.validate(raw_url)
|
uri, addresses = UrlSafety.validate(raw_url)
|
||||||
|
if allowed_hosts && !allowed_hosts.include?(uri.host.downcase)
|
||||||
|
raise FetchFailed, '許可されてゐない redirect 先です.'
|
||||||
|
end
|
||||||
response = request(uri, addresses.first, max_bytes)
|
response = request(uri, addresses.first, max_bytes)
|
||||||
|
|
||||||
if response.is_a?(Net::HTTPRedirection)
|
if response.is_a?(Net::HTTPRedirection)
|
||||||
@@ -50,7 +54,7 @@ module Preview
|
|||||||
raise FetchFailed, 'redirect 先が不正です.'
|
raise FetchFailed, 'redirect 先が不正です.'
|
||||||
end
|
end
|
||||||
|
|
||||||
return fetch(redirect_url, max_bytes:, redirects: redirects - 1)
|
return fetch(redirect_url, max_bytes:, redirects: redirects - 1, allowed_hosts:)
|
||||||
end
|
end
|
||||||
|
|
||||||
unless response.is_a?(Net::HTTPSuccess)
|
unless response.is_a?(Net::HTTPSuccess)
|
||||||
@@ -67,7 +71,10 @@ module Preview
|
|||||||
log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
|
log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
|
||||||
raise FetchTimeout, e.message
|
raise FetchTimeout, e.message
|
||||||
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
|
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
|
||||||
log_failure(:network_error, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
|
log_failure(:network_error,
|
||||||
|
url: uri&.to_s || raw_url,
|
||||||
|
error: e.class.name,
|
||||||
|
message: e.message)
|
||||||
raise FetchFailed, e.message
|
raise FetchFailed, e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ module Preview
|
|||||||
youtube_thumbnail(uri)
|
youtube_thumbnail(uri)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.youtube_video_id(uri)
|
||||||
|
case uri.host&.downcase
|
||||||
|
when 'youtu.be'
|
||||||
|
uri.path.split('/').reject(&:blank?).first
|
||||||
|
when 'www.youtube.com', 'youtube.com', 'm.youtube.com'
|
||||||
|
uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil
|
||||||
|
end&.then { _1 if _1.match?(/\A[A-Za-z0-9_-]{6,20}\z/) }
|
||||||
|
end
|
||||||
|
|
||||||
def self.niconico_video_id(uri)
|
def self.niconico_video_id(uri)
|
||||||
case uri.host&.downcase
|
case uri.host&.downcase
|
||||||
when 'www.nicovideo.jp', 'nicovideo.jp'
|
when 'www.nicovideo.jp', 'nicovideo.jp'
|
||||||
@@ -14,14 +23,8 @@ module Preview
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.youtube_thumbnail(uri)
|
def self.youtube_thumbnail(uri)
|
||||||
id =
|
id = youtube_video_id(uri)
|
||||||
case uri.host&.downcase
|
return unless id
|
||||||
when 'youtu.be'
|
|
||||||
uri.path.split('/').reject(&:blank?).first
|
|
||||||
when 'www.youtube.com', 'youtube.com', 'm.youtube.com'
|
|
||||||
uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil
|
|
||||||
end
|
|
||||||
return unless id&.match?(/\A[A-Za-z0-9_-]{6,20}\z/)
|
|
||||||
|
|
||||||
"https://i.ytimg.com/vi/#{ id }/hqdefault.jpg"
|
"https://i.ytimg.com/vi/#{ id }/hqdefault.jpg"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ Rails.application.routes.draw do
|
|||||||
get :thumbnail
|
get :thumbnail
|
||||||
end
|
end
|
||||||
|
|
||||||
|
scope 'posts/import', controller: :post_imports do
|
||||||
|
post :preview
|
||||||
|
post :validate
|
||||||
|
post '', action: :create
|
||||||
|
end
|
||||||
|
|
||||||
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
|
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
|
||||||
collection do
|
collection do
|
||||||
get :search
|
get :search
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ import NotFound from '@/pages/NotFound'
|
|||||||
import TOSPage from '@/pages/TOSPage.mdx'
|
import TOSPage from '@/pages/TOSPage.mdx'
|
||||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||||
|
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||||
|
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||||
|
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||||
import PostListPage from '@/pages/posts/PostListPage'
|
import PostListPage from '@/pages/posts/PostListPage'
|
||||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||||
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
||||||
@@ -75,6 +78,9 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
|||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
|
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
|
||||||
|
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
|
||||||
|
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
|
||||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||||
@@ -113,6 +119,9 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
|||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
|
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
|
||||||
|
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
|
||||||
|
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
|
||||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const menuOutline = (
|
|||||||
tag?: Tag | null
|
tag?: Tag | null
|
||||||
material?: Material | null
|
material?: Material | null
|
||||||
wikiId: number | null
|
wikiId: number | null
|
||||||
user: User | null,
|
user: User | null
|
||||||
pathName: string },
|
pathName: string },
|
||||||
): Menu => {
|
): Menu => {
|
||||||
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
|
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
|
||||||
@@ -43,6 +43,7 @@ export const menuOutline = (
|
|||||||
{ name: '一覧', to: '/posts' },
|
{ name: '一覧', to: '/posts' },
|
||||||
{ name: '検索', to: '/posts/search' },
|
{ name: '検索', to: '/posts/search' },
|
||||||
{ name: '追加', to: '/posts/new' },
|
{ name: '追加', to: '/posts/new' },
|
||||||
|
{ name: '取込', to: '/posts/import' },
|
||||||
{ name: '全体履歴', to: '/posts/changes' },
|
{ name: '全体履歴', to: '/posts/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||||
{ name: 'タグ', to: '/tags', subMenu: [
|
{ name: 'タグ', to: '/tags', subMenu: [
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import FieldError from '@/components/common/FieldError'
|
||||||
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
|
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import type { PostImportOrigin,
|
||||||
|
PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
thumbnailBase: string
|
||||||
|
originalCreatedFrom: string
|
||||||
|
originalCreatedBefore: string
|
||||||
|
duration: string
|
||||||
|
tags: string
|
||||||
|
parentPostIds: string }
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
row: PostImportRow | null
|
||||||
|
saving: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSave: (draft: Draft) => Promise<boolean> }
|
||||||
|
|
||||||
|
const originOf = (
|
||||||
|
row: PostImportRow,
|
||||||
|
field: string,
|
||||||
|
): PostImportOrigin =>
|
||||||
|
row.provenance[field] ?? 'automatic'
|
||||||
|
|
||||||
|
const buildDraft = (row: PostImportRow): Draft => ({
|
||||||
|
url: row.url,
|
||||||
|
title: String (row.attributes.title ?? ''),
|
||||||
|
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
|
||||||
|
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
|
||||||
|
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
|
||||||
|
duration: String (row.attributes.duration ?? ''),
|
||||||
|
tags: String (row.attributes.tags ?? ''),
|
||||||
|
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
||||||
|
|
||||||
|
|
||||||
|
const PostImportRowDialog: FC<Props> = (
|
||||||
|
{ open, row, saving, onOpenChange, onSave },
|
||||||
|
) => {
|
||||||
|
const [draft, setDraft] = useState<Draft | null> (null)
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (open && row)
|
||||||
|
setDraft (buildDraft (row))
|
||||||
|
}, [open, row])
|
||||||
|
|
||||||
|
if (!(row) || !(draft))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const update = <Key extends keyof Draft,> (
|
||||||
|
key: Key,
|
||||||
|
value: Draft[Key],
|
||||||
|
) => {
|
||||||
|
setDraft (current =>
|
||||||
|
current ? { ...current, [key]: value } : current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (await onSave (draft))
|
||||||
|
onOpenChange (false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
|
||||||
|
<DialogHeader className="pl-8">
|
||||||
|
<DialogTitle>投稿 {row.sourceRow} を編集</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
自動取得結果を確認し、必要な項目だけ修正してください.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ThumbnailPreview
|
||||||
|
url={draft.thumbnailBase}
|
||||||
|
className="h-28 w-28"/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<PostImportStatusBadge value={row.status}/>
|
||||||
|
<PostImportStatusBadge value={row.importStatus ?? 'pending'}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DialogField
|
||||||
|
label="URL"
|
||||||
|
value={draft.url}
|
||||||
|
origin={originOf (row, 'url')}
|
||||||
|
onChange={value => update ('url', value)}/>
|
||||||
|
<DialogField
|
||||||
|
label="タイトル"
|
||||||
|
value={draft.title}
|
||||||
|
origin={originOf (row, 'title')}
|
||||||
|
onChange={value => update ('title', value)}/>
|
||||||
|
<DialogField
|
||||||
|
label="サムネール基底 URL"
|
||||||
|
value={draft.thumbnailBase}
|
||||||
|
origin={originOf (row, 'thumbnailBase')}
|
||||||
|
onChange={value => update ('thumbnailBase', value)}/>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<DialogField
|
||||||
|
label="作成日時 From"
|
||||||
|
value={draft.originalCreatedFrom}
|
||||||
|
origin={originOf (row, 'originalCreatedFrom')}
|
||||||
|
onChange={value => update ('originalCreatedFrom', value)}/>
|
||||||
|
<DialogField
|
||||||
|
label="作成日時 Before"
|
||||||
|
value={draft.originalCreatedBefore}
|
||||||
|
origin={originOf (row, 'originalCreatedBefore')}
|
||||||
|
onChange={value => update ('originalCreatedBefore', value)}/>
|
||||||
|
</div>
|
||||||
|
<DialogField
|
||||||
|
label="動画時間"
|
||||||
|
value={draft.duration}
|
||||||
|
origin={originOf (row, 'duration')}
|
||||||
|
onChange={value => update ('duration', value)}/>
|
||||||
|
<DialogArea
|
||||||
|
label="タグ"
|
||||||
|
value={draft.tags}
|
||||||
|
origin={originOf (row, 'tags')}
|
||||||
|
onChange={value => update ('tags', value)}/>
|
||||||
|
<DialogField
|
||||||
|
label="親投稿"
|
||||||
|
value={draft.parentPostIds}
|
||||||
|
origin={originOf (row, 'parentPostIds')}
|
||||||
|
onChange={value => update ('parentPostIds', value)}/>
|
||||||
|
<FieldError messages={Object.values (row.validationErrors ?? { }).flat ()}/>
|
||||||
|
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange (false)}>
|
||||||
|
取消し
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogField = (
|
||||||
|
{ label, value, origin, onChange }: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
origin: PostImportOrigin
|
||||||
|
onChange: (value: string) => void },
|
||||||
|
) => (
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
<PostImportStatusBadge value={origin}/>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={ev => onChange (ev.target.value)}
|
||||||
|
className={inputClass (false)}/>
|
||||||
|
</label>)
|
||||||
|
|
||||||
|
const DialogArea = (
|
||||||
|
{ label, value, origin, onChange }: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
origin: PostImportOrigin
|
||||||
|
onChange: (value: string) => void },
|
||||||
|
) => (
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
<PostImportStatusBadge value={origin}/>
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
rows={4}
|
||||||
|
onChange={ev => onChange (ev.target.value)}
|
||||||
|
className={inputClass (false)}/>
|
||||||
|
</label>)
|
||||||
|
|
||||||
|
export default PostImportRowDialog
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
|
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
row: PostImportRow
|
||||||
|
onEdit: () => void }
|
||||||
|
|
||||||
|
const toneClass = (row: PostImportRow): string[] => {
|
||||||
|
const value = row.importStatus ?? row.status
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case 'created':
|
||||||
|
return [
|
||||||
|
'border-sky-200 bg-sky-50',
|
||||||
|
'dark:border-sky-900 dark:bg-sky-950/30']
|
||||||
|
case 'skipped':
|
||||||
|
return [
|
||||||
|
'border-stone-200 bg-stone-50',
|
||||||
|
'dark:border-stone-800 dark:bg-stone-900/60']
|
||||||
|
case 'failed':
|
||||||
|
case 'error':
|
||||||
|
return [
|
||||||
|
'border-rose-200 bg-rose-50',
|
||||||
|
'dark:border-rose-900 dark:bg-rose-950/30']
|
||||||
|
case 'warning':
|
||||||
|
return [
|
||||||
|
'border-amber-200 bg-amber-50',
|
||||||
|
'dark:border-amber-900 dark:bg-amber-950/30']
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
'border-border bg-white',
|
||||||
|
'dark:border-neutral-700 dark:bg-neutral-900']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryError = (row: PostImportRow): string | null => {
|
||||||
|
const validation = Object.values (row.validationErrors ?? { }).flat ()[0]
|
||||||
|
if (validation)
|
||||||
|
return validation
|
||||||
|
|
||||||
|
return Object.values (row.importErrors ?? { }).flat ()[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryDate = (row: PostImportRow): string =>
|
||||||
|
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
|
||||||
|
.filter (_1 => typeof _1 === 'string' && _1 !== '')
|
||||||
|
.join (' ~ ')
|
||||||
|
|
||||||
|
|
||||||
|
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||||
|
const error = summaryError (row)
|
||||||
|
const importStatus = row.importStatus ?? 'pending'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn (
|
||||||
|
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
|
||||||
|
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto_auto]',
|
||||||
|
toneClass (row),
|
||||||
|
'hover:bg-slate-50',
|
||||||
|
'dark:hover:bg-neutral-800',
|
||||||
|
)}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||||
|
<PostImportStatusBadge value={row.status}/>
|
||||||
|
</div>
|
||||||
|
<ThumbnailPreview
|
||||||
|
url={String (row.attributes.thumbnailBase ?? '')}
|
||||||
|
className="h-16 w-16"/>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="line-clamp-2 text-sm font-medium">
|
||||||
|
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||||
|
{row.url}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{String (row.attributes.tags ?? '') || 'タグなし'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{summaryDate (row) || '日時未取得'}
|
||||||
|
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<PostImportStatusBadge value={importStatus}/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{error
|
||||||
|
? <PostImportStatusBadge value="error"/>
|
||||||
|
: <PostImportStatusBadge value="ready"/>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onEdit}
|
||||||
|
disabled={row.importStatus === 'created'}>
|
||||||
|
編集
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn (
|
||||||
|
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||||
|
toneClass (row),
|
||||||
|
)}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ThumbnailPreview
|
||||||
|
url={String (row.attributes.thumbnailBase ?? '')}
|
||||||
|
className="h-20 w-20 shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="line-clamp-2 text-sm font-medium">
|
||||||
|
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||||
|
{row.url}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<PostImportStatusBadge value={row.status}/>
|
||||||
|
<PostImportStatusBadge value={importStatus}/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-red-700 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onEdit}
|
||||||
|
disabled={row.importStatus === 'created'}>
|
||||||
|
編集
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PostImportRowSummary
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { PostImportOrigin } from '@/lib/postImportSession'
|
||||||
|
|
||||||
|
type BadgeValue =
|
||||||
|
'ready'
|
||||||
|
| 'warning'
|
||||||
|
| 'error'
|
||||||
|
| 'pending'
|
||||||
|
| 'created'
|
||||||
|
| 'skipped'
|
||||||
|
| 'failed'
|
||||||
|
| PostImportOrigin
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: BadgeValue }
|
||||||
|
|
||||||
|
const LABELS: Record<BadgeValue, string> = {
|
||||||
|
ready: '登録可能',
|
||||||
|
warning: '警告',
|
||||||
|
error: '要修正',
|
||||||
|
pending: '未登録',
|
||||||
|
created: '登録済み',
|
||||||
|
skipped: 'スキップ',
|
||||||
|
failed: '失敗',
|
||||||
|
automatic: '自動取得',
|
||||||
|
manual: '手修正' }
|
||||||
|
|
||||||
|
const STYLES: Record<BadgeValue, string[]> = {
|
||||||
|
ready: [
|
||||||
|
'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||||
|
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'],
|
||||||
|
warning: [
|
||||||
|
'border-amber-300 bg-amber-50 text-amber-700',
|
||||||
|
'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'],
|
||||||
|
error: [
|
||||||
|
'border-red-300 bg-red-50 text-red-700',
|
||||||
|
'dark:border-red-900 dark:bg-red-950 dark:text-red-200'],
|
||||||
|
pending: [
|
||||||
|
'border-slate-300 bg-slate-50 text-slate-700',
|
||||||
|
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
|
||||||
|
created: [
|
||||||
|
'border-sky-300 bg-sky-50 text-sky-700',
|
||||||
|
'dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200'],
|
||||||
|
skipped: [
|
||||||
|
'border-stone-300 bg-stone-50 text-stone-700',
|
||||||
|
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'],
|
||||||
|
failed: [
|
||||||
|
'border-rose-300 bg-rose-50 text-rose-700',
|
||||||
|
'dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200'],
|
||||||
|
automatic: [
|
||||||
|
'border-slate-300 bg-slate-50 text-slate-700',
|
||||||
|
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
|
||||||
|
manual: [
|
||||||
|
'border-purple-300 bg-purple-50 text-purple-700',
|
||||||
|
'dark:border-purple-900 dark:bg-purple-950 dark:text-purple-200'] }
|
||||||
|
|
||||||
|
|
||||||
|
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
||||||
|
<span
|
||||||
|
className={cn (
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||||
|
STYLES[value],
|
||||||
|
)}>
|
||||||
|
{LABELS[value]}
|
||||||
|
</span>)
|
||||||
|
|
||||||
|
export default PostImportStatusBadge
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
alt?: string
|
||||||
|
className?: string }
|
||||||
|
|
||||||
|
|
||||||
|
const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className = 'h-16 w-16' }) => {
|
||||||
|
const [failed, setFailed] = useState (false)
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setFailed (false)
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
if (!(url))
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${ className } flex items-center justify-center rounded border
|
||||||
|
border-border bg-muted text-xs text-muted-foreground`}>
|
||||||
|
なし
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed)
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${ className } flex items-center justify-center rounded border
|
||||||
|
border-red-300 bg-red-50 p-2 text-center text-xs text-red-700
|
||||||
|
dark:border-red-900 dark:bg-red-950 dark:text-red-200`}>
|
||||||
|
表示不可
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
className={`${ className } rounded border border-border object-cover`}
|
||||||
|
onError={() => setFailed (true)}/>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThumbnailPreview
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
export type PostImportOrigin = 'automatic' | 'manual'
|
||||||
|
export type PostImportRepairMode = 'all' | 'failed'
|
||||||
|
export type PostImportStatus =
|
||||||
|
'pending'
|
||||||
|
| 'created'
|
||||||
|
| 'skipped'
|
||||||
|
| 'failed'
|
||||||
|
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||||
|
export type PostImportAttributeValue = string | number
|
||||||
|
|
||||||
|
export type PostImportRow = {
|
||||||
|
sourceRow: number
|
||||||
|
url: string
|
||||||
|
attributes: Record<string, PostImportAttributeValue>
|
||||||
|
warnings: string[]
|
||||||
|
validationErrors: Record<string, string[]>
|
||||||
|
importErrors?: Record<string, string[]>
|
||||||
|
provenance: Record<string, PostImportOrigin>
|
||||||
|
tagSources?: Record<PostImportOrigin, string>
|
||||||
|
status: 'ready' | 'warning' | 'error'
|
||||||
|
existing: boolean
|
||||||
|
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
|
||||||
|
existing: 'skip' | 'error'
|
||||||
|
rows: PostImportRow[]
|
||||||
|
repairMode: PostImportRepairMode }
|
||||||
|
|
||||||
|
const SESSION_VERSION = 1
|
||||||
|
const SESSION_PREFIX = 'post-import-session:'
|
||||||
|
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
|
||||||
|
|
||||||
|
|
||||||
|
export const createPostImportSessionId = (): string =>
|
||||||
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID ()
|
||||||
|
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
|
||||||
|
|
||||||
|
|
||||||
|
export const loadPostImportSourceDraft = (): {
|
||||||
|
source: string
|
||||||
|
existing: 'skip' | 'error' } => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return { source: '', existing: 'skip' }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const raw = sessionStorage.getItem (SOURCE_DRAFT_KEY)
|
||||||
|
if (raw == null)
|
||||||
|
return { source: '', existing: 'skip' }
|
||||||
|
|
||||||
|
const value = JSON.parse (raw) as {
|
||||||
|
source?: string
|
||||||
|
existing?: 'skip' | 'error' }
|
||||||
|
return {
|
||||||
|
source: typeof value.source === 'string' ? value.source : '',
|
||||||
|
existing: value.existing === 'error' ? 'error' : 'skip' }
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return { source: '', existing: 'skip' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const savePostImportSourceDraft = (
|
||||||
|
source: string,
|
||||||
|
existing: 'skip' | 'error',
|
||||||
|
) => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
|
||||||
|
sessionStorage.setItem (SOURCE_DRAFT_KEY, JSON.stringify ({ source, existing }))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
|
||||||
|
|
||||||
|
|
||||||
|
export const savePostImportSession = (
|
||||||
|
sessionId: string,
|
||||||
|
session: Omit<PostImportSession, 'version' | 'savedAt'>,
|
||||||
|
) => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
|
||||||
|
sessionStorage.setItem (sessionKey (sessionId), JSON.stringify ({
|
||||||
|
...session,
|
||||||
|
version: SESSION_VERSION,
|
||||||
|
savedAt: new Date ().toISOString () }))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const loadPostImportSession = (
|
||||||
|
sessionId: string,
|
||||||
|
): PostImportSession | null => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return null
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const raw = sessionStorage.getItem (sessionKey (sessionId))
|
||||||
|
if (raw == null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const value = JSON.parse (raw) as Partial<PostImportSession>
|
||||||
|
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: SESSION_VERSION,
|
||||||
|
savedAt: typeof value.savedAt === 'string' ? value.savedAt : '',
|
||||||
|
source: typeof value.source === 'string' ? value.source : '',
|
||||||
|
existing: value.existing === 'error' ? 'error' : 'skip',
|
||||||
|
rows: value.rows as PostImportRow[],
|
||||||
|
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const mergeValidatedImportRows = (
|
||||||
|
current: PostImportRow[],
|
||||||
|
validated: PostImportRow[],
|
||||||
|
): PostImportRow[] =>
|
||||||
|
current
|
||||||
|
.map (previous => {
|
||||||
|
if (previous.importStatus === 'created')
|
||||||
|
return previous
|
||||||
|
|
||||||
|
const row = validated.find (_1 => _1.sourceRow === previous.sourceRow)
|
||||||
|
return row
|
||||||
|
? {
|
||||||
|
...row,
|
||||||
|
importStatus: previous.importStatus,
|
||||||
|
createdPostId: previous.createdPostId,
|
||||||
|
importErrors: previous.importErrors,
|
||||||
|
validationErrors: row.validationErrors ?? { } }
|
||||||
|
: previous
|
||||||
|
})
|
||||||
|
.concat (validated.filter (row =>
|
||||||
|
!(current.some (_1 => _1.sourceRow === row.sourceRow))))
|
||||||
|
|
||||||
|
|
||||||
|
export const mergeImportResults = (
|
||||||
|
rows: PostImportRow[],
|
||||||
|
results: PostImportResultRow[],
|
||||||
|
): PostImportRow[] =>
|
||||||
|
rows.map (row => {
|
||||||
|
const result = results.find (_1 => _1.sourceRow === row.sourceRow)
|
||||||
|
return result
|
||||||
|
? {
|
||||||
|
...row,
|
||||||
|
importStatus: result.status,
|
||||||
|
createdPostId: result.post?.id,
|
||||||
|
importErrors: result.errors,
|
||||||
|
validationErrors: row.validationErrors }
|
||||||
|
: 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 rowMessages = (row: PostImportRow): string[] => [
|
||||||
|
...row.warnings,
|
||||||
|
...Object.values (row.validationErrors ?? { }).flat (),
|
||||||
|
...Object.values (row.importErrors ?? { }).flat ()]
|
||||||
|
|
||||||
|
|
||||||
|
export const countImportSourceLines = (source: string): number =>
|
||||||
|
source
|
||||||
|
.split (/\r\n|\n|\r/)
|
||||||
|
.map (_1 => _1.trim ())
|
||||||
|
.filter (_1 => _1 !== '')
|
||||||
|
.length
|
||||||
|
|
||||||
|
|
||||||
|
export const countImportSourceValidLines = (source: string): number =>
|
||||||
|
source
|
||||||
|
.split (/\r\n|\n|\r/)
|
||||||
|
.map (_1 => _1.trim ())
|
||||||
|
.filter (_1 => /^https?:\/\//.test (_1))
|
||||||
|
.length
|
||||||
|
|
||||||
|
|
||||||
|
export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
|
rows.filter (row => row.importStatus == null || row.importStatus === 'pending')
|
||||||
|
|
||||||
|
|
||||||
|
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
||||||
|
total: rows.length,
|
||||||
|
submittable: rows.filter (row =>
|
||||||
|
(row.importStatus == null || row.importStatus === 'pending')
|
||||||
|
&& Object.keys (row.validationErrors ?? { }).length === 0).length,
|
||||||
|
invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length,
|
||||||
|
skipPlanned: rows.filter (row =>
|
||||||
|
row.existing && row.importStatus !== 'created' && row.status !== 'error').length,
|
||||||
|
created: rows.filter (row => row.importStatus === 'created').length,
|
||||||
|
failed: rows.filter (row => row.importStatus === 'failed').length })
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
import { useNavigate,
|
||||||
|
useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import FieldError from '@/components/common/FieldError'
|
||||||
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import MainArea from '@/components/layout/MainArea'
|
||||||
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from '@/components/ui/use-toast'
|
||||||
|
import { SITE_TITLE } from '@/config'
|
||||||
|
import { apiPost } from '@/lib/api'
|
||||||
|
import { canEditContent } from '@/lib/users'
|
||||||
|
import { loadPostImportSession,
|
||||||
|
mergeImportResults,
|
||||||
|
retryImportRow,
|
||||||
|
savePostImportSession,
|
||||||
|
type PostImportResultRow,
|
||||||
|
type PostImportSession } from '@/lib/postImportSession'
|
||||||
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
type Props = { user: User | null }
|
||||||
|
|
||||||
|
const resultToneClass = (status: string | undefined): string[] => {
|
||||||
|
switch (status)
|
||||||
|
{
|
||||||
|
case 'created':
|
||||||
|
return [
|
||||||
|
'border-sky-200 bg-sky-50',
|
||||||
|
'dark:border-sky-900 dark:bg-sky-950/30']
|
||||||
|
case 'skipped':
|
||||||
|
return [
|
||||||
|
'border-stone-200 bg-stone-50',
|
||||||
|
'dark:border-stone-800 dark:bg-stone-900/60']
|
||||||
|
case 'failed':
|
||||||
|
return [
|
||||||
|
'border-rose-200 bg-rose-50',
|
||||||
|
'dark:border-rose-900 dark:bg-rose-950/30']
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
'border-border bg-white',
|
||||||
|
'dark:border-neutral-700 dark:bg-neutral-900']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||||
|
const editable = canEditContent (user)
|
||||||
|
const navigate = useNavigate ()
|
||||||
|
const { sessionId } = useParams ()
|
||||||
|
|
||||||
|
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||||
|
const [missing, setMissing] = useState (false)
|
||||||
|
const [loadingRow, setLoadingRow] = useState<number | null> (null)
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (!(sessionId))
|
||||||
|
return
|
||||||
|
|
||||||
|
const loaded = loadPostImportSession (sessionId)
|
||||||
|
setSession (loaded)
|
||||||
|
setMissing (loaded == null)
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (!(sessionId) || !(session))
|
||||||
|
return
|
||||||
|
|
||||||
|
savePostImportSession (sessionId, session)
|
||||||
|
}, [session, sessionId])
|
||||||
|
|
||||||
|
const counts = useMemo (() => ({
|
||||||
|
created: session?.rows.filter (_1 => _1.importStatus === 'created').length ?? 0,
|
||||||
|
skipped: session?.rows.filter (_1 => _1.importStatus === 'skipped').length ?? 0,
|
||||||
|
failed: session?.rows.filter (_1 => _1.importStatus === 'failed').length ?? 0 }), [session])
|
||||||
|
|
||||||
|
const retry = async (sourceRow: number) => {
|
||||||
|
if (!(session))
|
||||||
|
return
|
||||||
|
|
||||||
|
setLoadingRow (sourceRow)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const pendingRows = retryImportRow (session.rows, sourceRow)
|
||||||
|
setSession ({ ...session, rows: pendingRows })
|
||||||
|
const target = pendingRows.find (_1 => _1.sourceRow === sourceRow)
|
||||||
|
if (target == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const result = await apiPost<{
|
||||||
|
created: number
|
||||||
|
skipped: number
|
||||||
|
failed: number
|
||||||
|
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||||
|
rows: [{
|
||||||
|
sourceRow: target.sourceRow,
|
||||||
|
url: target.url,
|
||||||
|
attributes: target.attributes,
|
||||||
|
provenance: target.provenance,
|
||||||
|
tagSources: target.tagSources,
|
||||||
|
metadataUrl: target.metadataUrl }],
|
||||||
|
existing: session.existing })
|
||||||
|
setSession (current =>
|
||||||
|
current
|
||||||
|
? { ...current, rows: mergeImportResults (current.rows, result.rows) }
|
||||||
|
: current)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
setSession (session)
|
||||||
|
toast ({ title: '再試行に失敗しました' })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setLoadingRow (null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRepair = (sourceRow: number) => {
|
||||||
|
const nextSession = { ...session, repairMode: 'failed' as const }
|
||||||
|
setSession (nextSession)
|
||||||
|
if (sessionId)
|
||||||
|
savePostImportSession (sessionId, nextSession)
|
||||||
|
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(editable))
|
||||||
|
return <Forbidden/>
|
||||||
|
|
||||||
|
if (missing || !(sessionId) || !(session))
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<MainArea>
|
||||||
|
<div className="mx-auto max-w-4xl space-y-4 p-4">
|
||||||
|
<PageTitle>投稿インポート結果</PageTitle>
|
||||||
|
<FieldError messages={['取込状態が見つかりません.']}/>
|
||||||
|
<Button type="button" onClick={() => navigate ('/posts/import')}>
|
||||||
|
URL リスト入力へ戻る
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</MainArea>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainArea>
|
||||||
|
<Helmet>
|
||||||
|
<title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-5xl space-y-4 p-4">
|
||||||
|
<PageTitle>登録結果</PageTitle>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||||
|
dark:bg-neutral-900">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<SummaryChip label="登録成功" value={counts.created} badge="created"/>
|
||||||
|
<SummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
|
||||||
|
<SummaryChip label="失敗" value={counts.failed} badge="failed"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{session.rows.map (row => (
|
||||||
|
<div
|
||||||
|
key={row.sourceRow}
|
||||||
|
className={[
|
||||||
|
'rounded-lg border p-4',
|
||||||
|
...resultToneClass (row.importStatus)].join (' ')}>
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start
|
||||||
|
md:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">行 {row.sourceRow}</span>
|
||||||
|
<PostImportStatusBadge value={row.importStatus ?? 'pending'}/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-700 dark:text-neutral-200">
|
||||||
|
{String (row.attributes.title ?? '') || row.url}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{row.url}
|
||||||
|
</div>
|
||||||
|
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
{row.createdPostId && (
|
||||||
|
<Button type="button" variant="outline" asChild>
|
||||||
|
<PrefetchLink to={`/posts/${ row.createdPostId }`}>
|
||||||
|
投稿を開く
|
||||||
|
</PrefetchLink>
|
||||||
|
</Button>)}
|
||||||
|
{row.importStatus === 'failed' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openRepair (row.sourceRow)}>
|
||||||
|
修正
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => retry (row.sourceRow)}
|
||||||
|
disabled={loadingRow === row.sourceRow}>
|
||||||
|
再試行
|
||||||
|
</Button>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const nextSession = { ...session, repairMode: 'all' as const }
|
||||||
|
setSession (nextSession)
|
||||||
|
savePostImportSession (sessionId, nextSession)
|
||||||
|
navigate (`/posts/import/${ sessionId }/review`)
|
||||||
|
}}>
|
||||||
|
確認画面へ戻る
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate ('/posts/import')}>
|
||||||
|
新しい URL リストを入力
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainArea>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SummaryChip = (
|
||||||
|
{ label, value, badge }: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
badge: 'created' | 'skipped' | 'failed' },
|
||||||
|
) => (
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-border
|
||||||
|
bg-background px-3 py-1 text-sm">
|
||||||
|
<PostImportStatusBadge value={badge}/>
|
||||||
|
<span>{label} {value}</span>
|
||||||
|
</div>)
|
||||||
|
|
||||||
|
export default PostImportResultPage
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
import { useNavigate,
|
||||||
|
useParams,
|
||||||
|
useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import FieldError from '@/components/common/FieldError'
|
||||||
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
|
import MainArea from '@/components/layout/MainArea'
|
||||||
|
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog'
|
||||||
|
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||||||
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from '@/components/ui/use-toast'
|
||||||
|
import { SITE_TITLE } from '@/config'
|
||||||
|
import { apiPost } from '@/lib/api'
|
||||||
|
import { canEditContent } from '@/lib/users'
|
||||||
|
import { loadPostImportSession,
|
||||||
|
mergeImportResults,
|
||||||
|
mergeValidatedImportRows,
|
||||||
|
reviewSummaryCounts,
|
||||||
|
rowMessages,
|
||||||
|
savePostImportSession,
|
||||||
|
submittableImportRows,
|
||||||
|
type PostImportResultRow,
|
||||||
|
type PostImportRow,
|
||||||
|
type PostImportSession } from '@/lib/postImportSession'
|
||||||
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
type Props = { user: User | null }
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
thumbnailBase: string
|
||||||
|
originalCreatedFrom: string
|
||||||
|
originalCreatedBefore: string
|
||||||
|
duration: string
|
||||||
|
tags: string
|
||||||
|
parentPostIds: string }
|
||||||
|
|
||||||
|
|
||||||
|
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||||
|
const editable = canEditContent (user)
|
||||||
|
const navigate = useNavigate ()
|
||||||
|
const { sessionId } = useParams ()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams ()
|
||||||
|
|
||||||
|
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||||
|
const [loading, setLoading] = useState (false)
|
||||||
|
const [missing, setMissing] = useState (false)
|
||||||
|
const [savingRow, setSavingRow] = useState<number | null> (null)
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (!(sessionId))
|
||||||
|
return
|
||||||
|
|
||||||
|
const loaded = loadPostImportSession (sessionId)
|
||||||
|
setSession (loaded)
|
||||||
|
setMissing (loaded == null)
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (!(sessionId) || !(session))
|
||||||
|
return
|
||||||
|
|
||||||
|
savePostImportSession (sessionId, session)
|
||||||
|
}, [session, sessionId])
|
||||||
|
|
||||||
|
const rows = session?.rows ?? []
|
||||||
|
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
||||||
|
const editingRow =
|
||||||
|
Number.isFinite (editingSourceRow)
|
||||||
|
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
|
||||||
|
: null
|
||||||
|
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||||||
|
const submittable = useMemo (
|
||||||
|
() => submittableImportRows (rows).filter (row =>
|
||||||
|
Object.keys (row.validationErrors ?? { }).length === 0),
|
||||||
|
[rows],
|
||||||
|
)
|
||||||
|
const reviewRows =
|
||||||
|
session?.repairMode === 'failed'
|
||||||
|
? [...rows].sort ((a, b) => {
|
||||||
|
const aFailed = a.importStatus === 'failed' ? 0 : 1
|
||||||
|
const bFailed = b.importStatus === 'failed' ? 0 : 1
|
||||||
|
return aFailed - bFailed || a.sourceRow - b.sourceRow
|
||||||
|
})
|
||||||
|
: rows
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (editingRow == null || session?.repairMode !== 'failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
||||||
|
element?.scrollIntoView ({ block: 'center', behaviour: 'smooth' })
|
||||||
|
}, [editingRow, session?.repairMode])
|
||||||
|
|
||||||
|
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||||
|
setSession (current =>
|
||||||
|
current ? { ...current, rows: nextRows } : current)
|
||||||
|
|
||||||
|
const saveDraft = async (draft: Draft): Promise<boolean> => {
|
||||||
|
if (!(session))
|
||||||
|
return false
|
||||||
|
if (editingRow == null)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const urlChanged = draft.url !== editingRow.url
|
||||||
|
const nextProvenance = { ...editingRow.provenance }
|
||||||
|
const nextAttributes = { ...editingRow.attributes }
|
||||||
|
const nextTagSources = {
|
||||||
|
automatic: editingRow.tagSources?.automatic ?? '',
|
||||||
|
manual: editingRow.tagSources?.manual ?? '' }
|
||||||
|
const draftFields = [
|
||||||
|
['title', draft.title],
|
||||||
|
['thumbnailBase', draft.thumbnailBase],
|
||||||
|
['originalCreatedFrom', draft.originalCreatedFrom],
|
||||||
|
['originalCreatedBefore', draft.originalCreatedBefore],
|
||||||
|
['duration', draft.duration],
|
||||||
|
['parentPostIds', draft.parentPostIds],
|
||||||
|
] as const
|
||||||
|
draftFields.forEach (([field, value]) => {
|
||||||
|
nextAttributes[field] = value
|
||||||
|
nextProvenance[field] =
|
||||||
|
value !== String (editingRow.attributes[field] ?? '')
|
||||||
|
? 'manual'
|
||||||
|
: (editingRow.provenance[field] ?? 'automatic')
|
||||||
|
})
|
||||||
|
nextAttributes.tags = draft.tags
|
||||||
|
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
|
||||||
|
{
|
||||||
|
nextProvenance.tags = 'manual'
|
||||||
|
nextTagSources.manual = draft.tags
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
|
||||||
|
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingRow (editingRow.sourceRow)
|
||||||
|
const nextRows = session.rows.map (row =>
|
||||||
|
row.sourceRow === editingRow.sourceRow
|
||||||
|
? {
|
||||||
|
...row,
|
||||||
|
url: draft.url,
|
||||||
|
attributes: nextAttributes,
|
||||||
|
provenance: {
|
||||||
|
...nextProvenance,
|
||||||
|
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
|
||||||
|
tagSources: nextTagSources,
|
||||||
|
importStatus: row.importStatus === 'created' ? 'created' : 'pending',
|
||||||
|
importErrors: undefined }
|
||||||
|
: row)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||||
|
rows: nextRows.filter (row => row.importStatus !== 'created'),
|
||||||
|
existing: session.existing,
|
||||||
|
changed_row: urlChanged ? editingRow.sourceRow : -1 })
|
||||||
|
updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows))
|
||||||
|
setSearchParams ({ })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
toast ({ title: '行の再検証に失敗しました' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setSavingRow (null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateExisting = async (value: 'skip' | 'error') => {
|
||||||
|
if (!(session))
|
||||||
|
return
|
||||||
|
|
||||||
|
const pendingRows = session.rows.map (row =>
|
||||||
|
row.importStatus === 'created'
|
||||||
|
? row
|
||||||
|
: {
|
||||||
|
...row,
|
||||||
|
importStatus: 'pending' as const,
|
||||||
|
importErrors: undefined })
|
||||||
|
setSession ({ ...session, existing: value, rows: pendingRows })
|
||||||
|
setLoading (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||||
|
rows: pendingRows.filter (row => row.importStatus !== 'created'),
|
||||||
|
existing: value,
|
||||||
|
changed_row: -1 })
|
||||||
|
updateSessionRows (mergeValidatedImportRows (pendingRows, validated.rows))
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!(sessionId) || !(session) || submittable.length === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
setLoading (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const result = await apiPost<{
|
||||||
|
created: number
|
||||||
|
skipped: number
|
||||||
|
failed: number
|
||||||
|
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||||
|
rows: submittable.map (row => ({
|
||||||
|
sourceRow: row.sourceRow,
|
||||||
|
url: row.url,
|
||||||
|
attributes: row.attributes,
|
||||||
|
provenance: row.provenance,
|
||||||
|
tagSources: row.tagSources,
|
||||||
|
metadataUrl: row.metadataUrl })),
|
||||||
|
existing: session.existing })
|
||||||
|
const nextRows = mergeImportResults (session.rows, result.rows)
|
||||||
|
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
|
||||||
|
setSession (nextSession)
|
||||||
|
savePostImportSession (sessionId, nextSession)
|
||||||
|
navigate (`/posts/import/${ sessionId }/result`)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
toast ({ title: '登録に失敗しました' })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(editable))
|
||||||
|
return <Forbidden/>
|
||||||
|
|
||||||
|
if (missing || !(sessionId) || !(session))
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<MainArea>
|
||||||
|
<div className="mx-auto max-w-4xl space-y-4 p-4">
|
||||||
|
<PageTitle>投稿インポート</PageTitle>
|
||||||
|
<FieldError messages={['取込状態が見つかりません.']}/>
|
||||||
|
<Button type="button" onClick={() => navigate ('/posts/import')}>
|
||||||
|
URL リスト入力へ戻る
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</MainArea>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainArea>
|
||||||
|
<Helmet>
|
||||||
|
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-6xl space-y-4 p-4 pb-28">
|
||||||
|
<PageTitle>投稿情報の確認</PageTitle>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||||
|
dark:bg-neutral-900">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<SummaryChip label="全件" value={counts.total}/>
|
||||||
|
<SummaryChip label="登録対象" value={counts.submittable}/>
|
||||||
|
<SummaryChip label="要修正" value={counts.invalid}/>
|
||||||
|
<SummaryChip label="スキップ予定" value={counts.skipPlanned}/>
|
||||||
|
<SummaryChip label="登録済み" value={counts.created}/>
|
||||||
|
<SummaryChip label="失敗" value={counts.failed}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||||
|
dark:bg-neutral-900">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
|
URL {rows.length} 件
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<span>既存投稿</span>
|
||||||
|
<select
|
||||||
|
value={session.existing}
|
||||||
|
onChange={ev => updateExisting (ev.target.value as 'skip' | 'error')}
|
||||||
|
className="rounded border border-border bg-background px-3 py-2">
|
||||||
|
<option value="skip">スキップ</option>
|
||||||
|
<option value="error">エラー</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{reviewRows.map (row => (
|
||||||
|
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||||||
|
<PostImportRowSummary
|
||||||
|
row={row}
|
||||||
|
onEdit={() => {
|
||||||
|
setSearchParams ({ edit: String (row.sourceRow) })
|
||||||
|
}}/>
|
||||||
|
{rowMessages (row).length > 0 && (
|
||||||
|
<div className="mt-2 md:max-w-3xl">
|
||||||
|
<FieldError messages={rowMessages (row)}/>
|
||||||
|
</div>)}
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-0 bottom-0 border-t bg-white/95 p-4 backdrop-blur
|
||||||
|
dark:border-neutral-700 dark:bg-neutral-950/95">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||||||
|
md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<PostImportStatusBadge value="pending"/>
|
||||||
|
<span>登録対象 {submittable.length} 件</span>
|
||||||
|
<PostImportStatusBadge value="error"/>
|
||||||
|
<span>要修正 {counts.invalid} 件</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate ('/posts/import')}>
|
||||||
|
URL リスト入力へ戻る
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={loading || submittable.length === 0}>
|
||||||
|
有効な投稿を一括登録
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PostImportRowDialog
|
||||||
|
open={editingRow != null}
|
||||||
|
row={editingRow}
|
||||||
|
saving={savingRow != null}
|
||||||
|
onOpenChange={open => {
|
||||||
|
if (!(open))
|
||||||
|
setSearchParams ({ })
|
||||||
|
}}
|
||||||
|
onSave={saveDraft}/>
|
||||||
|
</MainArea>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SummaryChip = ({ label, value }: { label: string
|
||||||
|
value: number }) => (
|
||||||
|
<div className="rounded-full border border-border bg-background px-3 py-1 text-sm">
|
||||||
|
{label} {value}
|
||||||
|
</div>)
|
||||||
|
|
||||||
|
export default PostImportReviewPage
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import FieldError from '@/components/common/FieldError'
|
||||||
|
import FormField from '@/components/common/FormField'
|
||||||
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
|
import MainArea from '@/components/layout/MainArea'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from '@/components/ui/use-toast'
|
||||||
|
import { SITE_TITLE } from '@/config'
|
||||||
|
import { apiPost, isApiError } from '@/lib/api'
|
||||||
|
import { canEditContent } from '@/lib/users'
|
||||||
|
import { inputClass } from '@/lib/utils'
|
||||||
|
import { countImportSourceLines,
|
||||||
|
countImportSourceValidLines,
|
||||||
|
createPostImportSessionId,
|
||||||
|
loadPostImportSourceDraft,
|
||||||
|
savePostImportSession,
|
||||||
|
savePostImportSourceDraft,
|
||||||
|
type PostImportRow } from '@/lib/postImportSession'
|
||||||
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
type Props = { user: User | null }
|
||||||
|
|
||||||
|
const MAX_ROWS = 100
|
||||||
|
|
||||||
|
|
||||||
|
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||||
|
const editable = canEditContent (user)
|
||||||
|
const navigate = useNavigate ()
|
||||||
|
const draft = useMemo (() => loadPostImportSourceDraft (), [])
|
||||||
|
|
||||||
|
const [source, setSource] = useState (draft.source)
|
||||||
|
const [existing, setExisting] = useState<'skip' | 'error'> (draft.existing)
|
||||||
|
const [loading, setLoading] = useState (false)
|
||||||
|
const [error, setError] = useState<string | null> (null)
|
||||||
|
|
||||||
|
const lineCount = countImportSourceLines (source)
|
||||||
|
const validLineCount = countImportSourceValidLines (source)
|
||||||
|
const messages = [
|
||||||
|
...(lineCount === 0 ? ['URL を入力してください.'] : []),
|
||||||
|
...(lineCount > MAX_ROWS ? [`最大 ${ MAX_ROWS } 件までです.`] : []),
|
||||||
|
...(error ? [error] : [])]
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
savePostImportSourceDraft (source, existing)
|
||||||
|
}, [existing, source])
|
||||||
|
|
||||||
|
const preview = async () => {
|
||||||
|
setLoading (true)
|
||||||
|
setError (null)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
||||||
|
source,
|
||||||
|
existing })
|
||||||
|
const sessionId = createPostImportSessionId ()
|
||||||
|
savePostImportSession (sessionId, {
|
||||||
|
source,
|
||||||
|
existing,
|
||||||
|
rows: data.rows,
|
||||||
|
repairMode: 'all' })
|
||||||
|
navigate (`/posts/import/${ sessionId }/review`)
|
||||||
|
}
|
||||||
|
catch (requestError)
|
||||||
|
{
|
||||||
|
const message =
|
||||||
|
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
|
||||||
|
? requestError.response?.data?.message
|
||||||
|
?? requestError.response?.data?.baseErrors?.[0]
|
||||||
|
: undefined
|
||||||
|
setError (message ?? '入力を確認してください.')
|
||||||
|
toast ({
|
||||||
|
title: '投稿情報の取得に失敗しました',
|
||||||
|
description: message ?? '入力を確認してください.' })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(editable))
|
||||||
|
return <Forbidden/>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainArea>
|
||||||
|
<Helmet>
|
||||||
|
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-4xl space-y-4 p-4">
|
||||||
|
<PageTitle>投稿インポート</PageTitle>
|
||||||
|
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||||
|
dark:bg-neutral-900 md:p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold">URL リストを入力</h2>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
|
一行につき一つの URL を入力してください.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="URL リスト">
|
||||||
|
{() => (
|
||||||
|
<textarea
|
||||||
|
value={source}
|
||||||
|
rows={14}
|
||||||
|
className={inputClass (
|
||||||
|
messages.length > 0,
|
||||||
|
'font-mono text-sm',
|
||||||
|
)}
|
||||||
|
onChange={ev => setSource (ev.target.value)}/>)}
|
||||||
|
</FormField>
|
||||||
|
<FieldError messages={messages}/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm
|
||||||
|
text-neutral-600 dark:text-neutral-300">
|
||||||
|
<div>有効行数: {validLineCount}</div>
|
||||||
|
<div>最大 {MAX_ROWS} 件</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-sm font-medium">既存投稿</span>
|
||||||
|
<select
|
||||||
|
value={existing}
|
||||||
|
onChange={ev => setExisting (ev.target.value as typeof existing)}
|
||||||
|
className={inputClass (false, 'w-full md:w-auto')}>
|
||||||
|
<option value="skip">スキップ</option>
|
||||||
|
<option value="error">エラー</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={preview}
|
||||||
|
disabled={loading || lineCount === 0 || lineCount > MAX_ROWS}>
|
||||||
|
投稿情報を取得
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainArea>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PostImportSourcePage
|
||||||
@@ -25,7 +25,13 @@ import type { User } from '@/types'
|
|||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
type PostFormField =
|
type PostFormField =
|
||||||
'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
|
'url'
|
||||||
|
| 'title'
|
||||||
|
| 'tags'
|
||||||
|
| 'parentPostIds'
|
||||||
|
| 'videoMs'
|
||||||
|
| 'originalCreatedAt'
|
||||||
|
| 'thumbnail'
|
||||||
|
|
||||||
|
|
||||||
const PostNewPage: FC<Props> = ({ user }) => {
|
const PostNewPage: FC<Props> = ({ user }) => {
|
||||||
@@ -36,8 +42,10 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<PostFormField> ()
|
useValidationErrors<PostFormField> ()
|
||||||
|
|
||||||
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
|
const [originalCreatedBefore, setOriginalCreatedBefore] =
|
||||||
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
useState<string | null> (null)
|
||||||
|
const [originalCreatedFrom, setOriginalCreatedFrom] =
|
||||||
|
useState<string | null> (null)
|
||||||
const [parentPostIds, setParentPostIds] = useState ('')
|
const [parentPostIds, setParentPostIds] = useState ('')
|
||||||
const [tags, setTags] = useState ('')
|
const [tags, setTags] = useState ('')
|
||||||
const [duration, setDuration] = useState ('')
|
const [duration, setDuration] = useState ('')
|
||||||
@@ -50,8 +58,10 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const thumbnailPreviewRef = useRef ('')
|
const thumbnailPreviewRef = useRef ('')
|
||||||
const videoFlg =
|
const videoFlg =
|
||||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
useMemo (() =>
|
||||||
[tags])
|
tags.split (/\s+/).some (
|
||||||
|
tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
||||||
|
[tags])
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
clearValidationErrors ()
|
clearValidationErrors ()
|
||||||
@@ -72,14 +82,15 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
await apiPost ('/posts', formData,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
toast ({ title: '投稿成功!' })
|
toast ({ title: '投稿成功!' })
|
||||||
navigate ('/posts')
|
navigate ('/posts')
|
||||||
}
|
}
|
||||||
catch (e)
|
catch (e)
|
||||||
{
|
{
|
||||||
applyValidationError (e)
|
applyValidationError (e)
|
||||||
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
|
toast ({ title: '投稿失敗', description: '入力を確認してください.' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +98,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
setTitleLoading (true)
|
setTitleLoading (true)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
|
const data = await apiGet<{ title: string }> ('/preview/title',
|
||||||
|
{ params: { url } })
|
||||||
setTitle (data.title || '')
|
setTitle (data.title || '')
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -105,7 +117,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
const data = await apiGet<Blob> ('/preview/thumbnail',
|
const data = await apiGet<Blob> ('/preview/thumbnail',
|
||||||
{ params: { url }, responseType: 'blob' })
|
{ params: { url },
|
||||||
|
responseType: 'blob' })
|
||||||
const imageURL = URL.createObjectURL (data)
|
const imageURL = URL.createObjectURL (data)
|
||||||
setThumbnailPreview (imageURL)
|
setThumbnailPreview (imageURL)
|
||||||
setThumbnailFile (new File ([data],
|
setThumbnailFile (new File ([data],
|
||||||
@@ -163,7 +176,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void fetchTitle ()}
|
onClick={fetchTitle}
|
||||||
disabled={!(url) || titleLoading}>
|
disabled={!(url) || titleLoading}>
|
||||||
取得
|
取得
|
||||||
</Button>
|
</Button>
|
||||||
@@ -180,7 +193,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void fetchThumbnail ()}
|
onClick={fetchThumbnail}
|
||||||
disabled={!(url) || thumbnailLoading}>
|
disabled={!(url) || thumbnailLoading}>
|
||||||
取得
|
取得
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする