From d54e66a114a3aa65f4fbbeb4052a892533a44100 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 6 May 2026 15:47:20 +0900 Subject: [PATCH 1/7] #171 --- frontend/src/components/PostEditForm.tsx | 14 ++-- frontend/src/lib/posts.ts | 21 ++++- frontend/src/pages/posts/PostHistoryPage.tsx | 87 ++++++++++---------- frontend/src/types.ts | 2 + 4 files changed, 72 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 3ac2077..24d2dcc 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -5,7 +5,7 @@ import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeFi import Label from '@/components/common/Label' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' -import { apiPut } from '@/lib/api' +import { updatePost } from '@/lib/posts' import type { FC } from 'react' @@ -44,19 +44,17 @@ export default (({ post, onSave }: Props) => { const handleSubmit = async () => { try { - const data = await apiPut ( - `/posts/${ post.id }`, - { title, tags, parent_post_ids: parentPostIds, - original_created_from: originalCreatedFrom, - original_created_before: originalCreatedBefore }, - { headers: { 'Content-Type': 'multipart/form-data' } }) + const data = + await updatePost ({ id: post.id, versionNo: post.versionNo + 1, + title, tags, parentPostIds, + originalCreatedFrom, originalCreatedBefore }) onSave ({ ...post, title: data.title, tags: data.tags, parentPosts: data.parentPosts, childPosts: data.childPosts, siblingPosts: data.siblingPosts, - originalCreatedFrom: data.originalCreatedFrom, + originalCreatedFrom: data.originalCreatedFrom, originalCreatedBefore: data.originalCreatedBefore } as Post) toast ({ description: '更新しました.' }) } diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 57907dc..4197148 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -1,4 +1,4 @@ -import { apiDelete, apiGet, apiPost } from '@/lib/api' +import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api' import type { FetchPostsParams, Post, PostVersion } from '@/types' @@ -42,6 +42,25 @@ export const fetchPostChanges = async ( page, limit } }) +export const updatePost = async ( + post: { id: number + versionNo: number + title: string | null + tags: string + parentPostIds: string + originalCreatedFrom: string | null + originalCreatedBefore: string | null }, +) => + await apiPut ( + `/posts/${ post.id }`, + { version_no: post.versionNo, + title: post.title, + tags: post.tags, + parent_post_ids: post.parentPostIds, + original_created_from: post.originalCreatedFrom, + original_created_before: post.originalCreatedBefore }) + + export const toggleViewedFlg = async (id: string, viewed: boolean): Promise => { await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) } diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 2977527..0545b93 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -11,13 +11,14 @@ import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' -import { apiPut } from '@/lib/api' -import { fetchPostChanges } from '@/lib/posts' +import { fetchPostChanges, updatePost } from '@/lib/posts' import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { fetchTag } from '@/lib/tags' import { cn, dateString, originalCreatedAtString } from '@/lib/utils' -import type { FC } from 'react' +import type { FC, MouseEvent } from 'react' + +import type { PostVersion } from '@/types' const renderDiff = (diff: { current: string | null; prev: string | null }) => ( @@ -62,6 +63,45 @@ export default (() => { const qc = useQueryClient () + const handleRevert = async (e: MouseEvent, change: PostVersion) => { + e.preventDefault () + + if (!(confirm (`『${ change.title.current || change.url.current }』を版 ${ + change.versionNo } に差戻します.\nよろしいですか?`))) + return + + try + { + const id = change.postId + const versionNo = change.latestVersionNo + 1 + const title = change.title.current + const tags = + change.tags + .filter (t => t.type !== 'removed') + .map (t => t.name) + .filter (t => t.slice (0, 5) !== 'nico:') + .join (' ') + const parentPostIds = + (change.parentPosts ?? []) + .filter (p => p.type !== 'removed') + .map (p => p.id) + .join (' ') + const originalCreatedFrom = change.originalCreatedFrom.current + const originalCreatedBefore = change.originalCreatedBefore.current + await updatePost ({ id, versionNo, title, tags, parentPostIds, + originalCreatedFrom, originalCreatedBefore }) + + qc.invalidateQueries ({ queryKey: postsKeys.root }) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + + toast ({ description: '差戻しました.' }) + } + catch + { + toast ({ description: '差戻に失敗……' }) + } + } + useEffect (() => { document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) }, [location.search]) @@ -231,46 +271,7 @@ export default (() => { {dateString (change.createdAt)} - { - e.preventDefault () - - if (!(confirm ( - `『${ change.title.current - || change.url.current }』を版 ${ - change.versionNo } に差戻します.\nよろしいですか?`))) - return - - try - { - await apiPut ( - `/posts/${ change.postId }`, - { title: change.title.current, - tags: change.tags - .filter (t => t.type !== 'removed') - .map (t => t.name) - .filter (t => t.slice (0, 5) !== 'nico:') - .join (' '), - parent_post_ids: - (change.parentPosts ?? []) - .filter (p => p.type !== 'removed') - .map (p => p.id) - .join (' '), - original_created_from: - change.originalCreatedFrom.current, - original_created_before: - change.originalCreatedBefore.current }) - - qc.invalidateQueries ({ queryKey: postsKeys.root }) - qc.invalidateQueries ({ queryKey: tagsKeys.root }) - toast ({ description: '差戻しました.' }) - } - catch - { - toast ({ description: '差戻に失敗……' }) - } - }}> + await handleRevert (e, change)}> 復元 diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5fb8078..e552f69 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -121,6 +121,7 @@ export type Platform = typeof PLATFORMS[number] export type Post = { id: number + versionNo: number url: string title: string | null thumbnail: string | null @@ -146,6 +147,7 @@ export type PostTagChange = { export type PostVersion = { postId: number + latestVersionNo: number versionNo: number eventType: 'create' | 'update' | 'discard' | 'restore' title: { current: string | null; prev: string | null } -- 2.34.1 From 772c66aa64a8e692f0581e0da8b02f7de8fe79a2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 8 May 2026 02:08:18 +0900 Subject: [PATCH 2/7] #171 --- backend/app/controllers/posts_controller.rb | 206 +++++++++++++++++- backend/app/services/version_recorder.rb | 43 +++- .../20260507124000_add_version_no_to_posts.rb | 27 +++ .../20260507211600_add_version_no_to_tags.rb | 37 ++++ ...0507213300_add_version_no_to_wiki_pages.rb | 27 +++ backend/db/schema.rb | 8 +- 6 files changed, 336 insertions(+), 12 deletions(-) create mode 100644 backend/db/migrate/20260507124000_add_version_no_to_posts.rb create mode 100644 backend/db/migrate/20260507211600_add_version_no_to_tags.rb create mode 100644 backend/db/migrate/20260507213300_add_version_no_to_wiki_pages.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 363b926..272db8b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -173,15 +173,41 @@ class PostsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + base_version_no = parse_base_version_no + force = truthy_param?(params[:force]) + title = params[:title].presence tag_names = params[:tags].to_s.split original_created_from = params[:original_created_from] original_created_before = params[:original_created_before] parent_post_ids = parse_parent_post_ids - post = Post.find(params[:id].to_i) + post = nil + conflict_json = nil ApplicationRecord.transaction do + post = Post.find(params[:id].to_i) + + base_version = post.post_versions.find_by!(version_no: base_version_no) + + base_snapshot = post_snapshot_from_version(base_version) + current_snapshot = post_snapshot_from_record(post) + incoming_snapshot = post_incoming_snapshot(post, + title:, + original_created_from:, + original_created_before:, + tag_names:, + parent_post_ids:) + + if !(force) && post.version_no != base_version_no + conflict_json = post_conflict_json(post:, + base_version_no:, + base_snapshot:, + current_snapshot:, + incoming_snapshot:) + raise ActiveRecord::Rollback + end + PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) post.update!(title:, original_created_from:, original_created_before:) @@ -198,8 +224,10 @@ class PostsController < ApplicationController PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) end + return render json: conflict_json, status: :conflict if conflict_json + post.reload - json = post.as_json + json = PostRepr.base(post, current_user) json['tags'] = build_tag_tree_for(post.tags) render json:, status: :ok rescue Tag::NicoTagNormalisationError @@ -404,4 +432,178 @@ class PostsController < ApplicationController PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:) end end + + def parse_base_version_no + version_no = Integer(params[:base_version_no], exception: false) + raise ArgumentError, 'base_version_no は必須です.' unless version_no&.positive? + + version_no + end + + def truthy_param?(value) = ActiveModel::Type::Boolean.new.cast(value) + + def post_snapshot_from_version version + { title: version.title, + original_created_from: snapshot_time(version.original_created_from), + original_created_before: snapshot_time(version.original_created_before), + tag_names: version.tags.to_s.split.sort, + parent_post_ids: snapshot_parent_post_ids_from_version(version) } + end + + def post_snapshot_form_record post + { title: post.title, + original_created_from: snapshot_time(post.original_created_from), + original_created_before: snapshot_time(post.original_created_before), + tag_names: post.tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name'), + parent_post_ids: post.parent_posts.order(:id).pluck(:id) } + end + + def post_incoming_snapshot post, title:, original_created_from:, original_created_before:, + tag_names:, parent_post_ids: + { title: + original_created_from: snapshot_time(original_created_from), + original_created_before: snapshot_time(original_created_before), + tag_names: incoming_tag_names_for_snapshot(post, tag_names), + parent_post_ids: parent_post_ids.sort } + end + + def snapshot_parent_post_ids_from_version version + if version.respond_to?(:parent_post_ids) + version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort + elsif version.respond_to?(:parent_id) && version.parent_id + [version.parent_id] + else + [] + end + end + + def snapshot_time value + return nil if value.blank? + + value = Time.zone.parse(value.to_s) if value in String + value&.in_time_zone&.iso8601(6) + rescue ArgumentError, TypeError + value.to_s + end + + def incoming_tag_names_for_snapshot post, raw_tag_names + manual_names = normalised_manual_tag_names_for_snapshot(raw_tag_names) + nico_names = post.tags.nico.joins(:tag_name).pluck('tag_names.name') + + existing_tags = + Tag + .joins(:tag_name) + .where(tag_names: { name: manual_names + nico_names }) + .to_a + + expanded_names = Tag.expand_parent_tags(existing_tags).map(&:name) + + (manual_names + nico_names + expanded_names).uniq.sort + end + + def normalised_manual_tag_names_for_snapshot raw_tag_names + if raw_tag_names.any? { |name| name.downcase.start_with?('nico:') } + raise Tag::NicoTagNormalisationError + end + + pairs = raw_tag_names.map do |raw_name| + prefix, category = + Tag::CATEGORY_PREFIXES.find { |p, _| raw_name.downcase.start_with?(p) } || ['', nil] + + name = TagName.canonicalise(raw_name.sub(/\A#{ Regexp.escape(prefix) }/i, '')).first + + [name, category] + end + + names = pairs.map(&:first) + + has_deerjikist = pairs.any? do |name, category| + category == :deerjikist || + Tag.joins(:tag_name).where(category: :deerjikist, tag_names: { name: }).exists? + end + + names << Tag.no_deerjikist.name unless has_deerjikist + + names.uniq.sort + end + + def post_conflict_json post:, base_version_no:, base_snapshot:, + current_snapshot:, incoming_snapshot: + changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot) + conflicts = changes.select { |change| change[:conflict] } + + { error: 'conflict', + message: '競合が発生しました.', + post_id: post.id, + base_version_no:, + current_version_no: post.version_no, + base: base_snapshot, + current: current_snapshot, + mine: incoming_snapshot, + changes:, + conflicts:, + mergeable: conflicts.empty? } + end + + def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot + [scalar_snapshot_change(:title, 'タイトル', + base_snapshot, current_snapshot, incoming_snapshot), + scalar_snapshot_change(:original_created_from, '元コンテンツ作成日時(開始)', + base_snapshot, current_snapshot, incoming_snapshot), + scalar_snapshot_change(:original_created_before, '元コンテンツ作成日時(終了)', + base_snapshot, current_snapshot, incoming_snapshot), + set_snapshot_change(:tag_names, 'タグ', + base_snapshot, current_snapshot, incoming_snapshot), + set_snapshot_change(:parent_post_ids, '親投稿', + base_snapshot, current_snapshot, incoming_snapshot)].compact + end + + def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot + base = base_snapshot[field] + current = current_snapshot[field] + mine = incoming_snapshot[field] + + return nil if current == base && mine == base + + { field:, label:, base:, current:, mine:, + changed_by_current: current != base, + changed_by_me: mine != base, + conflict: scalar_snapshot_conflict?(base, current, mine) } + end + + def scalar_snapshot_conflict? base, current, mine + current != base && mine != base && current != mine + end + + def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot + base = base_snapshot[field].to_a + current = current_snapshot[field].to_a + mine = incoming_snapshot[field].to_a + + added_by_current = current - base + removed_by_current = base - current + added_by_me = mine - base + removed_by_me = base - mine + + if (added_by_current.empty? && + removed_by_current.empty? && + added_by_me.empty? && + removed_by_me.empty?) + return nil + end + + { field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:, + added_by_me:, removed_by_me:, + changed_by_current: added_by_current.present? || removed_by_current.present?, + changed_by_me: added_by_me.present? || removed_by_me.present?, + conflict: set_snapshot_conflict?(added_by_current:, + removed_by_current:, + added_by_me:, + removed_by_me:) } + end + + def set_snapshot_conflict? added_by_current:, removed_by_current:, + added_by_me:, removed_by_me: + (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? + end end diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb index e705ec3..fe7c368 100644 --- a/backend/app/services/version_recorder.rb +++ b/backend/app/services/version_recorder.rb @@ -16,19 +16,20 @@ class VersionRecorder @record = record_class.unscoped.lock.find(@record.id) latest = latest_version - if !(latest) && @event_type != 'create' - raise "#{ version_class.name } first event must be create" - end + validate_version_sequence! latest + + attrs = snapshot_attributes - if @event_type == 'create' && latest - raise "#{ version_class.name } create event already exists" + if @event_type == 'update' && latest && same_snapshot?(latest, attrs) + return latest end - attrs = snapshot_attributes + version = version_class.create!( + base_attributes(latest).merge(record_key => @record).merge(attrs)) - return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs) + update_record_version_no! version.version_no - version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs)) + version end end @@ -45,7 +46,31 @@ class VersionRecorder created_by_user: @created_by_user } end - def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v } + def update_record_version_no! version_no + @record.update_columns version_no: version_no + @record.version_no = version_no + end + + def validate_version_sequence! latest + if !(latest) && @event_type != 'create' + raise "#{ version_class.name } first event must be create" + end + + if @event_type == 'create' && latest + raise "#{ version_class.name } create event already exists" + end + + return unless latest + + if @record.version_no != latest.version_no + raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " + + "but latest #{ version_class.name } version_no is #{ latest.version_no }") + end + end + + def same_snapshot? version, attrs + attrs.all? { |k, v| version.public_send(k) == v } + end def validate_event_type! return if EVENT_TYPES.include?(@event_type) diff --git a/backend/db/migrate/20260507124000_add_version_no_to_posts.rb b/backend/db/migrate/20260507124000_add_version_no_to_posts.rb new file mode 100644 index 0000000..7161a23 --- /dev/null +++ b/backend/db/migrate/20260507124000_add_version_no_to_posts.rb @@ -0,0 +1,27 @@ +class AddVersionNoToPosts < ActiveRecord::Migration[8.0] + def up + add_column :posts, :version_no, :integer + + execute <<~SQL + UPDATE + posts + SET + version_no = ( + SELECT + MAX(version_no) + FROM + post_versions + WHERE + post_id = posts.id) + SQL + + change_column_null :posts, :version_no, false + + add_check_constraint :posts, 'version_no > 0', name: 'chk_posts_version_no_positive' + end + + def down + remove_check_constraint :posts, name: 'chk_posts_version_no_positive' + remove_column :posts, :version_no + end +end diff --git a/backend/db/migrate/20260507211600_add_version_no_to_tags.rb b/backend/db/migrate/20260507211600_add_version_no_to_tags.rb new file mode 100644 index 0000000..2a4ef30 --- /dev/null +++ b/backend/db/migrate/20260507211600_add_version_no_to_tags.rb @@ -0,0 +1,37 @@ +class AddVersionNoToTags < ActiveRecord::Migration[8.0] + def up + add_column :tags, :version_no, :integer + + execute <<~SQL + UPDATE + tags + SET + version_no = ( + CASE category + WHEN 'nico' THEN + (SELECT + MAX(version_no) + FROM + nico_tag_versions + WHERE + tag_id = tags.id) + ELSE + (SELECT + MAX(version_no) + FROM + tag_versions + WHERE + tag_id = tags.id) + END) + SQL + + change_column_null :tags, :version_no, false + + add_check_constraint :tags, 'version_no > 0', name: 'chk_tags_version_no_positive' + end + + def down + remove_check_constraint :tags, name: 'chk_tags_version_no_positive' + remove_column :tags, :version_no + end +end diff --git a/backend/db/migrate/20260507213300_add_version_no_to_wiki_pages.rb b/backend/db/migrate/20260507213300_add_version_no_to_wiki_pages.rb new file mode 100644 index 0000000..be34918 --- /dev/null +++ b/backend/db/migrate/20260507213300_add_version_no_to_wiki_pages.rb @@ -0,0 +1,27 @@ +class AddVersionNoToWikiPages < ActiveRecord::Migration[8.0] + def up + add_column :wiki_pages, :version_no, :integer + + execute <<~SQL + UPDATE + wiki_pages + SET + version_no = ( + SELECT + MAX(version_no) + FROM + wiki_versions + WHERE + wiki_page_id = wiki_pages.id) + SQL + + change_column_null :wiki_pages, :version_no, false + + add_check_constraint :wiki_pages, 'version_no > 0', name: 'chk_wiki_pages_version_no_positive' + end + + def down + remove_check_constraint :wiki_pages, name: 'chk_wiki_pages_version_no_positive' + remove_column :wiki_pages, :version_no + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 042d227..94edb82 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do +ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -186,8 +186,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do t.datetime "original_created_from" t.datetime "original_created_before" t.datetime "updated_at", null: false + t.integer "version_no", null: false t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true + t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -262,8 +264,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do t.datetime "updated_at", null: false t.integer "post_count", default: 0, null: false t.datetime "discarded_at" + t.integer "version_no", null: false t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true + t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -369,10 +373,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do t.datetime "updated_at", null: false t.datetime "discarded_at" t.integer "next_asset_no", default: 1, null: false + t.integer "version_no", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" + t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" end create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| -- 2.34.1 From de86879e791b3bee99031871fca8fe7cc9fe20e5 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 9 May 2026 19:53:30 +0900 Subject: [PATCH 3/7] #171 --- backend/app/controllers/posts_controller.rb | 121 ++++++++---- backend/app/models/tag.rb | 1 + frontend/src/App.tsx | 25 ++- frontend/src/components/PostEditForm.tsx | 56 +++++- .../components/dialogues/DialogueProvider.tsx | 184 ++++++++++++++++++ frontend/src/lib/posts.ts | 13 +- frontend/src/pages/posts/PostHistoryPage.tsx | 6 +- 7 files changed, 339 insertions(+), 67 deletions(-) create mode 100644 frontend/src/components/dialogues/DialogueProvider.tsx diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 272db8b..e0768de 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -44,7 +44,7 @@ class PostsController < ApplicationController filtered_posts .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(tags: [:materials, { tag_name: :wiki_page }]) + .preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) .with_attached_thumbnail q = q.where('posts.url LIKE ?', "%#{ url }%") if url @@ -95,7 +95,7 @@ class PostsController < ApplicationController end def random - post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }]) + post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) .order('RAND()') .first return head :not_found unless post @@ -104,7 +104,7 @@ class PostsController < ApplicationController end def show - post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) + post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) return head :not_found unless post render json: PostRepr.base(post, current_user) @@ -173,8 +173,12 @@ class PostsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? - base_version_no = parse_base_version_no force = truthy_param?(params[:force]) + merge = truthy_param?(params[:merge]) + return head :bad_request if force && merge + + base_version_no = nil + base_version_no = parse_base_version_no unless force title = params[:title].presence tag_names = params[:tags].to_s.split @@ -186,12 +190,17 @@ class PostsController < ApplicationController conflict_json = nil ApplicationRecord.transaction do - post = Post.find(params[:id].to_i) + post = Post.lock.find(params[:id].to_i) - base_version = post.post_versions.find_by!(version_no: base_version_no) + base_version = nil + base_snapshot = nil + current_snapshot = nil + unless force + base_version = post.post_versions.find_by!(version_no: base_version_no) - base_snapshot = post_snapshot_from_version(base_version) - current_snapshot = post_snapshot_from_record(post) + base_snapshot = post_snapshot_from_version(base_version) + current_snapshot = post_snapshot_from_record(post) + end incoming_snapshot = post_incoming_snapshot(post, title:, original_created_from:, @@ -199,29 +208,28 @@ class PostsController < ApplicationController tag_names:, parent_post_ids:) - if !(force) && post.version_no != base_version_no - conflict_json = post_conflict_json(post:, - base_version_no:, - base_snapshot:, - current_snapshot:, - incoming_snapshot:) - raise ActiveRecord::Rollback - end - - PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) - - post.update!(title:, original_created_from:, original_created_before:) - - normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false) - TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user) - - tags = post.tags.nico.to_a + normalised_tags - tags = Tag.expand_parent_tags(tags) - sync_post_tags!(post, tags) + snapshot_to_apply = + if post.version_no == base_version_no || force + incoming_snapshot + else + changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot) + conflicts = changes.select { |change| change[:conflict] } - sync_parent_posts!(post, parent_post_ids) + if merge && conflicts.empty? + merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot) + else + conflict_json = post_conflict_json(post:, + base_version_no:, + base_snapshot:, + current_snapshot:, + incoming_snapshot:, + changes:, + conflicts:) + raise ActiveRecord::Rollback + end + end - PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) + apply_post_snapshot!(post, snapshot_to_apply) end return render json: conflict_json, status: :conflict if conflict_json @@ -253,7 +261,7 @@ class PostsController < ApplicationController pts = pts.where(post_id: id) if id.present? pts = pts.where(tag_id:) if tag_id.present? pts = pts.includes(:post, :created_user, :deleted_user, - tag: [:materials, { tag_name: :wiki_page }]) + tag: [:deerjikists, :materials, { tag_name: :wiki_page }]) events = [] pts.each do |pt| @@ -446,11 +454,11 @@ class PostsController < ApplicationController { title: version.title, original_created_from: snapshot_time(version.original_created_from), original_created_before: snapshot_time(version.original_created_before), - tag_names: version.tags.to_s.split.sort, + tag_names: version.tags.to_s.split.filter { !(_1.start_with?('nico:')) }.sort, parent_post_ids: snapshot_parent_post_ids_from_version(version) } end - def post_snapshot_form_record post + def post_snapshot_from_record post { title: post.title, original_created_from: snapshot_time(post.original_created_from), original_created_before: snapshot_time(post.original_created_before), @@ -460,7 +468,7 @@ class PostsController < ApplicationController def post_incoming_snapshot post, title:, original_created_from:, original_created_before:, tag_names:, parent_post_ids: - { title: + { title:, original_created_from: snapshot_time(original_created_from), original_created_before: snapshot_time(original_created_before), tag_names: incoming_tag_names_for_snapshot(post, tag_names), @@ -488,17 +496,16 @@ class PostsController < ApplicationController def incoming_tag_names_for_snapshot post, raw_tag_names manual_names = normalised_manual_tag_names_for_snapshot(raw_tag_names) - nico_names = post.tags.nico.joins(:tag_name).pluck('tag_names.name') existing_tags = Tag .joins(:tag_name) - .where(tag_names: { name: manual_names + nico_names }) + .where(tag_names: { name: manual_names }) .to_a expanded_names = Tag.expand_parent_tags(existing_tags).map(&:name) - (manual_names + nico_names + expanded_names).uniq.sort + (manual_names + expanded_names).uniq.sort end def normalised_manual_tag_names_for_snapshot raw_tag_names @@ -528,10 +535,7 @@ class PostsController < ApplicationController end def post_conflict_json post:, base_version_no:, base_snapshot:, - current_snapshot:, incoming_snapshot: - changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot) - conflicts = changes.select { |change| change[:conflict] } - + current_snapshot:, incoming_snapshot:, changes:, conflicts: { error: 'conflict', message: '競合が発生しました.', post_id: post.id, @@ -548,9 +552,9 @@ 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(:original_created_from, '元コンテンツ作成日時(開始)', + scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)', base_snapshot, current_snapshot, incoming_snapshot), - scalar_snapshot_change(:original_created_before, '元コンテンツ作成日時(終了)', + scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)', base_snapshot, current_snapshot, incoming_snapshot), set_snapshot_change(:tag_names, 'タグ', base_snapshot, current_snapshot, incoming_snapshot), @@ -606,4 +610,37 @@ class PostsController < ApplicationController added_by_me:, removed_by_me: (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? end + + def apply_post_snapshot! post, snapshot + PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) + + post.update!(title: snapshot[:title], + original_created_from: snapshot[:original_created_from], + original_created_before: snapshot[:original_created_before]) + + tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false) + TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) + + tags = Tag.expand_parent_tags(tags) + sync_post_tags!(post, tags) + + 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, :tag_names, :parent_post_ids].map { + [_1, merge_scaler_snapshot_value(base_snapshot[_1], + current_snapshot[_1], + incoming_snapshot[_1])] + }.to_h + end + + def merge_scaler_snapshot_value base, current, mine + return mine if current == base + return current if mine == base || current == mine + + raise ArgumentError, '競合してゐる項目はマージできません.' + end end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 402e4fe..fe4e1d7 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -136,6 +136,7 @@ class Tag < ApplicationRecord tn = tn.canonical if tn.canonical_id? Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t| + t.version_no = TagVersion.where(tag_id: t.id).order(version_no: :desc).first || 1 t.category = category end rescue ActiveRecord::RecordNotUnique diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f52209d..da11ba2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { BrowserRouter, import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' +import DialogueProvider from '@/components/dialogues/DialogueProvider' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' @@ -138,17 +139,21 @@ export default (() => { return ( <> + - - - - - - - + + + + + + + + + + ) }) satisfies FC diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 24d2dcc..ed1fa08 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import PostFormTagsArea from '@/components/PostFormTagsArea' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import Label from '@/components/common/Label' +import { useDialogue } from '@/components/dialogues/DialogueProvider' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { updatePost } from '@/lib/posts' @@ -41,29 +42,68 @@ export default (({ post, onSave }: Props) => { const [tags, setTags] = useState ('') const [title, setTitle] = useState (post.title) - const handleSubmit = async () => { + const dialogue = useDialogue () + + const update = async (...args: Parameters) => { try { - const data = - await updatePost ({ id: post.id, versionNo: post.versionNo + 1, - title, tags, parentPostIds, - originalCreatedFrom, originalCreatedBefore }) + const data = await updatePost (...args) onSave ({ ...post, + versionNo: data.versionNo, title: data.title, tags: data.tags, parentPosts: data.parentPosts, childPosts: data.childPosts, siblingPosts: data.siblingPosts, - originalCreatedFrom: data.originalCreatedFrom, + originalCreatedFrom: data.originalCreatedFrom, originalCreatedBefore: data.originalCreatedBefore } as Post) toast ({ description: '更新しました.' }) } - catch + catch (e) { - toast ({ description: '更新はできなかったよ……' }) + if (e.response.status !== 409) + { + toast ({ description: '更新はできなかったよ……' }) + return + } + + const action = await dialogue.choice ({ + title: '競合が発生しました.', + description: ( +
+

