このコミットが含まれているのは:
@@ -61,6 +61,13 @@ class PostImportsController < ApplicationController
|
|||||||
columns = Array(value['columns'])
|
columns = Array(value['columns'])
|
||||||
rows = Array(value['rows'])
|
rows = Array(value['rows'])
|
||||||
raise ArgumentError, '解析結果の形式が不正です.' unless columns.all?(Hash) && rows.all?(Hash)
|
raise ArgumentError, '解析結果の形式が不正です.' unless columns.all?(Hash) && rows.all?(Hash)
|
||||||
|
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportSourceParser::MAX_ROWS
|
||||||
|
raise ArgumentError, '列数が多すぎます.' if columns.length > 50
|
||||||
|
unless rows.all? { |row| Array(row['values']).length <= columns.length &&
|
||||||
|
Array(row['values']).all? { _1.is_a?(String) && _1.bytesize <= PostImportSourceParser::MAX_CELL_BYTES } }
|
||||||
|
raise ArgumentError, '解析結果のセルが不正です.'
|
||||||
|
end
|
||||||
|
raise ArgumentError, '解析結果が大きすぎます.' if rows.sum { Array(_1['values']).sum(&:bytesize) } > PostImportSourceParser::MAX_BYTES
|
||||||
|
|
||||||
{ columns: columns.map { |column| column.slice('key', 'label') },
|
{ columns: columns.map { |column| column.slice('key', 'label') },
|
||||||
rows: rows.map { |row| { source_row: row['source_row'], values: Array(row['values']) } } }
|
rows: rows.map { |row| { source_row: row['source_row'], values: Array(row['values']) } } }
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ class PostImportPreviewer
|
|||||||
should_fetch = fetch_metadata == true || fetch_metadata.to_i == row[:source_row].to_i
|
should_fetch = fetch_metadata == true || fetch_metadata.to_i == row[:source_row].to_i
|
||||||
metadata, fetch_warnings = metadata_for(url, should_fetch, metadata_cache, fetch_warnings)
|
metadata, fetch_warnings = metadata_for(url, should_fetch, metadata_cache, fetch_warnings)
|
||||||
metadata.each do |field, value|
|
metadata.each do |field, value|
|
||||||
|
if field == 'tags' && provenance[field] != 'manual'
|
||||||
|
previous_origin = provenance[field]
|
||||||
|
attributes[field] = [attributes[field], value].compact.join(' ').split.uniq.join(' ')
|
||||||
|
provenance[field] = previous_origin == 'automatic' ? 'automatic' : previous_origin
|
||||||
|
next
|
||||||
|
end
|
||||||
next unless provenance[field] == 'automatic' || attributes[field].blank?
|
next unless provenance[field] == 'automatic' || attributes[field].blank?
|
||||||
|
|
||||||
attributes[field] = value
|
attributes[field] = value
|
||||||
|
|||||||
@@ -7,21 +7,39 @@ class PostMetadataFetcher
|
|||||||
content = -> name { document.at_css("meta[property='#{ name }'], meta[name='#{ name }']")&.[]('content')&.strip.presence }
|
content = -> name { document.at_css("meta[property='#{ name }'], meta[name='#{ name }']")&.[]('content')&.strip.presence }
|
||||||
duration = content.call('og:video:duration') || content.call('video:duration')
|
duration = content.call('og:video:duration') || content.call('video:duration')
|
||||||
published = content.call('article:published_time') || content.call('date')
|
published = content.call('article:published_time') || content.call('date')
|
||||||
published_at = Time.zone.parse(published.to_s) if published.present?
|
created_range = original_created_range(published)
|
||||||
platform_tags = platform_tags(uri)
|
platform_tags = platform_tags(uri)
|
||||||
{ title: metadata[:title],
|
{ title: metadata[:title],
|
||||||
thumbnail_base: Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
|
thumbnail_base: Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
|
||||||
original_created_from: published_at&.iso8601,
|
original_created_from: created_range&.first&.iso8601,
|
||||||
original_created_before: published_at && (published_at + 1.minute).iso8601,
|
original_created_before: created_range&.last&.iso8601,
|
||||||
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil },
|
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil },
|
||||||
tags: platform_tags.join(' ') }
|
tags: platform_tags.join(' ') }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.platform_tags uri
|
def self.platform_tags uri
|
||||||
return ['動画', 'YouTube'] if Preview::KnownSiteExtractor.thumbnail_url(uri)
|
return ['動画', 'YouTube'] if Preview::KnownSiteExtractor.youtube_video_id(uri)
|
||||||
return ['動画', 'ニコニコ'] if Preview::KnownSiteExtractor.niconico_video_id(uri)
|
return ['動画', 'ニコニコ'] if Preview::KnownSiteExtractor.niconico_video_id(uri)
|
||||||
|
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
private_class_method :platform_tags
|
|
||||||
|
def self.original_created_range value
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
raw = value.to_s.strip
|
||||||
|
from = Time.zone.parse(raw)
|
||||||
|
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 /:\d{2}(?:Z|[+-]\d{2}:?\d{2})?\z/ then from + 1.second
|
||||||
|
else from + 1.minute
|
||||||
|
end
|
||||||
|
[from, before]
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
private_class_method :platform_tags, :original_created_range
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ module Preview
|
|||||||
youtube_thumbnail(uri)
|
youtube_thumbnail(uri)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.youtube_video_id(uri)
|
||||||
|
case uri.host&.downcase
|
||||||
|
when 'youtu.be'
|
||||||
|
uri.path.split('/').reject(&:blank?).first
|
||||||
|
when 'www.youtube.com', 'youtube.com', 'm.youtube.com'
|
||||||
|
uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil
|
||||||
|
end&.then { _1 if _1.match?(/\A[A-Za-z0-9_-]{6,20}\z/) }
|
||||||
|
end
|
||||||
|
|
||||||
def self.niconico_video_id(uri)
|
def self.niconico_video_id(uri)
|
||||||
case uri.host&.downcase
|
case uri.host&.downcase
|
||||||
when 'www.nicovideo.jp', 'nicovideo.jp'
|
when 'www.nicovideo.jp', 'nicovideo.jp'
|
||||||
@@ -14,14 +23,8 @@ module Preview
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.youtube_thumbnail(uri)
|
def self.youtube_thumbnail(uri)
|
||||||
id =
|
id = youtube_video_id(uri)
|
||||||
case uri.host&.downcase
|
return unless id
|
||||||
when 'youtu.be'
|
|
||||||
uri.path.split('/').reject(&:blank?).first
|
|
||||||
when 'www.youtube.com', 'youtube.com', 'm.youtube.com'
|
|
||||||
uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil
|
|
||||||
end
|
|
||||||
return unless id&.match?(/\A[A-Za-z0-9_-]{6,20}\z/)
|
|
||||||
|
|
||||||
"https://i.ytimg.com/vi/#{ id }/hqdefault.jpg"
|
"https://i.ytimg.com/vi/#{ id }/hqdefault.jpg"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type ImportRow = {
|
|||||||
status: 'ready' | 'warning' | 'error'
|
status: 'ready' | 'warning' | 'error'
|
||||||
existing: boolean }
|
existing: boolean }
|
||||||
type ParsedImport = { columns: Column[], rows: { sourceRow: number, values: string[] }[] }
|
type ParsedImport = { columns: Column[], rows: { sourceRow: number, values: string[] }[] }
|
||||||
|
type Phase = 'source' | 'mapping' | 'preview' | 'result'
|
||||||
|
|
||||||
|
|
||||||
const detectFormat = (source: string): 'csv' | 'tsv' =>
|
const detectFormat = (source: string): 'csv' | 'tsv' =>
|
||||||
@@ -42,6 +43,7 @@ const publicGoogleSheetURL = (source: string): boolean =>
|
|||||||
|
|
||||||
const PostImportPage: FC<Props> = ({ user }) => {
|
const PostImportPage: FC<Props> = ({ user }) => {
|
||||||
const [source, setSource] = useState ('')
|
const [source, setSource] = useState ('')
|
||||||
|
const [phase, setPhase] = useState<Phase> ('source')
|
||||||
const [format, setFormat] = useState<'csv' | 'tsv' | 'json' | 'google_sheets'> ('tsv')
|
const [format, setFormat] = useState<'csv' | 'tsv' | 'json' | 'google_sheets'> ('tsv')
|
||||||
const [hasHeader, setHasHeader] = useState (false)
|
const [hasHeader, setHasHeader] = useState (false)
|
||||||
const [jsonPath, setJsonPath] = useState ('')
|
const [jsonPath, setJsonPath] = useState ('')
|
||||||
@@ -69,6 +71,15 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
setParsed (data)
|
setParsed (data)
|
||||||
if (data.columns.length === 1)
|
if (data.columns.length === 1)
|
||||||
setUrlColumn ('0')
|
setUrlColumn ('0')
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const candidate = data.columns.map ((_, index) => ({ index,
|
||||||
|
count: data.rows.filter (row => /^https?:\/\//.test (row.values[index] ?? '')).length }))
|
||||||
|
.sort ((a, b) => b.count - a.count)[0]
|
||||||
|
if (candidate?.count)
|
||||||
|
setUrlColumn (String (candidate.index))
|
||||||
|
}
|
||||||
|
setPhase ('mapping')
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -92,6 +103,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
mappings,
|
mappings,
|
||||||
existing })
|
existing })
|
||||||
setRows (data.rows)
|
setRows (data.rows)
|
||||||
|
setPhase ('preview')
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -168,6 +180,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
{ rows: targets })
|
{ rows: targets })
|
||||||
toast ({ title: `登録完了: ${result.created} 件`,
|
toast ({ title: `登録完了: ${result.created} 件`,
|
||||||
description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` })
|
description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` })
|
||||||
|
setPhase ('result')
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -186,7 +199,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet><title>{`投稿インポート | ${SITE_TITLE}`}</title></Helmet>
|
<Helmet><title>{`投稿インポート | ${SITE_TITLE}`}</title></Helmet>
|
||||||
<PageTitle>投稿インポート</PageTitle>
|
<PageTitle>投稿インポート</PageTitle>
|
||||||
{rows.length === 0 ? (
|
{phase === 'source' ? (
|
||||||
<Form>
|
<Form>
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
URL を一行に一件ずつ貼り付けるか、CSV・TSV・JSON を入力してください。
|
URL を一行に一件ずつ貼り付けるか、CSV・TSV・JSON を入力してください。
|
||||||
@@ -224,7 +237,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
非公開シートには対応していません。ウェブ公開 URL を使用するか、CSV を保存して選択、又は内容を貼り付けてください。
|
非公開シートには対応していません。ウェブ公開 URL を使用するか、CSV を保存して選択、又は内容を貼り付けてください。
|
||||||
</p>}
|
</p>}
|
||||||
<Button type="button" onClick={parseSource} disabled={loading || !(source)}>列を解析</Button>
|
<Button type="button" onClick={parseSource} disabled={loading || !(source)}>列を解析</Button>
|
||||||
</Form>) : parsed && rows.length === 0 ? (
|
</Form>) : phase === 'mapping' && parsed ? (
|
||||||
<Form>
|
<Form>
|
||||||
<PageTitle>列の割当て</PageTitle>
|
<PageTitle>列の割当て</PageTitle>
|
||||||
<p>URL 列と補助列を指定してから、投稿情報を取得します。</p>
|
<p>URL 列と補助列を指定してから、投稿情報を取得します。</p>
|
||||||
@@ -238,8 +251,10 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
<option value="">指定なし</option>
|
<option value="">指定なし</option>
|
||||||
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
|
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
|
||||||
</select></label>)}
|
</select></label>)}
|
||||||
|
<div className="overflow-x-auto"><table className="text-sm"><thead><tr>{columns.map (column => <th key={column.key}>{column.label}</th>)}</tr></thead><tbody>{parsed.rows.slice (0, 5).map (row => <tr key={row.sourceRow}>{row.values.map ((value, index) => <td key={index}>{value}</td>)}</tr>)}</tbody></table></div>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setPhase ('source')}>入力元へ戻る</Button>
|
||||||
<Button type="button" onClick={preview} disabled={loading || urlColumn === ''}>投稿情報を取得して確認</Button>
|
<Button type="button" onClick={preview} disabled={loading || urlColumn === ''}>投稿情報を取得して確認</Button>
|
||||||
</Form>) : (
|
</Form>) : phase === 'preview' ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
|
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
|
||||||
<label>URL 列 <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
|
<label>URL 列 <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
|
||||||
@@ -258,7 +273,12 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="space-y-3 md:hidden">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute}/>)}</div>
|
<div className="space-y-3 md:hidden">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute}/>)}</div>
|
||||||
<div className="hidden overflow-x-auto md:block"><table className="w-full text-sm"><thead><tr><th>行</th><th>URL</th><th>タイトル</th><th>サムネール</th><th>日時</th><th>動画時間</th><th>タグ</th><th>親投稿</th><th>状態</th></tr></thead>
|
<div className="hidden overflow-x-auto md:block"><table className="w-full text-sm"><thead><tr><th>行</th><th>URL</th><th>タイトル</th><th>サムネール</th><th>日時</th><th>動画時間</th><th>タグ</th><th>親投稿</th><th>状態</th></tr></thead>
|
||||||
<tbody>{rows.map ((row, index) => <tr key={row.sourceRow} className="border-t dark:border-neutral-700"><td>{row.sourceRow}</td><td><input value={row.url} onChange={ev => updateURL (index, ev.target.value)}/></td><td><input value={row.attributes.title ?? ''} onChange={ev => updateAttribute (index, 'title', ev.target.value)}/></td><td><input value={row.attributes.thumbnailBase ?? ''} onChange={ev => updateAttribute (index, 'thumbnailBase', ev.target.value)}/></td><td><input value={row.attributes.originalCreatedFrom ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedFrom', ev.target.value)}/><input value={row.attributes.originalCreatedBefore ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedBefore', ev.target.value)}/></td><td><input value={String (row.attributes.duration ?? '')} onChange={ev => updateAttribute (index, 'duration', ev.target.value)}/></td><td><input value={row.attributes.tags ?? ''} onChange={ev => updateAttribute (index, 'tags', ev.target.value)}/></td><td><input value={row.attributes.parentPostIds ?? ''} onChange={ev => updateAttribute (index, 'parentPostIds', ev.target.value)}/></td><td>{row.status}<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/></td></tr>)}</tbody></table></div>
|
<tbody>{rows.map ((row, index) => <tr key={row.sourceRow} className="border-t dark:border-neutral-700"><td>{row.sourceRow}</td><td><input value={row.url} onChange={ev => updateURL (index, ev.target.value)}/></td><td><input value={row.attributes.title ?? ''} onChange={ev => updateAttribute (index, 'title', ev.target.value)}/></td><td><input value={row.attributes.thumbnailBase ?? ''} onChange={ev => updateAttribute (index, 'thumbnailBase', ev.target.value)}/></td><td><input value={row.attributes.originalCreatedFrom ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedFrom', ev.target.value)}/><input value={row.attributes.originalCreatedBefore ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedBefore', ev.target.value)}/></td><td><input value={String (row.attributes.duration ?? '')} onChange={ev => updateAttribute (index, 'duration', ev.target.value)}/></td><td><input value={row.attributes.tags ?? ''} onChange={ev => updateAttribute (index, 'tags', ev.target.value)}/></td><td><input value={row.attributes.parentPostIds ?? ''} onChange={ev => updateAttribute (index, 'parentPostIds', ev.target.value)}/></td><td>{row.status}<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/></td></tr>)}</tbody></table></div>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setPhase ('mapping')}>列割当てへ戻る</Button>
|
||||||
<Button type="button" onClick={submit} disabled={loading}>有効な投稿を一括登録</Button>
|
<Button type="button" onClick={submit} disabled={loading}>有効な投稿を一括登録</Button>
|
||||||
|
</div>) : (
|
||||||
|
<div className="space-y-3"><p>インポート処理が完了しました。</p>
|
||||||
|
<Button type="button" onClick={() => setPhase ('preview')}>結果を確認</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setPhase ('source')}>新しい入力元を選ぶ</Button>
|
||||||
</div>)}
|
</div>)}
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする