コミットを比較

..
1 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker c0f42ea0c0 AI: address issue #170 2026-11-13 12:50:57 +09:00
13個のファイルの変更、190行の追加、72行の削除
+6 -6
ファイルの表示
@@ -37,7 +37,7 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
history_join_required = order[0] == 'updated_at' || updated_between.any?(&:present?) needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
q = q =
filtered_posts(locale) filtered_posts(locale)
@@ -57,8 +57,8 @@ class PostsController < ApplicationController
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0] q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
count_scope = q count_scope = q
if history_join_required if needs_history_join
history_times = PostVersion.all history_times = HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS latest_at').group(:post_id) .select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " + q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id') 'ON history_times.post_id = posts.id')
@@ -72,7 +72,7 @@ class PostsController < ApplicationController
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1]) q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end end
count_scope = q if updated_between.any?(&:present?) count_scope = q if updated_between.any?(&:present?)
if history_join_required if needs_history_join
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at") q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end end
@@ -92,10 +92,10 @@ class PostsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a
latest_version_times = latest_version_times =
if history_join_required if needs_history_join
{ } { }
else else
PostVersion.where(post_id: posts.map(&:id)) HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
.group(:post_id).maximum(:created_at) .group(:post_id).maximum(:created_at)
end end
+1
ファイルの表示
@@ -49,6 +49,7 @@ class TagDescriptionsController < ApplicationController
private private
def representation description def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion)
description.as_json(only: [ description.as_json(only: [
:tag_id, :tag_id,
:language_code, :language_code,
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class TagGroupsController < ApplicationController class TagGroupsController < ApplicationController
def index def index
locale = resolve_locale! locale = resolve_locale!
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id) groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a) render json: TagGroupRepr.many(locale, groups.to_a)
end end
+6 -5
ファイルの表示
@@ -8,11 +8,12 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip title = params[:title].to_s.strip
descriptions = descriptions =
TagDescription.where(language_code: locale.language_code) HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
.where(language_code: locale.language_code)
.includes(tag: :tag_names) .includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc) descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = TagGroup.order(updated_at: :desc, id: :asc) tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
if title.present? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions =
@@ -40,13 +41,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String) return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language) return head :not_found unless Language.exists?(code: language)
names = TagDescription.joins(tag: :tag_names) names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language }) .where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name') .pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name| candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:) WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end end
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name| candidates.concat(HistoryAccess.current(TagGroup.all, TagGroupVersion).order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:) WikiPageRepr.autolink_group(locale, id:, name:, language:)
end) end)
render json: candidates render json: candidates
@@ -410,7 +411,7 @@ class WikiPagesController < ApplicationController
end end
def tag_description_by_title locale, title def tag_description_by_title locale, title
TagDescription.joins(tag: :tag_names) HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.find_by(language_code: locale.language_code, .find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title }) tag_names: { language_code: locale.language_code, name: title })
end end
+23
ファイルの表示
@@ -147,6 +147,29 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
scope.where(Arel.sql(sql(scope.klass, row))) scope.where(Arel.sql(sql(scope.klass, row)))
end end
def self.current(scope, version_model)
return scope if admin?
table = scope.klass.table_name
keys = KEYS.fetch(version_model.table_name)
matches = keys.map do |key|
column = scope.klass.column_names.include?(key) ? key : 'id'
"current_snapshot.#{ key } = #{ table }.#{ column }"
end.join(' AND ')
boundary = "#{ matches } AND current_snapshot.version_no >= #{ table }.version_no " +
"AND current_snapshot.event_type = 'discard'"
scope = scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ boundary })"))
condition = "#{ matches } AND current_snapshot.version_no = #{ table }.version_no"
scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ condition } AND NOT (#{ sql(version_model, 'current_snapshot') }))"))
end
def self.require_current!(record, version_model)
return if admin?
raise Forbidden unless current(record.class.where(id: record.id), version_model).exists?
end
def self.blob_visible?(blob, seen = []) def self.blob_visible?(blob, seen = [])
return true if admin? return true if admin?
return false if seen.include?(blob.id) return false if seen.include?(blob.id)
+1
ファイルの表示
@@ -44,6 +44,7 @@ module TagGroupRepr
end end
def one locale, group def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first many(locale, [group]).first
end end
+1
ファイルの表示
@@ -5,6 +5,7 @@ module WikiPageRepr
module_function module_function
def tag_description locale, description def tag_description locale, description
HistoryAccess.require_current!(description, TagDescriptionVersion)
tag = description.tag tag = description.tag
{ 'id' => description.id, { 'id' => description.id,
+3 -3
ファイルの表示
@@ -14,7 +14,7 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
end end
end end
it 'keeps discarded history private while exposing the current description' do it 'denies the same discarded description through Counts, the facade and the ordinary API' do
get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history', get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history',
mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' } mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -22,8 +22,8 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
'excluded_count' => 2, 'versions' => []) 'excluded_count' => 2, 'versions' => [])
["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path| ["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path|
get path, params: { locale: 'ja' } get path, params: { locale: 'ja' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:not_found)
expect(response.parsed_body.fetch('body')).to eq(description.body) expect(response.body).not_to include('private body')
end end
get '/sitemap_entries/descriptions', params: { locale: 'ja' } get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
+10 -8
ファイルの表示
@@ -69,38 +69,40 @@ RSpec.describe 'Post history privacy', type: :request do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous' sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end end
it 'uses the latest version timestamp for display regardless of role' do it 'uses only authorised history timestamps for display' do
get '/posts' get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id } row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
expected = origin + 4.days expected = origin + (role == 'admin' ? 4 : 3).days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected) expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end end
it 'uses the latest version timestamp for date bounds and count regardless of role' do it 'uses the same visibility for date bounds and count' do
get '/posts', params: { updated_from: (origin + 4.days).iso8601, get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 } updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id') ids = response.parsed_body.fetch('posts').pluck('id')
expect(ids).to eq([post_record.id]) expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
expect(response.parsed_body.fetch('count')).to eq(ids.length) expect(response.parsed_body.fetch('count')).to eq(ids.length)
end end
it 'sorts using the latest version timestamps in both directions regardless of role' do it 'sorts using authorised timestamps in both directions' do
['asc', 'desc'].each do |direction| ['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" } get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expected = [other_post.id, post_record.id] expected = role == 'admin' ? [other_post.id, post_record.id] :
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc' expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected) expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end end
end end
it 'applies latest version timestamp ordering before pagination regardless of role' do it 'applies authorised timestamp ordering before pagination' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 } get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([post_record.id]) expected = role == 'admin' ? post_record.id : other_post.id
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
expect(response.parsed_body.fetch('count')).to eq(2) expect(response.parsed_body.fetch('count')).to eq(2)
end end
+74 -2
ファイルの表示
@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
import PostList from '@/components/PostList'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import PostDurationField from '@/components/posts/PostDurationField' import PostDurationField from '@/components/posts/PostDurationField'
@@ -10,14 +12,15 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts' import { fetchPosts, updatePost } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { msToTime } from '@/lib/utils' import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types' import type { FetchPostsParams, Post, TagWithSections } from '@/types'
type PostFormField = type PostFormField =
'parentPostIds' | 'tags' | 'videoMs' 'parentPostIds' | 'tags' | 'videoMs'
@@ -68,6 +71,33 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [tags, setTags] = useState<string> (() => tagsToStr (post.tags)) const [tags, setTags] = useState<string> (() => tagsToStr (post.tags))
const [title, setTitle] = useState (post.title) const [title, setTitle] = useState (post.title)
const [importVisible, setImportVisible] = useState (false)
const [sourceTitle, setSourceTitle] = useState ('')
const [sourcePage, setSourcePage] = useState (1)
const sourceParams: FetchPostsParams = {
title: sourceTitle, page: sourcePage, limit: 20,
url: '', tags: '', match: 'all', order: 'original_created_at:desc',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '' }
const { data: sources, isError: sourceError } = useQuery ({
queryKey: postsKeys.index (sourceParams, locale),
queryFn: () => fetchPosts (sourceParams, locale),
enabled: importVisible && !disabled })
const importTags = (source: Post) => {
if (disabled)
return
setTags (current => {
const existing = new Set (current.split (/\s+/))
const additions = tagsToStr (source.tags).split (/\s+/)
.filter (tag => tag !== '' && !existing.has (tag))
return additions.length > 0
? `${current}${current.trim () === '' ? '' : ' '}${additions.join (' ')}`
: current
})
setImportVisible (false)
}
const videoFlg = const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'), useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags]) [tags])
@@ -192,6 +222,48 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setTags={setTags} setTags={setTags}
errors={fieldErrors.tags}/> errors={fieldErrors.tags}/>
<Button
type="button"
variant="outline"
disabled={disabled}
aria-expanded={importVisible}
onClick={() => setImportVisible (visible => !visible)}>
{label ('edit.import')}
</Button>
{importVisible && (
<div className="space-y-2" onKeyDown={event => {
if (event.key === 'Enter' && event.target instanceof HTMLInputElement)
event.preventDefault ()
}}>
<PostTextField
label={label ('shared:labels.title')}
type="search"
value={sourceTitle}
disabled={disabled}
onChange={value => {
setSourceTitle (value)
setSourcePage (1)
}}/>
<FieldError messages={sourceError ? [label ('common:fetchError')] : []}/>
{!disabled && <PostList posts={sources?.posts ?? []} onSelect={importTags}/>}
<div className="flex flex-col gap-2 md:flex-row">
<Button
type="button"
variant="outline"
disabled={disabled || sourcePage <= 1}
onClick={() => setSourcePage (page => page - 1)}>
{label ('common:pagination.previous')}
</Button>
<Button
type="button"
variant="outline"
disabled={disabled || sourcePage * 20 >= (sources?.count ?? 0)}
onClick={() => setSourcePage (page => page + 1)}>
{label ('common:pagination.next')}
</Button>
</div>
</div>)}
{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
disabled={disabled} disabled={disabled}
+30 -16
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { Button } from '@/components/ui/button'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -13,10 +14,11 @@ import type { FC, MouseEvent } from 'react'
import type { Post } from '@/types' import type { Post } from '@/types'
type Props = { posts: Post[] type Props = { posts: Post[]
onSelect?: (post: Post) => void
onClick?: (event: MouseEvent<HTMLElement>) => void } onClick?: (event: MouseEvent<HTMLElement>) => void }
const PostList: FC<Props> = ({ posts, onClick }) => { const PostList: FC<Props> = ({ posts, onClick, onSelect }) => {
const { label } = useUITranslation ('shared') const { label } = useUITranslation ('shared')
const location = useLocation () const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
@@ -39,20 +41,11 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
const sharedId = `page-${ post.id }` const sharedId = `page-${ post.id }`
const layoutId = sharedId const layoutId = sharedId
return ( const card = (
<PrefetchLink
to={`/posts/${ post.id }`}
key={post.id}
className="w-40 h-40"
state={{ sharedId }}
onClick={e => {
setForLocationKey (location.key, sharedId)
onClick?.(e)
}}>
<motion.div <motion.div
ref={cardRef} ref={cardRef}
layout={animationMode === 'off' ? false : true} layout={animationMode === 'off' ? false : true}
layoutId={animationMode === 'off' ? undefined : layoutId} layoutId={(onSelect || animationMode === 'off') ? undefined : layoutId}
className={cn ('w-full h-full overflow-hidden rounded-xl shadow', className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
'transform-gpu will-change-transform', 'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500', (post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
@@ -87,13 +80,34 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
loading={ loading={
thumbnailMode === 'light' thumbnailMode === 'light'
? 'lazy' ? 'lazy'
: i < 12 : (i < 12 ? 'eager' : 'lazy')
? 'eager'
: 'lazy'
} }
decoding="async" decoding="async"
className="object-cover w-full h-full"/>)} className="object-cover w-full h-full"/>)}
</motion.div> </motion.div>)
return onSelect
? (
<Button
key={post.id}
type="button"
variant="ghost"
className="w-40 h-40 p-0"
aria-label={post.title || post.url}
onClick={() => onSelect (post)}>
{card}
</Button>)
: (
<PrefetchLink
to={`/posts/${ post.id }`}
key={post.id}
className="w-40 h-40"
state={{ sharedId }}
onClick={e => {
setForLocationKey (location.key, sharedId)
onClick?.(e)
}}>
{card}
</PrefetchLink>) </PrefetchLink>)
})} })}
</div>) </div>)
+2
ファイルの表示
@@ -1,4 +1,5 @@
{ {
"_untranslated": ["edit.import"],
"list": { "list": {
"tagline": "{{brand}}", "tagline": "{{brand}}",
"empty": "ミンタㇻ タ ネン カ イサㇺ", "empty": "ミンタㇻ タ ネン カ イサㇺ",
@@ -31,6 +32,7 @@
"showExternal": "ソイタㇰ ソㇱ アヌカレ" "showExternal": "ソイタㇰ ソㇱ アヌカレ"
}, },
"edit": { "edit": {
"import": "インポート",
"validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……", "validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……",
"conflictTitle": "モㇱマ クㇽ アシㇼ カㇻ ワ アン!", "conflictTitle": "モㇱマ クㇽ アシㇼ カㇻ ワ アン!",
"conflictDescription": "モㇱマ イカㇻ クㇽ エエトコ アシㇼ カㇻ ワ アン。", "conflictDescription": "モㇱマ イカㇻ クㇽ エエトコ アシㇼ カㇻ ワ アン。",
+1
ファイルの表示
@@ -22,6 +22,7 @@
"question": "表示しますか?", "showExternal": "外部ページを表示" "question": "表示しますか?", "showExternal": "外部ページを表示"
}, },
"edit": { "edit": {
"import": "インポート",
"validationFailed": "更新はできなかったよ……", "validationFailed": "更新はできなかったよ……",
"conflictTitle": "競合が発生しました.", "conflictTitle": "競合が発生しました.",
"conflictDescription": "ほかの耕作員が先に更新してゐます.", "conflictDescription": "ほかの耕作員が先に更新してゐます.",