From c51d7b98ba6a41194ff65c408d91030e34858d8e Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 11 Jul 2026 21:11:36 +0900 Subject: [PATCH] #399 --- .../controllers/post_imports_controller.rb | 37 ++++ backend/app/controllers/posts_controller.rb | 40 +--- backend/app/services/post_creator.rb | 112 +++++++++++ .../post_import_google_sheets_fetcher.rb | 59 ++++++ backend/app/services/post_import_previewer.rb | 79 ++++++++ backend/app/services/post_import_runner.rb | 24 +++ .../app/services/post_import_source_parser.rb | 56 ++++++ backend/app/services/post_metadata_fetcher.rb | 14 ++ backend/app/services/preview/http_fetcher.rb | 8 +- backend/config/routes.rb | 5 + frontend/src/App.tsx | 3 + frontend/src/components/TopNav.tsx | 1 + frontend/src/pages/posts/PostImportPage.tsx | 184 ++++++++++++++++++ frontend/src/pages/posts/PostNewPage.tsx | 37 +++- 14 files changed, 617 insertions(+), 42 deletions(-) create mode 100644 backend/app/controllers/post_imports_controller.rb create mode 100644 backend/app/services/post_creator.rb create mode 100644 backend/app/services/post_import_google_sheets_fetcher.rb create mode 100644 backend/app/services/post_import_previewer.rb create mode 100644 backend/app/services/post_import_runner.rb create mode 100644 backend/app/services/post_import_source_parser.rb create mode 100644 backend/app/services/post_metadata_fetcher.rb create mode 100644 frontend/src/pages/posts/PostImportPage.tsx diff --git a/backend/app/controllers/post_imports_controller.rb b/backend/app/controllers/post_imports_controller.rb new file mode 100644 index 0000000..65ffba8 --- /dev/null +++ b/backend/app/controllers/post_imports_controller.rb @@ -0,0 +1,37 @@ +class PostImportsController < ApplicationController + before_action :require_member! + + def preview + source = + if params[:format] == 'google_sheets' + PostImportGoogleSheetsFetcher.fetch!(params[:source], rate_key: current_user.id) + else + params[:source] + end + format = params[:format] == 'google_sheets' ? 'csv' : params[:format] + parsed = PostImportSourceParser.new(source:, format:, + has_header: params[:has_header], json_path: params[:json_path]).parse + url_column = params[:url_column].presence || (parsed[:columns].length == 1 ? 0 : nil) + return render_bad_request('URL 列を選択してください.') if url_column.nil? + + rows = PostImportPreviewer.new(parsed:, url_column:, mappings: params[:mappings], + existing: params[:existing]).preview + render json: parsed.slice(:columns).merge(rows:) + rescue ArgumentError => e + render_bad_request(e.message) + end + + def create + render json: PostImportRunner.new(actor: current_user, rows: params[:rows].to_a).run, + status: :created + end + + private + + def require_member! + return head :unauthorized unless current_user + return if current_user.gte_member? + + head :forbidden + end +end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 339519b..9da8633 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -142,37 +142,13 @@ class PostsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? - # TODO: サイトに応じて thumbnail_base 設定 - title = params[:title].presence - url = params[:url] - thumbnail = params[:thumbnail] - tag_names = params[:tags].to_s.split - original_created_from = params[:original_created_from] - original_created_before = params[:original_created_before] - parent_post_ids = parse_parent_post_ids - resized_thumbnail = thumbnail.present? ? Post.resized_thumbnail_attachment(thumbnail) : nil - - post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, - original_created_from:, original_created_before:) - post.thumbnail.attach(resized_thumbnail) if resized_thumbnail - - ApplicationRecord.transaction do - post.save! - - Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) => - { tags:, sections: } - TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) - - tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) - post.video_ms = normalise_video_ms(tags) - validate_video_sections!(post.video_ms, sections) - post.save! - sync_post_tags!(post, tags, sections) - - sync_parent_posts!(post, parent_post_ids) - - PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) - end + post = PostCreator.new(actor: current_user, + attributes: { title: params[:title], url: params[:url], + thumbnail: params[:thumbnail], tags: params[:tags], + original_created_from: params[:original_created_from], + original_created_before: params[:original_created_before], + parent_post_ids: parse_parent_post_ids, + video_ms: params[:video_ms], duration: params[:duration] }).create! post.reload render json: PostRepr.base(post), status: :created @@ -182,7 +158,7 @@ class PostsController < ApplicationController render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags rescue Tag::SectionLiteralParseError render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] } - rescue VideoMsParseError + rescue PostCreator::VideoMsParseError render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] } rescue MiniMagick::Error render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] } diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb new file mode 100644 index 0000000..816ef48 --- /dev/null +++ b/backend/app/services/post_creator.rb @@ -0,0 +1,112 @@ +class PostCreator + class VideoMsParseError < ArgumentError; end + + def initialize actor:, attributes: + @actor = actor + @attributes = attributes.symbolize_keys + end + + def create! + post = Post.new(title: @attributes[:title].presence, + url: @attributes[:url], + thumbnail_base: @attributes[:thumbnail_base].presence, + uploaded_user: @actor, + original_created_from: @attributes[:original_created_from].presence, + original_created_before: @attributes[:original_created_before].presence) + thumbnail = @attributes[:thumbnail] + post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) if thumbnail.present? + + ApplicationRecord.transaction do + post.save! + Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) => + { tags:, sections: } + TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor) + tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) + post.video_ms = normalise_video_ms(tags) + validate_video_sections!(post.video_ms, sections) + post.save! + sync_post_tags!(post, tags, sections) + sync_parent_posts!(post, parent_post_ids) + PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor) + end + post + end + + private + + def tag_names = @attributes[:tags].to_s.split + + def parent_post_ids + Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token| + id = Integer(token, exception: false) + raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0 + + id + }.uniq + end + + def normalise_video_ms tags + return nil unless tags.any? { _1.id == Tag.video.id } + + video_ms = @attributes[:video_ms] + if video_ms.present? + value = Integer(video_ms, exception: false) + raise VideoMsParseError unless value&.positive? + + return value + end + duration = @attributes[:duration] + return nil if duration.blank? + + return duration.to_i if duration.is_a?(Numeric) && duration.to_i.positive? + + value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間') + raise VideoMsParseError unless value.positive? + + value + rescue Tag::SectionLiteralParseError + raise VideoMsParseError + end + + def validate_video_sections! video_ms, sections + return unless video_ms + + sections.each_value do |ranges| + ranges.each do |begin_ms, end_ms| + next if begin_ms < video_ms && (!end_ms || end_ms <= video_ms) + + post = Post.new + post.errors.add :video_ms, 'タグ区間が動画時間の範囲外です.' + raise ActiveRecord::RecordInvalid, post + end + end + end + + def sync_post_tags! post, desired_tags, sections + desired_ids = desired_tags.map(&:id).to_set + current_ids = post.tags.pluck(:id).to_set + Tag.where(id: desired_ids - current_ids).find_each do |tag| + PostTag.create_or_find_by!(post:, tag:, created_user: @actor) + end + PostTagSection.where(post_id: post.id).destroy_all + sections.each do |tag_id, ranges| + ranges.each { |begin_ms, end_ms| PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:) } + end + PostTag.where(post_id: post.id, tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag| + post_tag.discard_by!(@actor) + end + end + + def sync_parent_posts! post, ids + if ids.include?(post.id) + post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.' + raise ActiveRecord::RecordInvalid, post + end + missing = ids - Post.where(id: ids).pluck(:id) + if missing.present? + post.errors.add :parent_post_ids, "存在しない親投稿 Id. があります: #{ missing.join(' ') }" + raise ActiveRecord::RecordInvalid, post + end + ids.each { |parent_post_id| PostImplication.create_or_find_by!(post:, parent_post_id:) } + end +end diff --git a/backend/app/services/post_import_google_sheets_fetcher.rb b/backend/app/services/post_import_google_sheets_fetcher.rb new file mode 100644 index 0000000..6da506f --- /dev/null +++ b/backend/app/services/post_import_google_sheets_fetcher.rb @@ -0,0 +1,59 @@ +class PostImportGoogleSheetsFetcher + HOST = 'docs.google.com'.freeze + PUBLICATION_ID = /\A[A-Za-z0-9_-]+\z/ + GID = /\A\d+\z/ + RATE_LIMIT = 10 + RATE_WINDOW = 1.minute + + def self.fetch! raw_url, rate_key: + new(raw_url, rate_key:).fetch! + end + + def initialize raw_url, rate_key: + @raw_url = raw_url.to_s + @rate_key = rate_key.to_s + end + + def fetch! + publication_id, gid = parse! + throttle! + csv_url = "https://#{ HOST }/spreadsheets/d/e/#{ publication_id }/pub?gid=#{ gid }&single=true&output=csv" + response = Preview::HttpFetcher.fetch(csv_url, + max_bytes: PostImportSourceParser::MAX_BYTES, + allowed_hosts: [HOST]) + raise ArgumentError, 'スプレッドシートを CSV として取得できませんでした.' unless csv?(response) + + response.body + rescue Preview::UrlSafety::UnsafeUrl, Preview::HttpFetcher::FetchFailed => e + raise ArgumentError, "スプレッドシートを取得できませんでした.ウェブ公開 URL を確認するか、ファイル保存又はコピー貼付けを使用してください: #{ e.message }" + end + + private + + def parse! + uri = URI.parse(@raw_url) + unless uri.scheme == 'https' && uri.host&.downcase == HOST && uri.query.blank? + raise ArgumentError, '公開 Google スプレッドシート URL を指定してください.' + end + match = uri.path.match(%r{\A/spreadsheets/d/e/([^/]+)/pubhtml\z}) + gid = URI.decode_www_form(uri.fragment.to_s).to_h['gid'] + unless match && PUBLICATION_ID.match?(match[1]) && GID.match?(gid.to_s) + raise ArgumentError, '公開 Google スプレッドシート URL の publication_id 又は gid が不正です.' + end + + [match[1], gid] + rescue URI::InvalidURIError + raise ArgumentError, '公開 Google スプレッドシート URL が不正です.' + end + + def throttle! + key = "post-import-google-sheets:#{ @rate_key }" + count = Rails.cache.increment(key, 1, expires_in: RATE_WINDOW, initial: 0) + raise ArgumentError, 'スプレッドシート取得の回数が多すぎます.しばらくしてから再試行してください.' if count > RATE_LIMIT + end + + def csv? response + type = response.content_type.to_s.downcase + type.include?('text/csv') || response.body.valid_encoding? + end +end diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb new file mode 100644 index 0000000..6d4323e --- /dev/null +++ b/backend/app/services/post_import_previewer.rb @@ -0,0 +1,79 @@ +class PostImportPreviewer + FIELDS = %w[title thumbnail_base original_created_from original_created_before duration tags parent_post_ids].freeze + + def initialize parsed:, url_column:, mappings: { }, existing: 'skip' + @parsed = parsed + @url_column = url_column.to_i + @mappings = mappings.to_h.stringify_keys + @existing = existing == 'error' ? 'error' : 'skip' + end + + def preview + urls = { } + @parsed[:rows].map do |row| + values = row[:values] + attributes = FIELDS.to_h { |field| [field, mapped_value(field, values)] } + url = values[@url_column].to_s.strip + metadata, warnings = fetch_metadata(url) + attributes = metadata.merge(attributes) { |_key, automatic, input| input.presence || automatic } + post = Post.new(url:) + post.valid? + normal_url = post.url + errors = post.errors.to_hash.transform_values(&:dup) + errors[:url] = ['URL が重複しています.'] if normal_url.present? && urls.key?(normal_url) + urls[normal_url] = true if normal_url.present? + if normal_url.present? && Post.exists?(url: normal_url) + @existing == 'error' ? errors[:url] = ['既存投稿です.'] : warnings << '既存投稿のためスキップします.' + end + validate_preview_tags(attributes['tags'], errors) + validate_parents(attributes['parent_post_ids'], errors) + { source_row: row[:source_row], url: normal_url || url, attributes:, warnings:, errors:, + status: errors.present? ? 'error' : (warnings.present? ? 'warning' : 'ready'), + existing: normal_url.present? && Post.exists?(url: normal_url) } + end + end + + private + + def mapped_value field, values + mapping = @mappings[field] + return '' if mapping.blank? + return mapping['value'].to_s if mapping['kind'] == 'fixed' + + Array(mapping['columns']).filter_map { |index| values[index.to_i].to_s.presence }.join(field == 'tags' ? ' ' : '') + end + + def fetch_metadata url + return [{ }, ['URL が空です.']] if url.blank? + + data = PostMetadataFetcher.fetch(url) + 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 + [{ }, ["メタデータを取得できませんでした: #{ e.message }"]] + end + + def validate_preview_tags raw, errors + names = raw.to_s.split + return if names.empty? + if names.any? { _1.downcase.start_with?('nico:') } + errors[:tags] = ['ニコニコ・タグは直接指定できません.'] + return + end + canonical = names.map { TagName.canonicalise(_1.sub(/\[.*\]\z/, '')).first } + if Tag.joins(:tag_name).where(tag_names: { name: canonical }, deprecated_at: nil).count < canonical.uniq.length + errors[:tags] = ['存在しないタグ又は廃止済みタグがあります.'] + end + rescue Tag::SectionLiteralParseError + errors[:tags] = ['タグ区間の記法が不正です.'] + end + + def validate_parents raw, errors + ids = raw.to_s.split.map { Integer(_1, exception: false) } + return if ids.compact.empty? && raw.to_s.blank? + errors[:parent_post_ids] = ['親投稿 Id. が不正です.'] and return if ids.any? { _1.nil? || _1 <= 0 } + errors[:parent_post_ids] = ['存在しない親投稿 Id. があります.'] if Post.where(id: ids).count != ids.uniq.length + end +end diff --git a/backend/app/services/post_import_runner.rb b/backend/app/services/post_import_runner.rb new file mode 100644 index 0000000..69231da --- /dev/null +++ b/backend/app/services/post_import_runner.rb @@ -0,0 +1,24 @@ +class PostImportRunner + def initialize actor:, rows: + @actor = actor + @rows = rows + end + + def run + results = @rows.map { |row| run_row(row.to_h.stringify_keys) } + { 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 + return row.slice('source_row').merge(status: 'skipped') if row['status'] == 'skipped' || row['existing'] + attributes = row.fetch('attributes', { }).to_h.transform_keys { _1.to_s.underscore } + attributes['url'] = row['url'] + post = PostCreator.new(actor: @actor, attributes:).create! + { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) } + rescue StandardError => e + { source_row: row['source_row'], status: 'failed', errors: { base: [e.message] } } + end +end diff --git a/backend/app/services/post_import_source_parser.rb b/backend/app/services/post_import_source_parser.rb new file mode 100644 index 0000000..999cb1c --- /dev/null +++ b/backend/app/services/post_import_source_parser.rb @@ -0,0 +1,56 @@ +require 'csv' + +class PostImportSourceParser + MAX_ROWS = 100 + MAX_BYTES = 1.megabyte + MAX_CELL_BYTES = 20.kilobytes + + def initialize source:, format:, has_header: false, json_path: nil + @source = source.to_s + @format = format.to_s + @has_header = ActiveModel::Type::Boolean.new.cast(has_header) + @json_path = json_path.to_s + end + + def parse + raise ArgumentError, '入力が大きすぎます.' if @source.bytesize > MAX_BYTES + + @format == 'json' ? parse_json : parse_delimited + end + + private + + def parse_delimited + separator = @format == 'csv' ? ',' : "\t" + table = CSV.parse(@source, col_sep: separator).map { |row| row.map { _1.to_s.strip } } + raise ArgumentError, '入力が空です.' if table.empty? + + headers = @has_header ? table.shift : nil + ensure_limits!(table) + width = table.map(&:length).max.to_i + { columns: (0...width).map { |index| { key: index.to_s, + label: "#{ ('A'.ord + index).chr }:#{ headers&.[](index).presence || '列' }" } }, + rows: table.each_with_index.map { |values, index| { source_row: index + (@has_header ? 2 : 1), values: } } } + rescue CSV::MalformedCSVError => e + raise ArgumentError, "CSV・TSV の形式が不正です: #{ e.message }" + end + + def parse_json + value = JSON.parse(@source, max_nesting: 20) + @json_path.split('.').reject(&:blank?).each { |part| value = value.is_a?(Array) ? value[Integer(part)] : value.fetch(part) } + raise ArgumentError, 'JSON の投稿候補は配列にしてください.' unless value.is_a?(Array) + + ensure_limits!(value) + keys = value.grep(Hash).flat_map(&:keys).map(&:to_s).uniq + { columns: keys.each_with_index.map { |key, index| { key:, label: "#{ ('A'.ord + index).chr }:#{ key }" } }, + rows: value.each_with_index.map { |item, index| { source_row: index + 1, + values: keys.map { item.is_a?(Hash) ? item[_1] || item[_1.to_sym] : nil } } } } + rescue JSON::ParserError, KeyError, ArgumentError => e + raise ArgumentError, "JSON の形式が不正です: #{ e.message }" + end + + def ensure_limits! rows + raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS + raise ArgumentError, 'セルが大きすぎます.' if rows.flatten.any? { _1.to_s.bytesize > MAX_CELL_BYTES } + end +end diff --git a/backend/app/services/post_metadata_fetcher.rb b/backend/app/services/post_metadata_fetcher.rb new file mode 100644 index 0000000..4ea6acb --- /dev/null +++ b/backend/app/services/post_metadata_fetcher.rb @@ -0,0 +1,14 @@ +class PostMetadataFetcher + def self.fetch raw_url + uri, = Preview::UrlSafety.validate(raw_url) + response = Preview::HttpFetcher.fetch(uri.to_s, max_bytes: Preview::ThumbnailFetcher::HTML_MAX_BYTES) + metadata = Preview::HtmlMetadataExtractor.extract(response) + document = Nokogiri::HTML.parse(response.body) + content = -> name { document.at_css("meta[property='#{ name }'], meta[name='#{ name }']")&.[]('content')&.strip.presence } + duration = content.call('og:video:duration') || content.call('video:duration') + { title: metadata[:title], + thumbnail_base: Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url], + original_created_from: content.call('article:published_time') || content.call('date'), + duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil } } + end +end diff --git a/backend/app/services/preview/http_fetcher.rb b/backend/app/services/preview/http_fetcher.rb index 30c1644..0ca503e 100644 --- a/backend/app/services/preview/http_fetcher.rb +++ b/backend/app/services/preview/http_fetcher.rb @@ -12,8 +12,12 @@ module Preview Response = Data.define(:body, :content_type, :url) - def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS) + def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS, + allowed_hosts: nil) uri, addresses = UrlSafety.validate(raw_url) + 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) @@ -50,7 +54,7 @@ module Preview raise FetchFailed, 'redirect 先が不正です.' end - return fetch(redirect_url, max_bytes:, redirects: redirects - 1) + return fetch(redirect_url, max_bytes:, redirects: redirects - 1, allowed_hosts:) end unless response.is_a?(Net::HTTPSuccess) diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 223332a..afa1f06 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -33,6 +33,11 @@ Rails.application.routes.draw do get :thumbnail end + scope 'posts/import', controller: :post_imports do + post :preview + post '', action: :create + end + resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do collection do get :search diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c2aa89b..afccd62 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,7 @@ import PostDetailPage from '@/pages/posts/PostDetailPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostListPage from '@/pages/posts/PostListPage' import PostNewPage from '@/pages/posts/PostNewPage' +import PostImportPage from '@/pages/posts/PostImportPage' import PostSearchPage from '@/pages/posts/PostSearchPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' import SettingPage from '@/pages/users/SettingPage' @@ -75,6 +76,7 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: { }/> }/> }/> + }/> }/> }/> }/> @@ -113,6 +115,7 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 034d5c5..e2215af 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -43,6 +43,7 @@ export const menuOutline = ( { name: '一覧', to: '/posts' }, { name: '検索', to: '/posts/search' }, { name: '追加', to: '/posts/new' }, + { name: 'インポート', to: '/posts/import' }, { name: '全体履歴', to: '/posts/changes' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'タグ', to: '/tags', subMenu: [ diff --git a/frontend/src/pages/posts/PostImportPage.tsx b/frontend/src/pages/posts/PostImportPage.tsx new file mode 100644 index 0000000..5a4f19f --- /dev/null +++ b/frontend/src/pages/posts/PostImportPage.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react' +import { Helmet } from 'react-helmet-async' + +import FieldError from '@/components/common/FieldError' +import Form from '@/components/common/Form' +import FormField from '@/components/common/FormField' +import PageTitle from '@/components/common/PageTitle' +import MainArea from '@/components/layout/MainArea' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' +import { SITE_TITLE } from '@/config' +import { apiPost } from '@/lib/api' +import { canEditContent } from '@/lib/users' +import { inputClass } from '@/lib/utils' +import Forbidden from '@/pages/Forbidden' + +import type { FC } from 'react' + +import type { User } from '@/types' + +type Props = { user: User | null } +type Column = { key: string, label: string } +type ImportRow = { + sourceRow: number + url: string + attributes: Record + warnings: string[] + errors: Record + status: 'ready' | 'warning' | 'error' + existing: boolean } + + +const detectFormat = (source: string): 'csv' | 'tsv' => + source.includes ('\t') ? 'tsv' : (source.includes (',') ? 'csv' : 'tsv') + +const publicGoogleSheetURL = (source: string): boolean => + /^https:\/\/docs\.google\.com\/spreadsheets\/d\/e\/[A-Za-z0-9_-]+\/pubhtml#gid=\d+$/.test ( + source.trim ()) + + +const PostImportPage: FC = ({ user }) => { + const [source, setSource] = useState ('') + const [format, setFormat] = useState<'csv' | 'tsv' | 'json' | 'google_sheets'> ('tsv') + const [hasHeader, setHasHeader] = useState (false) + const [jsonPath, setJsonPath] = useState ('') + const [urlColumn, setUrlColumn] = useState ('0') + const [columns, setColumns] = useState ([]) + const [rows, setRows] = useState ([]) + const [mappings, setMappings] = useState> ({ }) + const [existing, setExisting] = useState<'skip' | 'error'> ('skip') + const [loading, setLoading] = useState (false) + const editable = canEditContent (user) + + const preview = async () => { + setLoading (true) + try + { + const data = await apiPost<{ columns: Column[], rows: ImportRow[] }> ('/posts/import/preview', { + source, + format, + has_header: hasHeader, + json_path: jsonPath, + url_column: urlColumn, + mappings, + existing }) + setColumns (data.columns) + setRows (data.rows) + } + catch + { + toast ({ title: '読込みに失敗しました', description: '入力形式を確認してください。' }) + } + finally + { + setLoading (false) + } + } + + const updateAttribute = (index: number, field: string, value: string) => { + setRows (current => current.map ((row, rowIndex) => + rowIndex === index ? { ...row, attributes: { ...row.attributes, [field]: value } } : row)) + } + + const submit = async () => { + const targets = rows.filter (row => row.status !== 'error' && !(row.existing && existing === 'skip')) + if (targets.length === 0) + return + setLoading (true) + try + { + const result = await apiPost<{ created: number, skipped: number, failed: number }> ('/posts/import', + { rows: targets }) + toast ({ title: `登録完了: ${result.created} 件`, + description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` }) + } + catch + { + toast ({ title: '登録に失敗しました' }) + } + finally + { + setLoading (false) + } + } + + if (!(editable)) + return + + return ( + + {`投稿インポート | ${SITE_TITLE}`} + 投稿インポート + {rows.length === 0 ? ( +
+

+ URL を一行に一件ずつ貼り付けるか、CSV・TSV・JSON を入力してください。 +

+ {publicGoogleSheetURL (source) && ( +
+ 公開 Google スプレッドシート URL + + +
)} + + {() => } + + + {() =>