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 })