diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index 04032e3..ae3d897 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -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, diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 26e358e..339519b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -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 diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index b2835b0..cdfbaef 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -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) diff --git a/backend/app/models/post_tag.rb b/backend/app/models/post_tag.rb index 702f66a..ac56c77 100644 --- a/backend/app/models/post_tag.rb +++ b/backend/app/models/post_tag.rb @@ -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: { diff --git a/backend/app/models/post_tag_section.rb b/backend/app/models/post_tag_section.rb new file mode 100644 index 0000000..5c47e4c --- /dev/null +++ b/backend/app/models/post_tag_section.rb @@ -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 diff --git a/backend/app/models/post_version.rb b/backend/app/models/post_version.rb index 523d1a0..8fe52bb 100644 --- a/backend/app/models/post_version.rb +++ b/backend/app/models/post_version.rb @@ -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 diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 8e2002e..202610b 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -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(?\d+)(?:\.(?\d{1,3}))?\z/ + { hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds], + ms: Regexp.last_match[:ms] } + when /\A(?\d+):(?[0-5]?\d)(?:\.(?\d{1,3}))?\z/ + { hours: nil, minutes: Regexp.last_match[:minutes], + seconds: Regexp.last_match[:seconds], + ms: Regexp.last_match[:ms] } + when /\A(?\d+):(?[0-5]?\d):(?[0-5]?\d)(?:\.(?\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, 'ニコタグは廃止できません.' diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index 1c62f1e..c878291 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -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 diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb index 9e3fd15..0726884 100644 --- a/backend/app/services/post_version_recorder.rb +++ b/backend/app/services/post_version_recorder.rb @@ -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, diff --git a/backend/db/migrate/20260519013100_add_video_ms_to_posts.rb b/backend/db/migrate/20260519013100_add_video_ms_to_posts.rb new file mode 100644 index 0000000..8225e13 --- /dev/null +++ b/backend/db/migrate/20260519013100_add_video_ms_to_posts.rb @@ -0,0 +1,9 @@ +class AddVideoMsToPosts < ActiveRecord::Migration[8.0] + def change + add_column :posts, :video_ms, :integer + add_index :posts, [:video_ms, :id], name: 'idx_posts_video_ms_id' + + add_check_constraint :posts, 'video_ms IS NULL OR video_ms > 0', + name: 'chk_posts_video_ms_positive' + end +end diff --git a/backend/db/migrate/20260622010000_create_post_tag_sections.rb b/backend/db/migrate/20260622010000_create_post_tag_sections.rb new file mode 100644 index 0000000..042d05b --- /dev/null +++ b/backend/db/migrate/20260622010000_create_post_tag_sections.rb @@ -0,0 +1,19 @@ +class CreatePostTagSections < ActiveRecord::Migration[8.0] + def change + create_table :post_tag_sections, primary_key: [:post_id, :tag_id, :begin_ms] do |t| + t.references :post, null: false, foreign_key: true, index: false + t.references :tag, null: false, foreign_key: true, index: false + t.integer :begin_ms, null: false + t.integer :end_ms, null: true + t.timestamps + + t.index [:post_id, :begin_ms], name: 'idx_post_tag_sections_post_id_begin_ms' + + t.check_constraint 'begin_ms >= 0', + name: 'chk_post_tag_sections_begin_ms_natural' + + t.check_constraint 'end_ms IS NULL OR begin_ms < end_ms', + name: 'chk_post_tag_sections_end_ms_after_begin_ms' + end + end +end diff --git a/backend/db/migrate/20260622020000_add_video_ms_to_post_versions.rb b/backend/db/migrate/20260622020000_add_video_ms_to_post_versions.rb new file mode 100644 index 0000000..9741213 --- /dev/null +++ b/backend/db/migrate/20260622020000_add_video_ms_to_post_versions.rb @@ -0,0 +1,9 @@ +class AddVideoMsToPostVersions < ActiveRecord::Migration[8.0] + def change + add_column :post_versions, :video_ms, :integer + add_index :post_versions, [:video_ms, :post_id], name: 'idx_post_versions_video_ms_post_id' + + add_check_constraint :post_versions, 'video_ms IS NULL OR video_ms > 0', + name: 'chk_post_versions_video_ms_positive' + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index dbff31a..b9f669a 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -298,6 +298,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end + create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "post_id", null: false + t.bigint "tag_id", null: false + t.integer "begin_ms", null: false + t.integer "end_ms" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms" + t.index ["tag_id"], name: "fk_rails_8be3847903" + t.check_constraint "(`end_ms` is null) or (`begin_ms` < `end_ms`)", name: "chk_post_tag_sections_end_ms_after_begin_ms" + t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural" + end + create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "tag_id", null: false @@ -331,9 +344,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.datetime "original_created_before" t.datetime "created_at", null: false t.bigint "created_by_user_id" + t.integer "video_ms" t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true t.index ["post_id"], name: "index_post_versions_on_post_id" + t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id" + t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" end @@ -348,8 +364,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.datetime "original_created_before" t.datetime "updated_at", null: false t.integer "version_no", null: false + t.integer "video_ms" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true + t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" + t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end @@ -678,6 +697,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" + add_foreign_key "post_tag_sections", "posts" + add_foreign_key "post_tag_sections", "tags" add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "users", column: "created_user_id" diff --git a/backend/spec/factories/post_tag_sections.rb b/backend/spec/factories/post_tag_sections.rb new file mode 100644 index 0000000..2560395 --- /dev/null +++ b/backend/spec/factories/post_tag_sections.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :post_tag_section do + association :post + association :tag + begin_ms { 1_000 } + end_ms { 2_000 } + end +end diff --git a/backend/spec/factories/post_tags.rb b/backend/spec/factories/post_tags.rb new file mode 100644 index 0000000..6956ca3 --- /dev/null +++ b/backend/spec/factories/post_tags.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :post_tag do + association :post + association :tag + end +end diff --git a/backend/spec/factories/posts.rb b/backend/spec/factories/posts.rb new file mode 100644 index 0000000..9f46b2e --- /dev/null +++ b/backend/spec/factories/posts.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :post do + sequence(:url) { |n| "https://example.com/factory-post-#{ n }" } + title { 'factory post' } + thumbnail_base { nil } + uploaded_user { nil } + end +end diff --git a/backend/spec/models/post_tag_spec.rb b/backend/spec/models/post_tag_spec.rb new file mode 100644 index 0000000..6f89ac5 --- /dev/null +++ b/backend/spec/models/post_tag_spec.rb @@ -0,0 +1,41 @@ +RSpec.describe PostTag, type: :model do + describe '#sections' do + it 'loads sections by post_id and tag_id' do + post_tag = create(:post_tag) + section = create(:post_tag_section, + post: post_tag.post, + tag: post_tag.tag, + begin_ms: 1000, + end_ms: 2000) + + expect(post_tag.sections).to contain_exactly(section) + end + + it 'does not load sections for another tag on the same post' do + post = create(:post) + tag = create(:tag) + other_tag = create(:tag) + + post_tag = create(:post_tag, post:, tag:) + create(:post_tag_section, + post:, + tag: other_tag, + begin_ms: 1000, + end_ms: 2000) + + expect(post_tag.sections).to be_empty + end + + it 'allows open-ended sections' do + post_tag = create(:post_tag) + section = create(:post_tag_section, + post: post_tag.post, + tag: post_tag.tag, + begin_ms: 1000, + end_ms: nil) + + expect(section).to be_valid + expect(post_tag.sections).to contain_exactly(section) + end + end +end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 1b2549c..d1b64e0 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -19,6 +19,95 @@ RSpec.describe Tag, type: :model do expect(error.tag_names).to eq([deprecated_tag.name]) } end + + it 'rejects invalid section literals instead of treating them as zero' do + expect { + described_class.normalise_tags!( + ['normalise_invalid_section[1:aa-2:00]'], + with_sections: true + ) + }.to raise_error(Tag::SectionLiteralParseError) + end + + it 'parses open-ended section literals' do + result = described_class.normalise_tags!( + ['伊地知ニジカ[1:00-]'], + with_sections: true + ) + + tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) + end + + it 'parses omitted begin as zero' do + result = described_class.normalise_tags!( + ['伊地知ニジカ[-1:00]'], + with_sections: true + ) + + tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]]) + end + + it 'treats fully open section literals as plain tags' do + result = described_class.normalise_tags!( + ['伊地知ニジカ[-]'], + with_sections: true + ) + + tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + expect(result.fetch(:sections)[tag.id]).to be_nil + end + + it 'treats [0:00-] as a plain tag' do + result = described_class.normalise_tags!( + ['伊地知ニジカ[0:00-]'], + with_sections: true + ) + + tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + expect(result.fetch(:sections)[tag.id]).to be_nil + end + + it 'expands zero-width sections to one millisecond' do + result = described_class.normalise_tags!( + ['伊地知ニジカ[1:00-1:00]'], + with_sections: true + ) + + tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]]) + end + + it 'swaps reversed section boundaries' do + result = described_class.normalise_tags!( + ['伊地知ニジカ[2:00-1:00]'], + with_sections: true + ) + + tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]]) + end + + it 'merges open-ended sections over later bounded sections' do + result = described_class.normalise_tags!( + ['伊地知ニジカ[1:00-][2:00-3:00]'], + with_sections: true + ) + + tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) + end + + it 'merges adjacent bounded and open-ended sections' do + result = described_class.normalise_tags!( + ['伊地知ニジカ[1:00-3:00][3:00-]'], + with_sections: true + ) + + tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) + end end describe '.expand_parent_tags' do diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index b640f1a..752ac6a 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -54,6 +54,7 @@ RSpec.describe 'Posts API', type: :request do title: post.title, url: post.url, thumbnail_base: post.thumbnail_base, + video_ms: post.video_ms, tags: post.snapshot_tag_names.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '), original_created_from: post.original_created_from, @@ -145,6 +146,22 @@ RSpec.describe 'Posts API', type: :request do expect(all_tag_names).to include("spec_tag") end + it 'keeps children and sections keys in non-detail tag responses' do + PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil) + + get '/posts' + + expect(response).to have_http_status(:ok) + + hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id } + tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' } + + expect(tag_json.fetch('children')).to eq([]) + expect(tag_json.fetch('sections')).to eq([ + { 'begin_ms' => 1_000, 'end_ms' => nil } + ]) + end + context "when q is provided" do it "filters posts by q (hit case)" do get "/posts", params: { tags: "spec_tag" } @@ -845,6 +862,198 @@ RSpec.describe 'Posts API', type: :request do expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent') end + it 'returns validation error for an invalid section literal' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'invalid section literal', + url: 'https://example.com/invalid-section-literal', + tags: 'spec_tag[1:aa-2:00]', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include( + 'type' => 'validation_error', + 'message' => '入力内容を確認してください.' + ) + expect(json.fetch('errors')).to include( + 'tags' => ['タグ区間の記法が不正です.'] + ) + end + + it 'creates a video post with duration' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'video post', + url: 'https://example.com/video-post', + tags: '動画 spec_tag', + duration: '3:00.500', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:created) + expect(Post.find(json.fetch('id')).video_ms).to eq(180_500) + expect(json.fetch('video_ms')).to eq(180_500) + end + + it 'creates a video post with number input duration seconds' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'video post seconds', + url: 'https://example.com/video-post-seconds', + tags: '動画 spec_tag', + duration: '180.5', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:created) + expect(Post.find(json.fetch('id')).video_ms).to eq(180_500) + expect(json.fetch('video_ms')).to eq(180_500) + end + + it 'clears video_ms when the saved tags do not include 動画' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'non video post', + url: 'https://example.com/non-video-post', + tags: 'spec_tag', + duration: '3:00', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:created) + expect(Post.find(json.fetch('id')).video_ms).to be_nil + end + + it 'returns validation error when a bounded section exceeds duration' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'too long section', + url: 'https://example.com/too-long-section', + tags: '動画 伊地知ニジカ[2:50-3:10]', + duration: '3:00', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'video_ms' => ['タグ区間の終端が動画時間を超えてゐます.'] + ) + end + + it 'saves open-ended sections with end_ms NULL' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'open ended section literal', + url: 'https://example.com/open-ended-section-literal', + tags: '動画 伊地知ニジカ[1:00-]', + duration: '3:00', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:created) + + created_post = Post.find(json.fetch('id')) + tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' }) + section = PostTagSection.find_by!(post: created_post, tag:) + + expect(section.begin_ms).to eq(60_000) + expect(section.end_ms).to be_nil + end + + it 'does not save sections for [-]' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'fully open section literal', + url: 'https://example.com/fully-open-section-literal', + tags: '伊地知ニジカ[-]', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:created) + + created_post = Post.find(json.fetch('id')) + tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' }) + expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil + end + + it 'does not save sections for [0:00-]' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'zero open ended section literal', + url: 'https://example.com/zero-open-ended-section-literal', + tags: '伊地知ニジカ[0:00-]', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:created) + + created_post = Post.find(json.fetch('id')) + tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' }) + expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil + end + + it 'returns end_ms null for open-ended sections in show response' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'show open ended section literal', + url: 'https://example.com/show-open-ended-section-literal', + tags: '動画 伊地知ニジカ[1:00-]', + duration: '3:00', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:created) + + get "/posts/#{ json.fetch('id') }" + + expect(response).to have_http_status(:ok) + tag_json = json.fetch('tags').find { |item| item['name'] == '伊地知ニジカ' } + expect(tag_json.fetch('sections')).to eq([ + { 'begin_ms' => 60_000, 'end_ms' => nil } + ]) + end + + it 'allows open-ended sections when begin is within duration' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'valid open ended section literal', + url: 'https://example.com/valid-open-ended-section-literal', + tags: '動画 伊地知ニジカ[1:00-]', + duration: '3:00', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:created) + end + + it 'rejects open-ended sections when begin equals duration' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'invalid open ended section literal', + url: 'https://example.com/invalid-open-ended-section-literal', + tags: '動画 伊地知ニジカ[3:00-]', + duration: '3:00', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'video_ms' => ['タグ区間の開始が動画時間以上です.'] + ) + end + context "when nico tag already exists in tags" do before do Tag.find_undiscard_or_create_by!( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11f784c..b4e9b04 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { BrowserRouter, Routes, useLocation } from 'react-router-dom' +import DevModeWatermark from '@/components/DevModeWatermark' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' @@ -145,6 +146,7 @@ const App: FC = () => { return ( <> + {import.meta.env.DEV && } @@ -152,7 +154,7 @@ const App: FC = () => { + className="relative flex flex-col h-dvh w-full overflow-y-hidden"> diff --git a/frontend/src/components/DevModeWatermark.tsx b/frontend/src/components/DevModeWatermark.tsx new file mode 100644 index 0000000..5de8c0b --- /dev/null +++ b/frontend/src/components/DevModeWatermark.tsx @@ -0,0 +1,46 @@ +import nikumaru from '@/assets/fonts/nikumaru.otf' + +import type { FC } from 'react' + + +const ROW_COUNT = 8 +const COLUMN_COUNT = 12 + + +const DevModeWatermark: FC = () => { + return ( + ) +} + + +export default DevModeWatermark diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index e6c49ff..97e8248 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -87,10 +87,13 @@ const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTa setDropRef (node) }} style={style} - className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} + className={cn ( + 'min-w-0 max-w-full overflow-hidden rounded select-none', + over && 'ring-2 ring-offset-2')} {...attributes} {...listeners}> @@ -98,4 +101,4 @@ const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTa ) } -export default DraggableDroppableTagRow \ No newline at end of file +export default DraggableDroppableTagRow diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 399d9d8..8428c09 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -318,12 +318,20 @@ const MobileMaterialTreeNode: FC<{ depth?: number + [max-height:var(--tag-link-inline-size)] + [&_.tag-marquee]:block + [&_.tag-marquee]:h-full + [&_.tag-marquee]:overflow-hidden + [&_.tag-marquee__static]:h-full + [&_.tag-marquee__static]:overflow-hidden + [&_.tag-marquee__static]:text-ellipsis + [&_.tag-marquee__static]:[text-overflow:ellipsis]"/> {tag.hasChildren && (