コミットを比較
3
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
971b2632b0 | ||
|
|
ad04c03e14 | ||
|
|
2197a9192b |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -44,6 +44,7 @@ RSpec.configure do |config|
|
||||
end
|
||||
|
||||
config.include TestRecords
|
||||
config.include EventRecords
|
||||
config.include RakeTaskHelper
|
||||
|
||||
# FactoryBot の create / create_list を使へるやぅにする
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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 }"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
バイナリファイルは表示されません。
@@ -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/>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする