コミットを比較

..
3 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 971b2632b0 #414 2026-11-03 15:17:10 +09:00
みてるぞ ad04c03e14 #414 2026-11-03 13:37:09 +09:00
みてるぞ 2197a9192b #414 2026-11-03 12:22:15 +09:00
68個のファイルの変更、1203行の追加、1580行の削除
-2
ファイルの表示
@@ -71,5 +71,3 @@ gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false
gem 'rails-i18n', '~> 8.0.0'
gem "json", "~> 2.19.9"
+134 -142
ファイルの表示
@@ -1,29 +1,29 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.5.1)
actionpack (= 8.0.5.1)
activesupport (= 8.0.5.1)
actioncable (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.5.1)
actionpack (= 8.0.5.1)
activejob (= 8.0.5.1)
activerecord (= 8.0.5.1)
activestorage (= 8.0.5.1)
activesupport (= 8.0.5.1)
actionmailbox (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
mail (>= 2.8.0)
actionmailer (8.0.5.1)
actionpack (= 8.0.5.1)
actionview (= 8.0.5.1)
activejob (= 8.0.5.1)
activesupport (= 8.0.5.1)
actionmailer (8.0.2)
actionpack (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activesupport (= 8.0.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.5.1)
actionview (= 8.0.5.1)
activesupport (= 8.0.5.1)
actionpack (8.0.2)
actionview (= 8.0.2)
activesupport (= 8.0.2)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,35 +31,35 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.5.1)
actionpack (= 8.0.5.1)
activerecord (= 8.0.5.1)
activestorage (= 8.0.5.1)
activesupport (= 8.0.5.1)
actiontext (8.0.2)
actionpack (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.5.1)
activesupport (= 8.0.5.1)
actionview (8.0.2)
activesupport (= 8.0.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.5.1)
activesupport (= 8.0.5.1)
activejob (8.0.2)
activesupport (= 8.0.2)
globalid (>= 0.3.6)
activemodel (8.0.5.1)
activesupport (= 8.0.5.1)
activerecord (8.0.5.1)
activemodel (= 8.0.5.1)
activesupport (= 8.0.5.1)
activemodel (8.0.2)
activesupport (= 8.0.2)
activerecord (8.0.2)
activemodel (= 8.0.2)
activesupport (= 8.0.2)
timeout (>= 0.4.0)
activestorage (8.0.5.1)
actionpack (= 8.0.5.1)
activejob (= 8.0.5.1)
activerecord (= 8.0.5.1)
activesupport (= 8.0.5.1)
activestorage (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activesupport (= 8.0.2)
marcel (~> 1.0)
activesupport (8.0.5.1)
activesupport (8.0.2)
base64
benchmark (>= 0.3)
bigdecimal
@@ -92,22 +92,22 @@ GEM
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
base64 (0.2.0)
bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin)
bcrypt_pbkdf (1.1.1-x86_64-darwin)
benchmark (0.5.0)
bigdecimal (4.1.3)
benchmark (0.4.0)
bigdecimal (3.1.9)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.0.2)
racc
builder (3.3.0)
chronic (0.10.2)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.7)
date (3.6.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crass (1.0.6)
date (3.4.1)
diff-lcs (1.6.2)
discard (1.4.0)
activerecord (>= 4.2, < 9.0)
@@ -115,9 +115,8 @@ GEM
dotenv-rails (3.1.8)
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.3)
drb (2.2.1)
ed25519 (1.4.0)
erb (6.0.7)
erubi (1.13.1)
factory_bot (6.5.6)
activesupport (>= 6.1.0)
@@ -135,7 +134,7 @@ GEM
gemojione (4.3.3)
json
github-markup (4.0.2)
globalid (1.4.0)
globalid (1.2.1)
activesupport (>= 6.1)
gollum (6.1.0)
gemojione (~> 4.1)
@@ -167,20 +166,19 @@ GEM
gollum-rugged_adapter (3.0)
mime-types (~> 3.4)
rugged (~> 1.5)
i18n (1.15.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.9.4)
irb (1.18.0)
io-console (0.8.0)
irb (1.15.2)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.19.9)
jwt (3.3.0)
json (2.12.0)
jwt (2.10.1)
base64
kamal (2.6.1)
activesupport (>= 7.0)
@@ -200,16 +198,15 @@ GEM
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.2)
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
logger
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.2.1)
marcel (1.0.4)
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
@@ -218,46 +215,45 @@ GEM
benchmark
logger
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.5)
multi_json (1.21.2)
minitest (5.25.5)
msgpack (1.8.0)
multi_json (1.15.0)
mustache (1.1.1)
mustache-sinatra (2.0.0)
mustache (~> 1.0)
mustermann (3.1.1)
mustermann (3.0.3)
ruby2_keywords (~> 0.0.1)
mysql2 (0.5.6)
net-imap (0.6.8)
net-imap (0.5.8)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.4.0)
net-protocol (0.2.2)
timeout
net-scp (4.1.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.2)
net-smtp (0.5.1)
net-protocol
net-ssh (7.3.0)
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
nio4r (2.7.4)
nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-aarch64-linux-musl)
nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-gnu)
nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-musl)
nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
nokogiri (1.18.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-musl)
nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
octicons (19.15.2)
ostruct (0.6.1)
@@ -265,73 +261,70 @@ GEM
parser (3.3.8.0)
ast (~> 2.4.1)
racc
pp (0.6.4)
pp (0.6.2)
prettyprint
prettyprint (0.2.1)
prism (1.9.0)
psych (5.5.0)
prettyprint (0.2.0)
prism (1.4.0)
psych (5.2.6)
date
stringio
puma (6.6.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.7)
rack (3.1.14)
rack-cors (2.0.2)
rack (>= 2.0.0)
rack-protection (4.2.1)
rack-protection (4.1.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.3.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rackup (2.2.1)
rack (>= 3)
rails (8.0.5.1)
actioncable (= 8.0.5.1)
actionmailbox (= 8.0.5.1)
actionmailer (= 8.0.5.1)
actionpack (= 8.0.5.1)
actiontext (= 8.0.5.1)
actionview (= 8.0.5.1)
activejob (= 8.0.5.1)
activemodel (= 8.0.5.1)
activerecord (= 8.0.5.1)
activestorage (= 8.0.5.1)
activesupport (= 8.0.5.1)
rails (8.0.2)
actioncable (= 8.0.2)
actionmailbox (= 8.0.2)
actionmailer (= 8.0.2)
actionpack (= 8.0.2)
actiontext (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activemodel (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
bundler (>= 1.15.0)
railties (= 8.0.5.1)
rails-dom-testing (2.3.0)
railties (= 8.0.2)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.5.1)
actionpack (= 8.0.5.1)
activesupport (= 8.0.5.1)
railties (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.2)
rdoc (6.17.0)
erb
rake (13.2.1)
rdoc (6.13.1)
psych (>= 4.0.0)
tsort
regexp_parser (2.13.1)
reline (0.7.0)
regexp_parser (2.10.0)
reline (0.6.1)
io-console (~> 0.5)
rexml (3.4.4)
rexml (3.4.1)
rouge (3.30.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
@@ -352,20 +345,20 @@ GEM
rspec-support (3.13.6)
rss (0.3.1)
rexml
rubocop (1.92.0)
json (>= 2.3)
rubocop (1.75.6)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (>= 1.10)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.11.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.50.0)
rubocop-ast (1.44.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
prism (~> 1.4)
rubocop-performance (1.25.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
@@ -384,23 +377,24 @@ GEM
ruby-vips (2.2.3)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rugged (1.9.0)
securerandom (0.4.1)
sinatra (4.2.1)
sinatra (4.1.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-protection (= 4.1.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sinatra-contrib (4.2.1)
sinatra-contrib (4.1.1)
multi_json (>= 0.0.2)
mustermann (~> 3.0)
rack-protection (= 4.2.1)
sinatra (= 4.2.1)
rack-protection (= 4.1.1)
sinatra (= 4.1.1)
tilt (~> 2.0)
sprockets (4.4.1)
concurrent-ruby (~> 1.1)
sprockets (4.2.2)
concurrent-ruby (~> 1.0)
logger
rack (>= 2.2.4, < 4)
sprockets-helpers (1.4.0)
@@ -409,14 +403,14 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
sqlite3 (2.10.1-aarch64-linux-gnu)
sqlite3 (2.10.1-aarch64-linux-musl)
sqlite3 (2.10.1-arm-linux-gnu)
sqlite3 (2.10.1-arm-linux-musl)
sqlite3 (2.10.1-arm64-darwin)
sqlite3 (2.10.1-x86_64-darwin)
sqlite3 (2.10.1-x86_64-linux-gnu)
sqlite3 (2.10.1-x86_64-linux-musl)
sqlite3 (2.6.0-aarch64-linux-gnu)
sqlite3 (2.6.0-aarch64-linux-musl)
sqlite3 (2.6.0-arm-linux-gnu)
sqlite3 (2.6.0-arm-linux-musl)
sqlite3 (2.6.0-arm64-darwin)
sqlite3 (2.6.0-x86_64-darwin)
sqlite3 (2.6.0-x86_64-linux-gnu)
sqlite3 (2.6.0-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
@@ -424,19 +418,18 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
stringio (3.2.0)
stringio (3.1.7)
therubyrhino (2.1.2)
therubyrhino_jar (>= 1.7.4, < 1.7.9)
therubyrhino_jar (1.7.8)
thor (1.5.0)
thor (1.3.2)
thruster (0.1.13)
thruster (0.1.13-aarch64-linux)
thruster (0.1.13-arm64-darwin)
thruster (0.1.13-x86_64-darwin)
thruster (0.1.13-x86_64-linux)
tilt (2.9.0)
timeout (0.6.1)
tsort (0.2.0)
tilt (2.6.0)
timeout (0.4.3)
twitter-text (1.14.7)
unf (~> 0.1.0)
tzinfo (2.0.6)
@@ -447,16 +440,16 @@ GEM
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.1.2)
uri (1.0.3)
useragent (0.16.11)
webrick (1.9.1)
websocket-driver (0.8.2)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
whenever (1.0.0)
chronic (>= 0.6.3)
zeitwerk (2.8.3)
zeitwerk (2.7.2)
PLATFORMS
aarch64-linux
@@ -480,7 +473,6 @@ DEPENDENCIES
factory_bot_rails
gollum
image_processing (~> 1.14)
json (~> 2.19.9)
jwt
kamal
mysql2 (~> 0.5.6)
+2 -6
ファイルの表示
@@ -14,9 +14,8 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit
q = HistoryAccess.visible(PostVersion.all)
q = HistoryAccess.visible(version_scope)
q = q.where('post_versions.post_id = ?', post_id) if post_id
q = q.with_previous_version if tag_id || external_tag_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
@@ -29,12 +28,9 @@ class PostVersionsController < ApplicationController
count = q.except(:select, :order, :limit, :offset).count
ids = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
.limit(limit)
.offset(offset)
.pluck('post_versions.id')
versions = version_scope.merge(q).where(id: ids)
.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) }
end
+14 -25
ファイルの表示
@@ -37,10 +37,20 @@ class PostsController < ApplicationController
offset = (page - 1) * limit
needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
pt_max_sql =
HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id')
.to_sql
updated_at_all_sql =
'GREATEST(posts.updated_at,' +
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
q =
filtered_posts(locale)
.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(:external_tags, :uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
:tag_names, :tag_descriptions] }])
@@ -56,25 +66,12 @@ class PostsController < ApplicationController
end
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]
count_scope = q
if needs_history_join
history_times = HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id')
updated_at_all_sql = 'GREATEST(posts.updated_at, ' +
'COALESCE(history_times.latest_at, posts.updated_at))'
end
if updated_between[0]
q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
end
if updated_between[1]
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end
count_scope = q if updated_between.any?(&:present?)
if needs_history_join
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end
sort_sql =
case order[0]
@@ -91,20 +88,12 @@ class PostsController < ApplicationController
.limit(limit)
.offset(offset)
.to_a
latest_version_times =
if needs_history_join
{ }
else
HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
.group(:post_id).maximum(:created_at)
end
count_scope = count_scope.except(:select, :order)
q = q.except(:select, :order)
render json: { posts: posts.map { |post|
updated_at = [post.updated_at, latest_version_times[post.id]].compact.max
PostRepr.base(post, language_code: locale.language_code)
.merge('updated_at' => updated_at).tap do |json|
.merge(updated_at: post.updated_at_all).tap do |json|
json['thumbnail'] =
if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -112,7 +101,7 @@ class PostsController < ApplicationController
nil
end
end
}, count: count_scope.group_values.present? ? count_scope.count.size : count_scope.count }
}, count: q.group_values.present? ? q.count.size : q.count }
end
def random
+9
ファイルの表示
@@ -0,0 +1,9 @@
class Event < ApplicationRecord
has_many :post_versions
has_many :tag_versions
has_many :material_versions
has_many :tag_description_versions
has_many :tag_group_versions
belongs_to :created_by_user, class_name: 'User', optional: true
end
+9 -14
ファイルの表示
@@ -59,21 +59,20 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
end
def self.json_reference(table, key, document)
# A scalar lookup avoids MySQL's semijoin rewrite losing the JSON row correlation.
"(SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " +
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " +
"'$[*]' COLUMNS (reference_id BIGINT PATH '$' NULL ON EMPTY)) reference_entry " +
"JOIN (#{ discarded_keys(table, key) }) blocked " +
"ON reference_entry.reference_id = blocked.#{ key } LIMIT 1) IS NULL"
"ON reference_entry.reference_id = blocked.#{ key })"
end
def self.list_reference(table, key, document)
# Preserve FIND_IN_SET's exact token semantics, including empty tokens.
array = "CONCAT('[', REPLACE(JSON_QUOTE(REPLACE(COALESCE(#{ document }, ''), " +
"' ', ',')), ',', '\",\"'), ']')"
"(SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " +
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " +
"COLUMNS (reference_id VARCHAR(255) PATH '$')) reference_entry " +
"JOIN (#{ discarded_keys(table, key) }) blocked " +
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR) LIMIT 1) IS NULL"
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR))"
end
def self.snapshot_reference(model, row, external: false)
@@ -92,8 +91,8 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
match += ' AND reference_version.version_no = tag_reference.version_no'
latest = ''
end
"(SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest } LIMIT 1) IS NULL"
"NOT EXISTS (SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest })"
end
def self.reference_sql(table, row)
@@ -110,15 +109,11 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
# conservatively; never expose a private linked name through an external tag.
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
# DISTINCT prevents merging this independent name set into the correlated scan.
# Keep byte-distinct names even when the column collation considers them equal.
private_names = 'SELECT DISTINCT private_name.name, ' +
'CAST(private_name.name AS BINARY) AS name_bytes FROM tag_versions private_tag ' +
["NOT EXISTS (SELECT 1 FROM tag_versions private_tag " +
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
'ON blocked.tag_id = private_tag.tag_id ' +
"ON blocked.tag_id = private_tag.tag_id " +
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
"WHERE private_name.name <> ''"
["NOT EXISTS (SELECT 1 FROM (#{ private_names }) private_name WHERE " +
"WHERE private_name.name <> '' AND " +
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
when 'tag_versions'
+1 -3
ファイルの表示
@@ -11,7 +11,5 @@ class MaterialVersion < ApplicationRecord
belongs_to :parent, class_name: 'Material', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
def export_paths_hash
export_paths_json || {}
end
def export_paths_hash = export_paths_json || { }
end
+2
ファイルの表示
@@ -19,6 +19,8 @@ module VersionRecord
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :event
enum :event_type, event_type_map, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
+8 -5
ファイルの表示
@@ -1,20 +1,23 @@
class MaterialVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
def self.record! material:, event_type:, created_by_user:, file_snapshot: nil
new(material:, event_type:, created_by_user:, file_snapshot:).record!
def self.record! material:, event_type:, event:, created_by_user:, file_snapshot: nil
new(material:, event_type:, event:, created_by_user:, file_snapshot:).record!
end
def initialize material:, event_type:, created_by_user:, file_snapshot: nil
def initialize material:, event_type:, event:, created_by_user:, file_snapshot: nil
@file_snapshot = file_snapshot
super(record: material, event_type:, created_by_user:)
super(record: material, event_type:, event:, created_by_user:)
end
def self.ensure_snapshot! material, created_by_user:
return if material.material_versions.exists?
record!(material:, event_type: :create,
event = Event.create!(event_type: 'materials.create',
created_by_user:,
created_at: material.created_at)
record!(material:, event_type: :create, event:,
created_by_user: material.created_by_user || created_by_user)
end
+8 -5
ファイルの表示
@@ -1,16 +1,19 @@
class PostVersionRecorder < VersionRecorder
def self.record! post:, event_type:, created_by_user:
new(post:, event_type:, created_by_user:).record!
def self.record! post:, event_type:, event:, created_by_user:
new(post:, event_type:, event:, created_by_user:).record!
end
def initialize post:, event_type:, created_by_user:
super(record: post, event_type:, created_by_user:)
def initialize post:, event_type:, event:, created_by_user:
super(record: post, event_type:, event:, created_by_user:)
end
def self.ensure_snapshot! post, created_by_user:
return if post.post_versions.exists?
record!(post:, event_type: :create, created_by_user:)
event = Event.create!(event_type: 'posts.create',
created_by_user:,
created_at: post.created_at)
record!(post:, event_type: :create, event:, created_by_user:)
end
private
+4 -4
ファイルの表示
@@ -1,13 +1,13 @@
class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:, **_options
def self.record! tag:, event_type:, event:, created_by_user:, **_options
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
new(tag:, event_type:, created_by_user:).record!
new(tag:, event_type:, event:, created_by_user:).record!
end
end
def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
def initialize tag:, event_type:, event:, created_by_user:
super(record: tag, event_type:, event:, created_by_user:)
end
private
+5 -3
ファイルの表示
@@ -1,9 +1,10 @@
class VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
def initialize record:, event_type:, created_by_user:
def initialize record:, event_type:, event:, created_by_user:
@record = record
@event_type = event_type.to_s
@event = event
@created_by_user = created_by_user
validate_event_type!
@@ -42,8 +43,9 @@ class VersionRecorder
def base_attributes latest
{ version_no: (latest&.version_no || 0) + 1,
event_type: @event_type,
created_at: Time.current,
created_by_user: @created_by_user }
event: @event,
created_by_user: @created_by_user,
created_at: Time.current }
end
def update_record_version_no! version_no
+19
ファイルの表示
@@ -0,0 +1,19 @@
class CreateEvents < ActiveRecord::Migration[8.0]
def change
create_table :events do |t|
t.string :event_type, null: false, index: true
t.text :reason
t.references :reverted_from_event, foreign_key: { to_table: :events }
t.json :metadata
t.binary :ip_address, limit: 16, index: true
t.references :created_by_user, index: true, foreign_key: false
t.datetime :created_at, null: false, index: true
end
add_reference :post_versions, :event, foreign_key: true, after: :original_created_before
add_reference :tag_versions, :event, foreign_key: true, after: :deprecated_at
add_reference :material_versions, :event, foreign_key: true
add_reference :tag_description_versions, :event, foreign_key: true, after: :source_version_no
add_reference :tag_group_versions, :event, foreign_key: true, after: :placements
end
end
+207
ファイルの表示
@@ -0,0 +1,207 @@
class BackfillEventsFromExistingVersions < ActiveRecord::Migration[8.0]
class MigrationEvent < ActiveRecord::Base
self.table_name = 'events'
end
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationMaterialVersion < ActiveRecord::Base
self.table_name = 'material_versions'
end
class MigrationTagDescriptionVersion < ActiveRecord::Base
self.table_name = 'tag_description_versions'
end
class MigrationTagGroupVersion < ActiveRecord::Base
self.table_name = 'tag_group_versions'
end
class MigrationLocale < ActiveRecord::Base
self.table_name = 'locales'
end
def up
# create → posts.create
# update → posts.update
raise if MigrationPostVersion.where.not(event_type: ['create', 'update']).exists?
# create → materials.create
# update → materials.update
if MigrationMaterialVersion.where.not(event_type: ['create', 'update']).exists?
raise
end
# create → posts.create / posts.update / materials.create / materials.update / unknown
# update → tags.update / locales.create
# discard → unknown
if MigrationTagVersion.where.not(event_type: ['create', 'update', 'discard']).exists?
raise
end
# create → tag_descriptions.create
# update → tag_descriptions.update
if MigrationTagDescriptionVersion.where.not(event_type: ['create', 'update']).exists?
raise
end
# create → tag_groups.create
# update → tag_groups.update
if MigrationTagGroupVersion.where.not(event_type: ['create', 'update']).exists?
raise
end
# post_versions の Backfill
MigrationPostVersion.find_each do |pv|
ev = MigrationEvent.create!(event_type: "posts.#{ pv.event_type }",
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: pv.created_by_user_id,
created_at: pv.created_at)
pv.update_columns(event_id: ev.id)
end
# material_versions の Backfill
MigrationMaterialVersion.find_each do |m|
ev = MigrationEvent.create!(event_type: "materials.#{ m.event_type }",
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: m.created_by_user_id,
created_at: m.created_at)
m.update_columns(event_id: ev.id)
end
# ロカール追加に伴ふ該当語言のタグ名称追加異動のみ分離
ids_cause_of_locale_creation = []
MigrationLocale.where('created_at > ?', MigrationLocale.minimum(:created_at)).find_each do |l|
ev = MigrationEvent.create!(event_type: 'locales.create',
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: nil,
created_at: l.created_at)
MigrationTagVersion
.where('created_at > ?', l.created_at)
.where(event_type: 'update', event_id: nil)
.find_each do |t|
next unless t.names.key?(l.language_code)
prev = MigrationTagVersion.find_by(tag_id: t.tag_id, version_no: t.version_no - 1)
next if prev.nil? || prev.names.key?(l.language_code)
t.update_columns(event_id: ev.id)
ids_cause_of_locale_creation << t.id
end
ev.destroy! unless MigrationTagVersion.where(event_id: ev.id).exists?
end
# tag_versions の Backfill
MigrationTagVersion.where.not(id: ids_cause_of_locale_creation).find_each do |t|
ev =
case t.event_type
when 'create'
post =
MigrationPostVersion
.where('JSON_CONTAINS(tags_json, ?) <> 0', { tag_id: t.tag_id }.to_json)
.find_by(created_at: MigrationPostVersion
.where('JSON_CONTAINS(tags_json, ?) <> 0',
{ tag_id: t.tag_id }.to_json)
.minimum(:created_at))
material =
MigrationMaterialVersion
.where(tag_id: t.tag_id)
.find_by(created_at: MigrationMaterialVersion
.where(tag_id: t.tag_id)
.minimum(:created_at))
if post
if material
if post.created_at < material.created_at
MigrationEvent.find(post.event_id)
else
MigrationEvent.find(material.event_id)
end
else
MigrationEvent.find(post.event_id)
end
else
if material
MigrationEvent.find(material.event_id)
else
MigrationEvent.create!(event_type: 'unknown',
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: t.created_by_user_id,
created_at: t.created_at)
end
end
when 'update'
MigrationEvent.create!(event_type: 'tags.update',
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: t.created_by_user_id,
created_at: t.created_at)
when 'discard'
MigrationEvent.create!(event_type: 'unknown',
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: t.created_by_user_id,
created_at: t.created_at)
end
t.update_columns(event_id: ev.id)
end
# tag_description_versions の Backfill
MigrationTagDescriptionVersion.find_each do |td|
ev = MigrationEvent.create!(event_type: "tag_descriptions.#{ td.event_type }",
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: td.created_by_user_id,
created_at: td.created_at)
td.update_columns(event_id: ev.id)
end
# tag_group_versions の Backfill
MigrationTagGroupVersion.find_each do |tg|
ev = MigrationEvent.create!(event_type: "tag_groups.#{ tg.event_type }",
reason: nil,
reverted_from_event_id: nil,
metadata: nil,
ip_address: nil,
created_by_user_id: tg.created_by_user_id,
created_at: tg.created_at)
tg.update_columns(event_id: ev.id)
end
change_column_null :post_versions, :event_id, false
change_column_null :tag_versions, :event_id, false
change_column_null :material_versions, :event_id, false
change_column_null :tag_description_versions, :event_id, false
change_column_null :tag_group_versions, :event_id, false
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
生成ファイル
+32 -1
ファイルの表示
@@ -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_10_15_010000) do
ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) 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
@@ -48,6 +48,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end
create_table "events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "event_type", null: false
t.text "reason"
t.bigint "reverted_from_event_id"
t.json "metadata"
t.binary "ip_address", limit: 16
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_at"], name: "index_events_on_created_at"
t.index ["created_by_user_id"], name: "index_events_on_created_by_user_id"
t.index ["event_type"], name: "index_events_on_event_type"
t.index ["ip_address"], name: "index_events_on_ip_address"
t.index ["reverted_from_event_id"], name: "index_events_on_reverted_from_event_id"
end
create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "platform", limit: 16, null: false
t.string "name", null: false
@@ -250,8 +265,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.string "source_path"
t.string "source_file_id"
t.string "normalized_source_key"
t.bigint "event_id", null: false
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
t.index ["event_id"], name: "index_material_versions_on_event_id"
t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id"
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
t.index ["material_id"], name: "index_material_versions_on_material_id"
@@ -380,10 +397,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.text "parent_post_ids", null: false
t.datetime "original_created_from"
t.datetime "original_created_before"
t.bigint "event_id", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.integer "video_ms"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["event_id"], name: "index_post_versions_on_event_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id"
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
@@ -448,9 +467,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.text "body", null: false
t.string "source_language_code", limit: 16
t.integer "source_version_no"
t.bigint "event_id", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id"
t.index ["event_id"], name: "index_tag_description_versions_on_event_id"
t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc"
t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id"
@@ -533,9 +554,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.text "description"
t.json "members", null: false
t.json "placements", null: false
t.bigint "event_id", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id"
t.index ["event_id"], name: "index_tag_group_versions_on_event_id"
t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true
t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id"
t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive"
@@ -611,11 +634,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.text "aliases", null: false
t.text "parent_tag_ids", null: false
t.datetime "deprecated_at"
t.bigint "event_id", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.json "names", null: false
t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["event_id"], name: "index_tag_versions_on_event_id"
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
@@ -771,6 +796,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "events", "events", column: "reverted_from_event_id"
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
@@ -792,6 +818,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "material_sync_sources", "users", column: "created_by_user_id"
add_foreign_key "material_sync_sources", "users", column: "updated_by_user_id"
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "events"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "users", column: "created_by_user_id"
@@ -813,10 +840,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_versions", "events"
add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users"
add_foreign_key "tag_description_versions", "events"
add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
@@ -835,6 +864,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "tag_group_tags", "tag_groups"
add_foreign_key "tag_group_tags", "tags"
add_foreign_key "tag_group_tags", "users", column: "created_by_user_id"
add_foreign_key "tag_group_versions", "events"
add_foreign_key "tag_groups", "users", column: "created_by_user_id"
add_foreign_key "tag_groups", "users", column: "updated_by_user_id"
add_foreign_key "tag_implications", "tags"
@@ -844,6 +874,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "tag_names", "tags"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "events"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users"
+1
ファイルの表示
@@ -102,6 +102,7 @@ RSpec.describe 'Tag group persistence constraints' do
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
reject_statement do
insert_group_row('tag_group_versions', {
event_id: create_event!(event_type: 'unknown').id,
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
placements: nodes.to_json, created_at: Time.current })
+1
ファイルの表示
@@ -10,6 +10,7 @@ RSpec.describe 'TagVersion names database constraint' do
def insert_snapshot!(snapshot)
TagVersion.insert_all!([{ tag_id: tag.id, version_no: 1, event_type: 'create',
event_id: create_event!(event_type: 'unknown').id,
name: 'snapshot_name', category: 'general',
aliases: '', parent_tag_ids: '', names: snapshot,
created_at: Time.current }])
+21 -7
ファイルの表示
@@ -9,7 +9,9 @@ RSpec.describe HistoryAccess do
locale
rows = ['ja', 'ain'].flat_map do |language|
['create', 'discard', 'restore'].each_with_index.map do |event, index|
TagDescriptionVersion.create!(tag_id: tag.id, language_code: language,
TagDescriptionVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag_id: tag.id, language_code: language,
version_no: index + 1, event_type: language == 'ain' ? 'update' : event,
body: "#{ language } #{ index }", created_at: Time.utc(2024))
end
@@ -28,7 +30,9 @@ RSpec.describe HistoryAccess do
public_metadata = described_class.set(user: nil) { described_class.authority }
admin = create(:user, :admin)
admin_metadata = described_class.set(user: admin) { described_class.authority }
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private',
TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: 'discard', name: 'private',
category: 'general',
names: { ja: { name: 'private', script_code: 'Jpan',
auto_generated: false, aliases: [] } },
@@ -52,7 +56,9 @@ RSpec.describe HistoryAccess do
blob = ActiveStorage::Blob.create_before_direct_upload!(filename: 'private.png',
byte_size: 1, checksum: Base64.strict_encode64('checksum'), content_type: 'image/png')
ActiveStorage::Attachment.create!(name: 'thumbnail', record: post, blob:)
PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url,
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'discard', url: post.url,
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
variant = ActiveStorage::VariantRecord.create!(blob:, variation_digest: 'spec-variation')
derived = ActiveStorage::Blob.create_before_direct_upload!(filename: 'derived.png',
@@ -78,7 +84,9 @@ RSpec.describe 'Counts artifact policy and external references' do
it 'does not expose a private linked tag name through an external history snapshot' do
tag = create(:tag, primary_name: 'private-linked-name')
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
names: { ja: { name: 'private-linked-name', script_code: 'Jpan',
auto_generated: false, aliases: [] } })
@@ -87,7 +95,9 @@ RSpec.describe 'Counts artifact policy and external references' do
name: 'nico:public-external', linked_tags: 'private-linked-name',
created_at: Time.utc(2024))
post = create(:post)
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
snapshot = PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'create',
title: 'external reference', url: post.url, tags: '',
tags_json: [{ external_tag_id: external.id }], parent_post_ids: '',
created_at: Time.utc(2024, 2))
@@ -112,13 +122,17 @@ RSpec.describe 'Counts artifact policy and external references' do
child = create(:tag, primary_name: 'referencing child')
[[parent, 'discard', '', 'private parent'],
[child, 'create', parent.id.to_s, 'referencing child']].each do |tag, event, parents, name|
TagVersion.create!(tag:, version_no: 1, event_type: event, name:,
TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: event, name:,
category: 'general', aliases: '', parent_tag_ids: parents, created_at: Time.utc(2023),
names: { ja: { name:, script_code: 'Jpan',
auto_generated: false, aliases: [] } })
end
post = create(:post)
row = PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'snapshot',
row = PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'create', title: 'snapshot',
url: post.url, tags: '', parent_post_ids: '',
tags_json: [{ tag_id: child.id, version_no: 1, name: 'referencing child', sections: [] }],
created_at: Time.utc(2024))
+24 -1
ファイルの表示
@@ -23,9 +23,22 @@ RSpec.describe 'Locale activation', type: :model do
.and_raise('backfill failed')
end
it 'groups all tag histories from locale creation into one event' do
previous_ids = TagVersion.pluck(:id)
expect { build_locale.save! }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include('event_type' => 'locales.create', 'created_by_user_id' => nil)
versions = TagVersion.where.not(id: previous_ids)
expect(versions.pluck(:tag_id)).to include(first.id, second.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it 'rolls back a newly created locale and the preceding generated names' do
locale = build_locale
fail_second_generation!
events = event_rows
expect { locale.save! }.to raise_error('backfill failed')
@@ -33,6 +46,7 @@ RSpec.describe 'Locale activation', type: :model do
expect(generated_names).to be_empty
expect(first.reload.name('ja')).to eq('activation_first')
expect(second.reload.name('ja')).to eq('activation_second')
expect(event_rows).to eq(events)
end
it 'does not backfill while the locale is inactive' do
@@ -45,7 +59,14 @@ RSpec.describe 'Locale activation', type: :model do
locale = build_locale(deprecated_at: Time.current)
locale.save!
locale.update!(deprecated_at: nil)
previous_ids = TagVersion.pluck(:id)
expect { locale.update!(deprecated_at: nil) }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include('event_type' => 'locales.restore', 'created_by_user_id' => nil)
versions = TagVersion.where.not(id: previous_ids)
expect(versions.pluck(:tag_id)).to include(first.id, second.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
expect(generated_names).to all(have_attributes(
@@ -57,11 +78,13 @@ RSpec.describe 'Locale activation', type: :model do
locale.save!
previous_deprecated_at = locale.reload.deprecated_at
fail_second_generation!
events = event_rows
expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed')
expect(locale.reload.deprecated_at).to eq(previous_deprecated_at)
expect(generated_names).to be_empty
expect(event_rows).to eq(events)
end
it 'preserves a human primary through deactivation and repeated activation' do
+14 -14
ファイルの表示
@@ -12,20 +12,20 @@ RSpec.describe PostVersion, type: :model do
let!(:post_version) do
PostVersion.create!(
post: post_record,
version_no: 1,
event_type: 'create',
title: post_record.title,
url: post_record.url,
thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '),
tags_json: post_record.snapshot_tags_json,
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before,
created_at: Time.current,
created_by_user: nil
)
event_id: create_event!(event_type: 'unknown').id,
post: post_record,
version_no: 1,
event_type: 'create',
title: post_record.title,
url: post_record.url,
thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '),
tags_json: post_record.snapshot_tags_json,
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before,
created_at: Time.current,
created_by_user: nil)
end
it 'is read only after create' do
+27
ファイルの表示
@@ -34,6 +34,31 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
end
it 'groups renames, merges and affected post histories into one event' do
target = create(:tag, primary_name: 'foobar')
source = create(:tag, primary_name: 'temporary_source')
renamed = create(:tag, primary_name: 'temporary_rename')
source.tag_name('ja').update_columns(name: 'foo_bar')
renamed.tag_name('ja').update_columns(name: 'foo_baz')
post = create(:post)
PostTag.create!(post:, tag: source)
previous_tag_ids = TagVersion.pluck(:id)
previous_post_ids = PostVersion.pluck(:id)
expect { described_class.apply! }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include(
'event_type' => 'tag_name_sanitisation_rules.apply', 'created_by_user_id' => nil)
tag_versions = TagVersion.where.not(id: previous_tag_ids)
expect(tag_versions.pluck(:tag_id).uniq).to contain_exactly(target.id, source.id, renamed.id)
expect(tag_versions.pluck(:event_type)).to include('discard', 'update')
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
post_versions = PostVersion.where.not(id: previous_post_ids)
expect(post_versions.pluck(:post_id).uniq).to eq([post.id])
expect(post_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
[:primary, :alias].each do |source_kind|
it "keeps the primary identity when a #{ source_kind } collides within its Tag" do
tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar')
@@ -83,6 +108,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
PostTag.create!(post:, tag: source)
models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag]
snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) }
events = event_rows
expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid)
@@ -90,6 +116,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
model.order(*Array(model.primary_key)).map(&:attributes)
end
expect(after_state).to eq(snapshots)
expect(event_rows).to eq(events)
expect(post.reload.tags).to contain_exactly(source)
expect(target.reload.name('ja')).to eq('foobar')
end
+14 -14
ファイルの表示
@@ -665,20 +665,20 @@ RSpec.describe Tag, type: :model do
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
PostVersion.create!(
post: post,
version_no: version_no,
event_type: event_type,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,
created_by_user: created_by_user
)
event_id: create_event!(event_type: 'unknown').id,
post: post,
version_no: version_no,
event_type: event_type,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,
created_by_user: created_by_user)
end
context 'when post versions are enabled' do
+24 -22
ファイルの表示
@@ -6,17 +6,18 @@ RSpec.describe VersionRecord, type: :model do
it 'makes TagVersion read only after create' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: primary_tag_name_for(tag, 'ja').name,
category: tag.category,
aliases: '',
names: { ja: { name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', auto_generated: false, aliases: [] } },
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil)
event_id: create_event!(event_type: 'unknown').id,
tag: tag,
version_no: 1,
event_type: 'create',
name: primary_tag_name_for(tag, 'ja').name,
category: tag.category,
aliases: '',
names: { ja: { name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', auto_generated: false, aliases: [] } },
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil)
expect {
version.update!(name: 'changed')
@@ -25,17 +26,18 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents TagVersion destroy' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: primary_tag_name_for(tag, 'ja').name,
category: tag.category,
aliases: '',
names: { ja: { name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', auto_generated: false, aliases: [] } },
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil)
event_id: create_event!(event_type: 'unknown').id,
tag: tag,
version_no: 1,
event_type: 'create',
name: primary_tag_name_for(tag, 'ja').name,
category: tag.category,
aliases: '',
names: { ja: { name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', auto_generated: false, aliases: [] } },
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil)
expect {
version.destroy!
+1
ファイルの表示
@@ -44,6 +44,7 @@ RSpec.configure do |config|
end
config.include TestRecords
config.include EventRecords
config.include RakeTaskHelper
# FactoryBot の create / create_list を使へるやぅにする
+3 -1
ファイルの表示
@@ -84,7 +84,9 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
expect(response).to have_http_status(:not_found)
job, file, attempt = ready_job('json')
record = create(:post)
PostVersion.create!(post: record, version_no: 1, event_type: 'discard', title: 'private',
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: 1, event_type: 'discard', title: 'private',
url: record.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
get "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:not_found)
+3 -1
ファイルの表示
@@ -8,7 +8,9 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
before do
['create', 'discard'].each_with_index do |event, index|
TagDescriptionVersion.create!(tag_id: tag.id, language_code: 'ja',
TagDescriptionVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag_id: tag.id, language_code: 'ja',
version_no: index + 1, event_type: event, body: 'private body',
created_at: Time.utc(2024))
end
+6 -2
ファイルの表示
@@ -10,12 +10,16 @@ RSpec.describe 'Legacy Counts API', type: :request do
category: 'general', sections: [] }
[1, 2, 3].each do |number|
time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0)
PostVersion.create!(post:, version_no: number,
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '',
tags_json: number == 1 ? [snapshot] : [],
created_by_user: number == 3 ? nil : actor, created_at: time)
TagVersion.create!(tag:, version_no: number,
TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'counts tag', script_code: 'Jpan',
+27 -9
ファイルの表示
@@ -14,7 +14,9 @@ RSpec.describe 'Counts table API', type: :request do
end
before do
TagVersion.create!(tag:, version_no: 1, event_type: 'create', name: 'alpha',
TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: 'create', name: 'alpha',
category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2023))
@@ -24,7 +26,9 @@ RSpec.describe 'Counts table API', type: :request do
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
{ external_tag_id: external.id }]
[1, 2, 3].each do |number|
PostVersion.create!(post: record, version_no: number,
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: number,
event_type: number == 1 ? 'create' : 'update', url: record.url,
title: "snapshot #{ number }", tags: '', parent_post_ids: '',
tags_json: snapshots.first(number == 1 ? 2 : (number == 2 ? 1 : 0)),
@@ -134,7 +138,9 @@ RSpec.describe 'Counts table API', type: :request do
end
it 'checks the current latest flag beyond the execution timestamp' do
PostVersion.create!(post: record, version_no: 4, event_type: 'update',
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: 4, event_type: 'update',
url: record.url, title: 'after execution', tags: '', tags_json: [],
parent_post_ids: '', created_at: Time.utc(2025, 2, 1))
result = query
@@ -188,12 +194,16 @@ RSpec.describe 'Counts table API', type: :request do
tag.tag_names.find_by!(language_code: 'ja', primary_flg: true)
.update!(name: 'renamed-alpha')
create(:tag_name, :alias, tag:, name: 'alpha-alias')
TagVersion.create!(tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha',
TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha',
category: 'general', aliases: 'alpha-alias', parent_tag_ids: '',
names: { ja: { name: 'renamed-alpha', script_code: 'Jpan', auto_generated: false,
aliases: [{ name: 'alpha-alias', script_code: 'Jpan' }] } },
created_at: Time.utc(2024, 2, 1))
PostVersion.create!(post: record, version_no: 4, event_type: 'update',
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: 4, event_type: 'update',
url: record.url, title: 'after rename', tags: '', parent_post_ids: '',
tags_json: [{ tag_id: tag.id, version_no: 2, name: 'renamed-alpha',
category: 'general', sections: [] }],
@@ -257,7 +267,9 @@ RSpec.describe 'Counts table API', type: :request do
it 'reports column overflow separately from malformed input' do
101.times do |index|
PostVersion.create!(post: record, version_no: index + 4,
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: index + 4,
event_type: 'update', url: record.url, title: "column #{ index }",
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
end
@@ -301,7 +313,9 @@ RSpec.describe 'Counts table API', type: :request do
sign_in_as(create(:user, role)) if role
# Equal timestamps deliberately require version ordering, not a date comparison.
['discard', 'restore', 'discard', 'restore'].each_with_index do |event, index|
PostVersion.create!(post: record, version_no: index + 4, event_type: event,
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: index + 4, event_type: event,
url: record.url, title: event == 'restore' ? 'restored' : 'private snapshot',
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
end
@@ -329,7 +343,9 @@ RSpec.describe 'Counts table API', type: :request do
end
it 'does not resolve private condition names even when the result set is empty' do
TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'secret tag', script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2024, 2))
@@ -345,7 +361,9 @@ RSpec.describe 'Counts table API', type: :request do
end
end
it 'includes private history in the public timeline without an exclusion count' do
PostVersion.create!(post: record, version_no: 4, event_type: 'discard', url: record.url,
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: record, version_no: 4, event_type: 'discard', url: record.url,
title: 'hidden title', tags: '', tags_json: [], parent_post_ids: '',
created_at: Time.utc(2024, 3))
result = query(mode: 'summary', rows: 'created_at:month', cols: 'event_type')
+40 -1
ファイルの表示
@@ -314,6 +314,7 @@ RSpec.describe 'Materials API', type: :request do
.and change(Tag, :count).by(1)
.and change(TagName, :count).by(1)
.and change(MaterialVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:created)
@@ -325,6 +326,12 @@ RSpec.describe 'Materials API', type: :request do
expect(material.file.attached?).to be(true)
expect(material.version_no).to eq(1)
expect(material.material_versions.first.event_type).to eq('create')
event = event_rows.last
expect(event).to include(
'event_type' => 'materials.create',
'created_by_user_id' => member_user.id)
expect(material.material_versions.first.event_id).to eq(event.fetch('id'))
expect(material.tag.tag_versions.pluck(:event_id)).to eq([event.fetch('id')])
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png')
expect(material.material_versions.first.export_paths_json).to eq(
'legacy_drive' => '伊地知ニジカ/created.png'
@@ -509,6 +516,7 @@ RSpec.describe 'Materials API', type: :request do
export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' }
}
end.to change(MaterialVersion, :count).by(2)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok)
@@ -517,6 +525,12 @@ RSpec.describe 'Materials API', type: :request do
expect(material.tag.category).to eq('material')
expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user)
event = event_rows.last
expect(event).to include(
'event_type' => 'materials.update',
'created_by_user_id' => member_user.id)
expect(material.material_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
expect(material.tag.tag_versions.pluck(:event_id)).to eq([event.fetch('id')])
expect(material.file.attached?).to be(true)
expect(material.file.blob.id).not_to eq(old_blob_id)
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
@@ -632,6 +646,7 @@ RSpec.describe 'Materials API', type: :request do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
events = event_rows
expect do
put "/materials/#{ material.id }", params: {
@@ -643,6 +658,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:ok)
expect(material.reload.version_no).to eq(1)
expect(event_rows).to eq(events)
end
it 'records update version when only export_path changes' do
@@ -807,6 +823,12 @@ RSpec.describe 'Materials API', type: :request do
it 'creates source suppression as member' do
sign_in_as(member_user)
materials = ['a', 'b'].map do |name|
Material.create!(
source_kind: 'google_drive_file', source_file_id: "blocked-#{ name }",
source_path: "伊地知ニジカ/危険素材/#{ name }.png",
url: "https://example.com/blocked-#{ name }", created_by_user: member_user)
end
expect do
post '/materials/suppressions', params: {
@@ -815,6 +837,7 @@ RSpec.describe 'Materials API', type: :request do
reason: 'copyright_high_risk'
}
end.to change(MaterialSyncSuppression, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:created)
expect(json).to include(
@@ -822,6 +845,14 @@ RSpec.describe 'Materials API', type: :request do
'drive_path' => '伊地知ニジカ/危険素材',
'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材'
)
event = event_rows.last
expect(event).to include(
'event_type' => 'materials.block', 'created_by_user_id' => member_user.id)
expect(materials.map { _1.reload.discarded? }).to eq([true, true])
versions = MaterialVersion.where(material_id: materials.map(&:id))
expect(versions.where(event_type: 'discard').pluck(:material_id))
.to match_array(materials.map(&:id))
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
end
@@ -860,11 +891,19 @@ RSpec.describe 'Materials API', type: :request do
end
it 'discards the material and returns 204' do
delete "/materials/#{ material.id }"
expect { delete "/materials/#{ material.id }" }
.to change { event_rows.length }.by(1)
expect(response).to have_http_status(:no_content)
expect(Material.find_by(id: material.id)).to be_nil
expect(Material.with_discarded.find(material.id)).to be_discarded
event = event_rows.last
expect(event).to include(
'event_type' => 'materials.destroy',
'created_by_user_id' => member_user.id)
versions = MaterialVersion.where(material_id: material.id)
expect(versions.order(:version_no).last.event_type).to eq('discard')
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
end
end
+5
ファイルの表示
@@ -177,6 +177,7 @@ RSpec.describe 'NicoTags', type: :request do
}
}.to change(TagVersion, :count).by(2)
.and change(NicoTagVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok)
@@ -186,6 +187,10 @@ RSpec.describe 'NicoTags', type: :request do
linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id)
expect(linked_versions.map(&:event_type)).to eq(['create', 'create'])
expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id))
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => admin.id)
expect(linked_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
-129
ファイルの表示
@@ -1,129 +0,0 @@
require 'rails_helper'
RSpec.describe 'Post history privacy', type: :request do
let(:origin) { Time.utc(2020, 1, 1) }
let!(:post_record) do
create(:post, title: 'current', created_at: origin, updated_at: origin)
end
let!(:other_post) do
create(:post, created_at: origin, updated_at: origin + 3.days + 1.hour)
end
let!(:tag) { create(:tag, primary_name: 'private-history-tag') }
let!(:external) { create(:external_tag, name: 'external-history-tag') }
before do
['create', 'discard'].each_with_index do |event, index|
TagVersion.create!(tag:, version_no: index + 1, event_type: event,
name: 'private-history-tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'private-history-tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } },
created_at: origin + index.hours)
end
NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:external-history-tag', linked_tags: 'private-history-tag',
created_at: origin)
['create', 'discard', 'restore', 'update'].each_with_index do |event, index|
snapshots =
if index < 2
[{ tag_id: tag.id, version_no: 1, name: 'private-history-tag', sections: [] }]
elsif index == 3
[{ external_tag_id: external.id }]
else
[]
end
PostVersion.create!(post: post_record, version_no: index + 1, event_type: event,
title: "version #{ index + 1 }", url: post_record.url, tags: '',
tags_json: snapshots, parent_post_ids: '', created_at: origin + (index + 1).days)
end
end
it 'evaluates each reference condition against the restored snapshot alone' do
HistoryAccess.set(user: nil) do
restored = PostVersion.where(post_id: post_record.id, version_no: 3)
HistoryAccess.reference_sql('post_versions', 'post_versions').each do |condition|
expect(restored.where(Arel.sql(condition)).pluck(:version_no)).to eq([3])
end
expect(HistoryAccess.visible(PostVersion.where(post_id: post_record.id))
.pluck(:version_no)).to eq([3])
end
end
it 'keeps every version through a later discard private after another restore' do
['discard', 'restore'].each_with_index do |event, index|
PostVersion.create!(post: post_record, version_no: index + 5, event_type: event,
title: 'later snapshot', url: post_record.url, tags: '', tags_json: [],
parent_post_ids: '', created_at: origin + (index + 5).days)
end
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.fetch('versions').pluck('version_no')).to eq([6])
expect(response.parsed_body.fetch('count')).to eq(1)
expect(response.parsed_body.fetch('versions').first.fetch('title').fetch('prev')).to be_nil
end
['anonymous', 'member', 'admin'].each do |role|
context "as #{ role }" do
before do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end
it 'uses only authorised history timestamps for display' do
get '/posts'
expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
expected = origin + (role == 'admin' ? 4 : 3).days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end
it 'uses the same visibility for date bounds and count' do
get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id')
expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
expect(response.parsed_body.fetch('count')).to eq(ids.length)
end
it 'sorts using authorised timestamps in both directions' do
['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? [other_post.id, post_record.id] :
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end
end
it 'applies authorised timestamp ordering before pagination' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok)
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)
end
it 'counts only visible versions and masks a private previous version' do
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
body = response.parsed_body
expected = role == 'admin' ? [4, 3, 2, 1] : [3]
expect(body.fetch('count')).to eq(expected.length)
expect(body.fetch('versions').pluck('version_no')).to eq(expected)
restored = body.fetch('versions').find { _1.fetch('version_no') == 3 }
expect(restored.fetch('title').fetch('prev')).to eq(role == 'admin' ? 'version 2' : nil)
end
it 'uses only an authorised previous version for tag filtering' do
get '/posts/versions', params: { post: post_record.id, tag: tag.id }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? [3, 2, 1] : []
expect(response.parsed_body.fetch('versions').pluck('version_no')).to eq(expected)
expect(response.parsed_body.fetch('count')).to eq(expected.length)
end
end
end
end
+39 -57
ファイルの表示
@@ -50,6 +50,8 @@ RSpec.describe 'Posts API', type: :request do
post:,
version_no: 1,
event_type: 'create',
event_id: create_event!(
event_type: 'posts.create', created_by_user: post.uploaded_user).id,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
@@ -629,37 +631,6 @@ RSpec.describe 'Posts API', type: :request do
create(:post_tag, post: plain_post, tag:, created_at: t3)
end
it 'displays history time after pagination without joining history to posts or count' do
queries = []
subscriber = ->(event) { queries << event.payload.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
get '/posts', params: { url: '/version-time/', order: 'url:asc', limit: 1 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').pluck('id')).to eq([history_post.id])
expect(Time.zone.parse(json.fetch('posts').first.fetch('updated_at'))).to eq(t2)
expect(json.fetch('count')).to eq(3)
post_queries = queries.select { _1.include?('FROM `posts`') }
expect(post_queries).not_to be_empty
expect(post_queries.grep(/post_versions/)).to be_empty
end
it 'omits the history aggregate from count when only sorting by update time' do
queries = []
subscriber = ->(event) { queries << event.payload.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
get '/posts', params: { url: '/version-time/', order: 'updated_at:desc', limit: 1 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').pluck('id')).to eq([newer_post.id])
expect(json.fetch('count')).to eq(3)
count_queries = queries.select { _1.include?('FROM `posts`') && _1.match?(/COUNT\(/i) }
expect(count_queries).not_to be_empty
expect(count_queries.grep(/post_versions/)).to be_empty
end
['asc', 'desc'].each do |direction|
it "sorts by the later of post update and latest version time (#{ direction })" do
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
@@ -689,16 +660,6 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
expect(json.fetch('count')).to eq(1)
end
it 'counts every timestamp match before pagination' do
get '/posts', params: { url: '/version-time/', order: 'updated_at:asc',
updated_from: t1.iso8601, updated_to: t3.iso8601,
limit: 1, page: 2 }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').pluck('id')).to eq([history_post.id])
expect(json.fetch('count')).to eq(3)
end
end
context 'when original_created_from/original_created_to are provided' do
@@ -1068,7 +1029,7 @@ RSpec.describe 'Posts API', type: :request do
it 'dry-runs without persisting posts or new tags' do
sign_in_as(member)
counts = [Post.count, Tag.count, TagName.count]
counts = [Post.count, Tag.count, TagName.count, event_rows.length]
post '/posts?dry=1', params: post_write_params(
title: 'dry-run post',
@@ -1084,7 +1045,7 @@ RSpec.describe 'Posts API', type: :request do
[{ 'name' => 'new_dry_run_tag',
'category' => 'character',
'section_literals' => [] }])
expect([Post.count, Tag.count, TagName.count]).to eq(counts)
expect([Post.count, Tag.count, TagName.count, event_rows.length]).to eq(counts)
end
it 'dry-runs an existing URL without validating its upload' do
@@ -2259,20 +2220,20 @@ RSpec.describe 'Posts API', type: :request do
def create_post_version! post, version_no:, event_type:, created_by_user:, created_at:
PostVersion.create!(
post:,
version_no:,
event_type:,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at:,
created_by_user:
)
event_id: create_event!(event_type: 'unknown').id,
post:,
version_no:,
event_type:,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at:,
created_by_user:)
end
let!(:v1) do
@@ -2600,6 +2561,7 @@ RSpec.describe 'Posts API', type: :request do
tags: 'spec_tag',
thumbnail: dummy_upload)
end.to change(PostVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:created)
@@ -2611,6 +2573,13 @@ RSpec.describe 'Posts API', type: :request do
expect(version.url).to eq('https://example.com/versioned-post')
expect(version.created_by_user_id).to eq(member.id)
expect(version.tags).to eq(snapshot_tags(created_post))
event = event_rows.last
expect(event).to include(
'event_type' => 'posts.create', 'created_by_user_id' => member.id)
expect(version.event_id).to eq(event.fetch('id'))
tag_versions = TagVersion.where(tag_id: created_post.tags.select(:id))
expect(tag_versions).not_to be_empty
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it 'creates next version on PUT /posts/:id when snapshot changes' do
@@ -2626,6 +2595,7 @@ RSpec.describe 'Posts API', type: :request do
title: 'updated title',
tags: 'spec_tag_2')
end.to change(PostVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok)
@@ -2635,6 +2605,12 @@ RSpec.describe 'Posts API', type: :request do
expect(version.title).to eq('updated title')
expect(version.created_by_user_id).to eq(member.id)
expect(version.tags).to eq(snapshot_tags(post_record.reload))
event = event_rows.last
expect(event).to include(
'event_type' => 'posts.update', 'created_by_user_id' => member.id)
expect(version.event_id).to eq(event.fetch('id'))
tag_version = TagVersion.find_by!(tag_id: tag_name2.reload.tag_id, version_no: 1)
expect(tag_version.event_id).to eq(event.fetch('id'))
end
it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
@@ -2643,6 +2619,7 @@ RSpec.describe 'Posts API', type: :request do
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
base_version = create_post_version_for!(post_record.reload)
events = event_rows
expect {
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
@@ -2656,10 +2633,12 @@ RSpec.describe 'Posts API', type: :request do
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.tags).to eq(snapshot_tags(post_record))
expect(event_rows).to eq(events)
end
it 'does not create a version when POST /posts is invalid' do
sign_in_as(member)
events = event_rows
expect do
post '/posts', params: post_write_params(
@@ -2670,11 +2649,13 @@ RSpec.describe 'Posts API', type: :request do
end.not_to change(PostVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(event_rows).to eq(events)
end
it 'does not create a version when PUT /posts/:id is invalid' do
sign_in_as(member)
base_version = create_post_version_for!(post_record)
events = event_rows
expect do
put "/posts/#{post_record.id}", params: post_write_params(
@@ -2686,6 +2667,7 @@ RSpec.describe 'Posts API', type: :request do
end.not_to change(PostVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(event_rows).to eq(events)
end
it 'rejects second-bearing original created timestamps on POST /posts' do
+3 -1
ファイルの表示
@@ -119,7 +119,9 @@ RSpec.describe 'Sitemap entries API', type: :request do
# Legacy empty bodies can exist even though the write service rejects them.
empty = TagDescription.create!(tag: empty_tag, language_code: 'ain',
body: '', version_no: 1)
TagDescriptionVersion.create!(tag_id: empty_tag.id, language_code: 'ain',
TagDescriptionVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag_id: empty_tag.id, language_code: 'ain',
body: '', version_no: 1, event_type: 'create',
created_by_user_id: user.id, created_at: Time.current)
+10
ファイルの表示
@@ -53,8 +53,13 @@ RSpec.describe "TagChildren", type: :request do
expect { do_request }
.to change { parent.reload.children.ids.include?(child.id) }
.from(false).to(true)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:no_content)
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => admin.id)
expect(child.tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it 'returns 422 and does not create relation when the new link makes a cycle' do
@@ -132,6 +137,7 @@ RSpec.describe "TagChildren", type: :request do
expect {
do_request
}.to change(TagVersion, :count).by(2)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:no_content)
@@ -140,6 +146,10 @@ RSpec.describe "TagChildren", type: :request do
expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s)
expect(versions.second.parent_tag_ids).to eq('')
expect(versions.second.created_by_user_id).to eq(admin.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => admin.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
end
+2
ファイルの表示
@@ -8,6 +8,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
let!(:versions) do
['A', 'A', 'new-version'].each_with_index.map do |body, index|
TagDescriptionVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag_id: tag.id, language_code: 'ja', version_no: index + 1,
event_type: index.zero? ? 'create' : 'update', body:,
created_by_user_id: user.id, created_at: Time.current)
@@ -73,6 +74,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') }
let!(:other_version) do
TagDescriptionVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag_id: other_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'other tag history',
created_by_user_id: user.id, created_at: Time.current)
+13 -1
ファイルの表示
@@ -13,7 +13,7 @@ RSpec.describe 'Tag description native API', type: :request do
def stored_state
[TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model|
model.order(:id).map(&:attributes)
end
end + [event_rows]
end
def fail_save model
@@ -91,6 +91,8 @@ RSpec.describe 'Tag description native API', type: :request do
let!(:version) do
TagDescriptionVersion.create!(
tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1,
event_id: create_event!(
event_type: 'tag_descriptions.create', created_by_user: author).id,
event_type: 'create', created_by_user_id: author.id, created_at: Time.current)
end
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
@@ -185,6 +187,7 @@ RSpec.describe 'Tag description native API', type: :request do
expect { native_write(body: 'created natively') }
.to change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:created)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
expect_current(description)
@@ -192,6 +195,10 @@ RSpec.describe 'Tag description native API', type: :request do
expect(version).to have_attributes(
body: 'created natively', version_no: 1, event_type: 'create',
created_by_user_id: editor.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tag_descriptions.create', 'created_by_user_id' => editor.id)
expect(version.event_id).to eq(event.fetch('id'))
get "/wiki/#{ description.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => description.body, 'revision_id' => version.id)
@@ -223,6 +230,7 @@ RSpec.describe 'Tag description native API', type: :request do
expect(description.version_no).to eq(1)
expect { native_write(body: 'after') }
.to change(TagDescriptionVersion, :count).by(1)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes(
body: 'after', version_no: 2,
@@ -234,6 +242,10 @@ RSpec.describe 'Tag description native API', type: :request do
expect(version).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
body: 'after', created_by_user_id: editor.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tag_descriptions.update', 'created_by_user_id' => editor.id)
expect(version.event_id).to eq(event.fetch('id'))
expect(revision.body).to eq(version.body)
expect(description_versions(page).order(:version_no).last.body).to eq(version.body)
get "/wiki/#{ description.id }"
+5 -1
ファイルの表示
@@ -121,10 +121,14 @@ RSpec.describe 'Tag group native API', type: :request do
[:create, :update].each do |operation|
it "#{ operation }s structured data and keeps unplaced membership" do
previous = group_version(group_id, 1)
native_write(operation)
expect { native_write(operation) }.to change { event_rows.length }.by(1)
expect(response).to have_http_status(operation == :create ? :created : :ok)
id = operation == :create ? json.fetch('id') : group_id
number = operation == :create ? 1 : 2
event = event_rows.last
event_type = operation == :create ? 'tag_groups.create' : 'tag_groups.update'
expect(event).to include('event_type' => event_type, 'created_by_user_id' => user.id)
expect(group_version(id, number).fetch('event_id')).to eq(event.fetch('id'))
expect_group_snapshot(
id, number:, members: snapshot_members, placements:, description: '全体の説明')
expect(group_version(group_id, 1)).to eq(previous)
+1
ファイルの表示
@@ -30,6 +30,7 @@ RSpec.describe 'TagVersions API', type: :request do
)
version =
TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag: tag,
version_no: version_no,
event_type: event_type,
+10
ファイルの表示
@@ -705,6 +705,7 @@ RSpec.describe 'Tags API', type: :request do
expect {
patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
}.to change(TagVersion, :count).by(2)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok)
@@ -719,6 +720,10 @@ RSpec.describe 'Tags API', type: :request do
expect(version_primary_name(versions.second, 'ja')).to eq('new_tag_name')
expect(versions.second.category).to eq('meme')
expect(versions.second.created_by_user_id).to eq(member_user.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => member_user.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it 'updates deprecated state and records it in tag versions' do
@@ -1275,6 +1280,7 @@ RSpec.describe 'Tags API', type: :request do
deprecated: '0',
}
}.to change(TagVersion, :count).by(2)
.and change { event_rows.length }.by(1)
expect(response).to have_http_status(:ok)
@@ -1290,6 +1296,10 @@ RSpec.describe 'Tags API', type: :request do
expect(versions.second.category).to eq('meta')
expect(version_alias_names(versions.second, 'ja')).to include('spec_tag')
expect(versions.second.created_by_user_id).to eq(member_user.id)
event = event_rows.last
expect(event).to include(
'event_type' => 'tags.update', 'created_by_user_id' => member_user.id)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it '同じ tag の旧 alias へ戻しても rename できる' do
+5 -9
ファイルの表示
@@ -143,7 +143,9 @@ RSpec.describe 'Counts export contracts' do
expect(CountsExportDownload.find(token, 'owner')).not_to be_nil
expect(CountsExportDownload.find(token, 'other')).to be_nil
post = create(:post)
PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url,
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'discard', url: post.url,
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
expect(CountsExportDownload.find(token, 'owner')).to be_nil
end
@@ -221,15 +223,9 @@ RSpec.describe CountsExportDownload::ResponseBody do
body = described_class.new(source) { |state| states << state; true }
response = ActionDispatch::Response.new
response.body = body
_, _, rack_body = response.to_a
deferred = response.stream.to_ary
expect(states).to eq([])
chunks = []
rack_body.each { |chunk| chunks << chunk }
expect(chunks.join).to eq(payload)
expect(deferred.to_a.join).to eq(payload)
expect(states.count('started')).to eq(1)
end
end
+21 -7
ファイルの表示
@@ -13,7 +13,9 @@ RSpec.describe 'Counts masked pivot contracts' do
let!(:tags) do
['A', 'B', 'C'].map do |name|
tag = create(:tag, primary_name: name)
TagVersion.create!(tag:, version_no: 1, event_type: 'create', name:,
TagVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag:, version_no: 1, event_type: 'create', name:,
category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name:, script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2023))
@@ -25,12 +27,16 @@ RSpec.describe 'Counts masked pivot contracts' do
[[0, 1], [0], [1, 2]].each_with_index do |indexes, index|
post = create(:post)
snapshot = indexes.map { { tag_id: tags[_1].id, version_no: 1, sections: [] } }
PostVersion.create!(post:, version_no: 1, event_type: 'create',
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'create',
title: "hidden-title-#{ index }", url: post.url, tags: '', tags_json: snapshot,
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 1))
next if index == 2
PostVersion.create!(post:, version_no: 2, event_type: 'discard',
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 2, event_type: 'discard',
title: "private-discard-#{ index }", url: post.url, tags: '', tags_json: [],
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 2))
end
@@ -98,12 +104,16 @@ RSpec.describe 'Counts masked pivot contracts' do
it 'uses changed-record membership for totals and hides changes to private predecessors' do
post = PostVersion.order(:id).last.post
PostVersion.create!(post:, version_no: 2, event_type: 'update', title: 'changed-public-title',
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 2, event_type: 'update', title: 'changed-public-title',
url: post.url, tags: '',
tags_json: [{ tag_id: tags.last.id, version_no: 1, sections: [] }],
parent_post_ids: '', created_at: Time.utc(2024, 3))
restored = PostVersion.order(:id).first.post
PostVersion.create!(post: restored, version_no: 3, event_type: 'restore',
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post: restored, version_no: 3, event_type: 'restore',
title: 'restored-public-title', url: restored.url, tags: '',
tags_json: [{ tag_id: tags.first.id, version_no: 1, sections: [] }], parent_post_ids: '',
created_at: Time.utc(2024, 4))
@@ -134,7 +144,9 @@ RSpec.describe 'Counts masked pivot contracts' do
it 'keeps NULL as a public data value rather than treating it as a rollup' do
post = create(:post)
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'public bot',
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: 1, event_type: 'create', title: 'public bot',
url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 5))
page = result(nil, 'rows' => 'user', 'cols' => 'event_type')
expect(page[:cells].select { _1[:values].first.nil? }.sum { _1[:count] }).to eq(1)
@@ -145,7 +157,9 @@ RSpec.describe 'Counts masked pivot contracts' do
it 'sorts public numeric values numerically alongside a masked group' do
post = PostVersion.order(:id).last.post
[2, 10].each do |number|
PostVersion.create!(post:, version_no: number, event_type: 'update',
PostVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
post:, version_no: number, event_type: 'update',
title: 'public numeric sort', url: post.url, tags: '', tags_json: [],
parent_post_ids: '', created_at: Time.utc(2024, 5))
end
+2
ファイルの表示
@@ -56,6 +56,7 @@ RSpec.describe MaterialSyncImporter do
profile: 'legacy_drive',
export_path: '素材/a.png',
created_by_user: user)
events = event_rows
result =
described_class.import!(
@@ -73,6 +74,7 @@ RSpec.describe MaterialSyncImporter do
expect(result.action).to eq(:unchanged)
expect(material.reload.tag).to eq(tag)
expect(material.url).to eq('https://example.com/human')
expect(event_rows).to eq(events)
end
it 'suppresses a Google Drive candidate by relative path prefix' do
+41
ファイルの表示
@@ -14,6 +14,47 @@ RSpec.describe MaterialSyncRunner do
let(:drive_client) { instance_double(GoogleDrive::ApiClient) }
describe '#sync!' do
it 'groups created and updated materials across sync sources into one event' do
existing = Material.create!(
source_kind: 'google_drive_file', source_file_id: 'existing-file',
source_path: 'old.png', url: 'https://example.com/existing',
created_by_user: user, updated_by_user: user)
source
MaterialSyncSource.create!(
name: 'Second Drive source', source_kind: 'google_drive_path',
source_file_id: 'folder-456', profile: 'legacy_drive',
created_by_user: user, updated_by_user: user)
allow(GoogleDrive::ApiClient).to receive(:new).and_return(drive_client)
allow(drive_client).to receive(:each_material_file_under_folder) do |folder_id, &block|
id = folder_id == 'folder-123' ? 'existing-file' : 'new-file'
block.call(id:, name: "#{ id }.png", mime_type: 'image/png',
relative_path: "素材/#{ id }.png", sha256_checksum: nil,
web_view_link: "https://drive.google.com/file/d/#{ id }/view")
end
allow(drive_client).to receive(:download_to_tempfile) do |id, **|
Tempfile.new(['sync-event', '.png']).tap do |file|
file.binmode
file.write(id)
file.rewind
end
end
allow(MaterialThumbnailGenerator).to receive(:generate!)
previous_ids = MaterialVersion.pluck(:id)
result = nil
expect { result = described_class.sync_enabled! }
.to change { event_rows.length }.by(1)
expect(result).to have_attributes(imported: 1, updated: 1, failed: 0)
event = event_rows.last
expect(event.fetch('event_type')).to eq('materials.sync')
created = Material.find_by!(source_file_id: 'new-file')
versions = MaterialVersion.where.not(id: previous_ids)
expect(versions.pluck(:material_id).uniq).to contain_exactly(existing.id, created.id)
expect(versions.pluck(:event_type)).to include('create', 'update')
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it 'imports google drive path candidates as they are yielded' do
first_entry = { id: 'file-1',
name: 'a.png',
+83
ファイルの表示
@@ -88,3 +88,86 @@ RSpec.describe PostBulkCreator do
expect(creator).to have_received(:create!).with(locale).once
end
end
RSpec.describe 'PostBulkCreator event grouping', type: :model do
self.use_transactional_tests = false
before do
expect(Rails.env.test?).to be(true)
expect(ApplicationRecord.connection.open_transactions).to eq(0)
@prefix = "bulk_event_#{ SecureRandom.hex(8) }"
@actor = create(:user, :member)
allow(PostCreatePreflight).to receive(:new) do |attributes:, **|
specs = [{ name: attributes.fetch(:title), category: :general }]
instance_double(PostCreatePreflight, run: attributes.merge(
existing_post_id: nil, snapshot_tag_specs: specs, post_tag_specs: specs,
normalised_parent_post_ids: [], tag_sections: { }, video_ms: nil))
end
end
after do
if @actor
posts = Post.where(uploaded_user: @actor)
PostTagSection.where(post_id: posts.select(:id)).delete_all
PostTag.where(post_id: posts.select(:id)).delete_all
PostVersion.where(post_id: posts.select(:id)).delete_all
posts.delete_all
tag_ids = TagName.where(name: ["#{ @prefix }_1", "#{ @prefix }_2"]).pluck(:tag_id)
TagVersion.where(tag_id: tag_ids).delete_all
TagName.where(tag_id: tag_ids).delete_all
Tag.where(id: tag_ids).delete_all
EventRecords::EventRow.where(created_by_user_id: @actor.id).delete_all
@actor.delete
end
end
it 'shares one creation event between real workers and their tag histories' do
posts = [1, 2].map do |number|
{ title: "#{ @prefix }_#{ number }",
url: "https://example.com/#{ @prefix }/#{ number }" }
end
result = nil
expect {
result = PostBulkCreator.new(actor: @actor, posts:, thumbnails: { })
.run(Locale.nipponese)
}.to change { event_rows.length }.by(1)
expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['created', 'created'])
event = event_rows.last
expect(event).to include(
'event_type' => 'posts.create', 'created_by_user_id' => @actor.id)
versions = PostVersion.where(created_by_user_id: @actor.id)
expect(versions.count).to eq(2)
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
tag_versions = TagVersion.where(created_by_user_id: @actor.id)
expect(tag_versions.count).to eq(2)
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it 'does not leave an event when every row fails validation' do
preflight = instance_double(PostCreatePreflight)
allow(PostCreatePreflight).to receive(:new).and_return(preflight)
allow(preflight).to receive(:run).and_raise(
PostCreatePreflight::ValidationFailed.new(fields: { url: ['invalid'] }))
events = event_rows
result = PostBulkCreator.new(
actor: @actor, posts: [{ title: 'invalid' }], thumbnails: { }).run(Locale.nipponese)
expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['failed'])
expect(event_rows).to eq(events)
end
it 'does not leave an event when every row is skipped' do
preflight = instance_double(PostCreatePreflight, run: { existing_post_id: 123 })
allow(PostCreatePreflight).to receive(:new).and_return(preflight)
events = event_rows
result = PostBulkCreator.new(
actor: @actor, posts: [{ title: 'existing' }], thumbnails: { }).run(Locale.nipponese)
expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['skipped'])
expect(event_rows).to eq(events)
end
end
+10
ファイルの表示
@@ -16,6 +16,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
@tag = create(:tag, primary_name: "#{ @prefix }_owner")
@group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1)
@tag_ids = Queue.new
@event_ids = Queue.new
@threads = []
@release = Queue.new
end
@@ -42,6 +43,9 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
ids << @tag.id if @tag
TagName.where(tag_id: ids).delete_all
Tag.where(id: ids).delete_all
event_ids = []
event_ids << @event_ids.pop until @event_ids.empty? if @event_ids
EventRecords::EventRow.where(id: event_ids).delete_all
end
end
@@ -72,11 +76,17 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
when :description
TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1)
when :description_version
event = create_event!(event_type: 'unknown')
@event_ids << event.id
TagDescriptionVersion.create!(
event_id: event.id,
tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1,
event_type: 'create', created_at: Time.current)
when :group_version
event = create_event!(event_type: 'unknown')
@event_ids << event.id
TagGroupVersion.create!(
event_id: event.id,
tag_group: @group, name: @group.name, version_no: 1, event_type: 'create',
members: [], placements: [], created_at: Time.current)
when :group
+3
ファイルの表示
@@ -15,10 +15,12 @@ RSpec.describe SharedResourceIdAllocator do
TagGroup.new(id:, name: "group #{ number }", version_no: number)
when 'tag_description_versions'
TagDescriptionVersion.new(
event_id: create_event!(event_type: 'unknown').id,
id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number,
event_type: 'create', created_at: Time.current)
when 'tag_group_versions'
TagGroupVersion.new(
event_id: create_event!(event_type: 'unknown').id,
id:, tag_group_id: group.id, name: group.name, version_no: number,
event_type: 'create', members: [], placements: [], created_at: Time.current)
end
@@ -82,6 +84,7 @@ RSpec.describe SharedResourceIdAllocator do
it 'reserves a Tag ID referenced only by remaining description history' do
high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000
TagDescriptionVersion.create!(
event_id: create_event!(event_type: 'unknown').id,
tag_id: high, language_code: 'ja', version_no: 1, body: 'history',
event_type: 'create', created_at: Time.current)
expect(create(:tag).id).to be > high
+35
ファイルの表示
@@ -42,6 +42,41 @@ RSpec.describe Youtube::Sync do
end
describe '#sync!' do
it 'groups created and updated post histories into one sync event' do
allow(PostVersionRecorder).to receive(:record!).and_call_original
allow(PostVersionRecorder).to receive(:ensure_snapshot!).and_call_original
existing = create(:post, url: 'https://youtu.be/video-1', title: 'old title')
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(playlist_id: 'PL123', page_token: nil)
.and_return('items' => [
{ 'contentDetails' => { 'videoId' => 'video-1' } },
{ 'contentDetails' => { 'videoId' => 'video-2' } }])
allow(client).to receive(:videos).with(['video-1', 'video-2'])
.and_return('items' => [
youtube_video_item(id: 'video-1', title: 'updated', channel_id: 'UC_NONE'),
youtube_video_item(id: 'video-2', title: 'created', channel_id: 'UC_NONE')])
previous_post_ids = PostVersion.pluck(:id)
previous_tag_ids = TagVersion.pluck(:id)
expect { sync.sync! }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
versions = PostVersion.where.not(id: previous_post_ids)
created = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-2')
expect(versions.pluck(:post_id).uniq).to contain_exactly(existing.id, created.id)
expect(versions.pluck(:event_type)).to include('create', 'update')
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
tag_versions = TagVersion.where.not(id: previous_tag_ids)
expect(tag_versions).not_to be_empty
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
events = event_rows
expect { sync.sync! }.not_to change(PostVersion, :count)
expect(event_rows).to eq(events)
end
it 'returns without fetching video details when no video ids are discovered' do
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return([])
+15
ファイルの表示
@@ -0,0 +1,15 @@
module EventRecords
# Read the database contract without requiring an application Event model.
class EventRow < ActiveRecord::Base
self.table_name = 'events'
end
def event_rows
EventRow.order(:id).map(&:attributes)
end
def create_event! event_type:, created_by_user: nil
EventRow.create!(event_type:, created_by_user_id: created_by_user&.id,
created_at: Time.current)
end
end
+4 -1
ファイルの表示
@@ -3,11 +3,13 @@ require 'cgi'
# SQL fixtures use the migration contract without inventing application models.
# Include explicitly; these helpers must not affect unrelated examples.
module TagGroupRecords
include EventRecords
GROUP_TABLES = [
'tag_groups', 'tag_group_tags', 'tag_group_groups',
'tag_group_placements', 'tag_group_versions'].freeze
WRITE_TABLES = GROUP_TABLES + [
'tag_names', 'tag_description_versions',
'events', 'tag_names', 'tag_description_versions',
'tag_descriptions']
def group_connection = ApplicationRecord.connection
@@ -114,6 +116,7 @@ module TagGroupRecords
group_id = insert_group_row('tag_groups', attributes)
insert_group_row('tag_group_versions', {
tag_group_id: group_id, version_no: 1, event_type: 'create',
event_id: create_event!(event_type: 'tag_groups.create', created_by_user: user).id,
name:, description:, members: members.to_json, placements: placements.to_json,
created_by_user_id: user.id, created_at: now })
members.each do |member|
+43 -14
ファイルの表示
@@ -174,20 +174,20 @@ RSpec.describe 'nico:sync' do
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
PostVersion.create!(
post: post,
version_no: version_no,
event_type: event_type,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,
created_by_user: created_by_user
)
event_id: create_event!(event_type: 'unknown').id,
post: post,
version_no: version_no,
event_type: event_type,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,
created_by_user: created_by_user)
end
it '新規 post 作成時に version 1 を作る' do
@@ -209,6 +209,7 @@ RSpec.describe 'nico:sync' do
expect {
run_rake_task('nico:sync')
}.to change(PostVersion, :count).by(1)
.and change { event_rows.length }.by(1)
post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9')
version = post.post_versions.order(:version_no).last
@@ -217,6 +218,32 @@ RSpec.describe 'nico:sync' do
expect(version.event_type).to eq('create')
expect(version.created_by_user).to be_nil
expect(version.tags).to eq(snapshot_tags(post.reload))
event = event_rows.last
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
expect(version.event_id).to eq(event.fetch('id'))
end
it 'groups created and updated post histories into one sync event' do
existing = create(:post, title: 'old', url: 'https://www.nicovideo.jp/watch/sm9')
stub_python([
{ 'code' => 'sm9', 'title' => 'updated', 'tags' => [] },
{ 'code' => 'sm10', 'title' => 'created', 'tags' => [] }])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
previous_post_ids = PostVersion.pluck(:id)
previous_tag_ids = TagVersion.pluck(:id)
expect { run_rake_task('nico:sync') }.to change { event_rows.length }.by(1)
event = event_rows.last
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
created = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm10')
versions = PostVersion.where.not(id: previous_post_ids)
expect(versions.pluck(:post_id).uniq).to contain_exactly(existing.id, created.id)
expect(versions.pluck(:event_type)).to include('create', 'update')
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
tag_versions = TagVersion.where.not(id: previous_tag_ids)
expect(tag_versions).not_to be_empty
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
end
it '既存 post の内容または tags が変わったとき update version を作る' do
@@ -282,6 +309,7 @@ RSpec.describe 'nico:sync' do
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
events = event_rows
expect {
run_rake_task('nico:sync')
@@ -291,6 +319,7 @@ RSpec.describe 'nico:sync' do
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.tags).to eq(snapshot_tags(post.reload))
expect(event_rows).to eq(events)
end
it '新規 nico tag に nico tag version を作る' do
-94
ファイルの表示
@@ -1,94 +0,0 @@
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
バイナリファイルは表示されません。
-11
ファイルの表示
@@ -1,11 +0,0 @@
Noto Sans Mongolian Regular, unmodified TrueType font.
Font copyright: Copyright 2018 Google Inc. All Rights Reserved.
- Source: https://github.com/notofonts/noto-fonts/tree/main/hinted/ttf/NotoSansMongolian
- Font: https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansMongolian/NotoSansMongolian-Regular.ttf
- License: SIL Open Font License 1.1; see the accompanying LICENSE.
- Retrieved: 2026-11-08.
Used only by the Unicode Mongolian sample in the development layout route.
The font and license are served together from this directory.
+11 -16
ファイルの表示
@@ -20,13 +20,13 @@ import { Toaster } from '@/components/ui/toaster'
import { isApiError } from '@/lib/api'
import { initialiseUserSession } from '@/lib/users'
import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, isVerticalPreviewPath, legacyLocalePath,
localiseTo, switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
import { isCountsPath } from '@/lib/routeLocales'
import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki'
import { useLocale, useVerticalPreview } from '@/lib/useLocale'
import { useLocale } from '@/lib/useLocale'
import { applyClientAnimationMode,
applyClientAppearance,
fetchUserThemeSlots,
@@ -267,7 +267,6 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const client = useQueryClient ()
const location = useLocation ()
const locale = useLocale ()
const verticalPreview = useVerticalPreview ()
const route = resolvePageRoute (location.pathname)
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
const pagePath = unlocalisedPath (location.pathname)
@@ -394,8 +393,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
<motion.div
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
transition={{ layout: appLayoutTransition }}
data-vertical-preview={verticalPreview || undefined}
className="app-layout relative flex h-dvh w-full flex-col overflow-y-hidden">
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper
user={user}
@@ -414,7 +412,6 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const LocaleRoute: FC = () => {
const { locale } = useParams ()
const location = useLocation ()
const verticalPreview = isVerticalPreviewPath (location.pathname)
const { i18n } = useTranslation ()
const { data, isError } = useQuery ({
queryKey: localesKeys.index,
@@ -424,11 +421,10 @@ const LocaleRoute: FC = () => {
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true })
const contentLocale = verticalPreview ? 'ja' : locale
const activeLocale =
(isLocaleCode (contentLocale)
&& data?.locales.some (entry => entry.code === contentLocale))
? contentLocale
(isLocaleCode (locale)
&& data?.locales.some (entry => entry.code === locale))
? locale
: null
useLayoutEffect (() => {
@@ -439,15 +435,15 @@ const LocaleRoute: FC = () => {
}, [activeLocale, i18n])
const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null)
if (!(isLocaleCode (locale)) && !verticalPreview && redirect == null)
if (!(isLocaleCode (locale)) && redirect == null)
return <NotFound/>
if (isError && data == null)
return <ServiceUnavailable/>
if (data == null)
return null
if (isLocaleCode (locale) || verticalPreview)
if (isLocaleCode (locale))
{
return data.locales.some (entry => entry.code === contentLocale)
return data.locales.some (entry => entry.code === locale)
? <RoutedApp locales={data.locales}/>
: <NotFound/>
}
@@ -459,8 +455,7 @@ const LocaleRoute: FC = () => {
const LocaleRedirect = ({ to }: { to: string }) => {
const locale = useLocale ()
const location = useLocation ()
return <Navigate to={localiseTo (
{ ...location, pathname: to }, locale, location.pathname)} replace/>
return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/>
}
+1 -1
ファイルの表示
@@ -34,7 +34,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
const cardRef = useRef<HTMLDivElement> (null)
return (
<div className="post-list flex flex-wrap gap-6 p-4">
<div className="flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => {
const sharedId = `page-${ post.id }`
const layoutId = sharedId
+5 -9
ファイルの表示
@@ -1,10 +1,9 @@
import { useQueryClient } from '@tanstack/react-query'
import { forwardRef, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { createPath, useLocation } from 'react-router-dom'
import { createPath } from 'react-router-dom'
import { isVerticalPreviewPath, localeFromPath,
localiseTo, unlocalisedPath } from '@/lib/locales'
import { localiseTo } from '@/lib/locales'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
@@ -39,18 +38,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useLocaleNavigate ()
const locale = useLocale ()
const { pathname } = useLocation ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => {
const localised = localiseTo (to, locale, pathname)
const destination = typeof localised === 'string' ? localised : createPath (localised)
return (isVerticalPreviewPath (pathname) && localeFromPath (destination) === locale)
? `/mn-Mong${ unlocalisedPath (destination) }` : destination
const localised = localiseTo (to, locale)
return typeof localised === 'string' ? localised : createPath (localised)
},
[to, locale, pathname],
[to, locale],
)
const url = useMemo (() => {
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
-120
ファイルの表示
@@ -1,120 +0,0 @@
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagSearch from '@/components/TagSearch'
import * as api from '@/lib/api'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const LocationOutput = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}{location.search}</output>
}
const renderSearch = () => renderWithProviders (
<>
<TagSearch/>
<LocationOutput/>
</>, { route: '/ja/posts?page=3' })
describe ('TagSearch', () => {
beforeEach (() => {
vi.spyOn (api, 'apiGet').mockResolvedValue ([])
})
it ('searches through native submission without an Enter key event', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } })
expect (input.form).toBeInstanceOf (HTMLFormElement)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
expect (input).toHaveValue ('虹夏 not:test type:or')
})
it ('requests a search action from the software keyboard', () => {
renderSearch ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search')
})
it ('searches through the form when Enter is pressed', async () => {
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹夏{Enter}')
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it.each ([
{ label: 'composition flag', composing: true, keyCode: 13 },
{ label: 'legacy IME code', composing: false, keyCode: 229 }]) (
'cancels confirmation Enter with $label', ({ composing, keyCode }) => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: composing, keyCode })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('cancels Enter throughout composition even without keyboard IME flags', () => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: false, keyCode: 13 })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('blocks direct submission during composition and allows it after confirmation', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
fireEvent.submit (input.form!)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
fireEvent.compositionEnd (input)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it ('selects the active suggestion without submitting the form', async () => {
vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })])
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹')
await screen.findByText ('虹夏')
await user.keyboard ('{ArrowDown}{Enter}')
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ')
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
})
+24 -44
ファイルの表示
@@ -1,6 +1,6 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
@@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox'
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import type { Tag } from '@/types'
@@ -20,7 +20,6 @@ const TagSearch: FC = () => {
const locale = useLocale ()
const location = useLocation ()
const navigate = useLocaleNavigate ()
const compositionFlgRef = useRef (false)
const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('')
@@ -75,34 +74,7 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false)
}, [locale])
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
if (compositionFlgRef.current)
return
const parts = search.split (' ')
const match =
parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match })).toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
if (compositionFlgRef.current
|| ev.nativeEvent.isComposing
|| ev.nativeEvent.keyCode === 229)
{
if (ev.key === 'Enter')
ev.preventDefault ()
return
}
switch (ev.key)
{
case 'ArrowDown':
@@ -118,12 +90,13 @@ const TagSearch: FC = () => {
break
case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
{
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
if (selected == null)
break
ev.preventDefault ()
handleTagSelect (selected)
const selected = suggestions[activeIndex]
if (selected)
handleTagSelect (selected)
}
break
@@ -132,6 +105,20 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
const parts = search.split (' ')
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match }))
.toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
}
const handleTagSelect = (tag: Tag) => {
@@ -157,9 +144,8 @@ const TagSearch: FC = () => {
}, [location.search])
return (
<form onSubmit={handleSubmit} className="relative w-full">
<div className="relative w-full">
<input type="text"
enterKeyHint="search"
data-shortcut-focus-search="true"
placeholder={message ('labels.tagSearch').text}
value={search}
@@ -167,18 +153,12 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
onCompositionStart={() => {
compositionFlgRef.current = true
}}
onCompositionEnd={() => {
compositionFlgRef.current = false
}}
className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</form>)
</div>)
}
export default TagSearch
+27 -65
ファイルの表示
@@ -4,7 +4,7 @@ import { Globe } from 'lucide-react'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate, useVerticalPreview } from '@/lib/useLocale'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
@@ -137,7 +137,6 @@ export const menuOutline = (
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const verticalPreview = useVerticalPreview ()
const localeName = locales.find (entry => entry.code === locale)?.name
const navigate = useLocaleNavigate ()
const location = useLocation ()
@@ -181,12 +180,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
let node: HTMLElement | null = el
while (node != null && node !== nav)
{
left += verticalPreview ? node.offsetTop : node.offsetLeft
left += node.offsetLeft
const parent: Element | null = node.offsetParent
node = parent instanceof HTMLElement ? parent : null
}
const width = node === nav
? (verticalPreview ? el.offsetHeight : el.offsetWidth) : 0
const width = node === nav ? el.offsetWidth : 0
const visible = width > 0
setHL (current =>
(current.left === left && current.width === width && current.visible === visible)
@@ -204,7 +202,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [submenuHeight, setSubmenuHeight] = useState (0)
const [submenuWidth, setSubmenuWidth] = useState (0)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '')
@@ -258,22 +255,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
animationsOff
? undefined
: (reducedAnimations
? 'transition-[transform,width,height] duration-75 ease-linear'
: 'transition-[transform,width,height] duration-200 ease-out')
const submenuItemMotion = animationsOff ? { } : {
initial: { x: verticalPreview ? -40 : 0,
y: verticalPreview ? 0 : -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: verticalPreview ? -40 : 0,
y: verticalPreview ? 0 : -40, opacity: 0 } }
const submenuVariants = {
enter: (direction: -1 | 1) => ({
x: verticalPreview ? direction * 24 : 0,
y: verticalPreview ? 0 : direction * 24, opacity: 0 }),
centre: { x: 0, y: 0, opacity: 1 },
exit: (direction: -1 | 1) => ({
x: verticalPreview ? (-direction) * 24 : 0,
y: verticalPreview ? 0 : (-direction) * 24, opacity: 0 }) }
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out')
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -304,14 +287,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
return
}
if (verticalPreview)
{
setDesktop (true)
setDensity ('auto')
measure (moreVsbl ? -1 : activeIdx)
return
}
// Measure the same desktop elements while the mobile layout is active.
layout.setAttribute ('data-measuring', '')
const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
@@ -376,8 +351,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
removeEventListener ('resize', schedule)
document.fonts?.removeEventListener ('loadingdone', schedule)
}
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales,
menuLabels, verticalPreview])
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
useEffect (() => {
if (desktop == null || layoutReady)
@@ -404,8 +378,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
measure (moreVsbl ? -1 : activeIdx)
if (!(desktop))
setMoreVsbl (false)
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode,
menuLabels, verticalPreview])
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId)
@@ -419,7 +392,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const update = () => {
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
setSubmenuWidth (Math.ceil (content.getBoundingClientRect ().width))
}
const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
@@ -431,7 +403,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
observer?.disconnect ()
removeEventListener ('resize', update)
}
}, [activeIdx, moreVsbl, desktop, density, locale, verticalPreview])
}, [activeIdx, moreVsbl, desktop, density, locale])
useEffect (() => {
setMenuOpen (false)
@@ -507,11 +479,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
className={cn ('absolute inset-y-0 h-12',
'top-nav-highlight',
highlightTransitionClass)}
style={{ width: verticalPreview ? '100%' : hl.width,
height: verticalPreview ? hl.width : undefined,
transform: verticalPreview
? `translateY(${ hl.left }px)`
: `translateX(${ hl.left }px)`,
style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/>
{visibleMenu.map ((item, i) => (
@@ -560,18 +529,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
<TopNavUser user={user}/>
{localeSelector}
{verticalPreview && (
<span className="vertical-preview-sample" lang="mn-Mong">
ᠮᠣᠩᠭᠣᠯ
</span>)}
</div>
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
{menuOpen && localeSelector}
{verticalPreview && (
<span className="vertical-preview-sample" lang="mn-Mong">
ᠮᠣᠩᠭᠣᠯ
</span>)}
<button
type="button"
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
@@ -593,9 +554,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
layout
className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu"
animate={verticalPreview && desktop
? { width: submenuWidth, height: '100%' }
: { width: 'auto', height: desktop == null ? 'auto' : submenuHeight }}
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
@@ -607,8 +566,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
onLayoutAnimationComplete={() => {
measure (highlightTargetRef.current)
}}>
<div ref={submenuContentRef}
className={cn ('top-nav-submenu-content', moreVsbl && 'top-nav-more-menu')}>
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
{moreVsbl
? (
moreMenu.map ((item, i) => (
@@ -618,11 +576,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
{...((item.visible ?? true)
? { layoutId: `menu-${ menu.indexOf (item) }` }
: (animationsOff ? { }
: { initial: {
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 },
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: {
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 } }))}
exit: { x: 40, y: -40, opacity: 0 } }))}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
className="z-10 min-w-0 px-3 py-2 font-bold">
<h2>
@@ -643,7 +599,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
transition={topNavTransition}
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: submenuItemMotion)}>
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{subItem.component}
</motion.div>)
: (
@@ -653,7 +612,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
transition={topNavTransition}
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: submenuItemMotion)}>
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
@@ -671,8 +633,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<div className="relative min-h-[40px]">
{animationsOff
? (
<div className="top-nav-submenu-items flex min-h-[40px]
flex-wrap items-center px-3">
<div className="flex min-h-[40px] flex-wrap items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
@@ -701,9 +662,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<motion.div
key={activeIdx}
custom={dir}
variants={submenuVariants}
className="top-nav-submenu-items relative flex min-h-[40px]
flex-wrap items-center px-3"
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="relative flex min-h-[40px] flex-wrap items-center px-3"
initial="enter"
animate="centre"
exit="exit"
+5 -58
ファイルの表示
@@ -1,10 +1,7 @@
import { motion } from 'framer-motion'
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useVerticalPreview } from '@/lib/useLocale'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
@@ -12,71 +9,21 @@ import type { FC, ReactNode } from 'react'
type Props = {
children: ReactNode
className?: string
animateLayout?: boolean
verticalContent?: boolean }
animateLayout?: boolean }
const MainArea: FC<Props> = (
{ children, className, animateLayout = true, verticalContent = false },
) => {
const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
const behaviourSettings = useClientBehaviourSettings ()
const verticalPreview = useVerticalPreview ()
const horizontal = verticalPreview && verticalContent
const { pathname, search } = useLocation ()
const rootRef = useRef<HTMLElement | null> (null)
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } })
useEffect (() => {
if (horizontal && rootRef.current != null)
rootRef.current.scrollLeft = 0
}, [horizontal, pathname, search])
useEffect (() => {
const root = rootRef.current
if (!horizontal || root == null)
return
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey || event.shiftKey
|| event.deltaX !== 0 || event.deltaY === 0 || event.defaultPrevented)
return
// Nested tables, forms and code blocks retain their own scrolling.
let node = event.target instanceof HTMLElement ? event.target : null
while (node != null && node !== root)
{
if (node.matches ('input, select, textarea, [contenteditable="true"]'))
return
const style = getComputedStyle (node)
if ((/auto|scroll/.test (style.overflowY) && node.scrollHeight > node.clientHeight)
|| (/auto|scroll/.test (style.overflowX) && node.scrollWidth > node.clientWidth))
return
node = node.parentElement
}
const unit = event.deltaMode === 1 ? 16 : (event.deltaMode === 2 ? root.clientWidth : 1)
const delta = event.deltaY * unit
if ((delta < 0 && root.scrollLeft <= 0)
|| (delta > 0 && root.scrollLeft + root.clientWidth >= root.scrollWidth))
return
event.preventDefault ()
root.scrollLeft += delta
}
root.addEventListener ('wheel', handleWheel, { passive: false })
return () => root.removeEventListener ('wheel', handleWheel)
}, [horizontal])
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
return (
<motion.main
ref={rootRef}
data-vertical-content={horizontal || undefined}
tabIndex={horizontal ? 0 : undefined}
transition={{ layout: layoutTransition }}
className={cn ('main-area flex-1 overflow-y-auto p-4', className)}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
{children}
</motion.main>)
+103 -126
ファイルの表示
@@ -1,14 +1,13 @@
import { motion } from 'framer-motion'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { useVerticalPreview } from '@/lib/useLocale'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
type SidebarSide = 'left' | 'right'
@@ -24,12 +23,12 @@ type Props = {
type ResizeState = {
pointerId: number
startSize: number
startWidth: number
startX: number
startY: number
verticalPreview: boolean
side: SidebarSide }
type PendingPressState = ResizeState & { startY: number }
const DEFAULT_SIDEBAR_WIDTH = 256
const MIN_SIDEBAR_WIDTH = 192
const MIN_MAIN_WIDTH = 360
@@ -38,97 +37,84 @@ const LONG_PRESS_DELAY_MS = 350
const TOUCH_MOVE_THRESHOLD_PX = 8
const storageKeyForSidebar = (
sidebarKey: string,
side: SidebarSide,
verticalPreview = false,
): string =>
`sidebar:${ sidebarKey }:${ side }:${ verticalPreview ? 'height' : 'width' }`
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string =>
`sidebar:${ sidebarKey }:${ side }:width`
const getSidebarMaxWidth = (containerSize: number): number => {
const getSidebarMaxWidth = (containerWidth: number): number => {
const viewportWidth =
typeof window === 'undefined' ? containerSize : window.innerWidth
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const containerMaxWidth = Math.floor (containerSize - MIN_MAIN_WIDTH)
const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH)
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
}
const readStoredSidebarSize = (
const readStoredSidebarWidth = (
sidebarKey: string,
side: SidebarSide,
verticalPreview: boolean,
): number => {
side: SidebarSide): number => {
if (typeof window === 'undefined')
return DEFAULT_SIDEBAR_WIDTH
try
{
const value = window.localStorage.getItem (
storageKeyForSidebar (sidebarKey, side, verticalPreview))
const size = value == null ? Number.NaN : Number (value)
return Number.isFinite (size) ? size : DEFAULT_SIDEBAR_WIDTH
}
catch
{
return DEFAULT_SIDEBAR_WIDTH
}
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
const width = value == null ? Number.NaN : Number (value)
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
}
const SidebarComponent: FC<Props> = (
{ children, className, maxWidth, maxWidthRatio, animateLayout = true,
onWidthChange, sidebarKey, side = 'left' },
) => {
const SidebarComponent: FC<Props> = ({
children,
className,
maxWidth,
maxWidthRatio,
animateLayout = true,
onWidthChange,
sidebarKey,
side = 'left',
}) => {
const { message } = useUITranslation ('shared')
const behaviourSettings = useClientBehaviourSettings ()
const verticalPreview = useVerticalPreview ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } })
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth)
const maxWidthRatioRef = useRef (maxWidthRatio)
const getContainerSize = useCallback (() => {
const container = rootRef.current?.closest ('[data-sidebar-container]')
return verticalPreview
? container?.clientHeight ?? window.innerHeight
: container?.clientWidth ?? window.innerWidth
}, [verticalPreview])
const getClampedSize = useCallback ((
nextSize: number,
containerSize: number,
): number => {
if (verticalPreview)
{
const maximum = Math.max (64, Math.min (
window.innerHeight * .6, containerSize - 160))
return Math.min (maximum, Math.max (64, nextSize))
}
const getContainerWidth = useCallback (() => {
const containerWidth = rootRef.current
?.closest ('[data-sidebar-container]')
?.clientWidth
?? window.innerWidth
return containerWidth
}, [])
const getClampedWidth = useCallback ((
nextWidth: number,
containerWidth: number): number => {
const viewportWidth =
typeof window === 'undefined' ? containerSize : window.innerWidth
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const ratioMaxWidth = maxWidthRatioRef.current == null
? Infinity : Math.floor (containerSize * maxWidthRatioRef.current)
? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current)
const configuredMaxWidth = maxWidthRef.current == null
? getSidebarMaxWidth (containerSize)
? getSidebarMaxWidth (containerWidth)
: Math.max (MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current))
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
Math.min (ratioMaxWidth, configuredMaxWidth))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextSize))
}, [verticalPreview])
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
const [size, setSize] = useState (DEFAULT_SIDEBAR_WIDTH)
const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
const [resizing, setResizing] = useState (false)
const sizeRef = useRef (size)
const widthRef = useRef (width)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const pendingPressRef = useRef<ResizeState | null> (null)
const pendingPressRef = useRef<PendingPressState | null> (null)
const resizeRef = useRef<ResizeState | null> (null)
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
@@ -162,7 +148,7 @@ const SidebarComponent: FC<Props> = (
userSelect: document.body.style.userSelect }
}
document.body.style.cursor = state.verticalPreview ? 'row-resize' : 'col-resize'
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [clearLongPressTimer])
@@ -173,50 +159,43 @@ const SidebarComponent: FC<Props> = (
const state = resizeRef.current
resizeRef.current = null
try
{
if (state != null)
window.localStorage.setItem (
storageKeyForSidebar (sidebarKey, state.side, state.verticalPreview),
String (sizeRef.current))
}
catch
{
// Resizing remains usable when browser storage is unavailable.
}
if (state != null)
window.localStorage.setItem (
storageKeyForSidebar (sidebarKey, state.side),
String (widthRef.current))
setResizing (false)
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
const applySize = useCallback ((nextSize: number) => {
if (nextSize === sizeRef.current)
const applyWidth = useCallback ((nextWidth: number) => {
if (nextWidth === widthRef.current)
return
sizeRef.current = nextSize
setSize (nextSize)
widthRef.current = nextWidth
setWidth (nextWidth)
}, [])
useEffect (() => {
maxWidthRef.current = maxWidth
maxWidthRatioRef.current = maxWidthRatio
const containerSize = getContainerSize ()
const nextSize = getClampedSize (sizeRef.current, containerSize)
applySize (nextSize)
}, [applySize, getClampedSize, getContainerSize, maxWidth, maxWidthRatio])
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio])
const updateSize = useCallback ((clientX: number, clientY: number) => {
const updateWidth = useCallback ((clientX: number) => {
const state = resizeRef.current
if (state == null)
return
const containerSize = getContainerSize ()
const delta = state.verticalPreview
? clientY - state.startY
: (state.side === 'left' ? clientX - state.startX : state.startX - clientX)
const nextSize = getClampedSize (state.startSize + delta, containerSize)
applySize (nextSize)
}, [applySize, getClampedSize, getContainerSize])
const containerWidth = getContainerWidth ()
const delta = state.side === 'left'
? clientX - state.startX
: state.startX - clientX
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
@@ -232,10 +211,8 @@ const SidebarComponent: FC<Props> = (
const state = {
pointerId: ev.pointerId,
side,
startSize: sizeRef.current,
startX: ev.clientX,
startY: ev.clientY,
verticalPreview }
startWidth: widthRef.current,
startX: ev.clientX }
if (ev.pointerType === 'mouse')
{
@@ -243,7 +220,7 @@ const SidebarComponent: FC<Props> = (
return
}
pendingPressRef.current = state
pendingPressRef.current = { ...state, startY: ev.clientY }
clearLongPressTimer ()
longPressTimerRef.current = setTimeout (() => {
if (pendingPressRef.current != null)
@@ -257,7 +234,7 @@ const SidebarComponent: FC<Props> = (
if (resizeRef.current != null)
{
ev.preventDefault ()
updateSize (ev.clientX, ev.clientY)
updateWidth (ev.clientX)
return
}
@@ -285,58 +262,58 @@ const SidebarComponent: FC<Props> = (
finishResize ()
}
const resetSize = useCallback (() => {
const containerSize = getContainerSize ()
const nextSize = getClampedSize (DEFAULT_SIDEBAR_WIDTH, containerSize)
applySize (nextSize)
window.localStorage.removeItem (
storageKeyForSidebar (sidebarKey, side, verticalPreview))
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
const resetWidth = useCallback (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
applyWidth (nextWidth)
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
ev.preventDefault ()
ev.stopPropagation ()
resetSize ()
resetWidth ()
}
useEffect (() => {
const containerSize = getContainerSize ()
const nextSize = getClampedSize (
readStoredSidebarSize (sidebarKey, side, verticalPreview),
containerSize)
applySize (nextSize)
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
useEffect (() => {
const containerSize = getContainerSize ()
const nextSize = getClampedSize (sizeRef.current, containerSize)
applySize (nextSize)
}, [applySize, getClampedSize, getContainerSize])
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
if (typeof window === 'undefined')
return
const handleResize = () => {
const containerSize = getContainerSize ()
const nextSize = getClampedSize (sizeRef.current, containerSize)
applySize (nextSize)
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}
window.addEventListener ('resize', handleResize)
return () => window.removeEventListener ('resize', handleResize)
}, [applySize, getClampedSize, getContainerSize])
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
if (!verticalPreview)
onWidthChange?.(size)
}, [onWidthChange, size, verticalPreview])
onWidthChange?.(width)
}, [onWidthChange, width])
useEffect (() => () => finishResize (), [finishResize, verticalPreview])
useEffect (() => () => {
clearLongPressTimer ()
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle])
const style = {
'--sidebar-width': `${ size }px`,
'--sidebar-height': `${ size }px` } as CSSProperties
'--sidebar-width': `${ width }px` } as CSSProperties
return (
<motion.div
@@ -345,7 +322,7 @@ const SidebarComponent: FC<Props> = (
transition={{ layout: layoutTransition }}
style={style}
className={cn (
'sidebar-panel relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
className)}>
<Helmet>
<style>
@@ -368,16 +345,16 @@ const SidebarComponent: FC<Props> = (
<div
role="separator"
aria-orientation={verticalPreview ? 'horizontal' : 'vertical'}
aria-label={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').text}
lang={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').language}
aria-orientation="vertical"
aria-label={message ('sidebarWidth').text}
lang={message ('sidebarWidth').language}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick}
className={cn (
'sidebar-resize hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
'touch-none select-none',
side === 'left' ? '-right-2' : '-left-2',
'before:absolute before:top-0 before:bottom-0 before:w-px',
+3 -7
ファイルの表示
@@ -9,8 +9,7 @@ import { createStaticHandler, createStaticRouter,
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
import { apiGet, isApiError } from '@/lib/api'
import { createUII18n } from '@/lib/i18n'
import { fetchLocales, isLocaleCode, isVerticalPreviewPath,
legacyLocalePath } from '@/lib/locales'
import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { withRenderContext } from '@/lib/renderContext.server'
@@ -73,15 +72,13 @@ export const render = async (
pathname => resolvePageRoute (pathname) != null)
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
const segment = pathname.split ('/')[1]
const verticalPreview = isVerticalPreviewPath (pathname)
const known = resolvePageRoute (pathname)
if (legacy != null && known != null)
return { status: 308, location: legacy }
const locale = isLocaleCode (segment) ? segment : 'ja'
const routePrefix = verticalPreview ? 'mn-Mong' : locale
if (known?.route.handle?.redirectTo != null)
return { status: 308,
location: `/${routePrefix}${known.route.handle.redirectTo}${url.search}` }
location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
const requestContext: RenderContext & { status: number } = {
pathname: url.pathname, origin, apiBaseURL,
...authentication, status: 200 }
@@ -109,8 +106,7 @@ export const render = async (
}
}
const prepare = known?.route.handle?.prepare
const routeLocaleValid = isLocaleCode (segment) || verticalPreview
let status = known == null || !active || !routeLocaleValid ? 404 : 200
let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200
if (status === 200 && known?.route.handle?.authentication != null
&& client.getQueryData (sessionKeys.current) == null)
status = 401
-500
ファイルの表示
@@ -625,503 +625,3 @@ body
white-space: nowrap;
will-change: transform;
}
/* Loaded only by the development-only sample, with the OFL alongside the font. */
@font-face
{
font-family: 'BTRC Mongolian Preview';
src: url('/fonts/noto-sans-mongolian/NotoSansMongolian-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Development-only /mn-Mong layout. Unannotated pages retain horizontal text. */
[data-vertical-preview]
{
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-vertical-preview] > :not(.top-nav-layout)
{
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
}
[data-vertical-preview] [data-sidebar-container]
{
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-vertical-preview] .main-area
{
min-width: 0;
min-height: 0;
overscroll-behavior: contain;
}
[data-vertical-preview] .main-area[data-vertical-content]
{
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
}
[data-vertical-preview] .sidebar-panel
{
width: 100%;
height: var(--sidebar-height);
min-height: 0;
flex: none;
}
[data-vertical-preview] .sidebar
{
display: flex;
align-items: flex-start;
gap: 1rem;
direction: ltr;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior: contain;
}
[data-vertical-preview] .sidebar > *
{
flex-shrink: 0;
}
[data-vertical-preview] .sidebar > form
{
width: 16rem;
height: 100%;
overflow-y: auto;
}
[data-vertical-preview] .sidebar > form > ul
{
position: relative;
}
[data-vertical-preview] .sidebar > div
{
writing-mode: vertical-lr;
text-orientation: mixed;
height: 100%;
margin: 0;
}
[data-vertical-preview] .sidebar :is(h2, h3, ul)
{
margin: 0;
margin-block-end: 1rem;
}
[data-vertical-preview] .sidebar-resize
{
display: block;
inset: auto 0 -.5rem;
width: 100%;
height: 1rem;
cursor: row-resize;
}
[data-vertical-preview] .sidebar-resize::before
{
inset: 50% 0 auto;
width: 100%;
height: 1px;
}
[data-vertical-preview] .vertical-preview-sample
{
writing-mode: vertical-lr;
text-orientation: mixed;
font-family: 'BTRC Mongolian Preview', 'Mongolian Baiti', sans-serif;
font-size: 1.25rem;
line-height: 1.5;
flex-shrink: 0;
}
/* The post grid fills each column downwards before continuing to the right. */
[data-vertical-preview] .posts-main-area > div
{
display: flex;
height: 100%;
width: max-content;
margin: 0;
gap: 1rem;
}
[data-vertical-preview] .posts-main-area > div > div:first-child
{
flex-direction: column;
justify-content: flex-start;
writing-mode: vertical-lr;
text-orientation: mixed;
}
[data-vertical-preview] .posts-main-area > div > div:last-child
{
display: flex;
width: max-content;
height: 100%;
margin: 0;
gap: 1rem;
}
[data-vertical-preview] .main-area[data-vertical-content] .post-list
{
display: grid;
grid-auto-flow: column;
grid-auto-columns: 10rem;
grid-template-rows: repeat(auto-fill, min(10rem, 100%));
flex: none;
width: max-content;
height: 100%;
writing-mode: horizontal-tb;
}
[data-vertical-preview] .main-area[data-vertical-content] .post-list > a
{
height: 100%;
}
[data-vertical-preview] .main-area[data-vertical-content] nav
{
flex: none;
align-self: flex-start;
margin: 0;
writing-mode: horizontal-tb;
}
[data-vertical-preview] .tags-main-area
{
display: flex;
align-items: stretch;
gap: 1.5rem;
}
[data-vertical-preview] .tag-list-filter
{
flex: none;
width: min(24rem, 75vw);
max-width: none;
overflow-y: auto;
}
[data-vertical-preview] .tag-list-filter h1
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 8rem;
margin-block: 0;
}
[data-vertical-preview] .tag-list-filter input[type='number']
{
max-width: 7rem;
}
[data-vertical-preview] .tag-list-results
{
display: flex;
flex: none;
gap: 1rem;
max-width: none;
margin: 0;
}
[data-vertical-preview] .tag-list-table
{
max-width: none;
overflow-x: visible;
overflow-y: auto;
}
[data-vertical-preview] .tag-list-table table
{
writing-mode: vertical-lr;
text-orientation: mixed;
inline-size: max-content;
block-size: max-content;
min-width: 0;
}
[data-vertical-preview] .tag-list-table col
{
width: auto;
}
[data-vertical-preview] .tag-list-table :is(th, td)
{
min-inline-size: 2rem;
max-inline-size: 16rem;
text-align: start;
}
[data-vertical-preview] .tag-list-table thead
{
border-bottom: 0;
border-block-end: 2px solid currentColor;
}
[data-vertical-preview] .tag-list-table td:nth-child(2),
[data-vertical-preview] .tag-list-table time
{
writing-mode: horizontal-tb;
white-space: nowrap;
}
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee,
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__static
{
white-space: normal;
overflow: visible;
text-overflow: clip;
max-inline-size: 16rem;
opacity: 1;
}
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__animated
{
display: none;
}
/* Prose flows through the viewport height and grows into columns on the right. */
[data-vertical-preview] .wiki-main-area
{
display: flex;
gap: 1rem;
}
[data-vertical-preview] .wiki-main-area > div
{
flex: none;
writing-mode: vertical-lr;
text-orientation: mixed;
margin: 0;
}
[data-vertical-preview] .wiki-main-area > div > span
{
writing-mode: horizontal-tb;
}
[data-vertical-preview] .wiki-main-area article,
[data-vertical-preview] .posts-main-area .localised-document
{
writing-mode: vertical-lr;
text-orientation: mixed;
inline-size: 100%;
block-size: max-content;
max-width: none;
margin: 0;
flex: none;
}
[data-vertical-preview] .main-area[data-vertical-content] .localised-document
{
max-width: none;
overflow: visible;
}
[data-vertical-preview] .wiki-main-area article :is(h1, h2, h3, h4, p, ul, ol, blockquote)
{
margin: 0;
margin-block: 1em;
}
[data-vertical-preview] .wiki-main-area article :is(ul, ol)
{
padding: 0;
padding-inline-start: 1.5em;
}
[data-vertical-preview] .wiki-main-area article :is(img, video, iframe, svg)
{
writing-mode: horizontal-tb;
max-height: 100%;
max-width: none;
object-fit: contain;
}
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose),
[data-vertical-preview] .main-area[data-vertical-content] :is(code, time, input, select, textarea)
{
writing-mode: horizontal-tb;
text-orientation: mixed;
}
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose)
{
display: inline-block;
max-height: 100%;
max-width: min(48rem, 80vw);
overflow: auto;
vertical-align: top;
}
[data-vertical-preview] .wiki-main-area article .not-prose
{
width: max-content;
height: 100%;
}
[data-vertical-preview] .wiki-main-area .not-prose > div
{
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
}
[data-vertical-preview] .wiki-main-area .not-prose > div > div:last-child
{
flex: 1;
min-height: 0;
}
@media (min-width: 768px)
{
[data-vertical-preview].app-layout
{
flex-direction: row;
}
[data-vertical-preview] .top-nav-layout
{
display: flex;
width: max-content;
max-width: 50vw;
height: 100%;
}
[data-vertical-preview] .top-nav-bar
{
flex-direction: column;
flex: none;
width: 7rem;
height: 100%;
padding: .5rem;
gap: .5rem;
}
[data-vertical-preview] .top-nav-primary
{
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
height: auto;
overflow-y: auto;
}
[data-vertical-preview] .top-nav-brand-link
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 9rem;
white-space: normal;
margin-block: .5rem;
}
[data-vertical-preview] .top-nav-desktop-menu
{
flex-direction: column;
width: 100%;
height: auto;
}
[data-vertical-preview] .top-nav-desktop-menu > div:not(.top-nav-highlight)
{
width: 100%;
}
[data-vertical-preview] .top-nav-desktop-menu .top-nav-menu-link
{
justify-content: center;
width: 100%;
min-height: 3rem;
height: auto;
padding: .5rem;
}
[data-vertical-preview] .top-nav-menu-link > :not(svg, select),
[data-vertical-preview] .top-nav-more-menu h2
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 9rem;
white-space: normal;
}
[data-vertical-preview] .top-nav-highlight
{
inset-inline-start: 0;
}
[data-vertical-preview] .top-nav-account
{
flex-direction: column;
width: 100%;
margin: 0;
}
[data-vertical-preview] .top-nav-account .top-nav-user-desktop
{
max-width: 100%;
margin: 0;
padding: 0;
}
[data-vertical-preview] .top-nav-locale-name
{
display: inline;
writing-mode: horizontal-tb;
}
[data-vertical-preview] .top-nav-desktop-submenu
{
min-width: 0;
height: 100%;
}
[data-vertical-preview] .top-nav-submenu-content
{
width: max-content;
max-width: 35vw;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
[data-vertical-preview] :is(.top-nav-submenu-items, .top-nav-more-links)
{
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
}
[data-vertical-preview] .top-nav-submenu-items .top-nav-menu-link
{
justify-content: center;
}
}
@media (max-width: 767px)
{
[data-vertical-preview] .top-nav-layout
{
max-height: 50dvh;
overflow-y: auto;
}
}
+5 -13
ファイルの表示
@@ -28,11 +28,6 @@ export const localeFromPath = (pathname: string): LocaleCode | null => {
}
// A development-only layout route; API and dictionary locales stay unchanged.
export const isVerticalPreviewPath = (pathname: string): boolean =>
import.meta.env.DEV && /^\/mn-Mong(?:[/?#]|$)/.test (pathname)
export const resolveViewingLocale = (
pathname: string,
routeLocale?: unknown,
@@ -45,22 +40,20 @@ export const currentViewingLocale = (): LocaleCode =>
export const unlocalisedPath = (pathname: string): string => {
const locale = isVerticalPreviewPath (pathname) ? 'mn-Mong' : localeFromPath (pathname)
const locale = localeFromPath (pathname)
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
}
export const localiseTo = (to: To, locale: LocaleCode, viewingPath = ''): To => {
export const localiseTo = (to: To, locale: LocaleCode): To => {
const pathname = typeof to === 'string' ? to : to.pathname
if (pathname == null
|| !(pathname.startsWith ('/'))
|| pathname.startsWith ('//')
|| localeFromPath (pathname) != null
|| isVerticalPreviewPath (pathname))
|| localeFromPath (pathname) != null)
return to
const prefix = isVerticalPreviewPath (viewingPath) ? 'mn-Mong' : locale
const localised = `/${ prefix }${ pathname }`
const localised = `/${ locale }${ pathname }`
return typeof to === 'string' ? localised : { ...to, pathname: localised }
}
@@ -80,8 +73,7 @@ export const legacyLocalePath = (
location: { pathname: string; search: string; hash: string },
isPage: (pathname: string) => boolean = () => false,
): string | null => {
if (localeFromPath (location.pathname) != null
|| isVerticalPreviewPath (location.pathname) || !isPage (location.pathname))
if (localeFromPath (location.pathname) != null || !isPage (location.pathname))
return null
return switchLocalePath (location, automaticallySelectedLocale ())
}
+3 -8
ファイルの表示
@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { isVerticalPreviewPath, localiseTo, resolveViewingLocale } from '@/lib/locales'
import { localiseTo, resolveViewingLocale } from '@/lib/locales'
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
@@ -18,15 +18,10 @@ export const useLocale = (): LocaleCode => {
export const useLocaleNavigate = (): NavigateFunction => {
const navigate = useNavigate ()
const locale = useLocale ()
const { pathname } = useLocation ()
return useCallback ((to: To | number, options?: NavigateOptions) => {
if (typeof to === 'number')
navigate (to)
else
navigate (localiseTo (to, locale, pathname), options)
}, [locale, navigate, pathname])
navigate (localiseTo (to, locale), options)
}, [locale, navigate])
}
export const useVerticalPreview = (): boolean =>
isVerticalPreviewPath (useLocation ().pathname)
+1 -1
ファイルの表示
@@ -146,7 +146,7 @@ const PostListPage: FC = () => {
JSON.stringify (statesToSave))
}}/>
<MainArea className="posts-main-area" verticalContent>
<MainArea>
<TabGroup
rightAddon={
<label className="flex items-center gap-2 text-xs text-muted-foreground">
+4 -4
ファイルの表示
@@ -220,10 +220,10 @@ const TagListPage: FC = () => {
}), [page, totalPages, location.search]))
return (
<MainArea className="tags-main-area min-w-0" verticalContent>
<MainArea className="min-w-0">
<PageHead title={message ('title')}/>
<div className="tag-list-filter max-w-xl">
<div className="max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>{label ('title')}</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -358,8 +358,8 @@ const TagListPage: FC = () => {
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{loading ? label ('common:loading') : (results.length > 0 ? (
<div className="tag-list-results mt-4 min-w-0 max-w-full">
<div className="tag-list-table min-w-0 max-w-full overflow-x-auto">
<div className="mt-4 min-w-0 max-w-full">
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="w-full min-w-[2000px] table-auto border-collapse">
<colgroup>
<col className="w-72"/>
+1 -1
ファイルの表示
@@ -114,7 +114,7 @@ const WikiDetailPage: FC = () => {
return <NotFound/>
return (
<MainArea className="wiki-main-area" verticalContent>
<MainArea>
<PageHead title={{
text: `${displayTitle}${deprecated
? message ('labels.deprecatedSuffix').text : ''} Wiki`,