From 19a185d5b51e26dcf126369abf2a5baaf8b85632 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 2 Jul 2026 01:37:49 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E4=B8=8A=E6=98=A0=E4=BC=9A=E3=82=BF?= =?UTF-8?q?=E3=82=B0=E8=A1=A8=E7=A4=BA=E3=83=90=E3=82=B0=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20(#360)=20(#384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit まだ画面チェックしてゐないのでマージ禁止 Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/384 Co-authored-by: miteruzo Co-committed-by: miteruzo --- .../pages/theatres/TheatreDetailPage.test.tsx | 53 ++++++++++++++++- .../src/pages/theatres/TheatreDetailPage.tsx | 58 ++++++++++++++++--- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx index 7adab24..ccb5407 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx @@ -1,9 +1,10 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import { buildPost, + buildTag, buildTheatre, buildTheatreComment, buildTheatreInfo, @@ -96,6 +97,9 @@ const renderPage = (user = buildUser ({ id: 1, role: 'member' })) => { route: '/theatres/7' }, ) +const tagSection = (): HTMLElement => + screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')! + const mockDefaultApi = () => { api.apiGet.mockImplementation ((path: string) => { switch (path) @@ -245,6 +249,53 @@ describe ('TheatreDetailPage', () => { expect (postEmbed.seek).not.toHaveBeenCalledWith (0) }) + it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => { + const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' }) + const parentTag = buildTag ({ + id: 11, + name: '親タグ', + category: 'general', + children: [childTag], + }) + const duplicateParentTag = buildTag ({ + id: 13, + name: '別親タグ', + category: 'general', + children: [childTag], + }) + + postsApi.fetchPost.mockResolvedValueOnce (buildPost ({ + ...currentPost, + tags: [parentTag, duplicateParentTag], + })) + + renderPage () + + await screen.findByText ('Embed:上映中の投稿') + + expect (within (tagSection ()).getByRole ('link', { name: '親タグ' })) + .toBeInTheDocument () + expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' })) + .toBeInTheDocument () + expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })[0]) + .toBeInTheDocument () + expect (within (tagSection ()).getAllByText ('↳').length).toBeGreaterThan (0) + + fireEvent.click (screen.getByRole ('button', { name: '2 列 A 型' })) + fireEvent.click (screen.getAllByRole ('button', { name: '横並び' })[0]) + + await waitFor (() => { + expect (within (tagSection ()).getByRole ('link', { name: '親タグ' })) + .toBeInTheDocument () + expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' })) + .toBeInTheDocument () + expect (within (tagSection ()).getByRole ('link', { name: '子タグ' })) + .toBeInTheDocument () + expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })) + .toHaveLength (1) + }) + }) + it ('does not advance host post while video length is unknown', async () => { api.apiPut.mockImplementation ((path: string) => { switch (path) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index f3f27aa..e4db3d2 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -102,6 +102,28 @@ const compareTagName = (a: Tag, b: Tag): number => a.name === b.name ? 0 : (a.name < b.name ? -1 : 1) +const flattenTags = (tags: Tag[]): Tag[] => { + const flattened: Tag[] = [] + const seen = new Set () + + const visit = (tag: Tag) => { + if (seen.has (tag.id)) + return + + seen.add (tag.id) + flattened.push (tag) + + for (const child of tag.children ?? []) + visit (child) + } + + for (const tag of tags) + visit (tag) + + return flattened +} + + const tagsByCategory = (tags: Tag[]): Partial> => { const grouped: Partial> = { } @@ -118,15 +140,39 @@ const tagsByCategory = (tags: Tag[]): Partial> => { } +const renderReadonlyTagTree = ( + tag: Tag, + nestLevel: number, + path: string, + ancestors: Set = new Set (), +): ReactNode[] => { + const key = `${ path }-${ tag.id }` + const nextAncestors = new Set (ancestors) + nextAncestors.add (tag.id) + + return [ + ( +
  • + +
  • ), + ...((tag.children ?? []) + .filter (child => !(nextAncestors.has (child.id))) + .sort (compareTagName) + .flatMap (child => + renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))] +} + + const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( { tags, compact, flow = 'vertical' }) => { - const grouped = tagsByCategory (tags) + const horizontalGrouped = tagsByCategory (flattenTags (tags)) + const verticalGrouped = tagsByCategory (tags) if (flow === 'horizontal') { return (
      - {CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => ( + {CATEGORIES.flatMap (cat => horizontalGrouped[cat] ?? []).map (tag => (
    • ))} @@ -136,7 +182,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( return (
      {CATEGORIES.map (cat => { - const rows = grouped[cat] ?? [] + const rows = verticalGrouped[cat] ?? [] if (rows.length === 0) return null @@ -146,10 +192,8 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( {CATEGORY_NAMES[cat]}
        - {rows.map (tag => ( -
      • - -
      • ))} + {rows.flatMap (tag => + renderReadonlyTagTree (tag, 0, `cat-${ cat }`))}
      ) })} From 5657fa7e7b9dd820f509f2b7e72ee2e64092f87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Thu, 2 Jul 2026 12:47:19 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=80=9C=E9=96=8B=E7=99=BA=E3=83=A2?= =?UTF-8?q?=E3=83=BC=E3=83=89=E2=80=9D=20=E3=81=AE=E8=A1=A8=E7=A4=BA=20(#3?= =?UTF-8?q?89)=20(#393)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/393 --- frontend/src/App.tsx | 4 +- frontend/src/components/DevModeWatermark.tsx | 46 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/DevModeWatermark.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11f784c..b4e9b04 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { BrowserRouter, Routes, useLocation } from 'react-router-dom' +import DevModeWatermark from '@/components/DevModeWatermark' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' @@ -145,6 +146,7 @@ const App: FC = () => { return ( <> + {import.meta.env.DEV && } @@ -152,7 +154,7 @@ const App: FC = () => { + className="relative flex flex-col h-dvh w-full overflow-y-hidden"> diff --git a/frontend/src/components/DevModeWatermark.tsx b/frontend/src/components/DevModeWatermark.tsx new file mode 100644 index 0000000..5de8c0b --- /dev/null +++ b/frontend/src/components/DevModeWatermark.tsx @@ -0,0 +1,46 @@ +import nikumaru from '@/assets/fonts/nikumaru.otf' + +import type { FC } from 'react' + + +const ROW_COUNT = 8 +const COLUMN_COUNT = 12 + + +const DevModeWatermark: FC = () => { + return ( + ) +} + + +export default DevModeWatermark From f96ddb65f122942c96c81ca61b7cd6812a6e2383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Thu, 2 Jul 2026 23:40:01 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E3=82=BF=E3=82=B0=E3=81=8C=E8=A6=8B?= =?UTF-8?q?=E5=88=87=E3=82=8C=E3=82=8B=E5=95=8F=E9=A1=8C=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=20(#76)=20(#395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/395 --- .../components/DraggableDroppableTagRow.tsx | 7 +- frontend/src/components/MaterialSidebar.tsx | 10 +- .../src/components/ResponsiveMarqueeText.tsx | 250 ++++++++++++++++++ frontend/src/components/TagLink.tsx | 50 +++- frontend/src/components/TagSearchBox.tsx | 8 +- frontend/src/components/TagSidebar.tsx | 5 +- frontend/src/index.css | 17 ++ 7 files changed, 327 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/ResponsiveMarqueeText.tsx diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index e6c49ff..97e8248 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -87,10 +87,13 @@ const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTa setDropRef (node) }} style={style} - className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} + className={cn ( + 'min-w-0 max-w-full overflow-hidden rounded select-none', + over && 'ring-2 ring-offset-2')} {...attributes} {...listeners}> @@ -98,4 +101,4 @@ const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTa ) } -export default DraggableDroppableTagRow \ No newline at end of file +export default DraggableDroppableTagRow diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 399d9d8..8428c09 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -318,12 +318,20 @@ const MobileMaterialTreeNode: FC<{ depth?: number + [max-height:var(--tag-link-inline-size)] + [&_.tag-marquee]:block + [&_.tag-marquee]:h-full + [&_.tag-marquee]:overflow-hidden + [&_.tag-marquee__static]:h-full + [&_.tag-marquee__static]:overflow-hidden + [&_.tag-marquee__static]:text-ellipsis + [&_.tag-marquee__static]:[text-overflow:ellipsis]"/> {tag.hasChildren && (
    ) } -export default TagSearchBox \ No newline at end of file +export default TagSearchBox diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index 9c08ccc..46fd6fe 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -64,8 +64,9 @@ const TagSidebar: FC = ({ posts, onClick }) => {
      {CATEGORIES.flatMap (cat => cat in tags ? ( tags[cat].map (tag => ( -
    • +
    • @@ -128,4 +129,4 @@ const TagSidebar: FC = ({ posts, onClick }) => { ) } -export default TagSidebar \ No newline at end of file +export default TagSidebar diff --git a/frontend/src/index.css b/frontend/src/index.css index f3709a4..e70403a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -132,3 +132,20 @@ body 0%, 100% { color: #f87171; } 50% { color: #60a5fa; } } + +.tag-marquee +{ + position: relative; +} + +.tag-marquee__animated +{ + position: absolute; + inset: 0 auto 0 0; + min-width: 100%; + width: max-content; + opacity: 0; + pointer-events: none; + white-space: nowrap; + will-change: transform; +} From fdf652951aa66e065bb23dbf6cdc46f907d6f485 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 3 Jul 2026 03:23:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E3=80=90=E5=86=8D=E6=8E=B2=E3=80=91?= =?UTF-8?q?=E3=82=BF=E3=82=B0=E3=81=AE=E5=B1=80=E6=89=80=E8=A8=98=E8=BC=89?= =?UTF-8?q?=20(#351)=20(#380)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #353 を再開できなかったので. Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/380 Co-authored-by: miteruzo Co-committed-by: miteruzo --- .../controllers/post_versions_controller.rb | 5 + backend/app/controllers/posts_controller.rb | 159 ++++++++++--- backend/app/models/post.rb | 38 +++- backend/app/models/post_tag.rb | 6 + backend/app/models/post_tag_section.rb | 20 ++ backend/app/models/post_version.rb | 1 + backend/app/models/tag.rb | 115 +++++++++- backend/app/representations/post_repr.rb | 15 +- backend/app/services/post_version_recorder.rb | 1 + .../20260519013100_add_video_ms_to_posts.rb | 9 + ...20260622010000_create_post_tag_sections.rb | 19 ++ ...622020000_add_video_ms_to_post_versions.rb | 9 + backend/db/schema.rb | 21 ++ backend/spec/factories/post_tag_sections.rb | 8 + backend/spec/factories/post_tags.rb | 6 + backend/spec/factories/posts.rb | 8 + backend/spec/models/post_tag_spec.rb | 41 ++++ backend/spec/models/tag_spec.rb | 89 ++++++++ backend/spec/requests/posts_spec.rb | 209 ++++++++++++++++++ frontend/src/components/PostEditForm.test.tsx | 23 ++ frontend/src/components/PostEditForm.tsx | 53 ++++- frontend/src/components/TagDetailSidebar.tsx | 20 +- frontend/src/lib/posts.test.ts | 2 + frontend/src/lib/posts.ts | 2 + frontend/src/lib/utils.test.ts | 13 +- frontend/src/lib/utils.ts | 16 ++ frontend/src/pages/posts/PostHistoryPage.tsx | 6 +- frontend/src/pages/posts/PostNewPage.test.tsx | 18 ++ frontend/src/pages/posts/PostNewPage.tsx | 24 +- .../pages/theatres/TheatreDetailPage.test.tsx | 12 +- frontend/src/test/factories.ts | 7 +- frontend/src/types.ts | 8 +- 32 files changed, 920 insertions(+), 63 deletions(-) create mode 100644 backend/app/models/post_tag_section.rb create mode 100644 backend/db/migrate/20260519013100_add_video_ms_to_posts.rb create mode 100644 backend/db/migrate/20260622010000_create_post_tag_sections.rb create mode 100644 backend/db/migrate/20260622020000_add_video_ms_to_post_versions.rb create mode 100644 backend/spec/factories/post_tag_sections.rb create mode 100644 backend/spec/factories/post_tags.rb create mode 100644 backend/spec/factories/posts.rb create mode 100644 backend/spec/models/post_tag_spec.rb 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 26e358e..339519b 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 @@ -45,7 +49,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 +103,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 +118,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 @@ -125,7 +135,7 @@ class PostsController < ApplicationController child_posts:, sibling_posts:, related:) - .merge(tags: build_tag_tree_for(post.tags)) + .merge(tags: build_tag_tree_for(post)) end def create @@ -149,11 +159,15 @@ class PostsController < ApplicationController ApplicationRecord.transaction do post.save! - tags = Tag.normalise_tags!(tag_names, deny_deprecated: true) + Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) => + { tags:, sections: } TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) - sync_post_tags!(post, tags) + 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) @@ -166,6 +180,10 @@ 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 VideoMsParseError + render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] } rescue MiniMagick::Error render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] } rescue ArgumentError => e @@ -224,6 +242,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 = @@ -254,12 +274,16 @@ 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 render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] } rescue Tag::DeprecatedTagNormalisationError 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 @@ -358,7 +382,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 @@ -377,13 +401,21 @@ 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.reject(&:deprecated?).to_a + def build_tag_tree_for post + 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) @@ -398,6 +430,9 @@ class PostsController < ApplicationController root_ids = tag_ids - child_ids tags_by_id = tags.index_by(&: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 = { } @@ -405,8 +440,10 @@ class PostsController < ApplicationController tag = tags_by_id[tag_id] return nil unless tag + sections = sections_by_tag_id.fetch(tag_id, []) + if path.include?(tag_id) - return TagRepr.inline(tag).merge(children: []) + return TagRepr.inline(tag).merge(children: [], sections:) end if memo.key?(tag_id) @@ -418,7 +455,7 @@ class PostsController < ApplicationController children = child_ids.filter_map { |cid| build_node.(cid, new_path) } - memo[tag_id] = TagRepr.inline(tag).merge(children:) + memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:) end root_ids.filter_map { |id| build_node.call(id, []) } @@ -487,6 +524,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), @@ -499,6 +537,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), @@ -506,16 +545,41 @@ class PostsController < ApplicationController end def editable_tag_names_from_post post - post.tags.not_nico.where(deprecated_at: nil) - .joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + post + .post_tags + .kept + .joins(tag: :tag_name) + .merge(Tag.not_nico) + .merge(Tag.where(deprecated_at: nil)) + .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:, - 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 @@ -538,11 +602,8 @@ class PostsController < ApplicationController value.to_s end - def incoming_tag_names_for_snapshot raw_tag_names - tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false, - deny_deprecated: true) - - Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort + def section_literal section + "[#{ 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:, @@ -563,6 +624,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, 'オリジナルの作成日時(より前)', @@ -626,11 +689,14 @@ 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]) - editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, - deny_deprecated: true) + Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, + deny_deprecated: true, + 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 @@ -638,14 +704,17 @@ class PostsController < ApplicationController tags = readonly_tags + editable_tags tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) - sync_post_tags!(post, tags) + 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]) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) 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])] @@ -689,4 +758,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 b2835b0..cdfbaef 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -60,6 +60,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 @@ -83,7 +84,42 @@ 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) }-#{ section.end_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 + remainder_ms = ms % 1_000 + + 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_tag.rb b/backend/app/models/post_tag.rb index 702f66a..ac56c77 100644 --- a/backend/app/models/post_tag.rb +++ b/backend/app/models/post_tag.rb @@ -10,6 +10,12 @@ class PostTag < ApplicationRecord belongs_to :created_user, class_name: 'User', optional: true belongs_to :deleted_user, class_name: 'User', optional: true + has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection', + foreign_key: [:post_id, :tag_id], + primary_key: [:post_id, :tag_id], + dependent: :delete_all, + inverse_of: :post_tag + validates :post_id, presence: true validates :tag_id, presence: true validates :post_id, uniqueness: { diff --git a/backend/app/models/post_tag_section.rb b/backend/app/models/post_tag_section.rb new file mode 100644 index 0000000..5c47e4c --- /dev/null +++ b/backend/app/models/post_tag_section.rb @@ -0,0 +1,20 @@ +class PostTagSection < ApplicationRecord + self.primary_key = :post_id, :tag_id, :begin_ms + + belongs_to :post + belongs_to :tag + + belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id], + primary_key: [:post_id, :tag_id], + inverse_of: :sections, + optional: true + + validates :post_id, presence: true + validates :tag_id, presence: true + + validates :begin_ms, presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + validates :end_ms, numericality: { only_integer: true, greater_than: :begin_ms }, + allow_nil: true +end 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 8e2002e..202610b 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' @@ -105,26 +115,60 @@ class Tag < ApplicationRecord def self.normalise_tags! tag_names, with_tagme: true, with_no_deerjikist: true, deny_nico: true, - deny_deprecated: false + deny_deprecated: false, + 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| + raw_name = name pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] - name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first + + name = name.sub(/\A#{ pf }/i, '') + + 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], + tag_name: name) + end + + if name.include?('[') || name.include?(']') + raise SectionLiteralParseError.new(raw_name, raw_name) + end + + name = TagName.canonicalise(name).first + find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| if deny_deprecated && tag.deprecated? raise DeprecatedTagNormalisationError, [tag.name] end tag.update!(category: cat) if cat && tag.category != cat + next if sections_by_tag.empty? + + 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 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 @@ -150,6 +194,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? @@ -246,6 +329,32 @@ class Tag < ApplicationRecord end end + 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 + + raise SectionLiteralParseError.new(tag_name, str) unless match + + 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] + + total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i + end + def nico_tags_cannot_be_deprecated if nico? && deprecated_at.present? errors.add :deprecated_at, 'ニコタグは廃止できません.' diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index 1c62f1e..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, @@ -18,7 +19,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 +53,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/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/20260519013100_add_video_ms_to_posts.rb b/backend/db/migrate/20260519013100_add_video_ms_to_posts.rb new file mode 100644 index 0000000..8225e13 --- /dev/null +++ b/backend/db/migrate/20260519013100_add_video_ms_to_posts.rb @@ -0,0 +1,9 @@ +class AddVideoMsToPosts < ActiveRecord::Migration[8.0] + def change + add_column :posts, :video_ms, :integer + add_index :posts, [:video_ms, :id], name: 'idx_posts_video_ms_id' + + add_check_constraint :posts, 'video_ms IS NULL OR video_ms > 0', + name: 'chk_posts_video_ms_positive' + end +end diff --git a/backend/db/migrate/20260622010000_create_post_tag_sections.rb b/backend/db/migrate/20260622010000_create_post_tag_sections.rb new file mode 100644 index 0000000..042d05b --- /dev/null +++ b/backend/db/migrate/20260622010000_create_post_tag_sections.rb @@ -0,0 +1,19 @@ +class CreatePostTagSections < ActiveRecord::Migration[8.0] + def change + create_table :post_tag_sections, primary_key: [:post_id, :tag_id, :begin_ms] do |t| + t.references :post, null: false, foreign_key: true, index: false + t.references :tag, null: false, foreign_key: true, index: false + t.integer :begin_ms, null: false + t.integer :end_ms, null: true + 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 'end_ms IS NULL OR begin_ms < end_ms', + name: 'chk_post_tag_sections_end_ms_after_begin_ms' + end + end +end 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 dbff31a..b9f669a 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -298,6 +298,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) 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" + 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 "(`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 + 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 @@ -331,9 +344,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_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 @@ -348,8 +364,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) 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 @@ -678,6 +697,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" + add_foreign_key "post_tag_sections", "posts" + add_foreign_key "post_tag_sections", "tags" add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "users", column: "created_user_id" diff --git a/backend/spec/factories/post_tag_sections.rb b/backend/spec/factories/post_tag_sections.rb new file mode 100644 index 0000000..2560395 --- /dev/null +++ b/backend/spec/factories/post_tag_sections.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :post_tag_section do + association :post + association :tag + begin_ms { 1_000 } + end_ms { 2_000 } + end +end diff --git a/backend/spec/factories/post_tags.rb b/backend/spec/factories/post_tags.rb new file mode 100644 index 0000000..6956ca3 --- /dev/null +++ b/backend/spec/factories/post_tags.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :post_tag do + association :post + association :tag + end +end diff --git a/backend/spec/factories/posts.rb b/backend/spec/factories/posts.rb new file mode 100644 index 0000000..9f46b2e --- /dev/null +++ b/backend/spec/factories/posts.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :post do + sequence(:url) { |n| "https://example.com/factory-post-#{ n }" } + title { 'factory post' } + thumbnail_base { nil } + uploaded_user { nil } + end +end diff --git a/backend/spec/models/post_tag_spec.rb b/backend/spec/models/post_tag_spec.rb new file mode 100644 index 0000000..6f89ac5 --- /dev/null +++ b/backend/spec/models/post_tag_spec.rb @@ -0,0 +1,41 @@ +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 + + 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 1b2549c..d1b64e0 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -19,6 +19,95 @@ 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 '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)[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 + 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 b640f1a..752ac6a 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -54,6 +54,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, @@ -145,6 +146,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" } @@ -845,6 +862,198 @@ 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 '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 'creates a video post with number input duration seconds' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'video post seconds', + url: 'https://example.com/video-post-seconds', + tags: '動画 spec_tag', + duration: '180.5', + 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-]', + duration: '3: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 'does not save sections for [-]' 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: '伊地知ニジカ' }) + expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil + end + + 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 + 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-]', + duration: '3: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 + + 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.test.tsx b/frontend/src/components/PostEditForm.test.tsx index 1fc58a9..2ff28b1 100644 --- a/frontend/src/components/PostEditForm.test.tsx +++ b/frontend/src/components/PostEditForm.test.tsx @@ -66,4 +66,27 @@ describe ('PostEditForm', () => { expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) }) + + it ('preserves duration while the video tag is temporarily removed', () => { + const post = buildPost ({ + videoMs: 180_500, + tags: [ + buildTag ({ id: 1, name: '動画', category: 'general' }), + buildTag ({ id: 2, name: 'general-tag', category: 'general' }), + ], + }) + + render () + + expect (screen.getByRole ('spinbutton')).toHaveValue (180.5) + + const tags = screen.getAllByRole ('textbox')[2] + fireEvent.change (tags, { target: { value: 'general-tag' } }) + expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument () + + fireEvent.change (tags, { + target: { value: '動画 general-tag' }, + }) + expect (screen.getByRole ('spinbutton')).toHaveValue (180.5) + }) }) diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index b17fabc..744c132 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' @@ -9,29 +9,35 @@ import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { isApiError } from '@/lib/api' import { updatePost } from '@/lib/posts' -import { inputClass } from '@/lib/utils' +import { inputClass, msToTime } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC, FormEvent } from 'react' -import type { Post, Tag } from '@/types' +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: Tag[]): string => { - const result: Tag[] = [] +const tagsToStr = (tags: TagWithSections[]): string => { + const result: Omit[] = [] - const walk = (tag: Tag) => { + const walk = (tag: TagWithSections) => { const { children, ...rest } = tag result.push (rest) - children?.forEach (walk) + children.forEach (walk) } 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) }-${ s.endMs == null ? '' : msToTime (s.endMs) }]`) + .join ('') }`)))].join (' ') } @@ -41,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] = @@ -52,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) => { @@ -63,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, @@ -102,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 @@ -110,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 @@ -124,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 }) } @@ -134,7 +149,8 @@ const PostEditForm: FC = ({ post, onSave }) => { } useEffect (() => { - setTags(tagsToStr (post.tags)) + setTags (tagsToStr (post.tags)) + setDuration (videoMsToDurationValue (post.videoMs)) }, [post]) return ( @@ -149,7 +165,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)}/>)} {/* 親投稿 */} @@ -181,6 +197,20 @@ const PostEditForm: FC = ({ post, onSave }) => { setOriginalCreatedBefore={setOriginalCreatedBefore} errors={fieldErrors.originalCreatedAt}/> + {/* 動画時間 */} + {videoFlg && ( + + {({ invalid }) => ( + setDuration (e.target.value)}/>)} + )} + {/* 送信 */}