Compare commits

...

2 Commits

Author SHA1 Message Date
  みてるぞ 772c66aa64 #171 1 day ago
  みてるぞ d54e66a114 #171 3 days ago
10 changed files with 408 additions and 64 deletions
Split View
  1. +204
    -2
      backend/app/controllers/posts_controller.rb
  2. +34
    -9
      backend/app/services/version_recorder.rb
  3. +27
    -0
      backend/db/migrate/20260507124000_add_version_no_to_posts.rb
  4. +37
    -0
      backend/db/migrate/20260507211600_add_version_no_to_tags.rb
  5. +27
    -0
      backend/db/migrate/20260507213300_add_version_no_to_wiki_pages.rb
  6. +7
    -1
      backend/db/schema.rb
  7. +6
    -8
      frontend/src/components/PostEditForm.tsx
  8. +20
    -1
      frontend/src/lib/posts.ts
  9. +44
    -43
      frontend/src/pages/posts/PostHistoryPage.tsx
  10. +2
    -0
      frontend/src/types.ts

+ 204
- 2
backend/app/controllers/posts_controller.rb View File

@@ -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

+ 34
- 9
backend/app/services/version_recorder.rb View File

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


+ 27
- 0
backend/db/migrate/20260507124000_add_version_no_to_posts.rb View File

@@ -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

+ 37
- 0
backend/db/migrate/20260507211600_add_version_no_to_tags.rb View File

@@ -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

+ 27
- 0
backend/db/migrate/20260507213300_add_version_no_to_wiki_pages.rb View File

@@ -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

+ 7
- 1
backend/db/schema.rb View File

@@ -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|


+ 6
- 8
frontend/src/components/PostEditForm.tsx View File

@@ -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<Post> (
`/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: '更新しました.' })
}


+ 20
- 1
frontend/src/lib/posts.ts View File

@@ -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<Post> (
`/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<void> => {
await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`)
}

+ 44
- 43
frontend/src/pages/posts/PostHistoryPage.tsx View File

@@ -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<HTMLAnchorElement>, 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)}
</td>
<td className="p-2">
<a
href="#"
onClick={async e => {
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: '差戻に失敗……' })
}
}}>
<a href="#" onClick={async e => await handleRevert (e, change)}>
復元
</a>
</td>


+ 2
- 0
frontend/src/types.ts View File

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


Loading…
Cancel
Save