From 7b6b24b9c5b777129b861edad6c845394b592eba Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 22 May 2026 03:29:18 +0900 Subject: [PATCH 1/7] #351 --- backend/app/controllers/posts_controller.rb | 67 ++++++++++++++----- backend/app/models/post.rb | 33 ++++++++- backend/app/models/post_tag.rb | 6 ++ backend/app/models/post_tag_section.rb | 20 ++++++ backend/app/models/tag.rb | 46 ++++++++++++- ...20260518235300_create_post_tag_sections.rb | 19 ++++++ .../20260519013100_add_video_ms_to_posts.rb | 9 +++ backend/db/schema.rb | 21 +++++- backend/spec/models/post_tag_spec.rb | 29 ++++++++ frontend/src/components/PostEditForm.tsx | 5 +- frontend/src/lib/utils.ts | 12 ++++ .../deerjikists/DeerjikistDetailPage.tsx | 6 ++ frontend/src/types.ts | 7 +- 13 files changed, 255 insertions(+), 25 deletions(-) create mode 100644 backend/app/models/post_tag_section.rb create mode 100644 backend/db/migrate/20260518235300_create_post_tag_sections.rb create mode 100644 backend/db/migrate/20260519013100_add_video_ms_to_posts.rb create mode 100644 backend/spec/models/post_tag_spec.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index e8cae00..96f520c 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -95,9 +95,10 @@ class PostsController < ApplicationController end def random - post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) - .order('RAND()') - .first + post = filtered_posts + .preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) + .order('RAND()') + .first return head :not_found unless post render json: PostRepr.base(post, current_user) @@ -108,7 +109,7 @@ class PostsController < ApplicationController return head :not_found unless post render json: PostRepr.base(post, current_user) - .merge(tags: build_tag_tree_for(post.tags), + .merge(tags: build_tag_tree_for(post), related: PostRepr.many(post.related(limit: 20))) end @@ -132,11 +133,11 @@ class PostsController < ApplicationController ApplicationRecord.transaction do post.save! - tags = Tag.normalise_tags!(tag_names) + Tag.normalise_tags!(tag_names, with_sections: true) => { tags:, sections: } TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) tags = Tag.expand_parent_tags(tags) - sync_post_tags!(post, tags) + sync_post_tags!(post, tags, sections) sync_parent_posts!(post, parent_post_ids) @@ -235,7 +236,7 @@ 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 head :bad_request @@ -337,7 +338,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 @@ -356,13 +357,20 @@ 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.to_a + def build_tag_tree_for post + tags = post.tags.to_a tag_ids = tags.map(&:id) implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) @@ -384,8 +392,11 @@ class PostsController < ApplicationController tag = tags_by_id[tag_id] return nil unless tag + sections = PostTagSection.where(post_id: post.id, tag_id: tag.id) + .as_json(only: [:begin_ms, :end_ms]) + if path.include?(tag_id) - return TagRepr.base(tag).merge(children: []) + return TagRepr.base(tag).merge(children: [], sections:) end if memo.key?(tag_id) @@ -397,7 +408,7 @@ class PostsController < ApplicationController children = child_ids.filter_map { |cid| build_node.(cid, new_path) } - memo[tag_id] = TagRepr.base(tag).merge(children:) + memo[tag_id] = TagRepr.base(tag).merge(children:, sections:) end root_ids.filter_map { |id| build_node.call(id, []) } @@ -470,7 +481,21 @@ class PostsController < ApplicationController end def editable_tag_names_from_post post - post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + post + .post_tags + .kept + .joins(tag: :tag_name) + .merge(Tag.not_nico) + .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:, @@ -502,9 +527,16 @@ class PostsController < ApplicationController end def incoming_tag_names_for_snapshot raw_tag_names - tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false) + Tag.normalise_tags!(raw_tag_names, with_tagme: false, with_sections: true) => + { tags:, sections: } - Tag.expand_parent_tags(tags).map(&:name).uniq.sort + Tag.expand_parent_tags(tags).uniq(&:id).map { |tag| + "#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }" + }.sort + end + + def section_literal section + "[#{ Post.ms_to_time(section[0]) }-#{ Post.ms_to_time(section[1]) }]" end def post_conflict_json post:, base_version_no:, base_snapshot:, @@ -591,7 +623,8 @@ class PostsController < ApplicationController 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) + Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, 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 @@ -599,7 +632,7 @@ class PostsController < ApplicationController tags = readonly_tags + editable_tags tags = Tag.expand_parent_tags(tags) - sync_post_tags!(post, tags) + 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) diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 4c3ddb1..8970119 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -56,7 +56,38 @@ 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) }-#{ 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 + + if h.positive? + '%d:%02d:%02d' % [h, min, s] + else + '%d:%02d' % [min, s] + end + 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..8d6af9e --- /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, presence: true, + numericality: { only_integer: true, greater_than: :begin_ms } +end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 51ca783..0015250 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -92,22 +92,45 @@ class Tag < ApplicationRecord def self.normalise_tags! tag_names, with_tagme: true, with_no_deerjikist: true, - deny_nico: true + deny_nico: true, + 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| pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] + name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first + + sections_by_tag = [] + while n = name.sub!(/^(\S*?)\[([0-9:.]*?)-([0-9:.]*?)\](\S*?)$/, '\1\4 \2 \3') + name, *section_raw = n.split + + begin_ms, end_ms = section_raw.map { time_to_ms(_1) } + next if begin_ms == end_ms + + begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms + + sections_by_tag << [begin_ms, end_ms] + end + find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| tag.update!(category: cat) if cat && tag.category != cat + sections[tag.id] = sections_by_tag if sections_by_tag.present? 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 @@ -228,4 +251,23 @@ class Tag < ApplicationRecord errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' end end + + def self.time_to_ms str + parts = str.split(':') + + s_part = parts.pop + s, ms = s_part.split('.') + + total_s = s.to_i + + if parts.length >= 1 + total_s += parts.pop.to_i * 60 + end + + if parts.length >= 1 + total_s += parts.pop.to_i * 3_600 + end + + total_s * 1_000 + ms.to_s.ljust(3, '0')[0, 3].to_i + end end diff --git a/backend/db/migrate/20260518235300_create_post_tag_sections.rb b/backend/db/migrate/20260518235300_create_post_tag_sections.rb new file mode 100644 index 0000000..ff87e20 --- /dev/null +++ b/backend/db/migrate/20260518235300_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: false + 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 'begin_ms < end_ms', + name: 'chk_post_tag_sections_end_ms_after_begin_ms' + end + end +end 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/schema.rb b/backend/db/schema.rb index 94edb82..60e80eb 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do +ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -137,6 +137,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) 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", null: false + 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 "`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 @@ -187,8 +200,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) 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 @@ -290,6 +306,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at" + t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42" t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at" t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id" t.index ["user_id"], name: "index_theatre_watching_users_on_user_id" @@ -446,6 +463,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) 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/models/post_tag_spec.rb b/backend/spec/models/post_tag_spec.rb new file mode 100644 index 0000000..551f3fd --- /dev/null +++ b/backend/spec/models/post_tag_spec.rb @@ -0,0 +1,29 @@ +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 + end +end diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index bc1048e..4655f01 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { isApiError } from '@/lib/api' import { updatePost } from '@/lib/posts' +import { msToTime } from '@/lib/utils' import type { FC, FormEvent } from 'react' @@ -25,7 +26,7 @@ const tagsToStr = (tags: Tag[]): string => { tags.filter (t => t.category !== 'nico').forEach (walk) - return [...(new Set (result.map (t => t.name)))].join (' ') + return [...(new Set (result.map (t => `${ t.name }${ t.sections.map (s => `[${ msToTime (s.beginMs) }-${ msToTime (s.endMs) }]`).join ('') }`)))].join (' ') } @@ -167,4 +168,4 @@ const PostEditForm: FC = ({ post, onSave }) => { ) } -export default PostEditForm \ No newline at end of file +export default PostEditForm diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index aac3785..672ebd2 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -71,3 +71,15 @@ export const originalCreatedAtString = ( .join (' ')) return rtn === '〜' ? '年月日不詳' : rtn } + + +export const msToTime = (ms: number): string => { + const totalS = Math.trunc (ms / 1_000) + const s = String (totalS % 60) + const min = String (Math.trunc (totalS / 60) % 60) + const h = String (Math.trunc (totalS / 3_600)) + + return (h > 0 + ? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }` + : `${ min }:${ s.padStart (2, '0') }`) +} diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx index 69444b1..4901116 100644 --- a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx @@ -1,5 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' +import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' import TagLink from '@/components/TagLink' @@ -69,6 +70,11 @@ const DeerjikistDetailPage: FC = () => { return ( + + + {tag && {tag.name} | ニジラー情報} + + {(loading || !(tag)) ? 'Loading...' : (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e552f69..a4a7950 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -126,7 +126,7 @@ export type Post = { title: string | null thumbnail: string | null thumbnailBase: string | null - tags: Tag[] + tags: TagWithSections[] parentPosts?: Post[] childPosts?: Post[] siblingPosts?: Record<`${ number }`, Post[]> @@ -187,7 +187,6 @@ export type Tag = { hasWiki: boolean materialId: number | null hasDeerjikists: boolean - children?: Tag[] matchedAlias?: string | null } export type TagVersion = { @@ -201,6 +200,10 @@ export type TagVersion = { createdAt: string createdByUser: { id: number; name: string | null } | null } +export type TagWithSections = Tag & { sections: { beginMs: number + endMs: number }[] + children: TagWithSections[] } + export type Theatre = { id: number name: string | null -- 2.34.1 From 7fcfc8b8aa8ca24a1fca3f4109ccd19a7e77c654 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 23 May 2026 03:50:54 +0900 Subject: [PATCH 2/7] #351 --- backend/app/controllers/posts_controller.rb | 4 ++-- backend/app/models/tag.rb | 9 +++++++-- backend/spec/factories/post_tag_sections.rb | 8 ++++++++ backend/spec/factories/post_tags.rb | 6 ++++++ backend/spec/factories/posts.rb | 8 ++++++++ frontend/src/components/PostEditForm.tsx | 17 ++++++++++------- frontend/src/components/TagDetailSidebar.tsx | 16 ++++++++-------- frontend/src/lib/utils.ts | 2 +- frontend/src/pages/tags/TagDetailPage.tsx | 2 +- frontend/src/test/factories.ts | 6 ++++-- 10 files changed, 55 insertions(+), 23 deletions(-) create mode 100644 backend/spec/factories/post_tag_sections.rb create mode 100644 backend/spec/factories/post_tags.rb create mode 100644 backend/spec/factories/posts.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 96f520c..000577b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -392,7 +392,7 @@ class PostsController < ApplicationController tag = tags_by_id[tag_id] return nil unless tag - sections = PostTagSection.where(post_id: post.id, tag_id: tag.id) + sections = PostTagSection.where(post_id: post.id, tag_id:) .as_json(only: [:begin_ms, :end_ms]) if path.include?(tag_id) @@ -419,7 +419,7 @@ class PostsController < ApplicationController params[:parent_post_ids].to_s.split.map { |token| id = Integer(token, exception: false) - raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0 + raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if !(id) || id <= 0 id }.uniq diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 0015250..f65ce24 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -102,7 +102,7 @@ class Tag < ApplicationRecord tags = tag_names.map do |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 n = name.sub!(/^(\S*?)\[([0-9:.]*?)-([0-9:.]*?)\](\S*?)$/, '\1\4 \2 \3') @@ -116,9 +116,14 @@ class Tag < ApplicationRecord sections_by_tag << [begin_ms, end_ms] end + name = TagName.canonicalise(name).first + find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| tag.update!(category: cat) if cat && tag.category != cat - sections[tag.id] = sections_by_tag if sections_by_tag.present? + next if sections_by_tag.blank? + + sections[tag.id] ||= [] + sections[tag.id].concat(sections_by_tag) end end 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/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 4655f01..2a2083e 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -12,21 +12,24 @@ import { msToTime } from '@/lib/utils' import type { FC, FormEvent } from 'react' -import type { Post, Tag } from '@/types' +import type { Post, TagWithSections } from '@/types' -const tagsToStr = (tags: Tag[]): string => { - const result: Tag[] = [] +const tagsToStr = (tags: TagWithSections[]): string => { + const result: Omit[] = [] - const walk = (tag: Tag) => { + const walk = (tag: TagWithSections) => { const { children, ...rest } = tag result.push (rest) - children?.forEach (walk) + children.forEach (walk) } tags.filter (t => t.category !== 'nico').forEach (walk) - return [...(new Set (result.map (t => `${ t.name }${ t.sections.map (s => `[${ msToTime (s.beginMs) }-${ msToTime (s.endMs) }]`).join ('') }`)))].join (' ') + return [...(new Set (result.map (t => + `${ t.name }${ t.sections + .map (s => `[${ msToTime (s.beginMs) }-${ msToTime (s.endMs) }]`) + .join ('') }`)))].join (' ') } @@ -118,7 +121,7 @@ const PostEditForm: FC = ({ post, onSave }) => { } useEffect (() => { - setTags(tagsToStr (post.tags)) + setTags (tagsToStr (post.tags)) }, [post]) return ( diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index be95412..24dae59 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -26,13 +26,13 @@ import { dateString, originalCreatedAtString } from '@/lib/utils' import type { DragEndEvent } from '@dnd-kit/core' import type { FC, MutableRefObject, ReactNode } from 'react' -import type { Category, Post, Tag } from '@/types' +import type { Category, Post, TagWithSections } from '@/types' -type TagByCategory = { [key in Category]: Tag[] } +type TagByCategory = { [key in Category]: TagWithSections[] } const renderTagTree = ( - tag: Tag, + tag: TagWithSections, nestLevel: number, path: string, suppressClickRef: MutableRefObject, @@ -63,7 +63,7 @@ const renderTagTree = ( const isDescendant = ( - root: Tag, + root: TagWithSections, targetId: number, ): boolean => { if (!(root.children)) @@ -84,8 +84,8 @@ const isDescendant = ( const findTag = ( byCat: TagByCategory, id: number, -): Tag | undefined => { - const walk = (nodes: Tag[]): Tag | undefined => { +): TagWithSections | undefined => { + const walk = (nodes: TagWithSections[]): TagWithSections | undefined => { for (const t of nodes) { if (t.id === id) @@ -167,7 +167,7 @@ const TagDetailSidebar: FC = ({ post, sp }) => { } for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) - tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1) + tagsTmp[cat].sort ((tagA: TagWithSections, tagB: TagWithSections) => tagA.name < tagB.name ? -1 : 1) return tagsTmp }, [post]) @@ -378,4 +378,4 @@ const TagDetailSidebar: FC = ({ post, sp }) => { ) } -export default TagDetailSidebar \ No newline at end of file +export default TagDetailSidebar diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 672ebd2..6493039 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -77,7 +77,7 @@ export const msToTime = (ms: number): string => { const totalS = Math.trunc (ms / 1_000) const s = String (totalS % 60) const min = String (Math.trunc (totalS / 60) % 60) - const h = String (Math.trunc (totalS / 3_600)) + const h = Math.trunc (totalS / 3_600) return (h > 0 ? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }` diff --git a/frontend/src/pages/tags/TagDetailPage.tsx b/frontend/src/pages/tags/TagDetailPage.tsx index fc10727..7d6eb57 100644 --- a/frontend/src/pages/tags/TagDetailPage.tsx +++ b/frontend/src/pages/tags/TagDetailPage.tsx @@ -157,4 +157,4 @@ const TagDetailPage: FC = () => { ) } -export default TagDetailPage \ No newline at end of file +export default TagDetailPage diff --git a/frontend/src/test/factories.ts b/frontend/src/test/factories.ts index 2092619..929f4f1 100644 --- a/frontend/src/test/factories.ts +++ b/frontend/src/test/factories.ts @@ -1,6 +1,6 @@ -import type { Material, Post, Tag, User, WikiPage } from '@/types' +import type { Material, Post, TagWithSections, User, WikiPage } from '@/types' -export const buildTag = (overrides: Partial = {}): Tag => ({ +export const buildTag = (overrides: Partial = {}): TagWithSections => ({ id: 1, name: 'テストタグ', category: 'general', @@ -13,6 +13,8 @@ export const buildTag = (overrides: Partial = {}): Tag => ({ materialId: null, hasDeerjikists: false, matchedAlias: null, + sections: [], + children: [], ...overrides, }) -- 2.34.1 From 7e89fb286abbc2eff84e7c3c4e5edab471363369 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 23 May 2026 07:21:15 +0900 Subject: [PATCH 3/7] #351 --- backend/app/models/tag.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index f65ce24..1d19df5 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -120,7 +120,7 @@ class Tag < ApplicationRecord find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| tag.update!(category: cat) if cat && tag.category != cat - next if sections_by_tag.blank? + next if sections_by_tag.empty? sections[tag.id] ||= [] sections[tag.id].concat(sections_by_tag) -- 2.34.1 From d3af4563ca5995056971dab3315ecc019c7a0c86 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 22 Jun 2026 21:24:55 +0900 Subject: [PATCH 4/7] #351 --- backend/app/controllers/posts_controller.rb | 30 +++-- backend/app/models/post.rb | 2 +- backend/app/models/post_tag_section.rb | 4 +- backend/app/models/tag.rb | 103 ++++++++++++++---- backend/app/representations/post_repr.rb | 14 ++- ...0260622010000_create_post_tag_sections.rb} | 4 +- backend/db/schema.rb | 7 +- backend/spec/models/post_tag_spec.rb | 12 ++ backend/spec/models/tag_spec.rb | 79 ++++++++++++++ backend/spec/requests/posts_spec.rb | 97 +++++++++++++++++ frontend/src/components/PostEditForm.tsx | 2 +- frontend/src/types.ts | 2 +- 12 files changed, 309 insertions(+), 47 deletions(-) rename backend/db/migrate/{20260518235300_create_post_tag_sections.rb => 20260622010000_create_post_tag_sections.rb} (86%) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 068795a..941d7aa 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -45,7 +45,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 +99,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 +114,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 @@ -168,6 +174,8 @@ 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 ArgumentError => e render_validation_error fields: { parent_post_ids: [e.message] } rescue ActiveRecord::RecordInvalid => e @@ -260,6 +268,8 @@ 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 ArgumentError => e render_validation_error fields: { parent_post_ids: [e.message] } rescue ActiveRecord::RecordInvalid => e @@ -390,7 +400,8 @@ class PostsController < ApplicationController end def build_tag_tree_for post - tags = post.tags.reject(&:deprecated?).to_a + 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) @@ -405,11 +416,9 @@ class PostsController < ApplicationController root_ids = tag_ids - child_ids tags_by_id = tags.index_by(&:id) - sections_by_tag_id = - PostTagSection - .where(post_id: post.id, tag_id: tag_ids) - .order(:begin_ms) - .group_by(&:tag_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 = { } @@ -418,7 +427,6 @@ class PostsController < ApplicationController return nil unless tag sections = sections_by_tag_id.fetch(tag_id, []) - .as_json(only: [:begin_ms, :end_ms]) if path.include?(tag_id) return TagRepr.inline(tag).merge(children: [], sections:) @@ -578,7 +586,7 @@ class PostsController < ApplicationController end def section_literal section - "[#{ Post.ms_to_time(section[0]) }-#{ Post.ms_to_time(section[1]) }]" + "[#{ 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:, diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 0342c7f..913f1d4 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -86,7 +86,7 @@ class Post < ApplicationRecord end def self.section_literal section - "[#{ Post.ms_to_time(section.begin_ms) }-#{ Post.ms_to_time(section.end_ms) }]" + "[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]" end def self.ms_to_time ms diff --git a/backend/app/models/post_tag_section.rb b/backend/app/models/post_tag_section.rb index 8d6af9e..5c47e4c 100644 --- a/backend/app/models/post_tag_section.rb +++ b/backend/app/models/post_tag_section.rb @@ -15,6 +15,6 @@ class PostTagSection < ApplicationRecord validates :begin_ms, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :end_ms, presence: true, - numericality: { only_integer: true, greater_than: :begin_ms } + validates :end_ms, numericality: { only_integer: true, greater_than: :begin_ms }, + allow_nil: true end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 7cd6a48..6e87f44 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' @@ -113,20 +123,22 @@ class Tag < ApplicationRecord sections = { } tags = tag_names.map do |name| + raw_name = name pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] name = name.sub(/\A#{ pf }/i, '') sections_by_tag = [] - while n = name.sub!(/^(\S*?)\[([0-9:.]*?)-([0-9:.]*?)\](\S*?)$/, '\1\4 \2 \3') - name, *section_raw = n.split + while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)) + name = "#{ match[1] }#{ match[4] }" + sections_by_tag << normalise_section_range!( + begin_raw: match[2], + end_raw: match[3], + tag_name: name) + end - begin_ms, end_ms = section_raw.map { time_to_ms(_1) } - next if begin_ms == end_ms - - begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms - - sections_by_tag << [begin_ms, end_ms] + if name.include?('[') || name.include?(']') + raise SectionLiteralParseError.new(raw_name, raw_name) end name = TagName.canonicalise(name).first @@ -141,6 +153,7 @@ class Tag < ApplicationRecord sections[tag.id] ||= [] sections[tag.id].concat(sections_by_tag) + sections[tag.id] = merge_section_ranges(sections[tag.id]) end end @@ -178,6 +191,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? @@ -274,23 +326,30 @@ class Tag < ApplicationRecord end end - def self.time_to_ms str - parts = str.split(':') + 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 - s_part = parts.pop - s, ms = s_part.split('.') + raise SectionLiteralParseError.new(tag_name, str) unless match - total_s = s.to_i + 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] - if parts.length >= 1 - total_s += parts.pop.to_i * 60 - end - - if parts.length >= 1 - total_s += parts.pop.to_i * 3_600 - end - - total_s * 1_000 + ms.to_s.ljust(3, '0')[0, 3].to_i + total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i end def nico_tags_cannot_be_deprecated diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index 1c62f1e..8fe6f58 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -18,7 +18,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 +52,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/db/migrate/20260518235300_create_post_tag_sections.rb b/backend/db/migrate/20260622010000_create_post_tag_sections.rb similarity index 86% rename from backend/db/migrate/20260518235300_create_post_tag_sections.rb rename to backend/db/migrate/20260622010000_create_post_tag_sections.rb index ff87e20..042d05b 100644 --- a/backend/db/migrate/20260518235300_create_post_tag_sections.rb +++ b/backend/db/migrate/20260622010000_create_post_tag_sections.rb @@ -4,7 +4,7 @@ class CreatePostTagSections < ActiveRecord::Migration[8.0] 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: false + t.integer :end_ms, null: true t.timestamps t.index [:post_id, :begin_ms], name: 'idx_post_tag_sections_post_id_begin_ms' @@ -12,7 +12,7 @@ class CreatePostTagSections < ActiveRecord::Migration[8.0] t.check_constraint 'begin_ms >= 0', name: 'chk_post_tag_sections_begin_ms_natural' - t.check_constraint 'begin_ms < end_ms', + t.check_constraint 'end_ms IS NULL OR begin_ms < end_ms', name: 'chk_post_tag_sections_end_ms_after_begin_ms' end end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 62f6808..2cc6a9a 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_22_010000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -214,12 +214,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do t.bigint "post_id", null: false t.bigint "tag_id", null: false t.integer "begin_ms", null: false - t.integer "end_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 "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms" + 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 @@ -432,7 +432,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at" - t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42" t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at" t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id" t.index ["user_id"], name: "index_theatre_watching_users_on_user_id" diff --git a/backend/spec/models/post_tag_spec.rb b/backend/spec/models/post_tag_spec.rb index 551f3fd..6f89ac5 100644 --- a/backend/spec/models/post_tag_spec.rb +++ b/backend/spec/models/post_tag_spec.rb @@ -25,5 +25,17 @@ RSpec.describe PostTag, type: :model do 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 014cdde..5899acb 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -19,6 +19,85 @@ 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 'parses fully open section literals as zero to end-of-video' do + result = described_class.normalise_tags!( + ['伊地知ニジカ[-]'], + with_sections: true + ) + + tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 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 18d5b73..7381615 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -127,6 +127,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" } @@ -767,6 +783,87 @@ 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 '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-]', + 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 'treats [-] as [0:00-] and saves end_ms NULL' 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: '伊地知ニジカ' }) + section = PostTagSection.find_by!(post: created_post, tag:) + + expect(section.begin_ms).to eq(0) + expect(section.end_ms).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-]', + 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 + context "when nico tag already exists in tags" do before do Tag.find_undiscard_or_create_by!( diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 6e4b560..1ed75bd 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -33,7 +33,7 @@ const tagsToStr = (tags: TagWithSections[]): string => { return [...(new Set (result.map (t => `${ t.name }${ t.sections - .map (s => `[${ msToTime (s.beginMs) }-${ msToTime (s.endMs) }]`) + .map (s => `[${ msToTime (s.beginMs) }-${ s.endMs == null ? '' : msToTime (s.endMs) }]`) .join ('') }`)))].join (' ') } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 641af6c..6b37dc0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -221,7 +221,7 @@ export type TagVersion = { createdByUser: { id: number; name: string | null } | null } export type TagWithSections = Tag & { sections: { beginMs: number - endMs: number }[] + endMs: number | null }[] children: TagWithSections[] } export type Theatre = { -- 2.34.1 From 53d1cadefb7080310bcdf8784ab6626cbbbb5abe Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 23 Jun 2026 00:24:04 +0900 Subject: [PATCH 5/7] #351 --- .../controllers/post_versions_controller.rb | 5 + backend/app/controllers/posts_controller.rb | 88 +++++++++++--- backend/app/models/post.rb | 15 ++- backend/app/models/post_version.rb | 1 + backend/app/models/tag.rb | 3 + backend/app/representations/post_repr.rb | 1 + backend/app/services/post_version_recorder.rb | 1 + ...622020000_add_video_ms_to_post_versions.rb | 9 ++ backend/db/schema.rb | 5 +- backend/spec/models/tag_spec.rb | 14 ++- backend/spec/requests/posts_spec.rb | 108 +++++++++++++++++- frontend/src/components/PostEditForm.tsx | 39 ++++++- frontend/src/lib/posts.ts | 2 + frontend/src/lib/utils.test.ts | 13 ++- frontend/src/lib/utils.ts | 10 +- frontend/src/pages/posts/PostHistoryPage.tsx | 8 +- frontend/src/pages/posts/PostNewPage.tsx | 29 ++++- frontend/src/test/factories.ts | 1 + frontend/src/types.ts | 2 + 19 files changed, 316 insertions(+), 38 deletions(-) create mode 100644 backend/db/migrate/20260622020000_add_video_ms_to_post_versions.rb 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 941d7aa..73f3d1c 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 @@ -159,6 +163,9 @@ class PostsController < ApplicationController 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) @@ -176,6 +183,8 @@ class PostsController < ApplicationController 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 @@ -232,6 +241,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 = @@ -270,6 +281,8 @@ class PostsController < ApplicationController 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 @@ -510,6 +523,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), @@ -522,6 +536,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), @@ -548,11 +563,22 @@ class PostsController < ApplicationController 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 @@ -575,16 +601,6 @@ class PostsController < ApplicationController value.to_s end - def incoming_tag_names_for_snapshot raw_tag_names - Tag.normalise_tags!(raw_tag_names, with_tagme: false, deny_deprecated: true, - with_sections: true) => - { tags:, sections: } - - Tag.expand_parent_tags(tags).reject(&:deprecated?).uniq(&:id).map { |tag| - "#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }" - }.sort - end - def section_literal section "[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]" end @@ -607,6 +623,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, 'オリジナルの作成日時(より前)', @@ -670,6 +688,7 @@ 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]) @@ -684,6 +703,9 @@ class PostsController < ApplicationController tags = readonly_tags + editable_tags tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) + 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]) @@ -691,7 +713,7 @@ class PostsController < ApplicationController 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])] @@ -735,4 +757,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 913f1d4..633d3b4 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -46,6 +46,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 @@ -94,12 +95,16 @@ class Post < ApplicationRecord s = total_s % 60 min = (total_s / 60) % 60 h = total_s / 3_600 + remainder_ms = ms % 1_000 - if h.positive? - '%d:%02d:%02d' % [h, min, s] - else - '%d:%02d' % [min, s] - end + 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_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 6e87f44..202610b 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -131,6 +131,8 @@ class Tag < ApplicationRecord 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], @@ -154,6 +156,7 @@ class Tag < ApplicationRecord 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 diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index 8fe6f58..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, 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/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 2cc6a9a..d8c15cf 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_22_010000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_22_020000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -256,9 +256,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_22_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 diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 5899acb..deea984 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -49,14 +49,24 @@ RSpec.describe Tag, type: :model do expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]]) end - it 'parses fully open section literals as zero to end-of-video' do + 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).fetch(tag.id)).to eq([[0, nil]]) + 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 diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 7381615..d7f3473 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -36,6 +36,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, @@ -803,13 +804,62 @@ RSpec.describe 'Posts API', type: :request do ) 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 '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-]', + tags: '動画 伊地知ニジカ[1:00-]', + duration: '3:00', thumbnail: dummy_upload ) @@ -823,7 +873,7 @@ RSpec.describe 'Posts API', type: :request do expect(section.end_ms).to be_nil end - it 'treats [-] as [0:00-] and saves end_ms NULL' do + it 'does not save sections for [-]' do sign_in_as(member) post '/posts', params: post_write_params( @@ -837,10 +887,24 @@ RSpec.describe 'Posts API', type: :request do 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(PostTagSection.find_by(post: created_post, tag:)).to be_nil + end - expect(section.begin_ms).to eq(0) - expect(section.end_ms).to be_nil + 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 @@ -849,7 +913,8 @@ RSpec.describe 'Posts API', type: :request do post '/posts', params: post_write_params( title: 'show open ended section literal', url: 'https://example.com/show-open-ended-section-literal', - tags: '伊地知ニジカ[1:00-]', + tags: '動画 伊地知ニジカ[1:00-]', + duration: '3:00', thumbnail: dummy_upload ) @@ -864,6 +929,37 @@ RSpec.describe 'Posts API', type: :request do ]) 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/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 1ed75bd..c1fdd72 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import PostFormTagsArea from '@/components/PostFormTagsArea' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' @@ -17,7 +17,10 @@ import type { FC, FormEvent } from 'react' import type { Post, TagWithSections } from '@/types' type PostFormField = - 'parentPostIds' | 'tags' | 'originalCreatedAt' + 'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt' + +const videoMsToDurationValue = (videoMs: number | null): string => + videoMs == null ? '' : String (videoMs / 1_000) const tagsToStr = (tags: TagWithSections[]): string => { @@ -44,6 +47,7 @@ type Props = { post: Post const PostEditForm: FC = ({ post, onSave }) => { const [disabled, setDisabled] = useState (false) + const [duration, setDuration] = useState (videoMsToDurationValue (post.videoMs)) const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () const [originalCreatedBefore, setOriginalCreatedBefore] = @@ -55,6 +59,10 @@ const PostEditForm: FC = ({ post, onSave }) => { const [tags, setTags] = useState ('') const [title, setTitle] = useState (post.title) + const videoFlg = + useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'), + [tags]) + const dialogue = useDialogue () const update = async (...args: Parameters) => { @@ -66,6 +74,7 @@ const PostEditForm: FC = ({ post, onSave }) => { onSave ({ ...post, versionNo: data.versionNo, title: data.title, + videoMs: data.videoMs, tags: data.tags, parentPosts: data.parentPosts, childPosts: data.childPosts, @@ -105,6 +114,7 @@ const PostEditForm: FC = ({ post, onSave }) => { { // TODO: 差分 UI await update ({ id: post.id, title, tags, parentPostIds, + duration: videoFlg ? duration : null, originalCreatedFrom, originalCreatedBefore }, { baseVersionNo: post.versionNo, merge: true }) return @@ -113,6 +123,7 @@ const PostEditForm: FC = ({ post, onSave }) => { if (action === 'overwrite') { await update ({ id: post.id, title, tags, parentPostIds, + duration: videoFlg ? duration : null, originalCreatedFrom, originalCreatedBefore }, { baseVersionNo: post.versionNo, force: true }) return @@ -127,6 +138,7 @@ const PostEditForm: FC = ({ post, onSave }) => { try { await update ({ id: post.id, title, tags, parentPostIds, + duration: videoFlg ? duration : null, originalCreatedFrom, originalCreatedBefore }, { baseVersionNo: post.versionNo }) } @@ -138,8 +150,14 @@ const PostEditForm: FC = ({ post, onSave }) => { useEffect (() => { setTags (tagsToStr (post.tags)) + setDuration (videoMsToDurationValue (post.videoMs)) }, [post]) + useEffect (() => { + if (!(videoFlg)) + setDuration ('') + }, [videoFlg]) + return (
@@ -152,7 +170,7 @@ const PostEditForm: FC = ({ post, onSave }) => { disabled={disabled} className={inputClass (invalid)} value={title ?? ''} - onChange={ev => setTitle (ev.target.value)}/>)} + onChange={e => setTitle (e.target.value)}/>)} {/* 親投稿 */} @@ -184,6 +202,20 @@ const PostEditForm: FC = ({ post, onSave }) => { setOriginalCreatedBefore={setOriginalCreatedBefore} errors={fieldErrors.originalCreatedAt}/> + {/* 動画時間 */} + {videoFlg && ( + + {({ invalid }) => ( + setDuration (e.target.value)}/>)} + )} + {/* 送信 */}