このコミットが含まれているのは:
2026-06-23 00:24:04 +09:00
コミット 53d1cadefb
19個のファイルの変更316行の追加38行の削除
+5
ファイルの表示
@@ -25,6 +25,7 @@ class PostVersionsController < ApplicationController
SQL SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url', .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.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_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before') 'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
@@ -74,6 +75,10 @@ class PostVersionsController < ApplicationController
current: row.thumbnail_base, current: row.thumbnail_base,
prev: row.attributes['prev_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), tags: build_version_tags(cur_tags, prev_tags),
original_created_from: { original_created_from: {
current: row.original_created_from&.iso8601, current: row.original_created_from&.iso8601,
+75 -13
ファイルの表示
@@ -1,6 +1,10 @@
class PostsController < ApplicationController class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
class VideoMsParseError < ArgumentError
;
end
def index def index
url = params[:url].presence url = params[:url].presence
title = params[:title].presence title = params[:title].presence
@@ -159,6 +163,9 @@ class PostsController < ApplicationController
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) 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_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids) sync_parent_posts!(post, parent_post_ids)
@@ -176,6 +183,8 @@ class PostsController < ApplicationController
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] } render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -232,6 +241,8 @@ class PostsController < ApplicationController
original_created_from:, original_created_from:,
original_created_before:, original_created_before:,
tag_names:, tag_names:,
video_ms_param: params[:video_ms],
duration_param: params[:duration],
parent_post_ids:) parent_post_ids:)
snapshot_to_apply = snapshot_to_apply =
@@ -270,6 +281,8 @@ class PostsController < ApplicationController
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] } render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -510,6 +523,7 @@ class PostsController < ApplicationController
def post_snapshot_from_version version def post_snapshot_from_version version
{ title: version.title, { 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_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before), original_created_before: snapshot_time(version.original_created_before),
tag_names: editable_tag_names_from_version(version), tag_names: editable_tag_names_from_version(version),
@@ -522,6 +536,7 @@ class PostsController < ApplicationController
def post_snapshot_from_record post def post_snapshot_from_record post
{ title: post.title, { title: post.title,
video_ms: post.video_ms,
original_created_from: snapshot_time(post.original_created_from), original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before), original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post), tag_names: editable_tag_names_from_post(post),
@@ -548,11 +563,22 @@ class PostsController < ApplicationController
end end
def post_incoming_snapshot title:, original_created_from:, original_created_before:, 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:, { title:,
video_ms:,
original_created_from: snapshot_time(original_created_from), original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before), 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 } parent_post_ids: parent_post_ids.sort }
end end
@@ -575,16 +601,6 @@ class PostsController < ApplicationController
value.to_s value.to_s
end 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 def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]" "[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end end
@@ -607,6 +623,8 @@ class PostsController < ApplicationController
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル', [scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:video_ms, '動画時間',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)', scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)', scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
@@ -670,6 +688,7 @@ class PostsController < ApplicationController
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title], post.update!(title: snapshot[:title],
video_ms: snapshot[:video_ms],
original_created_from: snapshot[:original_created_from], original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before]) original_created_before: snapshot[:original_created_before])
@@ -684,6 +703,9 @@ class PostsController < ApplicationController
tags = readonly_tags + editable_tags tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) 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_post_tags!(post, tags, sections)
sync_parent_posts!(post, snapshot[:parent_post_ids]) sync_parent_posts!(post, snapshot[:parent_post_ids])
@@ -691,7 +713,7 @@ class PostsController < ApplicationController
end end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot 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], [_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1], current_snapshot[_1],
incoming_snapshot[_1])] incoming_snapshot[_1])]
@@ -735,4 +757,44 @@ class PostsController < ApplicationController
render_validation_error record render_validation_error record
end end
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 end
+5
ファイルの表示
@@ -46,6 +46,7 @@ class Post < ApplicationRecord
before_validation :normalise_url before_validation :normalise_url
validates :url, presence: true, uniqueness: true 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 :validate_original_created_range
validate :url_must_be_http_url validate :url_must_be_http_url
@@ -94,12 +95,16 @@ class Post < ApplicationRecord
s = total_s % 60 s = total_s % 60
min = (total_s / 60) % 60 min = (total_s / 60) % 60
h = total_s / 3_600 h = total_s / 3_600
remainder_ms = ms % 1_000
base =
if h.positive? if h.positive?
'%d:%02d:%02d' % [h, min, s] '%d:%02d:%02d' % [h, min, s]
else else
'%d:%02d' % [min, s] '%d:%02d' % [min, s]
end end
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
end end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id) def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
+1
ファイルの表示
@@ -5,6 +5,7 @@ class PostVersion < ApplicationRecord
belongs_to :parent, class_name: 'Post', optional: true belongs_to :parent, class_name: 'Post', optional: true
validates :url, presence: true validates :url, presence: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range validate :validate_original_created_range
+3
ファイルの表示
@@ -131,6 +131,8 @@ class Tag < ApplicationRecord
sections_by_tag = [] sections_by_tag = []
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)) while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
name = "#{ match[1] }#{ match[4] }" name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
sections_by_tag << normalise_section_range!( sections_by_tag << normalise_section_range!(
begin_raw: match[2], begin_raw: match[2],
end_raw: match[3], end_raw: match[3],
@@ -154,6 +156,7 @@ class Tag < ApplicationRecord
sections[tag.id] ||= [] sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag) sections[tag.id].concat(sections_by_tag)
sections[tag.id] = merge_section_ranges(sections[tag.id]) sections[tag.id] = merge_section_ranges(sections[tag.id])
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
end end
end end
+1
ファイルの表示
@@ -8,6 +8,7 @@ module PostRepr
:url, :url,
:title, :title,
:thumbnail_base, :thumbnail_base,
:video_ms,
:original_created_from, :original_created_from,
:original_created_before, :original_created_before,
:created_at, :created_at,
+1
ファイルの表示
@@ -23,6 +23,7 @@ class PostVersionRecorder < VersionRecorder
{ title: @record.title, { title: @record.title,
url: @record.url, url: @record.url,
thumbnail_base: @record.thumbnail_base, thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '), tags: @record.snapshot_tag_names.join(' '),
parent_post_ids: @record.snapshot_parent_post_ids.join(' '), parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from, original_created_from: @record.original_created_from,
+9
ファイルの表示
@@ -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
生成ファイル
+4 -1
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", 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 "original_created_before"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" 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 ["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", "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 ["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 "`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" t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end end
+12 -2
ファイルの表示
@@ -49,14 +49,24 @@ RSpec.describe Tag, type: :model do
expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
end 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!( result = described_class.normalise_tags!(
['伊地知ニジカ[-]'], ['伊地知ニジカ[-]'],
with_sections: true with_sections: true
) )
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } 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 end
it 'expands zero-width sections to one millisecond' do it 'expands zero-width sections to one millisecond' do
+102 -6
ファイルの表示
@@ -36,6 +36,7 @@ RSpec.describe 'Posts API', type: :request do
title: post.title, title: post.title,
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
video_ms: post.video_ms,
tags: post.snapshot_tag_names.join(' '), tags: post.snapshot_tag_names.join(' '),
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
@@ -803,13 +804,62 @@ RSpec.describe 'Posts API', type: :request do
) )
end 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 it 'saves open-ended sections with end_ms NULL' do
sign_in_as(member) sign_in_as(member)
post '/posts', params: post_write_params( post '/posts', params: post_write_params(
title: 'open ended section literal', title: 'open ended section literal',
url: 'https://example.com/open-ended-section-literal', url: 'https://example.com/open-ended-section-literal',
tags: '伊地知ニジカ[1:00-]', tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload thumbnail: dummy_upload
) )
@@ -823,7 +873,7 @@ RSpec.describe 'Posts API', type: :request do
expect(section.end_ms).to be_nil expect(section.end_ms).to be_nil
end end
it 'treats [-] as [0:00-] and saves end_ms NULL' do it 'does not save sections for [-]' do
sign_in_as(member) sign_in_as(member)
post '/posts', params: post_write_params( post '/posts', params: post_write_params(
@@ -837,10 +887,24 @@ RSpec.describe 'Posts API', type: :request do
created_post = Post.find(json.fetch('id')) created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' }) 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) it 'does not save sections for [0:00-]' do
expect(section.end_ms).to be_nil 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 end
it 'returns end_ms null for open-ended sections in show response' do 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( post '/posts', params: post_write_params(
title: 'show open ended section literal', title: 'show open ended section literal',
url: 'https://example.com/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 thumbnail: dummy_upload
) )
@@ -864,6 +929,37 @@ RSpec.describe 'Posts API', type: :request do
]) ])
end 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 context "when nico tag already exists in tags" do
before do before do
Tag.find_undiscard_or_create_by!( Tag.find_undiscard_or_create_by!(
+36 -3
ファイルの表示
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea' import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
@@ -17,7 +17,10 @@ import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types' import type { Post, TagWithSections } from '@/types'
type PostFormField = 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 => { const tagsToStr = (tags: TagWithSections[]): string => {
@@ -44,6 +47,7 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => { const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [disabled, setDisabled] = useState (false) const [disabled, setDisabled] = useState (false)
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> () useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] =
@@ -55,6 +59,10 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [tags, setTags] = useState<string> ('') const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title) const [title, setTitle] = useState (post.title)
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const dialogue = useDialogue () const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => { const update = async (...args: Parameters<typeof updatePost>) => {
@@ -66,6 +74,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
onSave ({ ...post, onSave ({ ...post,
versionNo: data.versionNo, versionNo: data.versionNo,
title: data.title, title: data.title,
videoMs: data.videoMs,
tags: data.tags, tags: data.tags,
parentPosts: data.parentPosts, parentPosts: data.parentPosts,
childPosts: data.childPosts, childPosts: data.childPosts,
@@ -105,6 +114,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{ {
// TODO: 差分 UI // TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true }) { baseVersionNo: post.versionNo, merge: true })
return return
@@ -113,6 +123,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
if (action === 'overwrite') if (action === 'overwrite')
{ {
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true }) { baseVersionNo: post.versionNo, force: true })
return return
@@ -127,6 +138,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
try try
{ {
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo }) { baseVersionNo: post.versionNo })
} }
@@ -138,8 +150,14 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
useEffect (() => { useEffect (() => {
setTags (tagsToStr (post.tags)) setTags (tagsToStr (post.tags))
setDuration (videoMsToDurationValue (post.videoMs))
}, [post]) }, [post])
useEffect (() => {
if (!(videoFlg))
setDuration ('')
}, [videoFlg])
return ( return (
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
@@ -152,7 +170,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
disabled={disabled} disabled={disabled}
className={inputClass (invalid)} className={inputClass (invalid)}
value={title ?? ''} value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>)} onChange={e => setTitle (e.target.value)}/>)}
</FormField> </FormField>
{/* 親投稿 */} {/* 親投稿 */}
@@ -184,6 +202,20 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{videoFlg && (
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
disabled={disabled}
className={inputClass (invalid)}
value={duration}
onChange={e => setDuration (e.target.value)}/>)}
</FormField>)}
{/* 送信 */} {/* 送信 */}
<Button type="submit" disabled={disabled}> <Button type="submit" disabled={disabled}>
@@ -191,4 +223,5 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
</form>) </form>)
} }
export default PostEditForm export default PostEditForm
+2
ファイルの表示
@@ -46,6 +46,7 @@ export const updatePost = async (
post: { id: number post: { id: number
title: string | null title: string | null
tags: string tags: string
duration: string | null
parentPostIds: string parentPostIds: string
originalCreatedFrom: string | null originalCreatedFrom: string | null
originalCreatedBefore: string | null }, originalCreatedBefore: string | null },
@@ -58,6 +59,7 @@ export const updatePost = async (
`/posts/${ post.id }`, `/posts/${ post.id }`,
{ title: post.title, { title: post.title,
tags: post.tags, tags: post.tags,
duration: post.duration,
parent_post_ids: post.parentPostIds, parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom, original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore }, original_created_before: post.originalCreatedBefore },
+12 -1
ファイルの表示
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { cn, originalCreatedAtString, toDate } from '@/lib/utils' import { cn, msToTime, originalCreatedAtString, toDate } from '@/lib/utils'
describe ('utils', () => { describe ('utils', () => {
it ('converts strings to dates and leaves date instances intact', () => { it ('converts strings to dates and leaves date instances intact', () => {
@@ -26,3 +26,14 @@ describe ('utils', () => {
).toContain ('時刻不詳') ).toContain ('時刻不詳')
}) })
}) })
describe ('msToTime', () => {
it ('keeps milliseconds when present', () => {
expect (msToTime (60_500)).toBe ('1:00.500')
expect (msToTime (60_001)).toBe ('1:00.001')
})
it ('omits milliseconds when they are zero', () => {
expect (msToTime (60_000)).toBe ('1:00')
})
})
+5 -1
ファイルの表示
@@ -78,10 +78,14 @@ export const msToTime = (ms: number): string => {
const s = String (totalS % 60) const s = String (totalS % 60)
const min = String (Math.trunc (totalS / 60) % 60) const min = String (Math.trunc (totalS / 60) % 60)
const h = Math.trunc (totalS / 3_600) const h = Math.trunc (totalS / 3_600)
const remainderMs = ms % 1_000
return (h > 0 const base =
(h > 0
? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }` ? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }`
: `${ min }:${ s.padStart (2, '0') }`) : `${ min }:${ s.padStart (2, '0') }`)
return remainderMs > 0 ? `${ base }.${ remainderMs.toString ().padStart (3, '0') }` : base
} }
+6 -2
ファイルの表示
@@ -15,7 +15,7 @@ import { SITE_TITLE } from '@/config'
import { fetchPostChanges, updatePost } from '@/lib/posts' import { fetchPostChanges, updatePost } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags' import { fetchTag } from '@/lib/tags'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils' import { cn, dateString, msToTime, originalCreatedAtString } from '@/lib/utils'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
@@ -91,9 +91,13 @@ const PostHistoryPage: FC = () => {
.filter (p => p.type !== 'removed') .filter (p => p.type !== 'removed')
.map (p => p.id) .map (p => p.id)
.join (' ') .join (' ')
const duration =
change.videoMs.current == null
? null
: msToTime (change.videoMs.current)
const originalCreatedFrom = change.originalCreatedFrom.current const originalCreatedFrom = change.originalCreatedFrom.current
const originalCreatedBefore = change.originalCreatedBefore.current const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, title, tags, parentPostIds, await updatePost ({ id, title, tags, duration, parentPostIds,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ force: true }) { force: true })
+27 -2
ファイルの表示
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@@ -25,7 +25,7 @@ import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
type PostFormField = type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds' | 'originalCreatedAt' | 'thumbnail' 'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => { const PostNewPage: FC<Props> = ({ user }) => {
@@ -40,6 +40,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('') const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('') const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('')
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false) const [thumbnailLoading, setThumbnailLoading] = useState (false)
@@ -51,6 +52,9 @@ const PostNewPage: FC<Props> = ({ user }) => {
const previousURLRef = useRef ('') const previousURLRef = useRef ('')
const thumbnailPreviewRef = useRef ('') const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors () clearValidationErrors ()
@@ -60,6 +64,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
formData.append ('url', url) formData.append ('url', url)
formData.append ('tags', tags) formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds) formData.append ('parent_post_ids', parentPostIds)
if (videoFlg && duration !== '')
formData.append ('duration', duration)
if (thumbnailFile) if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile) formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom) if (originalCreatedFrom)
@@ -129,6 +135,11 @@ const PostNewPage: FC<Props> = ({ user }) => {
fetchThumbnail () fetchThumbnail ()
}, [fetchThumbnail, thumbnailAutoFlg, url]) }, [fetchThumbnail, thumbnailAutoFlg, url])
useEffect (() => {
if (!(videoFlg))
setDuration ('')
}, [videoFlg])
if (!(editable)) if (!(editable))
return <Forbidden/> return <Forbidden/>
@@ -233,6 +244,20 @@ const PostNewPage: FC<Props> = ({ user }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{(videoFlg &&
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
value={duration}
onChange={e => setDuration (e.target.value)}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>)}
{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
+1
ファイルの表示
@@ -35,6 +35,7 @@ export const buildPost = (overrides: Partial<Post> = {}): Post => ({
title: 'テスト投稿', title: 'テスト投稿',
thumbnail: 'https://example.com/thumb.jpg', thumbnail: 'https://example.com/thumb.jpg',
thumbnailBase: null, thumbnailBase: null,
videoMs: null,
tags: [buildTag ()], tags: [buildTag ()],
parentPosts: [], parentPosts: [],
childPosts: [], childPosts: [],
+2
ファイルの表示
@@ -140,6 +140,7 @@ export type Post = {
title: string | null title: string | null
thumbnail: string | null thumbnail: string | null
thumbnailBase: string | null thumbnailBase: string | null
videoMs: number | null
postSimilarityEdges?: { postSimilarityEdges?: {
targetPostId: number targetPostId: number
cos: number cos: number
@@ -172,6 +173,7 @@ export type PostVersion = {
url: { current: string; prev: string | null } url: { current: string; prev: string | null }
thumbnail: { current: string | null; prev: string | null } thumbnail: { current: string | null; prev: string | null }
thumbnailBase: { current: string | null; prev: string | null } thumbnailBase: { current: string | null; prev: string | null }
videoMs: { current: number | null; prev: number | null }
tags: { name: string tags: { name: string
type: 'context' | 'added' | 'removed' }[] type: 'context' | 'added' | 'removed' }[]
parentPosts: { id: number parentPosts: { id: number