| @@ -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 | |||
| @@ -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 | |||
| @@ -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 ( | |||
| <> | |||
| <RouteBlockerOverlay/> | |||
| <BrowserRouter> | |||
| <LayoutGroup> | |||
| <motion.div | |||
| layout="position" | |||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }} | |||
| className="flex flex-col h-dvh w-full overflow-y-hidden"> | |||
| <TopNav user={user}/> | |||
| <RouteTransitionWrapper user={user} setUser={setUser}/> | |||
| </motion.div> | |||
| </LayoutGroup> | |||
| <Toaster/> | |||
| <DialogueProvider> | |||
| <LayoutGroup> | |||
| <motion.div | |||
| layout="position" | |||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }} | |||
| className="flex flex-col h-dvh w-full overflow-y-hidden"> | |||
| <TopNav user={user}/> | |||
| <RouteTransitionWrapper user={user} setUser={setUser}/> | |||
| </motion.div> | |||
| </LayoutGroup> | |||
| <Toaster/> | |||
| </DialogueProvider> | |||
| </BrowserRouter> | |||
| </>) | |||
| }) satisfies FC | |||
| @@ -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<string> ('') | |||
| const [title, setTitle] = useState (post.title) | |||
| const handleSubmit = async () => { | |||
| const dialogue = useDialogue () | |||
| const update = async (...args: Parameters<typeof updatePost>) => { | |||
| 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: ( | |||
| <div> | |||
| <p>ほかの耕作員が先に更新してゐます.</p> | |||
| <p>現在の変更をどう扱ひますか?</p> | |||
| </div>), | |||
| 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]) | |||
| @@ -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<T extends string> = { value: T | |||
| label: string | |||
| variant?: DialogueVariant } | |||
| type ChoiceOptions<T extends string> = { title: string | |||
| description?: ReactNode | |||
| choices: Choice<T>[] | |||
| 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<string> | |||
| resolve: (value: string | null) => void } | |||
| type DialogueAPI = | |||
| { confirm: (options: ConfirmOptions) => Promise<boolean> | |||
| alert: (options: AlertOptions) => Promise<void> | |||
| choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> } | |||
| const DialogueContext = createContext<DialogueAPI | null> (null) | |||
| let nextDialogueId = 1 | |||
| type Props = { children: ReactNode } | |||
| export default (({ children }: Props) => { | |||
| const [queue, setQueue] = useState<DialogueRequest[]> ([]) | |||
| const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => { | |||
| 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<DialogueAPI> (() => ({ | |||
| confirm: options => new Promise<boolean> (resolve => { | |||
| push ({ kind: 'confirm', options, resolve }) | |||
| }), | |||
| alert: options => new Promise<void> (resolve => { | |||
| push ({ kind: 'alert', options, resolve }) | |||
| }), | |||
| choice: options => new Promise (resolve => { | |||
| push ({ kind: 'choice', | |||
| options: options as ChoiceOptions<string>, | |||
| resolve: resolve as (value: string | null) => void }) | |||
| }) }), [push]) | |||
| const active = queue[0] | |||
| return ( | |||
| <DialogueContext.Provider value={api}> | |||
| {children} | |||
| <Dialog | |||
| open={Boolean (active)} | |||
| onOpenChange={open => { | |||
| if (!(open)) | |||
| closeActive (active?.kind !== 'confirm' && null) | |||
| }}> | |||
| {active && ( | |||
| <DialogContent> | |||
| <DialogTitle>{active.options.title}</DialogTitle> | |||
| {active.options.description && ( | |||
| <DialogDescription asChild> | |||
| <div>{active.options.description}</div> | |||
| </DialogDescription>)} | |||
| <DialogFooter> | |||
| {active.kind === 'confirm' && ( | |||
| <> | |||
| <Button | |||
| variant="outline" | |||
| onClick={() => closeActive (false)}> | |||
| {active.options.cancelText ?? '取消'} | |||
| </Button> | |||
| <Button | |||
| variant={(active.options.variant === 'danger') | |||
| ? 'destructive' | |||
| : 'default'} | |||
| onClick={() => closeActive (true)}> | |||
| {active.options.confirmText ?? '確定'} | |||
| </Button> | |||
| </>)} | |||
| {active.kind === 'alert' && ( | |||
| <Button onClick={() => closeActive ()}> | |||
| {active.options.okText ?? '確定'} | |||
| </Button>)} | |||
| {active.kind === 'choice' && ( | |||
| <> | |||
| <Button | |||
| variant="outline" | |||
| onClick={() => closeActive (null)}> | |||
| {active.options.cancelText ?? '取消'} | |||
| </Button> | |||
| {active.options.choices.map (choice => ( | |||
| <Button | |||
| key={choice.value} | |||
| variant={(choice.variant === 'danger') | |||
| ? 'destructive' | |||
| : 'default'} | |||
| onClick={() => closeActive (choice.value)}> | |||
| {choice.label} | |||
| </Button>))} | |||
| </>)} | |||
| </DialogFooter> | |||
| </DialogContent>)} | |||
| </Dialog> | |||
| </DialogueContext.Provider>) | |||
| }) satisfies FC<Props> | |||
| export const useDialogue = () => { | |||
| const dialogue = useContext (DialogueContext) | |||
| if (!(dialogue)) | |||
| throw new Error ('useDialogue must be used inside DialogueProvider') | |||
| return dialogue | |||
| } | |||
| @@ -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<Post> ( | |||
| `/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<void> => { | |||
| @@ -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 }) | |||