【再掲】タグの局所記載 (#351) (#380)

#353 を再開できなかったので.

Reviewed-on: #380
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #380 でマージされました.
このコミットが含まれているのは:
2026-07-03 03:23:36 +09:00
committed by みてるぞ
コミット fdf652951a
32個のファイルの変更920行の追加63行の削除
+5
ファイルの表示
@@ -25,6 +25,7 @@ class PostVersionsController < ApplicationController
SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
@@ -74,6 +75,10 @@ class PostVersionsController < ApplicationController
current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base']
},
video_ms: {
current: row.video_ms,
prev: row.attributes['prev_video_ms']
},
tags: build_version_tags(cur_tags, prev_tags),
original_created_from: {
current: row.original_created_from&.iso8601,
+134 -25
ファイルの表示
@@ -1,6 +1,10 @@
class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
class VideoMsParseError < ArgumentError
;
end
def index
url = params[:url].presence
title = params[:title].presence
@@ -45,7 +49,9 @@ class PostsController < ApplicationController
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -97,7 +103,9 @@ class PostsController < ApplicationController
def random
post = filtered_posts.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
@@ -110,7 +118,9 @@ class PostsController < ApplicationController
post =
Post
.includes(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post
@@ -125,7 +135,7 @@ class PostsController < ApplicationController
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post.tags))
.merge(tags: build_tag_tree_for(post))
end
def create
@@ -149,11 +159,15 @@ class PostsController < ApplicationController
ApplicationRecord.transaction do
post.save!
tags = Tag.normalise_tags!(tag_names, deny_deprecated: true)
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?)
sync_post_tags!(post, tags)
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)
@@ -166,6 +180,10 @@ class PostsController < ApplicationController
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e
@@ -224,6 +242,8 @@ class PostsController < ApplicationController
original_created_from:,
original_created_before:,
tag_names:,
video_ms_param: params[:video_ms],
duration_param: params[:duration],
parent_post_ids:)
snapshot_to_apply =
@@ -254,12 +274,16 @@ class PostsController < ApplicationController
post.reload
json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post.tags)
json['tags'] = build_tag_tree_for(post)
render json:, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e
@@ -358,7 +382,7 @@ class PostsController < ApplicationController
def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def sync_post_tags! post, desired_tags
def sync_post_tags! post, desired_tags, sections
desired_tags.each do |t|
t.save! if t.new_record?
end
@@ -377,13 +401,21 @@ class PostsController < ApplicationController
end
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: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
end
end
def build_tag_tree_for tags
tags = tags.reject(&:deprecated?).to_a
def build_tag_tree_for post
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
@@ -398,6 +430,9 @@ class PostsController < ApplicationController
root_ids = tag_ids - child_ids
tags_by_id = tags.index_by(&:id)
sections_by_tag_id = post_tags.to_h { |post_tag|
[post_tag.tag_id, post_tag.sections.as_json(only: [:begin_ms, :end_ms])]
}
memo = { }
@@ -405,8 +440,10 @@ class PostsController < ApplicationController
tag = tags_by_id[tag_id]
return nil unless tag
sections = sections_by_tag_id.fetch(tag_id, [])
if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: [])
return TagRepr.inline(tag).merge(children: [], sections:)
end
if memo.key?(tag_id)
@@ -418,7 +455,7 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.inline(tag).merge(children:)
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
@@ -487,6 +524,7 @@ class PostsController < ApplicationController
def post_snapshot_from_version version
{ title: version.title,
video_ms: version.respond_to?(:video_ms) ? version.video_ms : nil,
original_created_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before),
tag_names: editable_tag_names_from_version(version),
@@ -499,6 +537,7 @@ class PostsController < ApplicationController
def post_snapshot_from_record post
{ title: post.title,
video_ms: post.video_ms,
original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post),
@@ -506,16 +545,41 @@ class PostsController < ApplicationController
end
def editable_tag_names_from_post post
post.tags.not_nico.where(deprecated_at: nil)
.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids:
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
validate_video_sections!(video_ms, sections)
{ title:,
video_ms:,
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
tag_names: incoming_tag_names_for_snapshot(tag_names),
tag_names: tags.uniq(&:id).map { |tag|
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
}.sort,
parent_post_ids: parent_post_ids.sort }
end
@@ -538,11 +602,8 @@ class PostsController < ApplicationController
value.to_s
end
def incoming_tag_names_for_snapshot raw_tag_names
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false,
deny_deprecated: true)
Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort
def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end
def post_conflict_json post:, base_version_no:, base_snapshot:,
@@ -563,6 +624,8 @@ class PostsController < ApplicationController
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:video_ms, '動画時間',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
@@ -626,11 +689,14 @@ class PostsController < ApplicationController
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title],
video_ms: snapshot[:video_ms],
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true)
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true,
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
@@ -638,14 +704,17 @@ class PostsController < ApplicationController
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
sync_post_tags!(post, tags)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :original_created_from, :original_created_before].map {
[:title, :video_ms, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
@@ -689,4 +758,44 @@ class PostsController < ApplicationController
render_validation_error record
end
end
def normalise_video_ms tags, video_ms_param: params[:video_ms], duration_param: params[:duration]
return nil unless tags.any? { _1.id == Tag.video.id }
if video_ms_param.present?
video_ms = Integer(video_ms_param, exception: false)
raise VideoMsParseError unless video_ms&.positive?
return video_ms
end
return nil if duration_param.blank?
video_ms = Tag.time_to_ms!(duration_param.to_s, tag_name: '動画時間')
raise VideoMsParseError unless video_ms.positive?
video_ms
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|
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
+37 -1
ファイルの表示
@@ -60,6 +60,7 @@ class Post < ApplicationRecord
before_validation :normalise_url
validates :url, presence: true, uniqueness: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range
validate :url_must_be_http_url
@@ -83,7 +84,42 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil)
end
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def snapshot_tag_names
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
end
def self.ms_to_time ms
total_s = ms / 1_000
s = total_s % 60
min = (total_s / 60) % 60
h = total_s / 3_600
remainder_ms = ms % 1_000
base =
if h.positive?
'%d:%02d:%02d' % [h, min, s]
else
'%d:%02d' % [min, s]
end
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
+6
ファイルの表示
@@ -10,6 +10,12 @@ class PostTag < ApplicationRecord
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
dependent: :delete_all,
inverse_of: :post_tag
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
+20
ファイルの表示
@@ -0,0 +1,20 @@
class PostTagSection < ApplicationRecord
self.primary_key = :post_id, :tag_id, :begin_ms
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
validates :begin_ms, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :end_ms, numericality: { only_integer: true, greater_than: :begin_ms },
allow_nil: true
end
+1
ファイルの表示
@@ -5,6 +5,7 @@ class PostVersion < ApplicationRecord
belongs_to :parent, class_name: 'Post', optional: true
validates :url, presence: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range
+112 -3
ファイルの表示
@@ -17,6 +17,16 @@ class Tag < ApplicationRecord
end
end
class SectionLiteralParseError < ArgumentError
attr_reader :tag_name, :literal
def initialize tag_name, literal
@tag_name = tag_name
@literal = literal
super("invalid section literal for tag #{ tag_name }: #{ literal }")
end
end
has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
@@ -105,26 +115,60 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true,
deny_deprecated: false
deny_deprecated: false,
with_sections: false
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
sections = { }
tags = tag_names.map do |name|
raw_name = name
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
name = name.sub(/\A#{ pf }/i, '')
sections_by_tag = []
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
sections_by_tag << normalise_section_range!(
begin_raw: match[2],
end_raw: match[3],
tag_name: name)
end
if name.include?('[') || name.include?(']')
raise SectionLiteralParseError.new(raw_name, raw_name)
end
name = TagName.canonicalise(name).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name]
end
tag.update!(category: cat) if cat && tag.category != cat
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
sections[tag.id] = merge_section_ranges(sections[tag.id])
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
end
end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq(&:id)
tags.uniq!(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
end
def self.expand_parent_tags tags
@@ -150,6 +194,45 @@ class Tag < ApplicationRecord
(result + tags).uniq { |t| t.id }
end
def self.normalise_section_range! begin_raw:, end_raw:, tag_name:
begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)
if end_ms
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
end_ms = begin_ms + 1 if begin_ms == end_ms
end
[begin_ms, end_ms]
end
def self.merge_section_ranges ranges
sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
merged = []
sorted_ranges.each do |begin_ms, end_ms|
if merged.empty?
merged << [begin_ms, end_ms]
next
end
last_begin_ms, last_end_ms = merged[-1]
if last_end_ms.nil? || begin_ms <= last_end_ms
merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
else
merged << [begin_ms, end_ms]
end
end
merged
end
def self.merge_section_end left_end_ms, right_end_ms
return nil if left_end_ms.nil? || right_end_ms.nil?
[left_end_ms, right_end_ms].max
end
def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id?
@@ -246,6 +329,32 @@ class Tag < ApplicationRecord
end
end
def self.time_to_ms! str, tag_name:
match =
case str
when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: Regexp.last_match[:hours],
minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
end
raise SectionLiteralParseError.new(tag_name, str) unless match
total_s = match[:seconds].to_i
total_s += match[:minutes].to_i * 60 if match[:minutes]
total_s += match[:hours].to_i * 3_600 if match[:hours]
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
+12 -3
ファイルの表示
@@ -8,6 +8,7 @@ module PostRepr
:url,
:title,
:thumbnail_base,
:video_ms,
:original_created_from,
:original_created_before,
:created_at,
@@ -18,7 +19,7 @@ module PostRepr
def base post, current_user = nil
json = common(post)
json['tags'] = tag_json(post.tags)
json['tags'] = tag_json(post)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
json
@@ -52,8 +53,16 @@ module PostRepr
.merge('thumbnail' => thumbnail_url(post))
end
def tag_json tags
tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) }
def tag_json post
post
.active_post_tags
.reject { _1.tag.deprecated? }
.sort_by { _1.tag.name }
.map { |post_tag|
TagRepr.inline(post_tag.tag).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
}
end
def thumbnail_url post
+1
ファイルの表示
@@ -23,6 +23,7 @@ class PostVersionRecorder < VersionRecorder
{ title: @record.title,
url: @record.url,
thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '),
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from,