このコミットが含まれているのは:
@@ -141,140 +141,72 @@ npm run preview
|
|||||||
- Ruby の multi-line hash literal / keyword-like argument hash では、opening
|
- Ruby の multi-line hash literal / keyword-like argument hash では、opening
|
||||||
`{` を最初の pair と同じ行に置き、closing `}` を最後の pair と同じ行に
|
`{` を最初の pair と同じ行に置き、closing `}` を最後の pair と同じ行に
|
||||||
置く。Prettier 的な縦開き・縦閉じをしない。
|
置く。Prettier 的な縦開き・縦閉じをしない。
|
||||||
- Ruby の `if` / `unless` / `case` 条件で、複数行に分けるだけで安易に
|
- Ruby の guard 条件は、1 行で収まるなら modifier 形式を優先する。
|
||||||
`if ... end` へ展開しない。局所の既存コードが modifier 形式ならそれに
|
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
|
||||||
そろえる。
|
- Ruby の method chain や call argument を折り返す際、call-site の `)` を
|
||||||
- Ruby の guard 条件は、1 行で収まるなら modifier 形式を優先する。2 行以上に
|
block close のやうに独立させない。
|
||||||
なるなら通常の block 形式へ切り替へてよい。
|
|
||||||
- Ruby の method chain や call argument を折り返す際、call-site の `)`
|
|
||||||
直前で行を空けたり、closing delimiter を block のやうに独立させない。
|
|
||||||
|
|
||||||
Bad:
|
Bad:
|
||||||
|
|
||||||
```rb
|
```rb
|
||||||
source =
|
response = Example.fetch(
|
||||||
if params[:format] == 'google_sheets'
|
value,
|
||||||
PostImportGoogleSheetsFetcher.fetch!(params[:source],
|
option: option,
|
||||||
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:
|
Good:
|
||||||
|
|
||||||
```rb
|
```rb
|
||||||
PostTagSection.create!(post_id: post.id,
|
response = Example.fetch(
|
||||||
tag_id:,
|
value,
|
||||||
begin_ms:,
|
option: option)
|
||||||
end_ms:)
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
payload = {
|
||||||
|
title: title,
|
||||||
|
url: url,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
payload = {
|
||||||
|
title: title,
|
||||||
|
url: url }
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
raise ArgumentError, 'URL が長すぎます.' if url.bytesize > MAX_URL_BYTES && flag.present?
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
if url.bytesize > MAX_URL_BYTES && flag.present?
|
||||||
|
raise ArgumentError, 'URL が長すぎます.'
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
records.each {
|
||||||
|
do_work(_1) }
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
records.each {
|
||||||
|
do_work(_1)
|
||||||
|
}
|
||||||
```
|
```
|
||||||
- TypeScript and Python: use GNU-style spacing before parentheses where
|
- TypeScript and Python: use GNU-style spacing before parentheses where
|
||||||
syntactically valid.
|
syntactically valid.
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ class PostImportsController < ApplicationController
|
|||||||
rows: PostImportUrlListParser.parse(params[:source]))
|
rows: PostImportUrlListParser.parse(params[:source]))
|
||||||
render json: { rows: }
|
render json: { rows: }
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_bad_request(e.message)
|
render_bad_request e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate
|
def validate
|
||||||
rows = normalised_import_rows(allow_warning_fields: true)
|
rows = normalised_import_rows allow_warning_fields: true
|
||||||
changed_row = Integer(params[:changed_row], exception: false)
|
changed_row = Integer(params[:changed_row], exception: false)
|
||||||
result =
|
result =
|
||||||
PostImportPreviewer.new.preview_rows(rows:,
|
PostImportPreviewer.new.preview_rows(rows:,
|
||||||
@@ -18,7 +18,7 @@ class PostImportsController < ApplicationController
|
|||||||
metadata_cache: { })
|
metadata_cache: { })
|
||||||
render json: { rows: result }
|
render json: { rows: result }
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_bad_request(e.message)
|
render_bad_request e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -26,7 +26,7 @@ class PostImportsController < ApplicationController
|
|||||||
rows: normalised_import_rows).run
|
rows: normalised_import_rows).run
|
||||||
render json: result, status: result[:created].positive? ? :created : :ok
|
render json: result, status: result[:created].positive? ? :created : :ok
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_bad_request(e.message)
|
render_bad_request e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -174,14 +174,5 @@ class Post < ApplicationRecord
|
|||||||
return if url.blank?
|
return if url.blank?
|
||||||
|
|
||||||
self.url = PostUrlNormaliser.normalise(url) || url.strip
|
self.url = PostUrlNormaliser.normalise(url) || url.strip
|
||||||
|
|
||||||
u = URI.parse(url)
|
|
||||||
return unless u in URI::HTTP
|
|
||||||
|
|
||||||
u.host = u.host.downcase if u.host
|
|
||||||
u.path = u.path.sub(/\/\Z/, '') if u.path.present?
|
|
||||||
self.url = PostUrlSanitisationRule.sanitise(u.to_s)
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
;
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class PostImportPreviewer
|
|||||||
provenance['url'] = 'manual'
|
provenance['url'] = 'manual'
|
||||||
normal_url = normalised_url(url)
|
normal_url = normalised_url(url)
|
||||||
url_for_metadata = normal_url || url
|
url_for_metadata = normal_url || url
|
||||||
existing_post = normal_url.present? && Post.exists?(url: normal_url)
|
existing_post = normal_url.present? ? Post.find_by(url: normal_url) : nil
|
||||||
|
|
||||||
validation_errors = {}
|
validation_errors = {}
|
||||||
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
|
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
|
||||||
@@ -61,6 +61,8 @@ class PostImportPreviewer
|
|||||||
provenance:,
|
provenance:,
|
||||||
tag_sources:,
|
tag_sources:,
|
||||||
metadata_url: url_for_metadata,
|
metadata_url: url_for_metadata,
|
||||||
|
skip_reason: 'existing',
|
||||||
|
existing_post_id: existing_post.id,
|
||||||
field_warnings:,
|
field_warnings:,
|
||||||
base_warnings:,
|
base_warnings:,
|
||||||
validation_errors:,
|
validation_errors:,
|
||||||
@@ -93,6 +95,8 @@ class PostImportPreviewer
|
|||||||
provenance:,
|
provenance:,
|
||||||
tag_sources:,
|
tag_sources:,
|
||||||
metadata_url: url_for_metadata,
|
metadata_url: url_for_metadata,
|
||||||
|
skip_reason: nil,
|
||||||
|
existing_post_id: nil,
|
||||||
field_warnings:,
|
field_warnings:,
|
||||||
base_warnings:,
|
base_warnings:,
|
||||||
validation_errors:,
|
validation_errors:,
|
||||||
@@ -129,7 +133,9 @@ class PostImportPreviewer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def initial_field_warnings row
|
def initial_field_warnings row
|
||||||
(row[:field_warnings] || { }).stringify_keys.transform_values { |value| Array(value).map(&:to_s) }
|
(row[:field_warnings] || { })
|
||||||
|
.stringify_keys
|
||||||
|
.transform_values { |value| Array(value).map(&:to_s) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def initial_base_warnings row
|
def initial_base_warnings row
|
||||||
@@ -161,7 +167,9 @@ class PostImportPreviewer
|
|||||||
data = PostMetadataFetcher.fetch(url).stringify_keys.compact
|
data = PostMetadataFetcher.fetch(url).stringify_keys.compact
|
||||||
warnings = { }
|
warnings = { }
|
||||||
add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank?
|
add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank?
|
||||||
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING) if data['thumbnail_base'].blank?
|
if data['thumbnail_base'].blank?
|
||||||
|
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING)
|
||||||
|
end
|
||||||
{ data:, warnings: }
|
{ data:, warnings: }
|
||||||
rescue Preview::UrlSafety::UnsafeUrl,
|
rescue Preview::UrlSafety::UnsafeUrl,
|
||||||
Preview::HttpFetcher::FetchFailed,
|
Preview::HttpFetcher::FetchFailed,
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ class PostImportRowNormaliser
|
|||||||
unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) }
|
unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) }
|
||||||
raise ArgumentError, '警告の形式が不正です.'
|
raise ArgumentError, '警告の形式が不正です.'
|
||||||
end
|
end
|
||||||
raise ArgumentError, '警告が大きすぎます.' if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
||||||
|
raise ArgumentError, '警告が大きすぎます.'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
private_class_method :normalise_warning_values!
|
private_class_method :normalise_warning_values!
|
||||||
|
|
||||||
|
|||||||
@@ -6,40 +6,29 @@ class PostImportRunner
|
|||||||
|
|
||||||
def run
|
def run
|
||||||
normalised_rows = PostImportRowNormaliser.normalise!(@rows)
|
normalised_rows = PostImportRowNormaliser.normalise!(@rows)
|
||||||
|
previews = PostImportPreviewer.new.preview_rows(rows: normalised_rows,
|
||||||
|
fetch_metadata: false)
|
||||||
|
preview_map = previews.index_by { _1[:source_row] }
|
||||||
|
|
||||||
results = normalised_rows.map { |row| run_row(row) }
|
results = normalised_rows.map do |row|
|
||||||
{
|
run_row(row, preview_map.fetch(row['source_row']))
|
||||||
created: results.count { _1[:status] == 'created' },
|
end
|
||||||
|
|
||||||
|
{ created: results.count { _1[:status] == 'created' },
|
||||||
skipped: results.count { _1[:status] == 'skipped' },
|
skipped: results.count { _1[:status] == 'skipped' },
|
||||||
failed: results.count { _1[:status] == 'failed' },
|
failed: results.count { _1[:status] == 'failed' },
|
||||||
rows: results,
|
rows: results }
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def run_row row
|
def run_row row, preview
|
||||||
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
|
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
|
||||||
preview = PostImportPreviewer.new.preview_rows(
|
return row.slice('source_row').merge(status: 'skipped') if preview[:skip_reason] == 'existing'
|
||||||
rows: [{
|
|
||||||
source_row: row['source_row'],
|
|
||||||
url: row['url'],
|
|
||||||
attributes:,
|
|
||||||
provenance: row['provenance'],
|
|
||||||
tag_sources: row['tag_sources'],
|
|
||||||
}],
|
|
||||||
fetch_metadata: false).first
|
|
||||||
if Array(preview.dig(:field_warnings, 'url')).include?(PostImportPreviewer::EXISTING_SKIP_WARNING)
|
|
||||||
return row.slice('source_row').merge(status: 'skipped')
|
|
||||||
end
|
|
||||||
|
|
||||||
if preview[:validation_errors].present?
|
return { source_row: row['source_row'],
|
||||||
return {
|
status: 'failed',
|
||||||
source_row: row['source_row'],
|
errors: preview[:validation_errors] } if preview[:validation_errors].present?
|
||||||
status: 'failed',
|
|
||||||
errors: preview[:validation_errors],
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
attributes['tags'] = preview[:attributes]['tags']
|
attributes['tags'] = preview[:attributes]['tags']
|
||||||
attributes['url'] = row['url']
|
attributes['url'] = row['url']
|
||||||
@@ -48,15 +37,22 @@ class PostImportRunner
|
|||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
{ source_row: row['source_row'], status: 'failed', errors: e.record.errors.to_hash }
|
{ source_row: row['source_row'], status: 'failed', errors: e.record.errors.to_hash }
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
{ source_row: row['source_row'], status: 'failed', errors: { tags: ['ニコニコ・タグは直接指定できません.'] } }
|
{ source_row: row['source_row'],
|
||||||
|
status: 'failed',
|
||||||
|
errors: { tags: ['ニコニコ・タグは直接指定できません.'] } }
|
||||||
rescue Tag::DeprecatedTagNormalisationError
|
rescue Tag::DeprecatedTagNormalisationError
|
||||||
{ source_row: row['source_row'], status: 'failed', errors: { tags: ['廃止済みタグは付与できません.'] } }
|
{ source_row: row['source_row'],
|
||||||
|
status: 'failed',
|
||||||
|
errors: { tags: ['廃止済みタグは付与できません.'] } }
|
||||||
rescue ArgumentError, PostCreator::VideoMsParseError
|
rescue ArgumentError, PostCreator::VideoMsParseError
|
||||||
{ source_row: row['source_row'], status: 'failed', errors: { base: ['入力値が不正です.'] } }
|
{ source_row: row['source_row'],
|
||||||
|
status: 'failed',
|
||||||
|
errors: { base: ['入力値が不正です.'] } }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error(
|
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name,
|
||||||
"post_import_runner_failure #{ { error: e.class.name, message: e.message }.to_json }",
|
message: e.message }.to_json }")
|
||||||
)
|
{ source_row: row['source_row'],
|
||||||
{ source_row: row['source_row'], status: 'failed', errors: { base: ['登録中にエラーが発生しました.'] } }
|
status: 'failed',
|
||||||
|
errors: { base: ['登録中にエラーが発生しました.'] } }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -43,11 +43,27 @@ const originOf = (
|
|||||||
): PostImportOrigin =>
|
): PostImportOrigin =>
|
||||||
row.provenance[field] ?? 'automatic'
|
row.provenance[field] ?? 'automatic'
|
||||||
|
|
||||||
const originalCreatedOrigin = (row: PostImportRow): PostImportOrigin =>
|
const changedOrigin = (
|
||||||
originOf (row, 'originalCreatedFrom') === 'manual'
|
changed: boolean,
|
||||||
|| originOf (row, 'originalCreatedBefore') === 'manual'
|
row: PostImportRow,
|
||||||
|
field: string,
|
||||||
|
): PostImportOrigin =>
|
||||||
|
changed ? 'manual' : originOf (row, field)
|
||||||
|
|
||||||
|
const originalCreatedOrigin = (
|
||||||
|
row: PostImportRow,
|
||||||
|
originalDraft: Draft,
|
||||||
|
draft: Draft,
|
||||||
|
): PostImportOrigin =>
|
||||||
|
originalDraft.originalCreatedFrom !== draft.originalCreatedFrom
|
||||||
|
|| originalDraft.originalCreatedBefore !== draft.originalCreatedBefore
|
||||||
? 'manual'
|
? 'manual'
|
||||||
: 'automatic'
|
: (
|
||||||
|
originOf (row, 'originalCreatedFrom') === 'manual'
|
||||||
|
|| originOf (row, 'originalCreatedBefore') === 'manual'
|
||||||
|
? 'manual'
|
||||||
|
: 'automatic'
|
||||||
|
)
|
||||||
|
|
||||||
const buildDraft = (row: PostImportRow): Draft => ({
|
const buildDraft = (row: PostImportRow): Draft => ({
|
||||||
url: row.url,
|
url: row.url,
|
||||||
@@ -78,6 +94,10 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
if (!(row) || !(draft))
|
if (!(row) || !(draft))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
const originalDraft = buildDraft (row)
|
||||||
|
const fieldOrigin = (field: keyof Draft): PostImportOrigin =>
|
||||||
|
changedOrigin (draft[field] !== originalDraft[field], row, field)
|
||||||
|
|
||||||
const update = <Key extends keyof Draft,> (
|
const update = <Key extends keyof Draft,> (
|
||||||
key: Key,
|
key: Key,
|
||||||
value: Draft[Key],
|
value: Draft[Key],
|
||||||
@@ -117,21 +137,21 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="URL"
|
label="URL"
|
||||||
value={draft.url}
|
value={draft.url}
|
||||||
origin={originOf (row, 'url')}
|
origin={fieldOrigin ('url')}
|
||||||
warnings={row.fieldWarnings.url}
|
warnings={row.fieldWarnings.url}
|
||||||
errors={groupedMessages (row.validationErrors.url, row.importErrors?.url)}
|
errors={groupedMessages (row.validationErrors.url, row.importErrors?.url)}
|
||||||
onChange={value => update ('url', value)}/>
|
onChange={value => update ('url', value)}/>
|
||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="タイトル"
|
label="タイトル"
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
origin={originOf (row, 'title')}
|
origin={fieldOrigin ('title')}
|
||||||
warnings={row.fieldWarnings.title}
|
warnings={row.fieldWarnings.title}
|
||||||
errors={groupedMessages (row.validationErrors.title, row.importErrors?.title)}
|
errors={groupedMessages (row.validationErrors.title, row.importErrors?.title)}
|
||||||
onChange={value => update ('title', value)}/>
|
onChange={value => update ('title', value)}/>
|
||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="サムネール基底 URL"
|
label="サムネール基底 URL"
|
||||||
value={draft.thumbnailBase}
|
value={draft.thumbnailBase}
|
||||||
origin={originOf (row, 'thumbnailBase')}
|
origin={fieldOrigin ('thumbnailBase')}
|
||||||
warnings={row.fieldWarnings.thumbnailBase}
|
warnings={row.fieldWarnings.thumbnailBase}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
row.validationErrors.thumbnailBase,
|
row.validationErrors.thumbnailBase,
|
||||||
@@ -139,7 +159,10 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
)}
|
)}
|
||||||
onChange={value => update ('thumbnailBase', value)}/>
|
onChange={value => update ('thumbnailBase', value)}/>
|
||||||
<PostOriginalCreatedTimeField
|
<PostOriginalCreatedTimeField
|
||||||
labelAddon={<PostImportStatusBadge value={originalCreatedOrigin (row)}/>}
|
labelAddon={
|
||||||
|
<PostImportStatusBadge
|
||||||
|
value={originalCreatedOrigin (row, originalDraft, draft)}/>
|
||||||
|
}
|
||||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
originalCreatedFrom={draft.originalCreatedFrom || null}
|
||||||
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
||||||
originalCreatedBefore={draft.originalCreatedBefore || null}
|
originalCreatedBefore={draft.originalCreatedBefore || null}
|
||||||
@@ -155,7 +178,7 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="動画時間"
|
label="動画時間"
|
||||||
value={draft.duration}
|
value={draft.duration}
|
||||||
origin={originOf (row, 'duration')}
|
origin={fieldOrigin ('duration')}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
row.validationErrors.duration,
|
row.validationErrors.duration,
|
||||||
row.validationErrors.videoMs,
|
row.validationErrors.videoMs,
|
||||||
@@ -166,14 +189,14 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<DialogAreaField
|
<DialogAreaField
|
||||||
label="タグ"
|
label="タグ"
|
||||||
value={draft.tags}
|
value={draft.tags}
|
||||||
origin={originOf (row, 'tags')}
|
origin={fieldOrigin ('tags')}
|
||||||
warnings={row.fieldWarnings.tags}
|
warnings={row.fieldWarnings.tags}
|
||||||
errors={groupedMessages (row.validationErrors.tags, row.importErrors?.tags)}
|
errors={groupedMessages (row.validationErrors.tags, row.importErrors?.tags)}
|
||||||
onChange={value => update ('tags', value)}/>
|
onChange={value => update ('tags', value)}/>
|
||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="親投稿"
|
label="親投稿"
|
||||||
value={draft.parentPostIds}
|
value={draft.parentPostIds}
|
||||||
origin={originOf (row, 'parentPostIds')}
|
origin={fieldOrigin ('parentPostIds')}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
row.validationErrors.parentPostIds,
|
row.validationErrors.parentPostIds,
|
||||||
row.importErrors?.parentPostIds,
|
row.importErrors?.parentPostIds,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
|
||||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||||
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||||
@@ -71,10 +70,9 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
<div
|
<div
|
||||||
className={cn (
|
className={cn (
|
||||||
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
|
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
|
||||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto_auto]',
|
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
||||||
toneClass (row),
|
toneClass (row),
|
||||||
'hover:bg-slate-50',
|
'transition-shadow hover:shadow-sm',
|
||||||
'dark:hover:bg-neutral-800',
|
|
||||||
)}>
|
)}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||||
@@ -96,13 +94,6 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
{summaryDate (row) || '日時未取得'}
|
{summaryDate (row) || '日時未取得'}
|
||||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||||
</div>
|
</div>
|
||||||
{row.createdPostId && (
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/posts/${ row.createdPostId }`}
|
|
||||||
className="inline-block text-xs text-sky-700 underline
|
|
||||||
dark:text-sky-300">
|
|
||||||
投稿 #{row.createdPostId}
|
|
||||||
</PrefetchLink>)}
|
|
||||||
{warning && (
|
{warning && (
|
||||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
{warning}
|
{warning}
|
||||||
@@ -145,13 +136,6 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<PostImportStatusBadge value={effectiveStatus}/>
|
<PostImportStatusBadge value={effectiveStatus}/>
|
||||||
</div>
|
</div>
|
||||||
{row.createdPostId && (
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/posts/${ row.createdPostId }`}
|
|
||||||
className="inline-block text-xs text-sky-700 underline
|
|
||||||
dark:text-sky-300">
|
|
||||||
投稿 #{row.createdPostId}
|
|
||||||
</PrefetchLink>)}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-700 dark:text-red-200">
|
<div className="text-xs text-red-700 dark:text-red-200">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -2,22 +2,12 @@ import { cn } from '@/lib/utils'
|
|||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { PostImportOrigin } from '@/lib/postImportSession'
|
import type { PostImportBadgeValue } from '@/components/posts/import/postImportRowStatus'
|
||||||
|
|
||||||
type BadgeValue =
|
|
||||||
'ready'
|
|
||||||
| 'warning'
|
|
||||||
| 'error'
|
|
||||||
| 'pending'
|
|
||||||
| 'created'
|
|
||||||
| 'skipped'
|
|
||||||
| 'failed'
|
|
||||||
| PostImportOrigin
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: BadgeValue }
|
value: PostImportBadgeValue }
|
||||||
|
|
||||||
const LABELS: Record<BadgeValue, string> = {
|
const LABELS: Record<PostImportBadgeValue, string> = {
|
||||||
ready: '登録可能',
|
ready: '登録可能',
|
||||||
warning: '警告',
|
warning: '警告',
|
||||||
error: '要修正',
|
error: '要修正',
|
||||||
@@ -28,7 +18,7 @@ const LABELS: Record<BadgeValue, string> = {
|
|||||||
automatic: '自動取得',
|
automatic: '自動取得',
|
||||||
manual: '手修正' }
|
manual: '手修正' }
|
||||||
|
|
||||||
const STYLES: Record<BadgeValue, string[]> = {
|
const STYLES: Record<PostImportBadgeValue, string[]> = {
|
||||||
ready: [
|
ready: [
|
||||||
'border-emerald-300 bg-emerald-50 text-emerald-700',
|
'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||||
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'],
|
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
import type { PostImportOrigin,
|
||||||
|
PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
export type PostImportEffectiveStatus =
|
export type PostImportEffectiveStatus =
|
||||||
'created'
|
'created'
|
||||||
@@ -8,6 +9,11 @@ export type PostImportEffectiveStatus =
|
|||||||
| 'warning'
|
| 'warning'
|
||||||
| 'ready'
|
| 'ready'
|
||||||
|
|
||||||
|
export type PostImportBadgeValue =
|
||||||
|
PostImportEffectiveStatus
|
||||||
|
| 'pending'
|
||||||
|
| PostImportOrigin
|
||||||
|
|
||||||
|
|
||||||
export const effectivePostImportStatus = (
|
export const effectivePostImportStatus = (
|
||||||
row: PostImportRow,
|
row: PostImportRow,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type PostImportStatus =
|
|||||||
| 'created'
|
| 'created'
|
||||||
| 'skipped'
|
| 'skipped'
|
||||||
| 'failed'
|
| 'failed'
|
||||||
|
export type PostImportSkipReason = 'existing'
|
||||||
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||||
export type PostImportAttributeValue = string | number
|
export type PostImportAttributeValue = string | number
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ export type PostImportRow = {
|
|||||||
provenance: Record<string, PostImportOrigin>
|
provenance: Record<string, PostImportOrigin>
|
||||||
tagSources?: Record<PostImportOrigin, string>
|
tagSources?: Record<PostImportOrigin, string>
|
||||||
status: 'ready' | 'warning' | 'error'
|
status: 'ready' | 'warning' | 'error'
|
||||||
|
skipReason?: PostImportSkipReason
|
||||||
|
existingPostId?: number
|
||||||
metadataUrl?: string
|
metadataUrl?: string
|
||||||
createdPostId?: number
|
createdPostId?: number
|
||||||
importStatus?: PostImportStatus }
|
importStatus?: PostImportStatus }
|
||||||
@@ -185,6 +188,12 @@ const isValidOrigin = (
|
|||||||
value === 'automatic' || value === 'manual'
|
value === 'automatic' || value === 'manual'
|
||||||
|
|
||||||
|
|
||||||
|
const isValidSkipReason = (
|
||||||
|
value: unknown,
|
||||||
|
): value is PostImportSkipReason =>
|
||||||
|
value === 'existing'
|
||||||
|
|
||||||
|
|
||||||
const sanitiseRow = (value: unknown): PostImportRow | null => {
|
const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||||
if (!(isPlainObject (value)))
|
if (!(isPlainObject (value)))
|
||||||
return null
|
return null
|
||||||
@@ -200,6 +209,8 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
|||||||
return null
|
return null
|
||||||
if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
|
if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
|
||||||
return null
|
return null
|
||||||
|
if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
|
||||||
|
return null
|
||||||
|
|
||||||
const validationErrors = ensureStringListRecord (value.validationErrors)
|
const validationErrors = ensureStringListRecord (value.validationErrors)
|
||||||
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
||||||
@@ -239,6 +250,9 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
|||||||
provenance: value.provenance as Record<string, PostImportOrigin>,
|
provenance: value.provenance as Record<string, PostImportOrigin>,
|
||||||
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
|
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
|
||||||
status: value.status,
|
status: value.status,
|
||||||
|
skipReason: value.skipReason,
|
||||||
|
existingPostId:
|
||||||
|
Number.isInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
|
||||||
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
|
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
|
||||||
createdPostId:
|
createdPostId:
|
||||||
Number.isInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
|
Number.isInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
|
||||||
@@ -422,6 +436,8 @@ export const mergeValidatedImportRows = (
|
|||||||
attributes: row.attributes,
|
attributes: row.attributes,
|
||||||
provenance: row.provenance,
|
provenance: row.provenance,
|
||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
|
skipReason: row.skipReason,
|
||||||
|
existingPostId: row.existingPostId,
|
||||||
fieldWarnings,
|
fieldWarnings,
|
||||||
baseWarnings:
|
baseWarnings:
|
||||||
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
|
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
|
||||||
@@ -469,6 +485,8 @@ export const mergePreviewImportRows = (
|
|||||||
attributes: mergedAttributes,
|
attributes: mergedAttributes,
|
||||||
provenance: mergedProvenance,
|
provenance: mergedProvenance,
|
||||||
tagSources: mergedTagSources,
|
tagSources: mergedTagSources,
|
||||||
|
skipReason: row.skipReason,
|
||||||
|
existingPostId: row.existingPostId,
|
||||||
createdPostId: previous.createdPostId,
|
createdPostId: previous.createdPostId,
|
||||||
importStatus: previous.importStatus,
|
importStatus: previous.importStatus,
|
||||||
importErrors: previous.importErrors }
|
importErrors: previous.importErrors }
|
||||||
@@ -584,8 +602,8 @@ export const validateImportSource = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const hasSkipWarning = (row: PostImportRow): boolean =>
|
const hasSkipReason = (row: PostImportRow): boolean =>
|
||||||
(row.fieldWarnings.url ?? []).includes ('既存投稿のためスキップします.')
|
row.skipReason === 'existing'
|
||||||
|
|
||||||
|
|
||||||
export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
@@ -594,7 +612,7 @@ export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
|||||||
return false
|
return false
|
||||||
if (row.importStatus === 'skipped')
|
if (row.importStatus === 'skipped')
|
||||||
return false
|
return false
|
||||||
if (hasSkipWarning (row))
|
if (hasSkipReason (row))
|
||||||
return false
|
return false
|
||||||
if (row.importStatus === 'failed')
|
if (row.importStatus === 'failed')
|
||||||
return false
|
return false
|
||||||
@@ -609,6 +627,6 @@ export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
|||||||
&& Object.keys (row.validationErrors ?? { }).length === 0).length,
|
&& Object.keys (row.validationErrors ?? { }).length === 0).length,
|
||||||
invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length,
|
invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length,
|
||||||
skipPlanned: rows.filter (row =>
|
skipPlanned: rows.filter (row =>
|
||||||
hasSkipWarning (row) && row.importStatus !== 'created').length,
|
hasSkipReason (row) && row.importStatus !== 'created').length,
|
||||||
created: rows.filter (row => row.importStatus === 'created').length,
|
created: rows.filter (row => row.importStatus === 'created').length,
|
||||||
failed: rows.filter (row => row.importStatus === 'failed').length })
|
failed: rows.filter (row => row.importStatus === 'failed').length })
|
||||||
|
|||||||
@@ -192,13 +192,6 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{row.url}
|
{row.url}
|
||||||
</div>
|
</div>
|
||||||
{row.createdPostId && (
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/posts/${ row.createdPostId }`}
|
|
||||||
className="inline-block text-xs text-sky-700 underline
|
|
||||||
dark:text-sky-300">
|
|
||||||
投稿 #{row.createdPostId}
|
|
||||||
</PrefetchLink>)}
|
|
||||||
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import type { User } from '@/types'
|
|||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
const MAX_ROWS = 100
|
const MAX_ROWS = 100
|
||||||
|
const SOURCE_ERROR_ID = 'post-import-source-error'
|
||||||
|
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
||||||
|
|
||||||
|
|
||||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||||
@@ -45,6 +47,11 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const lineCount = countImportSourceLines (source)
|
const lineCount = countImportSourceLines (source)
|
||||||
const messages = sourceError ? [sourceError] : []
|
const messages = sourceError ? [sourceError] : []
|
||||||
|
const sourceDescribedBy = [
|
||||||
|
sourceError ? SOURCE_ERROR_ID : null,
|
||||||
|
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
|
||||||
|
.filter (_1 => _1 != null)
|
||||||
|
.join (' ')
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
cleanupExpiredPostImportSessions (message =>
|
cleanupExpiredPostImportSessions (message =>
|
||||||
@@ -134,6 +141,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
<textarea
|
<textarea
|
||||||
value={source}
|
value={source}
|
||||||
rows={14}
|
rows={14}
|
||||||
|
aria-invalid={messages.length > 0 || sourceIssues.length > 0}
|
||||||
|
aria-describedby={sourceDescribedBy || undefined}
|
||||||
className={inputClass (
|
className={inputClass (
|
||||||
messages.length > 0 || sourceIssues.length > 0,
|
messages.length > 0 || sourceIssues.length > 0,
|
||||||
'font-mono text-sm',
|
'font-mono text-sm',
|
||||||
@@ -150,8 +159,12 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
setSourceIssues ([])
|
setSourceIssues ([])
|
||||||
}}/>)}
|
}}/>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
<FieldError messages={messages}/>
|
<div id={messages.length > 0 ? SOURCE_ERROR_ID : undefined}>
|
||||||
<ul className="space-y-2 text-sm text-red-700 dark:text-red-300">
|
<FieldError messages={messages}/>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
id={SOURCE_ISSUES_ID}
|
||||||
|
className="space-y-2 text-sm text-red-700 dark:text-red-300">
|
||||||
{sourceIssues.map (issue => (
|
{sourceIssues.map (issue => (
|
||||||
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
|
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
|
||||||
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={fetchTitle}
|
onClick={() => void fetchTitle()}
|
||||||
disabled={!(url) || titleLoading}>
|
disabled={!(url) || titleLoading}>
|
||||||
取得
|
取得
|
||||||
</Button>
|
</Button>
|
||||||
@@ -193,7 +193,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={fetchThumbnail}
|
onClick={() => void fetchThumbnail()}
|
||||||
disabled={!(url) || thumbnailLoading}>
|
disabled={!(url) || thumbnailLoading}>
|
||||||
取得
|
取得
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする