| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
772c66aa64 | #171 | 1 day ago |
|
|
d54e66a114 | #171 | 2 days ago |
| @@ -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 | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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| | |||
| @@ -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: '更新しました.' }) | |||
| } | |||
| @@ -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`) | |||
| } | |||
| @@ -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> | |||
| @@ -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 } | |||