| @@ -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) | |||
| @@ -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) | |||
| @@ -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: { | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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" | |||
| @@ -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 | |||
| @@ -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<Props> = ({ post, onSave }) => { | |||
| </form>) | |||
| } | |||
| export default PostEditForm | |||
| export default PostEditForm | |||
| @@ -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') }`) | |||
| } | |||
| @@ -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 ( | |||
| <MainArea> | |||
| <Helmet> | |||
| <meta name="robots" content="noindex"/> | |||
| {tag && <title>{tag.name} | ニジラー情報</title>} | |||
| </Helmet> | |||
| {(loading || !(tag)) ? 'Loading...' : ( | |||
| <div className="max-w-xl"> | |||
| <PageTitle> | |||
| @@ -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 | |||