コミットを比較

..

8 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 5cf46406a9 #268 2026-07-08 00:00:03 +09:00
みてるぞ 29c385ed9f #268 2026-07-07 23:41:21 +09:00
みてるぞ abeee76ddd #268 2026-07-07 23:22:20 +09:00
みてるぞ 347a0ebbba #268 2026-07-07 23:04:19 +09:00
みてるぞ f8e8d8fbb0 #268 2026-07-07 22:35:09 +09:00
みてるぞ 33f9c6602f #268 2026-07-07 22:34:58 +09:00
みてるぞ 2d6af0aa5b #268 2026-07-07 21:01:57 +09:00
みてるぞ c0879ac117 #268 2026-07-07 20:57:12 +09:00
41個のファイルの変更1249行の追加3000行の削除
-149
ファイルの表示
@@ -114,9 +114,6 @@ npm run preview
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
- Ruby: never put a line break immediately before `)`.
- 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
clauses with `&&` or `||`, wrap the whole condition in parentheses.
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
@@ -130,152 +127,6 @@ npm run preview
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
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
syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
+8 -10
ファイルの表示
@@ -89,13 +89,15 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
tag = 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) unless tag
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)
@@ -137,7 +139,10 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag = 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) unless tag
material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob
material.file.attach(uploaded_blob)
@@ -145,7 +150,6 @@ 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)
@@ -236,12 +240,6 @@ 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
-45
ファイルの表示
@@ -1,45 +0,0 @@
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
+32 -8
ファイルの表示
@@ -142,13 +142,37 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
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!
# 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.reload
render json: PostRepr.base(post), status: :created
@@ -158,7 +182,7 @@ class PostsController < ApplicationController
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue PostCreator::VideoMsParseError
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
+37 -44
ファイルの表示
@@ -1,57 +1,50 @@
class PreviewController < ApplicationController
before_action :require_member!
def title
return render_bad_request('URL は必須です.') if params[:url].blank?
# TODO: # 既知サイトなら決まったフォーマットで title 取得するやぅに.
return head :unauthorized unless current_user
render json: { title: Preview::ThumbnailFetcher.title(params[:url]) }
rescue Preview::UrlSafety::UnsafeUrl => e
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_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
return render_bad_request('URL は必須です.') if params[:url].blank?
# TODO: 既知ドメインであれば指定のアドレスからサムネールを取得するやぅにする.
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?
head :forbidden
end
url = params[:url]
return render_bad_request('URL は必須です.') if url.blank?
def render_preview_error(message, status)
render json: { type: status.to_s,
message:,
errors: { },
base_errors: [message] },
status:
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
end
end
+10 -1
ファイルの表示
@@ -173,6 +173,15 @@ class Post < ApplicationRecord
def normalise_url
return if url.blank?
self.url = PostUrlNormaliser.normalise(url) || url.strip
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
;
end
end
-118
ファイルの表示
@@ -1,118 +0,0 @@
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
-226
ファイルの表示
@@ -1,226 +0,0 @@
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
-162
ファイルの表示
@@ -1,162 +0,0 @@
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
-64
ファイルの表示
@@ -1,64 +0,0 @@
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
-23
ファイルの表示
@@ -1,23 +0,0 @@
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
-60
ファイルの表示
@@ -1,60 +0,0 @@
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
-13
ファイルの表示
@@ -1,13 +0,0 @@
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
ファイルの表示
@@ -1,21 +0,0 @@
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
-129
ファイルの表示
@@ -1,129 +0,0 @@
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
ファイルの表示
@@ -1,34 +0,0 @@
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
ファイルの表示
@@ -1,95 +0,0 @@
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
ファイルの表示
@@ -1,55 +0,0 @@
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
-6
ファイルの表示
@@ -33,12 +33,6 @@ Rails.application.routes.draw do
get :thumbnail
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
collection do
get :search
+1102
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
+15
ファイルの表示
@@ -0,0 +1,15 @@
{
"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
ファイルの表示
@@ -0,0 +1,18 @@
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,25 +287,6 @@ 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',
@@ -485,73 +466,6 @@ 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
@@ -580,7 +494,6 @@ 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: {
@@ -588,8 +501,6 @@ 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
+14 -32
ファイルの表示
@@ -1,46 +1,28 @@
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 '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: '' }
it "400 when url blank" do
sign_in_as(create(:user))
get "/preview/title", params: { url: "" }
expect(response).to have_http_status(:bad_request)
end
it 'returns parsed title' do
sign_in_as(create(:user, :member))
allow(Preview::ThumbnailFetcher)
.to receive(:title)
.with('example.com')
.and_return('Hello')
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))
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')
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)
expect(json["title"]).to eq("Hello")
end
end
end
-23
ファイルの表示
@@ -1,23 +0,0 @@
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
ファイルの表示
@@ -1,51 +0,0 @@
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
ファイルの表示
@@ -1,15 +0,0 @@
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
-9
ファイルの表示
@@ -41,9 +41,6 @@ import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
import PostDetailPage from '@/pages/posts/PostDetailPage'
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 PostNewPage from '@/pages/posts/PostNewPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
@@ -78,9 +75,6 @@ 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={<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/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
@@ -119,9 +113,6 @@ 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={<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/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
+1 -2
ファイルの表示
@@ -26,7 +26,7 @@ export const menuOutline = (
tag?: Tag | null
material?: Material | null
wikiId: number | null
user: User | null
user: User | null,
pathName: string },
): Menu => {
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
@@ -43,7 +43,6 @@ 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: [
-203
ファイルの表示
@@ -1,203 +0,0 @@
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
-149
ファイルの表示
@@ -1,149 +0,0 @@
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
-70
ファイルの表示
@@ -1,70 +0,0 @@
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
-48
ファイルの表示
@@ -1,48 +0,0 @@
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
-5
ファイルの表示
@@ -239,11 +239,6 @@ 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
ファイルの表示
@@ -1,22 +0,0 @@
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)
})
})
-221
ファイルの表示
@@ -1,221 +0,0 @@
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 })
+1 -5
ファイルの表示
@@ -89,12 +89,8 @@ describe ('settings', () => {
})
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
const tokens = buildThemeTokens ('light', { })
expect (tokens.topNavMobileActiveBackground).toBe (
expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe (
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
expect (tokens.topNavMobileActiveBackground).not.toBe (
tokens.topNavRootBackgroundMobile)
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
DARK_THEME_TOKENS.topNavActiveBackground)
})
-253
ファイルの表示
@@ -1,253 +0,0 @@
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
-363
ファイルの表示
@@ -1,363 +0,0 @@
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
-153
ファイルの表示
@@ -1,153 +0,0 @@
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
+11 -24
ファイルの表示
@@ -25,13 +25,7 @@ import type { User } from '@/types'
type Props = { user: User | null }
type PostFormField =
'url'
| 'title'
| 'tags'
| 'parentPostIds'
| 'videoMs'
| 'originalCreatedAt'
| 'thumbnail'
'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => {
@@ -42,10 +36,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (null)
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('')
@@ -58,10 +50,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() =>
tags.split (/\s+/).some (
tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => {
clearValidationErrors ()
@@ -82,15 +72,14 @@ const PostNewPage: FC<Props> = ({ user }) => {
try
{
await apiPost ('/posts', formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
toast ({ title: '投稿成功!' })
navigate ('/posts')
}
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください' })
toast ({ title: '投稿失敗', description: '入力を確認してください' })
}
}
@@ -98,8 +87,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
setTitleLoading (true)
try
{
const data = await apiGet<{ title: string }> ('/preview/title',
{ params: { url } })
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
}
finally
@@ -117,8 +105,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
try
{
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url },
responseType: 'blob' })
{ params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
@@ -176,7 +163,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
<Button
type="button"
variant="outline"
onClick={fetchTitle}
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>
@@ -193,7 +180,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
<Button
type="button"
variant="outline"
onClick={fetchThumbnail}
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>