コミットを比較

..

21 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 155edfe018 #399 2026-07-12 02:22:36 +09:00
みてるぞ 23f1ffb04b #399 2026-07-12 02:08:32 +09:00
みてるぞ ccfe65a6a4 #399 2026-07-12 02:01:25 +09:00
みてるぞ 06d6e512e4 #399 2026-07-12 01:56:24 +09:00
みてるぞ 5bd097bcfe #399 2026-07-12 01:47:34 +09:00
みてるぞ f636d2a177 #399 2026-07-12 01:39:31 +09:00
みてるぞ 10dc776313 #399 2026-07-12 01:17:45 +09:00
みてるぞ a9e16735f8 #399 2026-07-12 01:11:07 +09:00
みてるぞ 440a6c9961 #399 2026-07-12 00:54:21 +09:00
みてるぞ 4535a9d260 #399 2026-07-12 00:46:53 +09:00
みてるぞ 440d3d38be #399 2026-07-11 23:21:59 +09:00
みてるぞ cb33d9ca25 #399 2026-07-11 23:08:03 +09:00
みてるぞ 19bf24432a #399 2026-07-11 23:07:51 +09:00
みてるぞ ac41385962 Merge remote-tracking branch 'origin/main' into feature/399 2026-07-11 22:36:03 +09:00
みてるぞ 08bf92ff79 #399 2026-07-11 21:46:10 +09:00
みてるぞ c51d7b98ba #399 2026-07-11 21:11:36 +09:00
みてるぞ b7b284c076 スマホでのメニュー表示デザイン・バグ (#405) (#408)
Reviewed-on: #408
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-11 05:02:23 +09:00
みてるぞ 6c451d260f サムネ取得 API 見直し (#398) (#402)
Reviewed-on: #402
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-10 01:27:11 +09:00
みてるぞ c1902fbc99 素材管理で新規タグ紐づけ時にタグ履歴が発生しない問題 (#386) (#392)
開発環境で動作確認したらマージする.

Reviewed-on: #392
2026-07-09 06:43:06 +09:00
みてるぞ 2f2b6e2afa タグ D&D 時の表示位置修正 (#268) (#400)
Reviewed-on: #400
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-08 00:01:07 +09:00
みてるぞ f5b632ed89 設定画面 (#34) (#397)
Reviewed-on: #397
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-07 00:48:41 +09:00
36個のファイルの変更2097行の追加1288行の削除
+10 -8
ファイルの表示
@@ -89,15 +89,13 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
tag = resolve_material_tag!(tag_name_raw)
material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user)
@@ -139,10 +137,7 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
tag = resolve_material_tag!(tag_name_raw)
material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob
material.file.attach(uploaded_blob)
@@ -150,6 +145,7 @@ class MaterialsController < ApplicationController
material.file.detach
end
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user)
@@ -240,6 +236,12 @@ class MaterialsController < ApplicationController
nil
end
def resolve_material_tag! tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end
def material_index_needs_tag_name? filters
filters[:q].present? || filters[:sort] == 'tag_name'
end
+176
ファイルの表示
@@ -0,0 +1,176 @@
class PostImportsController < ApplicationController
before_action :require_member!
def parse
source =
if params[:format] == 'google_sheets'
PostImportGoogleSheetsFetcher.fetch!(params[:source], rate_key: current_user.id)
else
params[:source]
end
format = params[:format] == 'google_sheets' ? 'csv' : params[:format]
parsed = PostImportSourceParser.new(source:, format:,
has_header: params[:has_header], json_path: params[:json_path]).parse
render json: parsed
rescue ArgumentError => e
render_bad_request(e.message)
end
def preview
parsed = parsed_params
url_column = params[:url_column].presence || (parsed[:columns].length == 1 ? 0 : nil)
return render_bad_request('URL 列を選択してください.') if url_column.nil?
rows = PostImportPreviewer.new(parsed:, url_column:, mappings: mappings_params,
existing: params[:existing]).preview
render json: parsed.slice(:columns).merge(rows:)
rescue ArgumentError => e
render_bad_request(e.message)
end
def validate
rows = validate_rows_params
changed_row = Integer(params[:changed_row], exception: false)
result = PostImportPreviewer.new(parsed: { columns: [], rows: [] }, url_column: 0,
mappings: {}, existing: params[:existing]).preview_rows(
rows:, fetch_metadata: changed_row,
metadata_cache: { })
render json: { rows: result }
rescue ArgumentError => e
render_bad_request(e.message)
end
def create
result = PostImportRunner.new(actor: current_user, rows: create_rows_params,
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 parsed_params
value = params.require(:parsed).to_unsafe_h.deep_transform_keys { _1.to_s.underscore }
columns = Array(value['columns'])
rows = Array(value['rows'])
raise ArgumentError, '解析結果の形式が不正です.' unless columns.all?(Hash) && rows.all?(Hash)
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportSourceParser::MAX_ROWS
raise ArgumentError, '列数が多すぎます.' if columns.length > 50
unless rows.all? { |row| Array(row['values']).length <= columns.length &&
Array(row['values']).all? { _1.is_a?(String) && _1.bytesize <= PostImportSourceParser::MAX_CELL_BYTES } }
raise ArgumentError, '解析結果のセルが不正です.'
end
raise ArgumentError, '解析結果が大きすぎます.' if rows.sum { Array(_1['values']).sum(&:bytesize) } > PostImportSourceParser::MAX_BYTES
parsed_rows = rows.map do |row|
source_row = Integer(row['source_row'], exception: false)
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
{ source_row:, values: Array(row['values']) }
end
raise ArgumentError, '元行番号が重複しています.' if parsed_rows.map { _1[:source_row] }.uniq.length != parsed_rows.length
{ columns: columns.map { |column| column.slice('key', 'label') }, rows: parsed_rows }
end
def mappings_params
mappings = params[:mappings]
return { } if mappings.blank?
raise ArgumentError, '列割当ての形式が不正です.' unless mappings.is_a?(ActionController::Parameters)
raw = mappings.to_unsafe_h
unknown_fields = raw.keys - PostImportPreviewer::FIELDS
raise ArgumentError, '列割当て先が不正です.' if unknown_fields.present?
permitted = mappings.permit(*PostImportPreviewer::FIELDS.map { |field|
{ field => [:kind, :value, { columns: [] }] }
}).to_h
raw.each_with_object({ }) do |(field, value), result|
raise ArgumentError, '列割当ての形式が不正です.' unless value.is_a?(Hash)
raise ArgumentError, '列割当ての項目が不正です.' unless (value.keys - ['kind', 'value', 'columns']).empty?
value = permitted.fetch(field)
kind = value['kind'].presence || 'columns'
raise ArgumentError, '列割当ての種別が不正です.' unless kind.in?(['columns', 'fixed'])
columns = value['columns']
unless columns.nil? || (columns.is_a?(Array) && columns.all? { |index| Integer(index, exception: false)&.>= 0 })
raise ArgumentError, '列番号が不正です.'
end
raise ArgumentError, '固定値が不正です.' if kind == 'fixed' && !value['value'].is_a?(String)
result[field] = { 'kind' => kind, 'value' => value['value'].to_s,
'columns' => Array(columns).map { Integer(_1) } }
end
end
def validate_rows_params
rows = Array(params[:rows])
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportSourceParser::MAX_ROWS
normalised_rows = rows.map do |row|
value = ActionController::Parameters.new(row).permit(:source_row, :sourceRow, :url, :metadata_url, :metadataUrl,
attributes: {}, provenance: {}, tag_sources: {},
fetch_warnings: [], validation_warnings: [])
normalised = value.to_h.deep_transform_keys { _1.to_s.underscore }
attributes = normalised.fetch('attributes', { })
provenance = normalised.fetch('provenance', { })
allowed = PostImportPreviewer::FIELDS
unless attributes.is_a?(Hash) && provenance.is_a?(Hash) &&
(attributes.keys - allowed).empty? &&
(provenance.keys - (allowed + ['url'])).empty?
raise ArgumentError, '行の形式が不正です.'
end
unless provenance.values.all? { _1.in?(['automatic', 'mapped', 'fixed', 'manual']) }
raise ArgumentError, '値の由来が不正です.'
end
raise ArgumentError, 'セルが大きすぎます.' if [normalised['url'], *attributes.values].any? { _1.to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES }
if normalised['tag_sources'].present? &&
(!normalised['tag_sources'].is_a?(Hash) ||
(normalised['tag_sources'].keys - ['automatic', 'mapped', 'fixed', 'manual']).present?)
raise ArgumentError, 'タグ由来の形式が不正です.'
end
if normalised['tag_sources'].present? &&
(!normalised['tag_sources'].values.all?(String) ||
normalised['tag_sources'].values.any? { _1.bytesize > PostImportSourceParser::MAX_CELL_BYTES } ||
normalised['tag_sources'].values.sum(&:bytesize) > PostImportSourceParser::MAX_CELL_BYTES)
raise ArgumentError, 'タグ由来が大きすぎます.'
end
source_row = Integer(normalised['source_row'], exception: false)
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
normalised['source_row'] = source_row
normalised.symbolize_keys
end
source_rows = normalised_rows.map { _1[:source_row] }
if source_rows.uniq.length != source_rows.length
raise ArgumentError, '元行番号が不正です.'
end
normalised_rows
end
def create_rows_params
rows = params[:rows]
raise ArgumentError, '取込行の形式が不正です.' unless rows.is_a?(Array)
rows.map do |row|
unless row.is_a?(Hash) || row.is_a?(ActionController::Parameters)
raise ArgumentError, '取込行の形式が不正です.'
end
parameters = row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row)
parameters.permit(:source_row, :sourceRow, :url, :metadata_url, :metadataUrl,
attributes: {}, provenance: {}, tag_sources: {}, tagSources: {}).to_h
end
end
end
+8 -32
ファイルの表示
@@ -142,37 +142,13 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
# TODO: サイトに応じて thumbnail_base 設定
title = params[:title].presence
url = params[:url]
thumbnail = params[:thumbnail]
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
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 = PostCreator.new(actor: current_user,
attributes: { title: params[:title], url: params[:url],
thumbnail: params[:thumbnail], tags: params[:tags],
original_created_from: params[:original_created_from],
original_created_before: params[:original_created_before],
parent_post_ids: parse_parent_post_ids,
video_ms: params[:video_ms], duration: params[:duration] }).create!
post.reload
render json: PostRepr.base(post), status: :created
@@ -182,7 +158,7 @@ class PostsController < ApplicationController
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
rescue PostCreator::VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
+44 -37
ファイルの表示
@@ -1,50 +1,57 @@
class PreviewController < ApplicationController
before_action :require_member!
def title
# TODO: # 既知サイトなら決まったフォーマットで title 取得するやぅに.
return head :unauthorized unless current_user
return render_bad_request('URL は必須です.') if params[:url].blank?
url = params[:url]
return render_bad_request('URL は必須です.') unless url.present?
unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url
end
html = URI.open(url, open_timeout: 5, read_timeout: 5).read
doc = Nokogiri::HTML.parse(html)
title = doc.at('title')&.text&.strip
render json: { title: title }
rescue => e
render json: { title: Preview::ThumbnailFetcher.title(params[:url]) }
rescue Preview::UrlSafety::UnsafeUrl => e
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
render_preview_error(e.message, :gateway_timeout)
rescue Preview::HttpFetcher::ResponseTooLarge => e
render_preview_error(e.message, :payload_too_large)
rescue Preview::HttpFetcher::FetchFailed => e
render_preview_error(e.message, :bad_gateway)
end
def thumbnail
# TODO: 既知ドメインであれば指定のアドレスからサムネールを取得するやぅにする.
return render_bad_request('URL は必須です.') if params[:url].blank?
image = MiniMagick::Image.read(Preview::ThumbnailFetcher.fetch(params[:url]))
image.auto_orient
image.resize '180x180>'
image.format 'png'
width, height = image.dimensions
raise Preview::ThumbnailFetcher::GenerationFailed, 'サムネール画像の変換に失敗しました.' if width > 180 || height > 180
send_data image.to_blob, type: 'image/png', disposition: 'inline'
rescue Preview::UrlSafety::UnsafeUrl => e
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
render_preview_error(e.message, :gateway_timeout)
rescue Preview::HttpFetcher::ResponseTooLarge => e
render_preview_error(e.message, :payload_too_large)
rescue Preview::HttpFetcher::FetchFailed => e
render_preview_error(e.message, :bad_gateway)
rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e
render_unprocessable_entity(e.message)
end
private
def require_member!
return head :unauthorized unless current_user
return if current_user.gte_member?
url = params[:url]
return render_bad_request('URL は必須です.') if url.blank?
head :forbidden
end
unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url
end
path = Rails.root.join('tmp', "thumb_#{ SecureRandom.hex }.png")
system("node #{ Rails.root }/lib/screenshot.js #{ Shellwords.escape(url) } #{ path }")
if File.exist?(path)
image = MiniMagick::Image.open(path)
image.resize '180x180'
File.delete(path) rescue nil
send_file image.path, type: 'image/png', disposition: 'inline'
else
render json: { type: 'internal_server_error',
message: 'サムネールを生成できませんでした.',
errors: { },
base_errors: ['サムネールを生成できませんでした.'] },
status: :internal_server_error
end
def render_preview_error(message, status)
render json: { type: status.to_s,
message:,
errors: { },
base_errors: [message] },
status:
end
end
+1 -10
ファイルの表示
@@ -173,15 +173,6 @@ class Post < ApplicationRecord
def normalise_url
return if url.blank?
self.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
;
self.url = PostUrlNormaliser.normalise(url) || url.strip
end
end
+112
ファイルの表示
@@ -0,0 +1,112 @@
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 { |begin_ms, end_ms| PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:) }
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
+61
ファイルの表示
@@ -0,0 +1,61 @@
class PostImportGoogleSheetsFetcher
HOST = 'docs.google.com'.freeze
PUBLICATION_ID = /\A[A-Za-z0-9_-]+\z/
GID = /\A\d+\z/
RATE_LIMIT = 10
RATE_WINDOW = 1.minute
def self.fetch! raw_url, rate_key:
new(raw_url, rate_key:).fetch!
end
def initialize raw_url, rate_key:
@raw_url = raw_url.to_s
@rate_key = rate_key.to_s
end
def fetch!
publication_id, gid = parse!
throttle!
csv_url = "https://#{ HOST }/spreadsheets/d/e/#{ publication_id }/pub?gid=#{ gid }&single=true&output=csv"
response = Preview::HttpFetcher.fetch(csv_url,
max_bytes: PostImportSourceParser::MAX_BYTES,
allowed_hosts: [HOST])
raise ArgumentError, 'スプレッドシートを CSV として取得できませんでした.' unless csv?(response)
response.body
rescue Preview::UrlSafety::UnsafeUrl, Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e
Rails.logger.info("post_import_google_sheets_fetch_failure #{ { error: e.class.name, message: e.message }.to_json }")
raise ArgumentError, 'スプレッドシートを取得できませんでした.ウェブ公開 URL を確認するか、ファイル保存又はコピー貼付けを使用してください.'
end
private
def parse!
uri = URI.parse(@raw_url)
unless uri.scheme == 'https' && uri.host&.downcase == HOST && uri.query.blank?
raise ArgumentError, '公開 Google スプレッドシート URL を指定してください.'
end
match = uri.path.match(%r{\A/spreadsheets/d/e/([^/]+)/pubhtml\z})
gid = URI.decode_www_form(uri.fragment.to_s).to_h['gid']
unless match && PUBLICATION_ID.match?(match[1]) && GID.match?(gid.to_s)
raise ArgumentError, '公開 Google スプレッドシート URL の publication_id 又は gid が不正です.'
end
[match[1], gid]
rescue URI::InvalidURIError
raise ArgumentError, '公開 Google スプレッドシート URL が不正です.'
end
def throttle!
key = "post-import-google-sheets:#{ @rate_key }"
count = Rails.cache.increment(key, 1, expires_in: RATE_WINDOW, initial: 0)
raise ArgumentError, 'スプレッドシート取得の回数が多すぎます.しばらくしてから再試行してください.' if count > RATE_LIMIT
end
def csv? response
type = response.content_type.to_s.downcase
type.include?('text/csv') || type.include?('application/csv')
end
end
+196
ファイルの表示
@@ -0,0 +1,196 @@
class PostImportPreviewer
FIELDS = %w[title thumbnail_base original_created_from original_created_before duration tags parent_post_ids].freeze
def initialize parsed:, url_column:, mappings: { }, existing: 'skip'
@parsed = parsed
@url_column = url_column.to_i
@mappings = mappings.stringify_keys
@existing = existing == 'error' ? 'error' : 'skip'
end
def preview
preview_rows(rows: @parsed[:rows])
end
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
urls = { }
rows.map do |row|
row = row.symbolize_keys
values = row[:values] || []
attributes = row[:attributes]&.stringify_keys || FIELDS.to_h { |field| [field, mapped_value(field, values)] }
provenance = row[:provenance]&.stringify_keys || mapped_provenance(attributes)
tag_sources = row[:tag_sources]&.stringify_keys || initial_tag_sources(attributes, provenance)
tag_sources['manual'] = attributes['tags'].to_s if provenance['tags'] == 'manual'
url = row[:url].presence || values[@url_column].to_s.strip
url_for_metadata = normalised_url(url) || url
existing_post = normalised_url(url).present? && Post.exists?(url: normalised_url(url))
if existing_post && @existing == 'skip'
next { source_row: row[:source_row], url: normalised_url(url) || url, attributes:,
provenance:, tag_sources:, metadata_url: url_for_metadata,
fetch_warnings: [], validation_warnings: ['既存投稿のためスキップします.'],
warnings: ['既存投稿のためスキップします.'], validation_errors: { },
status: 'warning', existing: true }
end
url_changed = row[:metadata_url].present? && row[:metadata_url] != url_for_metadata
clear_automatic_values!(attributes, provenance, tag_sources) if url_changed
provenance['url'] ||= row[:url].present? ? 'manual' : 'mapped'
attributes['url'] = url
fetch_warnings = Array(row[:fetch_warnings]).dup
should_fetch =
case fetch_metadata
when true then true
when Integer then fetch_metadata == row[:source_row].to_i
when false, nil then false
else raise ArgumentError, 'メタデータ取得対象が不正です.'
end
metadata, fetch_warnings = metadata_for(url_for_metadata, should_fetch, metadata_cache, fetch_warnings)
metadata.each do |field, value|
if field == 'tags' && provenance[field] != 'manual'
tag_sources['automatic'] = value.to_s
attributes[field] = merged_tags(tag_sources)
next
end
next unless provenance[field] == 'automatic' || attributes[field].blank?
attributes[field] = value
provenance[field] = 'automatic'
end
normal_url = normalised_url(url)
errors = { }
errors[:url] = ['URL が不正です.'] if normal_url.blank?
validation_warnings = []
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
@existing == 'error' ? errors[:url] = ['既存投稿です.'] : validation_warnings << '既存投稿のためスキップします.'
end
validate_basic_data(attributes, errors)
validate_preview_tags(merged_tags(tag_sources, provenance['tags']), errors, validation_warnings)
validate_parents(attributes['parent_post_ids'], 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: errors,
status: errors.present? ? 'error' : ((fetch_warnings + validation_warnings).present? ? 'warning' : 'ready'),
existing: normal_url.present? && Post.exists?(url: normal_url) }
end
end
def metadata_for url, fetch_metadata, cache, previous_warnings
return [{ }, previous_warnings] unless fetch_metadata
cache[url] ||= fetch_metadata(url)
end
def normalised_url value
PostUrlNormaliser.normalise(value)
end
private
def mapped_value field, values
mapping = @mappings[field]
return '' if mapping.blank?
return mapping['value'].to_s if mapping['kind'] == 'fixed'
Array(mapping['columns']).filter_map { |index| values[index.to_i].to_s.presence }.join(field == 'tags' ? ' ' : '')
end
def mapped_provenance attributes
attributes.to_h.to_h { |field, value|
mapping = @mappings[field.to_s]
origin =
if value.present? && mapping&.[]('kind') == 'fixed'
'fixed'
elsif value.present?
'mapped'
else
'automatic'
end
[field.to_s, origin]
}
end
def initial_tag_sources attributes, provenance
origin = provenance['tags'] == 'fixed' ? 'fixed' : 'mapped'
{ 'automatic' => '', 'mapped' => origin == 'mapped' ? attributes['tags'].to_s : '',
'fixed' => origin == 'fixed' ? attributes['tags'].to_s : '', 'manual' => '' }
end
def merged_tags sources, origin = nil
return sources['manual'].to_s if origin == 'manual'
%w[automatic mapped fixed manual].flat_map { sources[_1].to_s.split }.uniq.join(' ')
end
def clear_automatic_values! attributes, provenance, tag_sources
%w[title thumbnail_base original_created_from original_created_before duration].each do |field|
attributes[field] = '' if provenance[field] == 'automatic'
end
tag_sources['automatic'] = ''
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 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 }
errors[:parent_post_ids] = ['存在しない親投稿 Id. があります.'] if Post.where(id: ids).count != ids.uniq.length
end
end
+100
ファイルの表示
@@ -0,0 +1,100 @@
class PostImportRunner
MAX_ROWS = PostImportSourceParser::MAX_ROWS
ATTRIBUTE_KEYS = %w[title thumbnail_base original_created_from original_created_before duration tags parent_post_ids video_ms].freeze
def initialize actor:, rows:, existing: 'skip'
@actor = actor
@rows = rows
@existing = existing == 'error' ? 'error' : 'skip'
end
def run
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if @rows.length > MAX_ROWS
raise ArgumentError, '取込データが大きすぎます.' if @rows.sum { |row| row.to_json.bytesize } > PostImportSourceParser::MAX_BYTES
normalised_rows = @rows.map { |row| normalise_row(row) }
source_rows = normalised_rows.map { _1['source_row'] }
raise ArgumentError, '元行番号が重複しています.' if source_rows.uniq.length != source_rows.length
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
validate_row_structure!(row)
attributes = row.fetch('attributes', { }).to_h.transform_keys { _1.to_s.underscore }
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_KEYS).empty?
raise ArgumentError, '取込項目が大きすぎます.' if attributes.values.any? { _1.to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES }
raise ArgumentError, 'URL が長すぎます.' if row['url'].to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES
validate_tag_sources!(row['tag_sources'])
preview = PostImportPreviewer.new(parsed: { columns: [], rows: [] }, url_column: 0,
mappings: {}, 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 preview[:validation_errors].present?
return { source_row: row['source_row'], status: 'failed',
errors: preview[:validation_errors] }
end
return row.slice('source_row').merge(status: 'skipped') if @existing == 'skip' && preview[:existing]
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
def validate_tag_sources! sources
return if sources.blank?
sources = sources.to_h
raise ArgumentError unless (sources.keys - %w[automatic mapped fixed manual]).empty?
raise ArgumentError unless sources.values.all?(String)
raise ArgumentError if sources.values.any? { _1.bytesize > PostImportSourceParser::MAX_CELL_BYTES }
raise ArgumentError if sources.values.sum(&:bytesize) > PostImportSourceParser::MAX_CELL_BYTES
end
def validate_row_structure! row
raise ArgumentError unless row['source_row'].is_a?(Integer) && row['source_row'].positive?
raise ArgumentError unless row['url'].is_a?(String)
raise ArgumentError unless row['attributes'].is_a?(Hash)
raise ArgumentError unless row['attributes'].values.all? { _1.is_a?(String) || _1.is_a?(Numeric) || _1 == true || _1 == false || _1.nil? }
provenance = row['provenance']
raise ArgumentError unless provenance.is_a?(Hash)
allowed = ATTRIBUTE_KEYS + ['url']
raise ArgumentError unless (provenance.keys - allowed).empty?
raise ArgumentError unless provenance.values.all? { _1.in?(['automatic', 'mapped', 'fixed', 'manual']) }
metadata_url = row['metadata_url']
raise ArgumentError unless metadata_url.nil? || metadata_url.is_a?(String)
raise ArgumentError if metadata_url.to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES
raise ArgumentError if row.to_json.bytesize > PostImportSourceParser::MAX_BYTES
tag_sources = row['tag_sources']
raise ArgumentError unless tag_sources.nil? || tag_sources.is_a?(Hash)
end
def normalise_row row
unless row.is_a?(Hash)
raise ArgumentError, '取込行の形式が不正です.'
end
normalised = row.deep_transform_keys { _1.to_s.underscore }
source_row = Integer(normalised['source_row'], exception: false)
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
normalised['source_row'] = source_row
normalised
end
end
+100
ファイルの表示
@@ -0,0 +1,100 @@
require 'csv'
class PostImportSourceParser
MAX_ROWS = 100
MAX_BYTES = 1.megabyte
MAX_CELL_BYTES = 20.kilobytes
MAX_COLUMNS = 50
def initialize source:, format:, has_header: false, json_path: nil
@source = source.to_s
@format = format.to_s
@has_header = ActiveModel::Type::Boolean.new.cast(has_header)
@json_path = json_path.to_s
end
def parse
raise ArgumentError, '入力が大きすぎます.' if @source.bytesize > MAX_BYTES
@format == 'json' ? parse_json : parse_delimited
end
private
def parse_delimited
separator = @format == 'csv' ? ',' : "\t"
table = CSV.parse(@source, col_sep: separator).map { |row| row.map { _1.to_s.strip } }
raise ArgumentError, '入力が空です.' if table.empty?
headers = @has_header ? table.shift : nil
ensure_limits!(table)
width = table.map(&:length).max.to_i
raise ArgumentError, "列数は #{ MAX_COLUMNS } 列までです." if width > MAX_COLUMNS
{ columns: (0...width).map { |index| { key: index.to_s,
label: "#{ column_name(index) }#{ headers&.[](index).presence || '列' }" } },
rows: table.each_with_index.map { |values, index| { source_row: index + (@has_header ? 2 : 1), values: } } }
rescue CSV::MalformedCSVError => e
raise ArgumentError, "CSV・TSV の形式が不正です: #{ e.message }"
end
def parse_json
value = JSON.parse(@source, max_nesting: 20)
@json_path.split('.').reject(&:blank?).each do |part|
value =
case value
when Array
index = Integer(part, exception: false)
raise ArgumentError if index.nil? || index.negative? || index >= value.length
value[index]
when Hash
raise ArgumentError unless value.key?(part) || value.key?(part.to_sym)
value.key?(part) ? value[part] : value[part.to_sym]
else
raise ArgumentError
end
end
raise ArgumentError, 'JSON の投稿候補は配列にしてください.' unless value.is_a?(Array)
ensure_limits!(value)
unless value.all?(Hash)
invalid = value.each_with_index.filter_map { |item, index| index + 1 unless item.is_a?(Hash) }
raise ArgumentError, "JSON の投稿候補は object の配列にしてください: 行 #{ invalid.join(', ') }"
end
keys = value.flat_map(&:keys).map(&:to_s).uniq
raise ArgumentError, "列数は #{ MAX_COLUMNS } 列までです." if keys.length > MAX_COLUMNS
{ columns: keys.each_with_index.map { |key, index| { key:, label: "#{ column_name(index) }#{ key }" } },
rows: value.each_with_index.map { |item, index| { source_row: index + 1,
values: keys.map { |key|
value = item.key?(key) ? item[key] : item[key.to_sym]
json_cell(value)
} } } }
rescue JSON::ParserError, KeyError, ArgumentError => e
raise ArgumentError, "JSON の形式が不正です: #{ e.message }"
end
def ensure_limits! rows
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
raise ArgumentError, 'セルが大きすぎます.' if rows.flatten.any? { _1.to_s.bytesize > MAX_CELL_BYTES }
end
def json_cell value
case value
when nil then ''
when String, Numeric, TrueClass, FalseClass then value.to_s
when Array, Hash then JSON.generate(value)
else raise ArgumentError, 'JSON のセル値が不正です.'
end
end
def column_name index
name = +''
loop do
name.prepend((65 + (index % 26)).chr)
index = (index / 26) - 1
break if index < 0
end
name
end
end
+49
ファイルの表示
@@ -0,0 +1,49 @@
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 = -> 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
+13
ファイルの表示
@@ -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
+21
ファイルの表示
@@ -0,0 +1,21 @@
module Preview
class HtmlMetadataExtractor
IMAGE_SELECTORS = [
'meta[property="og:image"]',
'meta[name="twitter:image"]',
'meta[name="thumbnail"]'
].freeze
def self.extract(response)
document = Nokogiri::HTML.parse(response.body)
image_url = IMAGE_SELECTORS.filter_map {
document.at_css(_1)&.[]('content')&.strip.presence
}.first
{ title: document.at_css('title')&.text&.strip,
image_url: image_url ? URI.join(response.url, image_url).to_s : nil }
rescue URI::InvalidURIError
{ title: document.at_css('title')&.text&.strip, image_url: nil }
end
end
end
+126
ファイルの表示
@@ -0,0 +1,126 @@
require 'net/http'
require 'json'
module Preview
class HttpFetcher
class FetchFailed < StandardError; end
class FetchTimeout < FetchFailed; end
class ResponseTooLarge < FetchFailed; end
MAX_REDIRECTS = 5
DEFAULT_MAX_BYTES = 5.megabytes
Response = Data.define(:body, :content_type, :url)
def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS,
allowed_hosts: nil)
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)
if response.is_a?(Net::HTTPRedirection)
location = response['location']
if redirects.zero?
log_failure(:redirect_limit,
url: uri.to_s,
redirects:,
location:,
content_type: response['content-type'],
content_length: response['content-length'])
raise FetchFailed, 'redirect が多すぎます.'
end
if location.blank?
log_failure(:blank_redirect_location,
url: uri.to_s,
redirects:,
content_type: response['content-type'],
content_length: response['content-length'])
raise FetchFailed, 'redirect 先が不正です.'
end
redirect_url =
begin
URI.join(uri, location).to_s
rescue URI::InvalidURIError => e
log_failure(:invalid_redirect_location,
url: uri.to_s,
redirects:,
location:,
error: e.class.name,
message: e.message)
raise FetchFailed, 'redirect 先が不正です.'
end
return fetch(redirect_url, max_bytes:, redirects: redirects - 1, allowed_hosts:)
end
unless response.is_a?(Net::HTTPSuccess)
log_failure(:http_status,
url: uri.to_s,
code: response.code,
content_type: response['content-type'],
content_length: response['content-length'])
raise FetchFailed, "外部サーバーが HTTP #{ response.code } を返しました."
end
Response.new(response.body, response['content-type'].to_s, uri.to_s)
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
raise FetchTimeout, e.message
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)
raise FetchFailed, e.message
end
def self.request(uri, ip_address, max_bytes)
http = Net::HTTP.new(uri.host, uri.port)
http.ipaddr = ip_address
http.use_ssl = uri.scheme == 'https'
http.open_timeout = 5
http.read_timeout = 8
http.write_timeout = 5
request = Net::HTTP::Get.new(uri)
request['User-Agent'] = 'BTRC-Hub thumbnail preview'
request['Accept'] = 'text/html,image/*;q=0.9,*/*;q=0.1'
http.request(request) do |response|
length = response['content-length'].to_i
if length > max_bytes
log_failure(:response_too_large,
url: uri.to_s,
content_type: response['content-type'],
content_length: response['content-length'],
max_bytes:)
raise ResponseTooLarge, '外部データが大きすぎます.'
end
body = +''
response.read_body do |chunk|
body << chunk
next unless body.bytesize > max_bytes
log_failure(:response_too_large,
url: uri.to_s,
content_type: response['content-type'],
content_length: response['content-length'],
bytes_read: body.bytesize,
max_bytes:)
raise ResponseTooLarge, '外部データが大きすぎます.'
end
response.instance_variable_set(:@body, body)
response.instance_variable_set(:@read, true)
return response
end
end
def self.log_failure(reason, **payload)
Rails.logger.warn("preview_http_fetcher_failure #{ { reason:, **payload }.to_json }")
end
private_class_method :request, :log_failure
end
end
+34
ファイルの表示
@@ -0,0 +1,34 @@
module Preview
class KnownSiteExtractor
def self.thumbnail_url(uri)
youtube_thumbnail(uri)
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)
case uri.host&.downcase
when 'www.nicovideo.jp', 'nicovideo.jp'
uri.path[%r{\A/watch/(sm\d+)\z}, 1]
when 'nico.ms'
uri.path[%r{\A/(sm\d+)\z}, 1]
end
end
def self.youtube_thumbnail(uri)
id = youtube_video_id(uri)
return unless id
"https://i.ytimg.com/vi/#{ id }/hqdefault.jpg"
end
private_class_method :youtube_thumbnail
end
end
+95
ファイルの表示
@@ -0,0 +1,95 @@
module Preview
class ThumbnailFetcher
class GenerationFailed < StandardError; end
ALLOWED_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze
HTML_MAX_BYTES = 1.megabyte
NICONICO_XML_MAX_BYTES = 256.kilobytes
def self.fetch(raw_url)
uri, = UrlSafety.validate(raw_url)
known_url = KnownSiteExtractor.thumbnail_url(uri)
image = fetch_image_or_nil(known_url) if known_url
return image if image
niconico_url = niconico_thumbnail_url(uri)
image = fetch_image_or_nil(niconico_url) if niconico_url
return image if image
page = HttpFetcher.fetch(uri.to_s, max_bytes: HTML_MAX_BYTES)
metadata = HtmlMetadataExtractor.extract(page)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.' if metadata[:image_url].blank?
fetch_image!(metadata[:image_url])
end
def self.title(raw_url)
uri, = UrlSafety.validate(raw_url)
HtmlMetadataExtractor.extract(
HttpFetcher.fetch(uri.to_s, max_bytes: HTML_MAX_BYTES))[:title]
end
def self.fetch_image_or_nil(url)
return nil if url.blank?
response = HttpFetcher.fetch(url)
return nil unless allowed_image_content_type?(response.content_type)
response.body
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::FetchFailed
nil
end
def self.fetch_image!(url)
response = HttpFetcher.fetch(url)
unless allowed_image_content_type?(response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
response.body
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::ResponseTooLarge
raise
rescue HttpFetcher::FetchFailed
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
end
def self.niconico_thumbnail_url(uri)
video_id = KnownSiteExtractor.niconico_video_id(uri)
return nil if video_id.blank?
response = HttpFetcher.fetch("https://ext.nicovideo.jp/api/getthumbinfo/#{ video_id }",
max_bytes: NICONICO_XML_MAX_BYTES)
xml = Nokogiri::XML(response.body)
return nil unless xml.at_xpath('/nicovideo_thumb_response/@status')&.value == 'ok'
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
nil
rescue Nokogiri::XML::SyntaxError => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
nil
end
def self.allowed_image_content_type?(content_type)
mime_type = content_type.to_s.split(';', 2).first.downcase.strip
ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type)
end
private_class_method :fetch_image_or_nil, :fetch_image!,
:niconico_thumbnail_url,
:allowed_image_content_type?
end
end
+55
ファイルの表示
@@ -0,0 +1,55 @@
require 'resolv'
require 'ipaddr'
require 'uri'
module Preview
class UrlSafety
class UnsafeUrl < StandardError; end
FORBIDDEN_NETWORKS = [
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8',
'169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24',
'192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15',
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4',
'240.0.0.0/4', '::/128', '::1/128', 'fc00::/7', 'fe80::/10',
'ff00::/8', '2001:db8::/32', '::ffff:0:0/96'
].map { IPAddr.new(_1) }.freeze
def self.validate(raw_url)
value = raw_url.to_s.strip
if value.match?(/\A[a-z][a-z0-9+\-.]*:/i)
unless value.match?(/\Ahttps?:\/\//i)
raise UnsafeUrl, 'http または https の URL を指定してください.'
end
else
value = "http://#{ value }"
end
uri = URI.parse(value)
unless ['http', 'https'].include?(uri.scheme&.downcase) && uri.host.present?
raise UnsafeUrl, 'http または https の URL を指定してください.'
end
raise UnsafeUrl, 'userinfo つき URL は使用できません.' if uri.userinfo.present?
addresses = Resolv.getaddresses(uri.host)
raise UnsafeUrl, 'URL のホストを解決できません.' if addresses.empty?
parsed_addresses = addresses.map { IPAddr.new(_1) }
if parsed_addresses.any? { |address| forbidden?(address) }
raise UnsafeUrl, '安全でない接続先は使用できません.'
end
[uri, parsed_addresses.map(&:to_s)]
rescue Resolv::ResolvError
raise UnsafeUrl, 'URL のホストを解決できません.'
rescue URI::InvalidURIError, IPAddr::InvalidAddressError
raise UnsafeUrl, 'URL が不正です.'
end
def self.forbidden?(address)
FORBIDDEN_NETWORKS.any? { _1.include?(address) }
end
private_class_method :forbidden?
end
end
+7
ファイルの表示
@@ -33,6 +33,13 @@ Rails.application.routes.draw do
get :thumbnail
end
scope 'posts/import', controller: :post_imports do
post :parse
post :preview
post :validate
post '', action: :create
end
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
collection do
get :search
-1102
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
-15
ファイルの表示
@@ -1,15 +0,0 @@
{
"name": "lib",
"version": "1.0.0",
"main": "screenshot.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"puppeteer": "^24.10.0"
}
}
-18
ファイルの表示
@@ -1,18 +0,0 @@
const puppeteer = require ('puppeteer')
const fs = require ('fs')
void (async () => {
const url = process.argv[2]
const output = process.argv[3]
const browser = await puppeteer.launch ({
args: ['--no-sandbox', '--disable-setuid-sandbox'] })
const page = await browser.newPage ()
await page.setViewport ({ width: 960, height: 960 })
await page.goto (url, { waitUntil: 'networkidle2', timeout: 15000 })
await page.screenshot ({ path: output })
await browser.close ()
}) ()
+89
ファイルの表示
@@ -287,6 +287,25 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
end
it 'creates a create tag_version for a newly created material tag' do
expect do
post '/materials', params: {
tag: 'material_create_versioned_tag',
file: dummy_upload(filename: 'created.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:created)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_create_versioned_tag' })
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_create_versioned_tag')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'snapshots attached file metadata and sha256' do
post '/materials', params: {
tag: 'material_create_file_version',
@@ -466,6 +485,73 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_new')
end
it 'creates a create tag_version when update creates a new tag' do
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_versioned_tag',
file: dummy_upload(filename: 'updated.png')
}
end.to change(Tag, :count).by(1)
.and change(TagName, :count).by(1)
.and change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_update_versioned_tag' })
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_versioned_tag')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'backfills a create tag_version for an existing material tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'),
category: :material)
expect(existing_tag.tag_versions).to be_empty
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_existing_no_history',
file: dummy_upload(filename: 'updated.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = existing_tag.reload.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_existing_no_history')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'backfills a create tag_version for an existing character tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'),
category: :character)
expect(existing_tag.tag_versions).to be_empty
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_character_no_history',
file: dummy_upload(filename: 'updated.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = existing_tag.reload.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_character_no_history')
expect(version.category).to eq('character')
expect(version.created_by_user).to eq(member_user)
end
it 'detaches the existing file without purging blob when url replaces file' do
old_blob_id = material.file.blob.id
@@ -494,6 +580,7 @@ RSpec.describe 'Materials API', type: :request do
it 'does not increase version for the same snapshot update' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
@@ -501,6 +588,8 @@ RSpec.describe 'Materials API', type: :request do
}
end.not_to change(MaterialVersion, :count)
expect(tag.reload.tag_versions.count).to eq(1)
expect(response).to have_http_status(:ok)
expect(material.reload.version_no).to eq(1)
end
+32 -14
ファイルの表示
@@ -1,28 +1,46 @@
require "rails_helper"
require 'rails_helper'
RSpec.describe "Preview", type: :request do
describe "GET /preview/title" do
it "401 unless logged in" do
RSpec.describe 'Preview', type: :request do
describe 'GET /preview/title' do
it '401 unless logged in' do
sign_out
get "/preview/title", params: { url: "example.com" }
get '/preview/title', params: { url: 'example.com' }
expect(response).to have_http_status(:unauthorized)
end
it "400 when url blank" do
sign_in_as(create(:user))
get "/preview/title", params: { url: "" }
it '403 when logged in as guest' do
sign_in_as(create(:user, :guest))
get '/preview/title', params: { url: 'example.com' }
expect(response).to have_http_status(:forbidden)
end
it '400 when url blank' do
sign_in_as(create(:user, :member))
get '/preview/title', params: { url: '' }
expect(response).to have_http_status(:bad_request)
end
it "returns parsed title (stubbing URI.open)" do
sign_in_as(create(:user))
fake_html = "<html><head><title> Hello </title></head></html>"
allow(URI).to receive(:open).and_return(StringIO.new(fake_html))
it 'returns parsed title' do
sign_in_as(create(:user, :member))
allow(Preview::ThumbnailFetcher)
.to receive(:title)
.with('example.com')
.and_return('Hello')
get "/preview/title", params: { url: "example.com" }
get '/preview/title', params: { url: 'example.com' }
expect(response).to have_http_status(:ok)
expect(json["title"]).to eq("Hello")
expect(json['title']).to eq('Hello')
end
it '413 when fetched response is too large' do
sign_in_as(create(:user, :member))
allow(Preview::ThumbnailFetcher)
.to receive(:title)
.and_raise(Preview::HttpFetcher::ResponseTooLarge, '外部データが大きすぎます.')
get '/preview/title', params: { url: 'example.com' }
expect(response).to have_http_status(:payload_too_large)
end
end
end
+23
ファイルの表示
@@ -0,0 +1,23 @@
require 'rails_helper'
RSpec.describe Preview::HttpFetcher do
describe '.fetch' do
it 'raises FetchFailed when redirect location is invalid' do
redirect = Net::HTTPFound.new('1.1', '302', 'Found')
redirect['location'] = 'http://[invalid'
allow(Preview::UrlSafety).to receive(:validate)
.with('https://example.com/page')
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(described_class).to receive(:request)
.and_return(redirect)
allow(Rails.logger).to receive(:warn)
expect {
described_class.fetch('https://example.com/page')
}.to raise_error(Preview::HttpFetcher::FetchFailed)
expect(Rails.logger).to have_received(:warn)
.with(/invalid_redirect_location/)
end
end
end
+51
ファイルの表示
@@ -0,0 +1,51 @@
require 'rails_helper'
RSpec.describe Preview::ThumbnailFetcher do
describe '.fetch' do
it 'rejects svg thumbnails' do
page = Preview::HttpFetcher::Response.new(
'<meta property="og:image" content="https://example.com/thumb.svg">',
'text/html',
'https://example.com/page')
svg = Preview::HttpFetcher::Response.new(
'<svg></svg>',
'image/svg+xml',
'https://example.com/thumb.svg')
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.svg')
.and_return(svg)
expect {
described_class.fetch('https://example.com/page')
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
end
it 'accepts allowed image content type with parameters' do
page = Preview::HttpFetcher::Response.new(
'<meta property="og:image" content="https://example.com/thumb.jpg">',
'text/html',
'https://example.com/page')
image = Preview::HttpFetcher::Response.new(
'jpeg-bytes',
'image/jpeg; charset=binary',
'https://example.com/thumb.jpg')
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/thumb.jpg')
.and_return(image)
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
end
end
end
+15
ファイルの表示
@@ -0,0 +1,15 @@
require 'rails_helper'
RSpec.describe Preview::UrlSafety do
describe '.validate' do
it 'raises UnsafeUrl when DNS resolution fails' do
allow(Resolv).to receive(:getaddresses)
.with('missing.example')
.and_raise(Resolv::ResolvError)
expect {
described_class.validate('https://missing.example')
}.to raise_error(Preview::UrlSafety::UnsafeUrl)
end
end
end
+3
ファイルの表示
@@ -43,6 +43,7 @@ import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostListPage from '@/pages/posts/PostListPage'
import PostNewPage from '@/pages/posts/PostNewPage'
import PostImportPage from '@/pages/posts/PostImportPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage'
@@ -75,6 +76,7 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<PostImportPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
@@ -113,6 +115,7 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<PostImportPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
+75
ファイルの表示
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const dndKit = vi.hoisted (() => ({
useDraggable: vi.fn (),
useDroppable: vi.fn (),
}))
vi.mock ('@dnd-kit/core', () => dndKit)
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
const renderRow = (activeDndId?: string) => {
renderWithProviders (
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={2}
pathKey="cat-general-7"
suppressClickRef={{ current: false }}/>,
)
}
const tagBody = (): HTMLElement => {
const link = document.querySelector<HTMLElement> ('a[title="ドラッグ元"]')
if (link == null)
throw new Error ('tag link not found')
const body = link.closest ('div')
if (body == null)
throw new Error ('tag body not found')
return body
}
describe ('DraggableDroppableTagRow', () => {
beforeEach (() => {
vi.clearAllMocks ()
dndKit.useDraggable.mockReturnValue ({
attributes: { 'aria-describedby': 'drag-source' },
listeners: { onPointerDown: vi.fn () },
setNodeRef: vi.fn (),
transform: null })
dndKit.useDroppable.mockReturnValue ({
isOver: false,
setNodeRef: vi.fn () })
})
it ('passes dndId through draggable data for active-row tracking', () => {
renderRow ()
expect (dndKit.useDraggable).toHaveBeenCalledWith (
expect.objectContaining ({
id: 'tag-node:cat-general-7',
data: expect.objectContaining ({
dndId: 'tag-node:cat-general-7',
nestLevel: 2,
tagId: 7 }) }))
})
it ('hides only the active drag source from the explicit active dnd id', () => {
renderRow ('tag-node:cat-general-7')
expect (tagBody ()).toHaveStyle ({ visibility: 'hidden' })
})
it ('keeps inactive tag rows visible while another tag is dragged', () => {
renderRow ('tag-node:other')
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
})
})
+51 -22
ファイルの表示
@@ -13,6 +13,7 @@ import type { CSSProperties, FC, MutableRefObject } from 'react'
import type { Tag } from '@/types'
type Props = {
activeDndId?: string
tag: Tag
nestLevel: number
pathKey: string
@@ -21,7 +22,15 @@ type Props = {
sp?: boolean }
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => {
const DraggableDroppableTagRow: FC<Props> = ({
activeDndId,
tag,
nestLevel,
pathKey,
parentTagId,
suppressClickRef,
sp,
}) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
@@ -53,18 +62,23 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
const { attributes,
listeners,
setNodeRef: setDragRef,
transform,
isDragging: dragging } = useDraggable ({ id: dndId,
data: { kind: 'tag',
tagId: tag.id,
parentTagId } })
transform } = useDraggable ({ id: dndId,
data: { kind: 'tag',
dndId,
tagId: tag.id,
parentTagId,
nestLevel } })
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
id: dndId,
data: { kind: 'tag', tagId: tag.id } })
const activeDragging = activeDndId === dndId
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
visibility: dragging ? 'hidden' : 'visible' }
visibility: activeDragging ? 'hidden' : 'visible' }
const innerClassName = cn (
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
sp && 'touch-pan-y')
return (
<div
@@ -77,12 +91,16 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
return
const dx = e.clientX - p.x
const dy = e.clientY - p.y
if (dx * dx + dy * dy >= 9)
armEatNextClick ()
}}
onPointerUpCapture={() => {
downPosRef.current = null
}}
onPointerCancelCapture={() => {
downPosRef.current = null
}}
onClickCapture={e => {
if (suppressClickRef.current)
{
@@ -90,24 +108,35 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
e.stopPropagation ()
}
}}
ref={node => {
setDragRef (node)
setDropRef (node)
}}
style={style}
ref={setDropRef}
className={cn (
'min-w-0 max-w-full overflow-hidden rounded select-none',
sp && 'touch-pan-y',
over && 'ring-2 ring-offset-2')}
{...attributes}
{...listeners}>
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: layoutTransition }}
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>
>
{activeDragging
? (
<div
ref={setDragRef}
style={style}
className={innerClassName}
{...attributes}
{...listeners}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</div>)
: (
<motion.div
ref={setDragRef}
style={style}
className={innerClassName}
{...attributes}
{...listeners}
transition={{ layout: layoutTransition }}
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>)}
</div>)
}
+86 -21
ファイルの表示
@@ -1,11 +1,13 @@
import { DndContext,
DragOverlay,
MeasuringStrategy,
MouseSensor,
TouchSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors } from '@dnd-kit/core'
import { restrictToWindowEdges } from '@dnd-kit/modifiers'
import { restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers'
import { useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -25,15 +27,23 @@ import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core'
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, TagWithSections } from '@/types'
type TagByCategory = { [key in Category]: TagWithSections[] }
const tagCollisionDetection: CollisionDetection = args => {
return pointerWithin (args)
}
const alwaysMeasureDroppables = {
droppable: { strategy: MeasuringStrategy.Always } } as const
const renderTagTree = (
activeDndId: string | undefined,
tag: TagWithSections,
nestLevel: number,
path: string,
@@ -45,6 +55,7 @@ const renderTagTree = (
const self = (
<li key={key} className="mb-1">
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={nestLevel}
pathKey={key}
@@ -58,7 +69,14 @@ const renderTagTree = (
...((tag.children
?.sort ((a, b) => a.name < b.name ? -1 : 1)
.flatMap (child =>
renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp)))
renderTagTree (
activeDndId,
child,
nestLevel + 1,
key,
suppressClickRef,
tag.id,
sp)))
?? [])]
}
@@ -177,12 +195,35 @@ const DropSlot = ({ cat }: { cat: Category }) => {
</li>)
}
const EmptyCategoryDropSection = (
{ cat, children }: { cat: Category
children: ReactNode }) => {
const { setNodeRef, isOver: over } = useDroppable ({
id: `slot:${ cat }`,
data: { kind: 'slot', cat } })
return (
<div ref={setNodeRef} className="my-3">
{children}
<ul>
<li className="h-1">
{over && <div className="h-0.5 w-full rounded bg-sky-400"/>}
</li>
</ul>
</div>)
}
type Props = {
className?: string
post: Post
sp?: boolean }
type ActiveTagDrag = {
dndId: string
tagId: number
nestLevel: number }
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
sp = Boolean (sp)
@@ -212,7 +253,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
return tagsTmp
}, [post])
const [activeTagId, setActiveTagId] = useState<number | null> (null)
const [activeTagDrag, setActiveTagDrag] = useState<ActiveTagDrag | null> (null)
const [dragging, setDragging] = useState (false)
const [saving, setSaving] = useState (false)
const [tags, setTags] = useState (baseTags)
@@ -220,6 +261,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
() => buildFlatTagByCategory (tags),
[tags],
)
const activeDndId = activeTagDrag?.dndId
const suppressClickRef = useRef (false)
@@ -328,9 +370,19 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
<TagSearch/>
<DndContext
sensors={sensors}
collisionDetection={tagCollisionDetection}
measuring={alwaysMeasureDroppables}
onDragStart={e => {
if (e.active.data.current?.kind === 'tag')
setActiveTagId (e.active.data.current?.tagId ?? null)
{
const tagId = e.active.data.current?.tagId
const nestLevel = e.active.data.current?.nestLevel
const dndId = e.active.data.current?.dndId
setActiveTagDrag (
tagId == null || nestLevel == null || dndId == null
? null
: { dndId, tagId, nestLevel })
}
setDragging (true)
suppressClickRef.current = true
document.body.style.userSelect = 'none'
@@ -341,13 +393,13 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
suppressClickRef.current = false}, { capture: true, once: true })
}}
onDragCancel={() => {
setActiveTagId (null)
setActiveTagDrag (null)
setDragging (false)
document.body.style.userSelect = ''
suppressClickRef.current = false
}}
onDragEnd={async e => {
setActiveTagId (null)
setActiveTagDrag (null)
setDragging (false)
await onDragEnd (e)
document.body.style.userSelect = ''
@@ -362,22 +414,31 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
if (!(categoryTags.length > 0 || dragging))
return null
const sectionTitle = (
<SubsectionTitle>
<motion.div
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: layoutTransition }}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>)
if (!(categoryTags.length > 0))
return (
<EmptyCategoryDropSection cat={cat} key={cat}>
{sectionTitle}
</EmptyCategoryDropSection>)
return (
<div className="my-3" key={cat}>
<SubsectionTitle>
<motion.div
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: layoutTransition }}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>
{sectionTitle}
<ul>
{(tagRelationDisplay === 'grouped'
? (tags[cat] ?? []).flatMap (tag =>
renderTagTree (
activeDndId,
tag,
0,
`cat-${ cat }`,
@@ -389,6 +450,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
: (flatTagsByCategory[cat] ?? []).map (tag => (
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={0}
pathKey={`flat-${ cat }-${ tag.id }`}
@@ -441,11 +503,14 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
</ul>
</motion.div>)}
<DragOverlay adjustScale={false}>
<DragOverlay
adjustScale={false}
modifiers={[snapCenterToCursor]}
dropAnimation={animationMode === 'off' ? null : undefined}>
<div className="pointer-events-none">
{activeTagId != null && (() => {
const tag = findTag (tags, activeTagId)
return tag && <TagLink tag={tag}/>
{activeTagDrag != null && (() => {
const tag = findTag (tags, activeTagDrag.tagId)
return tag && <TagLink tag={tag} nestLevel={activeTagDrag.nestLevel}/>
}) ()}
</div>
</DragOverlay>
+1
ファイルの表示
@@ -43,6 +43,7 @@ export const menuOutline = (
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new' },
{ name: 'インポート', to: '/posts/import' },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
+5
ファイルの表示
@@ -239,6 +239,11 @@ body
background: var(--top-nav-submenu-bg);
}
.top-nav-mobile-menu .top-nav-submenu
{
background: var(--top-nav-mobile-active-bg);
}
.top-nav-mobile-menu
{
background: var(--top-nav-mobile-menu-bg);
+22
ファイルの表示
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const indexCss = readFileSync (
resolve (process.cwd (), 'src/index.css'),
'utf8')
const compactIndexCss = indexCss.replace (/\s+/g, ' ')
const mobileActiveRule = '.top-nav-mobile-active {'
+ ' background: var(--top-nav-mobile-active-bg); }'
const mobileSubmenuRule = '.top-nav-mobile-menu .top-nav-submenu {'
+ ' background: var(--top-nav-mobile-active-bg); }'
describe ('TopNav mobile menu colours', () => {
it ('uses one background variable for active rows and expanded submenus', () => {
expect (compactIndexCss).toContain (mobileActiveRule)
expect (compactIndexCss).toContain (mobileSubmenuRule)
})
})
+5 -1
ファイルの表示
@@ -89,8 +89,12 @@ describe ('settings', () => {
})
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe (
const tokens = buildThemeTokens ('light', { })
expect (tokens.topNavMobileActiveBackground).toBe (
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
expect (tokens.topNavMobileActiveBackground).not.toBe (
tokens.topNavRootBackgroundMobile)
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
DARK_THEME_TOKENS.topNavActiveBackground)
})
+402
ファイルの表示
@@ -0,0 +1,402 @@
import { useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import FieldError from '@/components/common/FieldError'
import PrefetchLink from '@/components/PrefetchLink'
import Form from '@/components/common/Form'
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 Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
import type { User } from '@/types'
type Props = { user: User | null }
type Column = { key: string, label: string }
type ImportRow = {
sourceRow: number
url: string
attributes: Record<string, string>
warnings: string[]
validationErrors: Record<string, string[]>
importErrors?: Record<string, string[]>
provenance: Record<string, 'automatic' | 'mapped' | 'fixed' | 'manual'>
tagSources?: Record<'automatic' | 'mapped' | 'fixed' | 'manual', string>
status: 'ready' | 'warning' | 'error'
existing: boolean
metadataUrl?: string
createdPostId?: number
importStatus?: 'pending' | 'created' | 'skipped' | 'failed' }
type ParsedImport = { columns: Column[], rows: { sourceRow: number, values: string[] }[] }
type Phase = 'source' | 'mapping' | 'preview' | 'result'
type ImportResultStatus = 'created' | 'skipped' | 'failed'
const detectFormat = (source: string): 'csv' | 'tsv' =>
source.includes ('\t') ? 'tsv' : (source.includes (',') ? 'csv' : 'tsv')
const publicGoogleSheetURL = (source: string): boolean =>
/^https:\/\/docs\.google\.com\/spreadsheets\/d\/e\/[A-Za-z0-9_-]+\/pubhtml#gid=\d+$/.test (
source.trim ())
const PostImportPage: FC<Props> = ({ user }) => {
const [source, setSource] = useState ('')
const [phase, setPhase] = useState<Phase> ('source')
const [format, setFormat] = useState<'csv' | 'tsv' | 'json' | 'google_sheets'> ('tsv')
const [hasHeader, setHasHeader] = useState (false)
const [jsonPath, setJsonPath] = useState ('')
const [urlColumn, setUrlColumn] = useState ('')
const [parsed, setParsed] = useState<ParsedImport | null> (null)
const [columns, setColumns] = useState<Column[]> ([])
const [rows, setRows] = useState<ImportRow[]> ([])
const [results, setResults] = useState<{ sourceRow: number, status: ImportResultStatus, post?: { id: number }, errors?: Record<string, string[]> }[]> ([])
const [mappings, setMappings] = useState<Record<string, { columns: string[] }>> ({ })
const [existing, setExisting] = useState<'skip' | 'error'> ('skip')
const [loading, setLoading] = useState (false)
const validationGeneration = useRef (0)
const editable = canEditContent (user)
const parseSource = async () => {
setLoading (true)
try
{
const data = await apiPost<ParsedImport> ('/posts/import/parse', {
source,
format,
has_header: hasHeader,
json_path: jsonPath,
existing })
setColumns (data.columns)
setParsed (data)
setMappings ({ })
setRows ([])
setResults ([])
setUrlColumn ('')
if (data.columns.length === 1)
setUrlColumn ('0')
else
{
const candidate = data.columns.map ((_, index) => ({ index,
count: data.rows.filter (row => /^https?:\/\//.test (row.values[index] ?? '')).length }))
.sort ((a, b) => b.count - a.count)[0]
if (candidate?.count)
setUrlColumn (String (candidate.index))
}
setPhase ('mapping')
}
catch
{
toast ({ title: '読込みに失敗しました', description: '入力形式を確認してください。' })
}
finally
{
setLoading (false)
}
}
const preview = async () => {
if (!parsed || urlColumn === '')
return
setLoading (true)
try
{
const data = await apiPost<{ rows: ImportRow[] }> ('/posts/import/preview', {
parsed: { columns: parsed.columns, rows: parsed.rows },
url_column: urlColumn,
mappings,
existing })
setRows (current => mergeValidatedRows (current, data.rows))
setPhase ('preview')
}
catch (error)
{
const message =
isApiError<{ message?: string, baseErrors?: string[] }> (error)
? error.response?.data?.message ?? error.response?.data?.baseErrors?.[0]
: undefined
toast ({ title: '投稿プレビューに失敗しました', description: message ?? '入力を確認してください。' })
}
finally
{
setLoading (false)
}
}
const mergeValidatedRows = (current: ImportRow[], validated: ImportRow[]): ImportRow[] =>
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)))
const updateAttribute = async (index: number, field: string, value: string) => {
const row = rows[index]
const next = { ...row,
attributes: { ...row.attributes, [field]: value },
provenance: { ...row.provenance, [field]: 'manual' },
importStatus: 'pending' as const,
importErrors: undefined }
const nextRows = rows.map ((currentRow, rowIndex) => rowIndex === index ? next : currentRow)
setRows (nextRows)
const generation = ++validationGeneration.current
setLoading (true)
try
{
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate',
{ rows: nextRows.filter (row => row.importStatus !== 'created'), existing,
changed_row: -1 })
if (generation === validationGeneration.current)
setRows (current => mergeValidatedRows (current, validated.rows))
}
catch
{
toast ({ title: '行の再検証に失敗しました' })
}
finally
{
if (generation === validationGeneration.current)
setLoading (false)
}
}
const updateURL = async (index: number, value: string) => {
const row = rows[index]
const next = { ...row, url: value, provenance: { ...row.provenance, url: 'manual' },
importStatus: 'pending' as const, importErrors: undefined }
const nextRows = rows.map ((currentRow, rowIndex) => rowIndex === index ? next : currentRow)
setRows (nextRows)
const generation = ++validationGeneration.current
setLoading (true)
try
{
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate',
{ rows: nextRows.filter (row => row.importStatus !== 'created'), existing,
changed_row: row.sourceRow })
if (generation === validationGeneration.current)
setRows (current => mergeValidatedRows (current, validated.rows))
}
catch
{
toast ({ title: 'URL の再検証に失敗しました' })
}
finally
{
if (generation === validationGeneration.current)
setLoading (false)
}
}
const updateExisting = async (value: 'skip' | 'error') => {
setExisting (value)
const pendingRows = rows.map (row => row.importStatus === 'created' ? row :
{ ...row, importStatus: 'pending' as const, importErrors: undefined })
setRows (pendingRows)
const generation = ++validationGeneration.current
setLoading (true)
try
{
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate',
{ rows: pendingRows.filter (row => row.importStatus !== 'created'), existing: value, changed_row: -1 })
if (generation === validationGeneration.current)
setRows (current => mergeValidatedRows (current, validated.rows))
}
finally
{
if (generation === validationGeneration.current)
setLoading (false)
}
}
const submit = async () => {
const targets = rows.filter (row => row.importStatus == null || row.importStatus === 'pending')
if (targets.length === 0)
return
setLoading (true)
try
{
const result = await apiPost<{ created: number, skipped: number, failed: number,
rows: { sourceRow: number, status: ImportResultStatus, post?: { id: number }, errors?: Record<string, string[]> }[] }> ('/posts/import', {
rows: targets.map (row => ({ sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
existing })
if (result.rows.some (row => !(['created', 'skipped', 'failed'] as string[]).includes (row.status)))
throw new Error ('不正な登録結果です。')
toast ({ title: `登録完了: ${result.created}`,
description: `スキップ ${result.skipped} 件、失敗 ${result.failed}` })
setPhase ('result')
setResults (result.rows)
setRows (current => current.map (row => {
const resultRow = result.rows.find (_1 => _1.sourceRow === row.sourceRow)
return resultRow ? { ...row, importStatus: resultRow.status,
createdPostId: resultRow.post?.id,
importErrors: resultRow.errors,
validationErrors: row.validationErrors } : row
}))
}
catch
{
toast ({ title: '登録に失敗しました' })
}
finally
{
setLoading (false)
}
}
if (!(editable))
return <Forbidden/>
return (
<MainArea>
<Helmet><title>{`投稿インポート | ${SITE_TITLE}`}</title></Helmet>
<PageTitle>稿</PageTitle>
{phase === 'source' ? (
<Form>
<p className="text-sm text-neutral-600 dark:text-neutral-300">
URL CSVTSVJSON
</p>
{publicGoogleSheetURL (source) && (
<fieldset className="space-y-1 rounded border p-3 dark:border-neutral-700">
<legend> Google URL</legend>
<label className="mr-3"><input type="radio" checked={format === 'google_sheets'}
onChange={() => setFormat ('google_sheets')}/></label>
<label><input type="radio" checked={format !== 'google_sheets'}
onChange={() => setFormat ('tsv')}/>URL 稿</label>
</fieldset>)}
<FormField label="入力形式">
{() => <select value={format} onChange={ev => setFormat (ev.target.value as typeof format)}
className={inputClass (false)}>
<option value="tsv">TSV</option><option value="csv">CSV</option>
<option value="json">JSON</option>
<option value="google_sheets"> Google </option>
</select>}
</FormField>
<FormField label="入力データ">
{() => <textarea value={source} rows={12} className={inputClass (false)}
onChange={ev => { setSource (ev.target.value); if (format !== 'json' && format !== 'google_sheets') setFormat (detectFormat (ev.target.value)) }}/>}
</FormField>
<input type="file" accept=".txt,.csv,.tsv,.json" onChange={ev => {
const file = ev.target.files?.[0]
if (file) file.text ().then (text => { setSource (text); setFormat (file.name.endsWith ('.json') ? 'json' : detectFormat (text)) })
}}/>
{format !== 'json' && <label className="flex gap-2"><input type="checkbox" checked={hasHeader}
onChange={ev => setHasHeader (ev.target.checked)}/>使</label>}
{format === 'json' && <FormField label="投稿候補の位置(例: data.posts)">
{() => <input value={jsonPath} onChange={ev => setJsonPath (ev.target.value)} className={inputClass (false)}/>}
</FormField>}
{format === 'google_sheets' && <p className="text-sm text-neutral-600 dark:text-neutral-300">
URL 使CSV
</p>}
<Button type="button" onClick={parseSource} disabled={loading || !(source)}></Button>
</Form>) : phase === 'mapping' && parsed ? (
<Form>
<PageTitle></PageTitle>
<p>URL 稿</p>
<label>URL <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
<option value=""></option>
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
</select></label>
{['title', 'thumbnail_base', 'original_created_from', 'original_created_before', 'duration', 'tags', 'parent_post_ids'].map (field =>
<label key={field}>{field}<select value={mappings[field]?.columns?.[0] ?? ''}
onChange={ev => setMappings (current => ({ ...current, [field]: { columns: ev.target.value === '' ? [] : [ev.target.value] } }))}>
<option value=""></option>
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
</select></label>)}
<div className="overflow-x-auto"><table className="text-sm"><thead><tr>{columns.map (column => <th key={column.key}>{column.label}</th>)}</tr></thead><tbody>{parsed.rows.slice (0, 5).map (row => <tr key={row.sourceRow}>{row.values.map ((value, index) => <td key={index}>{value}</td>)}</tr>)}</tbody></table></div>
<Button type="button" variant="outline" onClick={() => setPhase ('source')}></Button>
<Button type="button" onClick={preview} disabled={loading || urlColumn === ''}>稿</Button>
</Form>) : phase === 'preview' ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
<label>URL <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
</select></label>
<label>稿 <select value={existing} onChange={ev => updateExisting (ev.target.value as typeof existing)}>
<option value="skip"></option><option value="error"></option>
</select></label>
{['title', 'thumbnail_base', 'original_created_from', 'original_created_before', 'duration', 'tags', 'parent_post_ids'].map (field =>
<label key={field}>{field}<select value={mappings[field]?.columns?.[0] ?? ''}
onChange={ev => setMappings (current => ({ ...current, [field]: { columns: ev.target.value === '' ? [] : [ev.target.value] } }))}>
<option value=""></option>{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
</select></label>)}
<Button type="button" variant="outline" onClick={preview} disabled={loading}></Button>
</div>
<div className="grid gap-3 md:grid-cols-2">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute} updateURL={updateURL}/>)}</div>
<Button type="button" variant="outline" onClick={() => setPhase ('mapping')}></Button>
<Button type="button" onClick={submit} disabled={loading}>稿</Button>
</div>) : (
<div className="space-y-3"><p></p>
{results.map (result => <div key={result.sourceRow} className="rounded border p-2 dark:border-neutral-700"> {result.sourceRow}{result.status} {result.post && <PrefetchLink to={`/posts/${result.post.id}`}>稿</PrefetchLink>}<FieldError messages={Object.values (result.errors ?? { }).flat ()}/></div>)}
<Button type="button" onClick={() => setPhase ('preview')}></Button>
<Button type="button" variant="outline" onClick={() => setPhase ('preview')}></Button>
<Button type="button" variant="outline" onClick={() => setPhase ('source')}></Button>
</div>)}
</MainArea>)
}
const RowCard = ({ row, index, update, updateURL }: { row: ImportRow, index: number, update: (index: number, field: string, value: string) => void, updateURL: (index: number, value: string) => void }) => (
<article className="space-y-2 rounded border bg-white p-3 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100">
<p> {row.sourceRow}{row.status}</p>
{row.importStatus === 'created' ? <p className="rounded bg-green-100 p-2 dark:bg-green-900">{row.createdPostId && <PrefetchLink to={`/posts/${row.createdPostId}`}>稿</PrefetchLink>}</p> : <>
<EditableValue label="URL" value={row.url} origin={row.provenance.url} onCommit={value => updateURL (index, value)}/>
<EditableValue label="タイトル" value={row.attributes.title ?? ''} origin={row.provenance.title} onCommit={value => update (index, 'title', value)}/>
<EditableValue label="サムネール基底 URL" value={row.attributes.thumbnailBase ?? ''} origin={row.provenance.thumbnailBase} onCommit={value => update (index, 'thumbnailBase', value)}/>
<ThumbnailPreview url={row.attributes.thumbnailBase ?? ''}/>
<EditableValue label="作成日時 From" value={row.attributes.originalCreatedFrom ?? ''} origin={row.provenance.originalCreatedFrom} onCommit={value => update (index, 'originalCreatedFrom', value)}/>
<EditableValue label="作成日時 Before" value={row.attributes.originalCreatedBefore ?? ''} origin={row.provenance.originalCreatedBefore} onCommit={value => update (index, 'originalCreatedBefore', value)}/>
<EditableValue label="動画時間" value={String (row.attributes.duration ?? '')} origin={row.provenance.duration} onCommit={value => update (index, 'duration', value)}/>
<EditableValue label="タグ" value={row.attributes.tags ?? ''} origin={row.provenance.tags} onCommit={value => update (index, 'tags', value)}/>
<EditableValue label="親投稿" value={row.attributes.parentPostIds ?? ''} origin={row.provenance.parentPostIds} onCommit={value => update (index, 'parentPostIds', value)}/>
</>}
<FieldError messages={[...row.warnings,
...Object.values (row.validationErrors ?? { }).flat (),
...Object.values (row.importErrors ?? { }).flat ()]}/>
</article>)
const EditableValue = ({ label, value, origin, onCommit }: { label: string, value: string, origin: string | undefined, onCommit: (value: string) => void }) => {
const [editing, setEditing] = useState (false)
const [draft, setDraft] = useState (value)
const finished = useRef (false)
useEffect (() => { if (!editing) setDraft (value) }, [editing, value])
const originLabel = { automatic: '自動取得', mapped: '入力データ', fixed: '固定値', manual: '手修正' }[origin ?? 'automatic']
const begin = () => { finished.current = false; setDraft (value); setEditing (true) }
const commit = () => { if (finished.current) return; finished.current = true; setEditing (false); if (draft !== value) onCommit (draft) }
const cancel = () => { if (finished.current) return; finished.current = true; setDraft (value); setEditing (false) }
return <div className={origin === 'manual' ? 'rounded bg-amber-100 p-1 dark:bg-amber-900' : 'p-1'}>
<span className="text-xs">{label}{originLabel}</span>
{editing ? <input autoFocus value={draft} onChange={ev => setDraft (ev.target.value)} onBlur={commit}
onKeyDown={ev => { if (ev.key === 'Escape') cancel (); if (ev.key === 'Enter') { ev.preventDefault (); commit () } } } className={inputClass (false)}/>
: <button type="button" className="block w-full text-left" onDoubleClick={begin} onClick={begin}>{value || '(未指定)'} </button>}
</div>
}
const ThumbnailPreview = ({ url }: { url: string }) => {
const [failed, setFailed] = useState (false)
useEffect (() => setFailed (false), [url])
if (!url)
return <p className="text-sm"></p>
if (failed)
return <p className="text-sm text-red-600 dark:text-red-300"></p>
return <img src={url} alt="サムネール" className="max-h-32" onError={() => setFailed (true)}/>
}
export default PostImportPage
+29 -8
ファイルの表示
@@ -49,6 +49,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [url, setURL] = useState ('')
const thumbnailPreviewRef = useRef ('')
const urlRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
@@ -84,11 +85,13 @@ const PostNewPage: FC<Props> = ({ user }) => {
}
const fetchTitle = useCallback (async () => {
setTitleLoading (true)
const requestedURL = url
setTitleLoading (true)
try
{
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
if (requestedURL === urlRef.current)
setTitle (current => current || data.title || '')
}
finally
{
@@ -97,6 +100,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
}, [url])
const fetchThumbnail = useCallback (async () => {
const requestedURL = url
setThumbnailPreview ('')
setThumbnailFile (null)
setThumbnailLoading (true)
@@ -107,10 +111,13 @@ const PostNewPage: FC<Props> = ({ user }) => {
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
if (requestedURL === urlRef.current)
{
setThumbnailPreview (current => current || imageURL)
setThumbnailFile (current => current || new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
}
}
finally
{
@@ -122,6 +129,20 @@ const PostNewPage: FC<Props> = ({ user }) => {
thumbnailPreviewRef.current = thumbnailPreview
}, [thumbnailPreview])
useEffect (() => {
urlRef.current = url
}, [url])
useEffect (() => {
if (!(url))
return
const timer = window.setTimeout (() => {
fetchTitle ().catch (() => undefined)
fetchThumbnail ().catch (() => undefined)
}, 500)
return () => window.clearTimeout (timer)
}, [fetchThumbnail, fetchTitle, url])
if (!(editable))
return <Forbidden/>
@@ -159,7 +180,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<span>URL </span>
<Button
type="button"
variant="outline"
@@ -176,7 +197,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
{({ describedBy, invalid }) => (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<span>URL </span>
<Button
type="button"
variant="outline"