From d3af4563ca5995056971dab3315ecc019c7a0c86 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 22 Jun 2026 21:24:55 +0900 Subject: [PATCH] #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 = {