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).uniq(&:id).map { |tag| + "#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }" + }.sort + end - Tag.expand_parent_tags(tags).map(&:name).uniq.sort + 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