ほかの耕作員が先に更新してゐます.

+

現在の変更をどう扱ひますか?

+
), + choices: [{ value: 'merge', label: '差分をマージ' }, + { value: 'overwrite', label: '強制上書き', variant: 'danger' }] }) + + if (action === 'merge') + { + // TODO: 差分 UI + await update ({ id: post.id, title, tags, parentPostIds, + originalCreatedFrom, originalCreatedBefore }, + { baseVersionNo: post.versionNo, merge: true }) + return + } + + if (action === 'overwrite') + { + await update ({ id: post.id, title, tags, parentPostIds, + originalCreatedFrom, originalCreatedBefore }, + { baseVersionNo: post.versionNo, force: true }) + return + } } } + const handleSubmit = async e => { + e.preventDefault () + + await update ({ id: post.id, title, tags, parentPostIds, + originalCreatedFrom, originalCreatedBefore }, + { baseVersionNo: post.versionNo }) + } + useEffect (() => { setTags(tagsToStr (post.tags)) }, [post]) diff --git a/frontend/src/components/dialogues/DialogueProvider.tsx b/frontend/src/components/dialogues/DialogueProvider.tsx new file mode 100644 index 0000000..8cb0990 --- /dev/null +++ b/frontend/src/components/dialogues/DialogueProvider.tsx @@ -0,0 +1,184 @@ +import { createContext, useCallback, useContext, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle } from '@/components/ui/dialog' + +import type { FC, ReactNode } from 'react' + +type DialogueVariant = 'default' | 'danger' + +type ConfirmOptions = { title: string + description?: ReactNode + confirmText?: string + cancelText?: string + variant?: DialogueVariant } + +type AlertOptions = { title: string + description?: ReactNode + okText?: string } + +type Choice = { value: T + label: string + variant?: DialogueVariant } + +type ChoiceOptions = { title: string + description?: ReactNode + choices: Choice[] + cancelText?: string } + +type DialogueRequest = + | { id: number + kind: 'confirm' + options: ConfirmOptions + resolve: (value: boolean) => void } + | { id: number + kind: 'alert' + options: AlertOptions + resolve: () => void } + | { id: number + kind: 'choice' + options: ChoiceOptions + resolve: (value: string | null) => void } + +type DialogueAPI = + { confirm: (options: ConfirmOptions) => Promise + alert: (options: AlertOptions) => Promise + choice: (options: ChoiceOptions) => Promise } + +const DialogueContext = createContext (null) + +let nextDialogueId = 1 + +type Props = { children: ReactNode } + + +export default (({ children }: Props) => { + const [queue, setQueue] = useState ([]) + + const push = useCallback ((request: Omit) => { + const id = nextDialogueId + ++nextDialogueId + + setQueue (q => [...q, { ...request, id } as DialogueRequest]) + }, []) + + const closeActive = useCallback ((result?: unknown) => { + setQueue (q => { + const [active, ...rest] = q + + if (!(active)) + return rest + + switch (active.kind) + { + case 'confirm': + active.resolve (Boolean (result)) + break + + case 'alert': + active.resolve () + break + + case 'choice': + active.resolve ((result ?? null) as string | null) + break + } + + return rest + }) + }, []) + + const api = useMemo (() => ({ + confirm: options => new Promise (resolve => { + push ({ kind: 'confirm', options, resolve }) + }), + alert: options => new Promise (resolve => { + push ({ kind: 'alert', options, resolve }) + }), + choice: options => new Promise (resolve => { + push ({ kind: 'choice', + options: options as ChoiceOptions, + resolve: resolve as (value: string | null) => void }) + }) }), [push]) + + const active = queue[0] + + return ( + + {children} + + { + if (!(open)) + closeActive (active?.kind !== 'confirm' && null) + }}> + {active && ( + + {active.options.title} + + {active.options.description && ( + +
{active.options.description}
+
)} + + + {active.kind === 'confirm' && ( + <> + + + + )} + + {active.kind === 'alert' && ( + )} + + {active.kind === 'choice' && ( + <> + + + {active.options.choices.map (choice => ( + ))} + )} + +
)} +
+
) +}) satisfies FC + + +export const useDialogue = () => { + const dialogue = useContext (DialogueContext) + + if (!(dialogue)) + throw new Error ('useDialogue must be used inside DialogueProvider') + + return dialogue +} diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 4197148..a1febff 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -44,21 +44,26 @@ export const fetchPostChanges = async ( export const updatePost = async ( post: { id: number - versionNo: number title: string | null tags: string parentPostIds: string originalCreatedFrom: string | null originalCreatedBefore: string | null }, + { baseVersionNo, force, merge }: { + baseVersionNo?: number + force?: boolean + merge?: boolean } ) => await apiPut ( `/posts/${ post.id }`, - { version_no: post.versionNo, - title: post.title, + { title: post.title, tags: post.tags, parent_post_ids: post.parentPostIds, original_created_from: post.originalCreatedFrom, - original_created_before: post.originalCreatedBefore }) + original_created_before: post.originalCreatedBefore }, + { params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }), + force: force ? '1' : '0', + merge: merge ? '1' : '0' } }) export const toggleViewedFlg = async (id: string, viewed: boolean): Promise => { diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 0545b93..8e6f21e 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -73,7 +73,6 @@ export default (() => { try { const id = change.postId - const versionNo = change.latestVersionNo + 1 const title = change.title.current const tags = change.tags @@ -88,8 +87,9 @@ export default (() => { .join (' ') const originalCreatedFrom = change.originalCreatedFrom.current const originalCreatedBefore = change.originalCreatedBefore.current - await updatePost ({ id, versionNo, title, tags, parentPostIds, - originalCreatedFrom, originalCreatedBefore }) + await updatePost ({ id, title, tags, parentPostIds, + originalCreatedFrom, originalCreatedBefore }, + { force: true }) qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) -- 2.34.1 From 5b50642756311e87eac40fa05dd55ede9779dee6 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 10 May 2026 05:03:27 +0900 Subject: [PATCH 4/7] #171 --- backend/app/controllers/posts_controller.rb | 107 +++++++++--------- backend/app/models/post.rb | 2 + backend/app/models/tag.rb | 3 +- backend/app/models/wiki_page.rb | 2 + frontend/src/components/PostEditForm.tsx | 37 ++++-- frontend/src/components/PostFormTagsArea.tsx | 14 ++- .../PostOriginalCreatedTimeField.tsx | 8 +- frontend/src/components/TopNav.tsx | 6 +- .../src/components/common/DateTimeField.tsx | 9 +- frontend/src/components/ui/dialog.tsx | 24 ++-- frontend/src/index.css | 78 ++++++++----- frontend/tailwind.config.js | 17 ++- 12 files changed, 185 insertions(+), 122 deletions(-) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index e0768de..cc5afea 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -173,8 +173,8 @@ class PostsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? - force = truthy_param?(params[:force]) - merge = truthy_param?(params[:merge]) + force = bool?(:force) + merge = bool?(:merge) return head :bad_request if force && merge base_version_no = nil @@ -201,15 +201,14 @@ class PostsController < ApplicationController base_snapshot = post_snapshot_from_version(base_version) current_snapshot = post_snapshot_from_record(post) end - incoming_snapshot = post_incoming_snapshot(post, - title:, + incoming_snapshot = post_incoming_snapshot(title:, original_created_from:, original_created_before:, tag_names:, parent_post_ids:) snapshot_to_apply = - if post.version_no == base_version_no || force + if force || post.version_no == base_version_no || current_snapshot == base_snapshot incoming_snapshot else changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot) @@ -448,30 +447,36 @@ class PostsController < ApplicationController version_no end - def truthy_param?(value) = ActiveModel::Type::Boolean.new.cast(value) - def post_snapshot_from_version version { title: version.title, original_created_from: snapshot_time(version.original_created_from), original_created_before: snapshot_time(version.original_created_before), - tag_names: version.tags.to_s.split.filter { !(_1.start_with?('nico:')) }.sort, + tag_names: editable_tag_names_from_version(version), parent_post_ids: snapshot_parent_post_ids_from_version(version) } end + def editable_tag_names_from_version version + version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort + end + def post_snapshot_from_record post { title: post.title, original_created_from: snapshot_time(post.original_created_from), original_created_before: snapshot_time(post.original_created_before), - tag_names: post.tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name'), + tag_names: editable_tag_names_from_post(post), parent_post_ids: post.parent_posts.order(:id).pluck(:id) } end - def post_incoming_snapshot post, title:, original_created_from:, original_created_before:, - tag_names:, parent_post_ids: + def editable_tag_names_from_post post + post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + end + + def post_incoming_snapshot title:, original_created_from:, original_created_before:, + tag_names:, parent_post_ids: { title:, original_created_from: snapshot_time(original_created_from), original_created_before: snapshot_time(original_created_before), - tag_names: incoming_tag_names_for_snapshot(post, tag_names), + tag_names: incoming_tag_names_for_snapshot(tag_names), parent_post_ids: parent_post_ids.sort } end @@ -494,44 +499,10 @@ class PostsController < ApplicationController value.to_s end - def incoming_tag_names_for_snapshot post, raw_tag_names - manual_names = normalised_manual_tag_names_for_snapshot(raw_tag_names) - - existing_tags = - Tag - .joins(:tag_name) - .where(tag_names: { name: manual_names }) - .to_a + def incoming_tag_names_for_snapshot raw_tag_names + tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false) - expanded_names = Tag.expand_parent_tags(existing_tags).map(&:name) - - (manual_names + expanded_names).uniq.sort - end - - def normalised_manual_tag_names_for_snapshot raw_tag_names - if raw_tag_names.any? { |name| name.downcase.start_with?('nico:') } - raise Tag::NicoTagNormalisationError - end - - pairs = raw_tag_names.map do |raw_name| - prefix, category = - Tag::CATEGORY_PREFIXES.find { |p, _| raw_name.downcase.start_with?(p) } || ['', nil] - - name = TagName.canonicalise(raw_name.sub(/\A#{ Regexp.escape(prefix) }/i, '')).first - - [name, category] - end - - names = pairs.map(&:first) - - has_deerjikist = pairs.any? do |name, category| - category == :deerjikist || - Tag.joins(:tag_name).where(category: :deerjikist, tag_names: { name: }).exists? - end - - names << Tag.no_deerjikist.name unless has_deerjikist - - names.uniq.sort + Tag.expand_parent_tags(tags).map(&:name).uniq.sort end def post_conflict_json post:, base_version_no:, base_snapshot:, @@ -618,29 +589,53 @@ class PostsController < ApplicationController original_created_from: snapshot[:original_created_from], original_created_before: snapshot[:original_created_before]) - tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false) - TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) + editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false) + TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) + + readonly_tags = post.tags.nico.to_a + tags = readonly_tags + editable_tags tags = Tag.expand_parent_tags(tags) - sync_post_tags!(post, tags) + sync_post_tags!(post, tags) 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, :tag_names, :parent_post_ids].map { - [_1, merge_scaler_snapshot_value(base_snapshot[_1], + [:title, :original_created_from, :original_created_before].map { + [_1, merge_scalar_snapshot_value(base_snapshot[_1], current_snapshot[_1], incoming_snapshot[_1])] - }.to_h + }.to_h.merge([:tag_names, :parent_post_ids].map { + [_1, merge_set_snapshot_value(base_snapshot[_1], + current_snapshot[_1], + incoming_snapshot[_1])] + }.to_h) end - def merge_scaler_snapshot_value base, current, mine + def merge_scalar_snapshot_value base, current, mine return mine if current == base return current if mine == base || current == mine raise ArgumentError, '競合してゐる項目はマージできません.' end + + def merge_set_snapshot_value base, current, mine + base = base.to_a + current = current.to_a + mine = mine.to_a + + added_by_current = current - base + removed_by_current = base - current + added_by_me = mine - base + removed_by_me = base - mine + + merged = base + added_by_current + added_by_me + merged -= removed_by_current + merged -= removed_by_me + + merged.uniq.sort + end end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index d036fae..4c3ddb1 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -28,6 +28,8 @@ class Post < ApplicationRecord has_one_attached :thumbnail + attribute :version_no, :integer, default: 1 + before_validation :normalise_url validates :url, presence: true, uniqueness: true diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index fe4e1d7..51ca783 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -40,6 +40,8 @@ class Tag < ApplicationRecord belongs_to :tag_name delegate :wiki_page, to: :tag_name + attribute :version_no, :integer, default: 1 + delegate :name, to: :tag_name, allow_nil: true validates :tag_name, presence: true @@ -136,7 +138,6 @@ class Tag < ApplicationRecord tn = tn.canonical if tn.canonical_id? Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t| - t.version_no = TagVersion.where(tag_id: t.id).order(version_no: :desc).first || 1 t.category = category end rescue ActiveRecord::RecordNotUnique diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index b725706..efe7868 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord has_many :wiki_versions + attribute :version_no, :integer, default: 1 + belongs_to :tag_name validates :tag_name, presence: true validates :body, presence: true diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index ed1fa08..337ed08 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { updatePost } from '@/lib/posts' -import type { FC } from 'react' +import type { FC, FormEvent } from 'react' import type { Post, Tag } from '@/types' @@ -33,6 +33,7 @@ type Props = { post: Post export default (({ post, onSave }: Props) => { + const [disabled, setDisabled] = useState (false) const [originalCreatedBefore, setOriginalCreatedBefore] = useState (post.originalCreatedBefore) const [originalCreatedFrom, setOriginalCreatedFrom] = @@ -61,7 +62,9 @@ export default (({ post, onSave }: Props) => { } catch (e) { - if (e.response.status !== 409) + const response = (e as any)?.response + + if (response?.status !== 409) { toast ({ description: '更新はできなかったよ……' }) return @@ -74,7 +77,7 @@ export default (({ post, onSave }: Props) => {

ほかの耕作員が先に更新してゐます.

現在の変更をどう扱ひますか?

), - choices: [{ value: 'merge', label: '差分をマージ' }, + choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []), { value: 'overwrite', label: '強制上書き', variant: 'danger' }] }) if (action === 'merge') @@ -96,12 +99,14 @@ export default (({ post, onSave }: Props) => { } } - const handleSubmit = async e => { + const handleSubmit = async (e: FormEvent) => { e.preventDefault () + setDisabled (true) await update ({ id: post.id, title, tags, parentPostIds, originalCreatedFrom, originalCreatedBefore }, { baseVersionNo: post.versionNo }) + setDisabled (false) } useEffect (() => { @@ -113,10 +118,12 @@ export default (({ post, onSave }: Props) => { {/* タイトル */}
- setTitle (ev.target.value)}/> + setTitle (ev.target.value)}/>
{/* 親投稿 */} @@ -124,24 +131,32 @@ export default (({ post, onSave }: Props) => { setParentPostIds (e.target.value)} className="w-full border p-2 rounded"/> {/* タグ */} - + {/* オリジナルの作成日時 */} {/* 送信 */} - ) diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index 92450c1..b23e121 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -7,7 +7,7 @@ import Label from '@/components/common/Label' import TextArea from '@/components/common/TextArea' import { apiGet } from '@/lib/api' -import type { FC, SyntheticEvent } from 'react' +import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react' import type { Tag } from '@/types' @@ -31,12 +31,13 @@ const replaceToken = (value: string, start: number, end: number, text: string) = `${ value.slice (0, start) }${ text }${ value.slice (end) }` -type Props = { - tags: string - setTags: (tags: string) => void } +type Props = + & { tags: string + setTags: (tags: string) => void } + & ComponentPropsWithoutRef<'textarea'> -export default (({ tags, setTags }: Props) => { +export default (({ tags, setTags, ...rest }: Props) => { const ref = useRef (null) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) @@ -87,7 +88,8 @@ export default (({ tags, setTags }: Props) => { onBlur={() => { setFocused (false) setSuggestionsVsbl (false) - }}/> + }} + {...rest}/> {focused && ( 0 diff --git a/frontend/src/components/PostOriginalCreatedTimeField.tsx b/frontend/src/components/PostOriginalCreatedTimeField.tsx index 3709ae1..9fbc232 100644 --- a/frontend/src/components/PostOriginalCreatedTimeField.tsx +++ b/frontend/src/components/PostOriginalCreatedTimeField.tsx @@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button' import type { FC } from 'react' type Props = { + disabled?: boolean originalCreatedFrom: string | null setOriginalCreatedFrom: (x: string | null) => void originalCreatedBefore: string | null setOriginalCreatedBefore: (x: string | null) => void } -export default (({ originalCreatedFrom, +export default (({ disabled, + originalCreatedFrom, setOriginalCreatedFrom, originalCreatedBefore, setOriginalCreatedBefore }: Props) => ( @@ -21,6 +23,7 @@ export default (({ originalCreatedFrom,
{ @@ -40,6 +43,7 @@ export default (({ originalCreatedFrom,
-
) + ) }) satisfies FC diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index b23e121..58e0db4 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -31,13 +31,12 @@ const replaceToken = (value: string, start: number, end: number, text: string) = `${ value.slice (0, start) }${ text }${ value.slice (end) }` -type Props = - & { tags: string - setTags: (tags: string) => void } - & ComponentPropsWithoutRef<'textarea'> +type Props = Omit, 'value' | 'onChange' | 'onBlur'> & { + tags: string + setTags: (tags: string) => void } -export default (({ tags, setTags, ...rest }: Props) => { +export default (({ tags, setTags, onBlur, ...rest }: Props) => { const ref = useRef (null) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) @@ -77,6 +76,7 @@ export default (({ tags, setTags, ...rest }: Props) => {