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)}/>)} + )} + {/* 送信 */}