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,