class PostCreatePlan VIDEO_TAG_NAME = '動画'.freeze TAGME_TAG_NAME = 'タグ希望'.freeze NO_DEERJIKIST_TAG_NAME = 'ニジラー情報不詳'.freeze def initialize attributes: @attributes = attributes.symbolize_keys @existing_tags_by_name = nil end def build! direct_tag_specs, tag_sections = parse_direct_tag_specs default_tag_specs = build_default_tag_specs(direct_tag_specs) snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs) post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs) video_ms = normalise_video_ms(snapshot_tag_specs) validate_video_sections!(video_ms, tag_sections) parent_post_ids = normalise_parent_post_ids validate_parent_post_ids!(parent_post_ids) { url: @attributes[:url], title: @attributes[:title].to_s, thumbnail_base: @attributes[:thumbnail_base].presence, original_created_from: @attributes[:original_created_from].presence, original_created_before: @attributes[:original_created_before].presence, tags: serialised_tags(direct_tag_specs, tag_sections), duration: @attributes[:duration].to_s, video_ms: video_ms, parent_post_ids: parent_post_ids.join(' '), direct_tag_specs: direct_tag_specs, default_tag_specs: default_tag_specs, snapshot_tag_specs: snapshot_tag_specs, post_tag_specs: post_tag_specs, tag_sections: tag_sections, normalised_parent_post_ids: parent_post_ids } end private def tag_names = @attributes[:tags].to_s.split def parse_direct_tag_specs tag_sections = { } direct_tag_specs = [] tag_names.each do |raw_name| tag_name, category, sections = parse_raw_tag_name(raw_name) existing_tag = existing_tags_by_name[tag_name] raise Tag::NicoTagNormalisationError if existing_tag&.nico? raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated? direct_tag_specs << { name: tag_name, category: (category || existing_tag&.category || 'general').to_sym } if sections.present? tag_sections[tag_name] ||= [] tag_sections[tag_name].concat(sections) tag_sections[tag_name] = Tag.merge_section_ranges(tag_sections[tag_name]) tag_sections.delete(tag_name) if tag_sections[tag_name] == [[0, nil]] end end [merge_tag_specs(direct_tag_specs), tag_sections] end def parse_raw_tag_name raw_name name = raw_name.to_s prefix, category = Tag::CATEGORY_PREFIXES.find { name.downcase.start_with?(_1[0]) } || ['', nil] name = name.sub(/\A#{ prefix }/i, '') sections = [] while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)) name = "#{ match[1] }#{ match[4] }" next if match[2].empty? && match[3].empty? sections << Tag.normalise_section_range!( begin_raw: match[2], end_raw: match[3], tag_name: name) end raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']') [TagName.canonicalise(name).first, category&.to_sym, sections] end def build_default_tag_specs direct_tag_specs default_tag_specs = [] if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME } default_tag_specs << { name: TAGME_TAG_NAME, category: :meta } end if direct_tag_specs.none? { deerjikist_tag_spec?(_1) } default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta } end default_tag_specs end def expand_parent_tag_specs snapshot_tag_specs existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] } expanded_parent_specs = Tag.expand_parent_tags(existing_snapshot_tags) .reject(&:deprecated?) .map { |tag| { name: tag.name, category: tag.category.to_sym } } merge_tag_specs(snapshot_tag_specs + expanded_parent_specs) end def merge_tag_specs specs specs.each_with_object({ }) do |spec, merged| merged[spec[:name]] = if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general merged[spec[:name]] else { name: spec[:name], category: spec[:category] } end end.values.sort_by { _1[:name] } end def existing_tags_by_name @existing_tags_by_name ||= begin names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name) end end def canonical_tag_name_without_sections raw_name name, = parse_raw_tag_name(raw_name) name end def deerjikist_tag_spec? spec return true if spec[:category] == :deerjikist existing_tags_by_name[spec[:name]]&.deerjikist? end def normalise_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.sort end def validate_parent_post_ids! ids missing = ids - Post.where(id: ids).pluck(:id) raise ArgumentError, "存在しない親投稿 Id. があります: #{ missing.join(' ') }" if missing.present? end def serialised_tags direct_tag_specs, tag_sections direct_tag_specs.map { |spec| "#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) }.join }" }.sort.join(' ') end def normalise_video_ms snapshot_tag_specs return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME } video_ms = @attributes[:video_ms] if video_ms.present? value = Integer(video_ms, exception: false) raise PostCreator::VideoMsParseError unless value&.positive? return value end duration = @attributes[:duration] return nil if duration.blank? value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間') raise PostCreator::VideoMsParseError unless value.positive? value rescue Tag::SectionLiteralParseError raise PostCreator::VideoMsParseError end def validate_video_sections! video_ms, tag_sections return unless video_ms tag_sections.each_value do |ranges| ranges.each do |begin_ms, end_ms| if begin_ms >= video_ms post = Post.new post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.' raise ActiveRecord::RecordInvalid, post end if end_ms && end_ms > video_ms post = Post.new post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.' raise ActiveRecord::RecordInvalid, post end end end end end