コミットを比較

..
39 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 83563e36ef #428 2026-10-19 23:43:28 +09:00
みてるぞ 4ab13c4244 #428 2026-10-19 23:08:59 +09:00
みてるぞ d365406019 #428 2026-10-19 20:56:07 +09:00
みてるぞ 4e656bad55 #428 2026-10-19 12:46:57 +09:00
みてるぞ 0622d5ba0b #428 2026-10-19 12:30:59 +09:00
みてるぞ 716428b047 #428 2026-10-19 08:42:23 +09:00
みてるぞ aa11d09fc3 #428 2026-10-19 00:46:03 +09:00
みてるぞ d5046a17c9 #428 2026-10-19 00:28:16 +09:00
みてるぞ a86129006d #428 2026-10-19 00:03:55 +09:00
みてるぞ 112d75c3bb #428 2026-10-18 23:31:17 +09:00
みてるぞ 8bcc61c0f8 #428 2026-10-18 23:26:55 +09:00
みてるぞ e0970a81a4 #428 2026-10-18 22:14:40 +09:00
みてるぞ 22f4a0af40 #428 2026-10-18 19:03:02 +09:00
みてるぞ 912b941f90 #428 2026-10-18 18:54:59 +09:00
みてるぞ 569b9234c6 #428 2026-10-18 18:28:05 +09:00
みてるぞ e187331af4 #428 2026-10-18 17:41:37 +09:00
みてるぞ b3b00e170f #428 2026-10-18 17:05:36 +09:00
みてるぞ c9628a120d #428 2026-10-18 16:03:30 +09:00
みてるぞ 525f6883c9 #428 2026-10-18 15:50:53 +09:00
みてるぞ 20085d5017 #428 2026-10-18 15:36:09 +09:00
みてるぞ bbee83e35b #428 2026-10-18 13:56:03 +09:00
みてるぞ 1dcb790f44 #428 2026-10-18 12:57:09 +09:00
みてるぞ b3c42b8eb3 #428 2026-10-18 12:32:30 +09:00
みてるぞ a7e350d28e #428 2026-10-18 12:18:52 +09:00
みてるぞ d9bfac8392 #428 2026-10-18 03:13:31 +09:00
みてるぞ 4c1178dab5 #428 2026-10-18 03:06:53 +09:00
みてるぞ 2d1a8b4730 #428 2026-10-18 02:45:26 +09:00
みてるぞ 61d7e6f44e #428 2026-10-18 02:31:03 +09:00
みてるぞ 753a150128 #428 2026-10-18 02:22:00 +09:00
みてるぞ cd948611af #428 2026-10-18 01:59:59 +09:00
みてるぞ 34a4ffefb4 #428 2026-10-18 01:52:09 +09:00
みてるぞ eda81cfa89 #428 2026-10-18 01:39:03 +09:00
みてるぞ 0c1f459d4e #428 2026-10-18 01:09:31 +09:00
みてるぞ 9be0c7b1d9 #428 2026-10-18 00:20:30 +09:00
みてるぞ 63860c94af #428 2026-10-18 00:07:07 +09:00
みてるぞ 851d5e8e09 #428 2026-10-17 23:49:20 +09:00
みてるぞ 91296f7477 #428 2026-10-17 23:01:24 +09:00
みてるぞ f063fc54bb #428 2026-10-17 19:59:18 +09:00
みてるぞ b945ceef60 #428 2026-10-17 16:47:08 +09:00
288個のファイルの変更、13103行の追加、3413行の削除
+48 -2
ファイルの表示
@@ -1,22 +1,52 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
include ActionController::Cookies
include ActionController::RequestForgeryProtection
class UnsupportedLocale < StandardError
;
end
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
rescue_from(UnsupportedLocale) { head :not_found }
before_action :reject_banned_ip_address! before_action :reject_banned_ip_address!
before_action :authenticate_user before_action :authenticate_user
before_action :reject_banned_user! before_action :reject_banned_user!
before_action :validate_explicit_locale!
protect_from_forgery with: :exception
self.forgery_protection_origin_check = true
rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf
after_action :prevent_authenticated_caching
def current_user = @current_user def current_user = @current_user
private private
def reject_invalid_csrf
response.headers['X-CSRF-Invalid'] = '1'
response.headers['Cache-Control'] = 'private, no-store'
head :forbidden
end
def authenticate_user def authenticate_user
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE'] code = session[:inheritance_code]
return if code.blank? return if code.blank?
@current_user = User.find_by(inheritance_code: code) @current_user = User.find_by(inheritance_code: code)
end end
def prevent_authenticated_caching
response.headers['Cache-Control'] = 'private, no-store' if current_user
end
def establish_user_session user
reset_session
session[:inheritance_code] = user.inheritance_code
@current_user = user
response.headers['Cache-Control'] = 'private, no-store'
end
def bool? key, default: false def bool? key, default: false
return default if params[key].nil? return default if params[key].nil?
@@ -93,5 +123,21 @@ class ApplicationController < ActionController::API
value value
end end
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese def resolve_locale!
return @resolved_locale if defined?(@resolved_locale)
code = params.fetch(:locale, 'ja')
unless code.is_a?(String) && Locale.codes.value?(code)
raise UnsupportedLocale
end
locale = Locale.find_by(code:)
raise UnsupportedLocale unless locale && locale.deprecated_at.nil?
@resolved_locale = locale
end
def validate_explicit_locale!
resolve_locale! if params.key?(:locale)
end
end end
+10
ファイルの表示
@@ -0,0 +1,10 @@
class LocalesController < ApplicationController
def index
locales = Locale.where(deprecated_at: nil).order(:created_at).map do |locale|
{ code: locale.code_before_type_cast, name: locale.name }
end
response.headers['Cache-Control'] = 'no-store'
render json: { locales: }
end
end
+3 -3
ファイルの表示
@@ -24,7 +24,7 @@ class NicoTagsController < ApplicationController
q = q =
ExternalTag ExternalTag
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " +
'ON post_tag_max.external_tag_id = external_tags.id') 'ON post_tag_max.external_tag_id = external_tags.id')
.includes(linked_tags: [:tag_names, :tag_descriptions]) .includes(linked_tags: [:tag_names, :tag_descriptions])
if name if name
@@ -37,7 +37,7 @@ class NicoTagsController < ApplicationController
linked_tag_ids = linked_tag_ids =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.where(tag_names: { language_code: 'ja', primary_flg: true }) .where(tag_names: { language_code: locale.language_code, primary_flg: true })
.where('tag_names.name LIKE ?', "%#{ linked_tag }%") .where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id) .pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id) linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
@@ -45,7 +45,7 @@ class NicoTagsController < ApplicationController
end end
if link_status.in?(['linked', 'unlinked']) if link_status.in?(['linked', 'unlinked'])
exists_sql = exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \ 'EXISTS (SELECT 1 FROM nico_tag_relations ' +
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)' 'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }") q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end end
+33 -12
ファイルの表示
@@ -1,5 +1,6 @@
class PostVersionsController < ApplicationController class PostVersionsController < ApplicationController
def index def index
locale = resolve_locale!
post_id = params[:post].presence post_id = params[:post].presence
tag_id = params[:tag].presence&.to_i tag_id = params[:tag].presence&.to_i
external_tag_id = params[:external_tag].presence&.to_i external_tag_id = params[:external_tag].presence&.to_i
@@ -45,12 +46,12 @@ class PostVersionsController < ApplicationController
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
render json: { versions: serialise_versions(versions), count: } render json: { versions: serialise_versions(versions, locale.language_code), count: }
end end
private private
def serialise_versions rows def serialise_versions rows, language_code
rows = rows.to_a rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
@@ -58,12 +59,16 @@ class PostVersionsController < ApplicationController
[normalise_json(row.tags_json), [normalise_json(row.tags_json),
normalise_json(row.attributes['prev_tags_json']) || []] normalise_json(row.attributes['prev_tags_json']) || []]
} }
tag_ids = snapshots.flatten.filter_map { _1['tag_id'] }.uniq
tag_names = TagName.where(tag_id: tag_ids, language_code:, primary_flg: true)
.pluck(:tag_id, :name).to_h
external_tag_names = external_tag_names_for(snapshots) external_tag_names = external_tag_names_for(snapshots)
rows.map do |row| rows.map do |row|
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names) cur_tags = snapshot_tag_literals(
normalise_json(row.tags_json), tag_names, external_tag_names)
prev_tags = snapshot_tag_literals( prev_tags = snapshot_tag_literals(
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names) normalise_json(row.attributes['prev_tags_json']) || [], tag_names, external_tag_names)
{ post_id: row.post_id, { post_id: row.post_id,
version_no: row.version_no, version_no: row.version_no,
@@ -75,6 +80,7 @@ class PostVersionsController < ApplicationController
prev: row.attributes['prev_thumbnail_base'] }, prev: row.attributes['prev_thumbnail_base'] },
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] }, video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
tags: build_version_tags(cur_tags, prev_tags), tags: build_version_tags(cur_tags, prev_tags),
parent_post_ids: row.parent_post_ids,
original_created_from: { original_created_from: {
current: row.original_created_from&.iso8601, current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601 }, prev: row.attributes['prev_original_created_from']&.iso8601 },
@@ -106,17 +112,27 @@ class PostVersionsController < ApplicationController
names names
end end
def snapshot_tag_literals snapshots, external_tag_names def snapshot_tag_literals snapshots, tag_names, external_tag_names
snapshots snapshots
.filter_map { |snapshot| .filter_map { |snapshot|
if snapshot.key?('tag_id') if snapshot.key?('tag_id')
sections = snapshot.fetch('sections', []).map { |section|
[section.fetch('begin_ms'), section['end_ms']]
}.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
name = tag_names[snapshot['tag_id']] || snapshot.fetch('name')
[tag_category_order(snapshot['category']), [tag_category_order(snapshot['category']),
Post.tag_snapshot_literal(snapshot)] { identity: [:tag, snapshot['tag_id'], sections],
name: Post.tag_snapshot_literal(snapshot.merge('name' => name)),
restore_name: tag_names[snapshot['tag_id']] &&
Post.tag_snapshot_literal(snapshot.merge('name' => name)),
external: false }]
elsif external_tag_names[snapshot['external_tag_id']] elsif external_tag_names[snapshot['external_tag_id']]
[6, external_tag_names[snapshot['external_tag_id']]] [6, { identity: [:external_tag, snapshot['external_tag_id']],
name: external_tag_names[snapshot['external_tag_id']],
restore_name: nil, external: true }]
end end
} }
.sort_by { |order, name| [order, name.downcase] } .sort_by { |order, tag| [order, tag[:name].downcase] }
.map(&:second) .map(&:second)
end end
@@ -125,17 +141,22 @@ class PostVersionsController < ApplicationController
end end
def build_version_tags cur_tags, prev_tags def build_version_tags cur_tags, prev_tags
(cur_tags | prev_tags).map do |name| current = cur_tags.index_by { _1[:identity] }
previous = prev_tags.index_by { _1[:identity] }
(current.keys | previous.keys).map do |identity|
type = type =
if cur_tags.include?(name) && prev_tags.include?(name) if current.key?(identity) && previous.key?(identity)
'context' 'context'
elsif cur_tags.include?(name) elsif current.key?(identity)
'added' 'added'
else else
'removed' 'removed'
end end
{ name:, type: } entry = current[identity] || previous[identity]
{ name: entry[:name], restore_name: entry[:restore_name],
external: entry[:external], type: }
end end
end end
end end
+98 -8
ファイルの表示
@@ -400,8 +400,76 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record render_post_form_record_invalid e.record
end end
def tag_filters
source = resolve_locale!
code = params[:target_locale]
return head :not_found unless code.is_a?(String) && Locale.codes.value?(code)
target = Locale.find_by(code:, deprecated_at: nil)
return head :not_found unless target
literals = params[:tags].to_s.split
references = tag_filter_references(literals)
names = literals.each_with_index.map do |literal, index|
reference = references&.[](index)
(reference && reference['negative'] == false) ? literal : literal.sub(/\Anot:/i, '')
end
internal = references ? {} : TagName.where(
language_code: source.language_code, name: names).pluck(:name, :tag_id).to_h
canonical = references ? {} : TagName.where(
language_code: source.language_code, primary_flg: true,
tag_id: internal.values).pluck(:tag_id, :name).to_h
external_names = names.map { canonical[internal[_1]] || _1 }
external = references ? {} : ExternalTag.where(
"CONCAT(platform, ':', name) IN (?)", external_names).pluck(
Arel.sql("CONCAT(platform, ':', name)"), :id).to_h
ids = references ? references.filter_map { _1['tag_id'] } : internal.values
translated = TagName.where(language_code: target.language_code,
primary_flg: true, tag_id: ids).pluck(:tag_id, :name).to_h
filters = literals.each_with_index.map do |literal, index|
name = names[index]
reference = if references
references[index].except('name')
else
matches = {}
matches['tag_id'] = internal[name] if internal[name]
external_id = external[external_names[index]]
matches['external_tag_id'] = external_id if external_id
matches.empty? ? { 'missing' => true } : matches
end
name = translated[reference['tag_id']] || name
negative = reference.fetch('negative', literal.downcase.start_with?('not:'))
prefix = negative ? literal[/\Anot:/i] || 'not:' : ''
reference.merge('name' => "#{ prefix }#{ name }", 'negative' => negative)
end
render json: { references: filters }
end
private private
def tag_filter_references literals
raw = params[:tag_refs]
return nil if raw.blank?
references = JSON.parse(raw)
valid = references.is_a?(Array) && references.all? do |reference|
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
ids = [reference['tag_id'], reference['external_tag_id']].compact
((ids.any? || reference['missing'] == true) &&
ids.all? { _1.is_a?(Integer) && _1.positive? } &&
(!reference.key?('missing') || (reference['missing'] == true && ids.empty?)) &&
(!reference.key?('negative') || [true, false].include?(reference['negative'])))
end
raise ActionController::BadRequest unless valid
return nil unless references.map { _1['name'] } == literals
references
rescue JSON::ParserError, TypeError
raise ActionController::BadRequest
end
def filtered_posts locale def filtered_posts locale
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
match_type = params[:match] match_type = params[:match]
@@ -413,20 +481,29 @@ class PostsController < ApplicationController
end end
def filter_posts_by_tags locale, tag_names, match_type def filter_posts_by_tags locale, tag_names, match_type
literals = tag_names.map do |raw_name| references = tag_filter_references(tag_names)
{ name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first, literals = tag_names.each_with_index.map do |raw_name, index|
negative: raw_name.downcase.start_with?('not:') } name = raw_name.sub(/\Anot:/i, '')
name = TagName.canonicalise(locale, name).first unless references
reference = references&.[](index)
negative = if reference&.key?('negative')
reference['negative']
else
raw_name.downcase.start_with?('not:')
end
{ name:, negative:, reference: }
end end
return Post.all if literals.empty? return Post.all if literals.empty?
if match_type == 'any' if match_type == 'any'
literals.reduce(Post.none) do |posts, literal| literals.reduce(Post.none) do |posts, literal|
posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative])) posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative],
reference: literal[:reference]))
end end
else else
literals.reduce(Post.all) do |posts, literal| literals.reduce(Post.all) do |posts, literal|
ids = tagged_post_ids_for(locale, literal[:name]) ids = tagged_post_ids_for(locale, literal[:name], reference: literal[:reference])
if literal[:negative] if literal[:negative]
posts.where.not(id: ids) posts.where.not(id: ids)
else else
@@ -436,8 +513,8 @@ class PostsController < ApplicationController
end end
end end
def tag_literal_relation locale, name, negative: def tag_literal_relation locale, name, negative:, reference: nil
ids = tagged_post_ids_for(locale, name) ids = tagged_post_ids_for(locale, name, reference:)
if negative if negative
Post.where.not(id: ids) Post.where.not(id: ids)
else else
@@ -445,7 +522,20 @@ class PostsController < ApplicationController
end end
end end
def tagged_post_ids_for locale, name def tagged_post_ids_for locale, name, reference: nil
if reference
matches = Post.none
if reference['tag_id']
matches = matches.or(Post.where(id: PostTag.where(
tag_id: reference['tag_id']).select(:post_id)))
end
if reference['external_tag_id']
matches = matches.or(Post.where(id: PostExternalTag.where(
external_tag_id: reference['external_tag_id']).select(:post_id)))
end
return matches.select(:id)
end
posts_by_internal_tags = posts_by_internal_tags =
Post Post
.joins(tags: :tag_names) .joins(tags: :tag_names)
+52 -10
ファイルの表示
@@ -35,8 +35,23 @@ class TagVersionsController < ApplicationController
private private
def serialise_versions rows, language_code def serialise_versions rows, language_code
rows = rows.to_a
tag_ids = rows.map(&:tag_id).uniq
current_names_by_tag_id = TagName.where(tag_id: tag_ids, language_code:, primary_flg: true)
.pluck(:tag_id, :name, :auto_generated).to_h do |id, name, automatic|
[id, { name:, auto_generated: automatic }]
end
user_ids = rows.map(&:created_by_user_id).compact.uniq user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
parent_ids = rows.flat_map do |row|
split_parent_tag_ids(row.parent_tag_ids) +
split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
end.uniq
parents_by_id = Tag.includes(
:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.where(id: parent_ids).index_by(&:id)
parent_json = parents_by_id.transform_values { TagRepr.base(_1, language_code:) }
rows.map do |row| rows.map do |row|
current_names = names_for_locale(row.names, language_code) current_names = names_for_locale(row.names, language_code)
@@ -47,18 +62,10 @@ class TagVersionsController < ApplicationController
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids) cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids']) prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids)
tags_by_id =
Tag
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.where(id: all_parent_tag_ids)
.index_by(&:id)
parent_tags = parent_tags =
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id) build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
.map do |h| .map do |h|
{ tag: TagRepr.base(tags_by_id[h[:tag_id]], language_code:), { tag: parent_json.fetch(h[:tag_id]),
type: h[:type] } type: h[:type] }
end end
@@ -66,6 +73,8 @@ class TagVersionsController < ApplicationController
version_no: row.version_no, version_no: row.version_no,
event_type: row.event_type, event_type: row.event_type,
names: row.names, names: row.names,
name_changes: name_changes(row.names, row.attributes['prev_names']),
current_name_reference: current_names_by_tag_id[row.tag_id],
name: { current: current_names['name'], prev: previous_names['name'] }, name: { current: current_names['name'], prev: previous_names['name'] },
category: { current: row.category, prev: row.attributes['prev_category'] }, category: { current: row.category, prev: row.attributes['prev_category'] },
deprecated_at: { current: row.deprecated_at&.iso8601, deprecated_at: { current: row.deprecated_at&.iso8601,
@@ -97,11 +106,44 @@ class TagVersionsController < ApplicationController
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?) def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
def names_for_locale names, language_code def names_for_locale names, language_code
values = snapshot_names(names)[language_code]
values.is_a?(Hash) ? values : {}
end
def snapshot_names names
values = names.is_a?(String) ? JSON.parse(names) : names values = names.is_a?(String) ? JSON.parse(names) : names
values&.fetch(language_code, {}) || {} values.is_a?(Hash) ? values : {}
rescue JSON::ParserError rescue JSON::ParserError
{} {}
end end
def name_changes current_names, previous_names
current = snapshot_names(current_names)
previous = snapshot_names(previous_names)
(current.keys | previous.keys).sort.filter_map do |language|
current_name = language_snapshot(current[language])
previous_name = language_snapshot(previous[language])
next if current_name == previous_name
{ language_code: language, current: current_name, prev: previous_name }
end
end
def language_snapshot entry
return nil unless entry.is_a?(Hash)
aliases = Array(entry['aliases']).filter_map do |value|
value = { 'name' => value } if value.is_a?(String)
next unless value.is_a?(Hash) && value['name'].is_a?(String)
{ name: value['name'], script_code: value['script_code'] }
end
aliases = aliases.uniq.sort_by { [_1[:name], _1[:script_code].to_s] }
{ name: entry['name'],
script_code: entry['script_code'],
auto_generated: entry['auto_generated'],
aliases: }
end
def split_parent_tag_ids(values) = split_values(values).map(&:to_i) def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
end end
+18 -9
ファイルの表示
@@ -175,7 +175,8 @@ class TagsController < ApplicationController
tags_by_id = tags_by_id =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.where(tag_names: { language_code: locale.language_code, primary_flg: true }, .where(tag_names: { language_code: locale.language_code, primary_flg: true },
id: tag_ids) id: tag_ids)
.index_by(&:id) .index_by(&:id)
@@ -238,7 +239,8 @@ class TagsController < ApplicationController
base = base =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.where(tag_names: { language_code: locale.language_code, primary_flg: true }, .where(tag_names: { language_code: locale.language_code, primary_flg: true },
deprecated_at: nil) deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only base = base.where('tags.post_count > 0') if present_only
@@ -282,7 +284,8 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.find_by(id: params[:id], .find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true }) tag_names: { language_code: locale.language_code, primary_flg: true })
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
@@ -302,8 +305,9 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.find_by(tag_names: { name:, language_code: locale.language_code })
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
platform, external_name = name.split(':', 2) platform, external_name = name.split(':', 2)
@@ -320,7 +324,8 @@ class TagsController < ApplicationController
tag = tag =
Tag.joins(:tag_names) Tag.joins(:tag_names)
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions) .preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.find_by(id: params[:id], .find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true }) tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag return head :not_found unless tag
@@ -340,7 +345,8 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions) .preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag return head :not_found unless tag
@@ -359,7 +365,8 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions) .preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.find_by(id: params[:id], .find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true }) tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag return head :not_found unless tag
@@ -368,6 +375,7 @@ class TagsController < ApplicationController
return if performed? return if performed?
ApplicationRecord.transaction do ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock! tag.lock!
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
@@ -424,7 +432,8 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag return head :not_found unless tag
+33 -3
ファイルの表示
@@ -1,39 +1,69 @@
class UsersController < ApplicationController class UsersController < ApplicationController
def create def create
if current_user
return render json: { code: current_user.inheritance_code,
user: current_user.slice(:id, :name, :inheritance_code, :role) }
end
user = nil user = nil
User.transaction do User.transaction do
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest) user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
attach_ip_address!(user) attach_ip_address!(user)
end end
establish_user_session(user)
render json: { code: user.inheritance_code, render json: { code: user.inheritance_code,
user: user.slice(:id, :name, :inheritance_code, :role) }, user: user.slice(:id, :name, :inheritance_code, :role) },
status: :created status: :created
end end
def verify def verify
user = User.find_by(inheritance_code: params[:code]) user = if bool?(:restore) && current_user
current_user
else
User.find_by(inheritance_code: params[:code])
end
return render json: { valid: false } unless user return render json: { valid: false } unless user
return head :forbidden if user.banned? return head :forbidden if user.banned?
attach_ip_address!(user) attach_ip_address!(user)
establish_user_session(user)
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
end end
def show_session
return head :unauthorized unless current_user
render json: current_user.slice(:id, :name, :role)
end
def csrf
response.headers['Cache-Control'] = 'private, no-store'
render json: { token: form_authenticity_token }
end
def destroy_session
reset_session
response.headers['Cache-Control'] = 'private, no-store'
head :no_content
end
def renew def renew
return head :unauthorized unless current_user return head :unauthorized unless current_user
user = current_user user = current_user
user.inheritance_code = SecureRandom.uuid user.inheritance_code = SecureRandom.uuid
user.save! user.save!
establish_user_session(user)
render json: { code: user.inheritance_code } render json: { code: user.inheritance_code }
end end
def me def me
user = User.find_by(inheritance_code: params[:code]) user = current_user
return head :not_found unless user return head :unauthorized unless user
attach_ip_address!(user)
render json: user.slice(:id, :name, :inheritance_code, :role) render json: user.slice(:id, :name, :inheritance_code, :role)
end end
+35 -1
ファイルの表示
@@ -34,11 +34,33 @@ class WikiPagesController < ApplicationController
render json: render json:
end end
def autolink_candidates
locale = resolve_locale!
language = params.fetch(:language, locale.language_code)
language = 'ja' if language == 'und'
return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language)
names = TagDescription.joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:)
end)
render json: candidates
end
def show def show
locale = resolve_locale! locale = resolve_locale!
tag_description = TagDescription.find_by(id: params[:id], language_code: locale.language_code) reference = TagDescription.find_by(id: params[:id])
tag_description = reference && TagDescription.find_by(
tag_id: reference.tag_id, language_code: locale.language_code)
return render_wiki_page_by_tag_description locale, tag_description if tag_description return render_wiki_page_by_tag_description locale, tag_description if tag_description
return head :not_found if reference
tag_group = TagGroup.find_by(id: params[:id]) tag_group = TagGroup.find_by(id: params[:id])
return render_tag_group locale, tag_group if tag_group return render_tag_group locale, tag_group if tag_group
@@ -63,6 +85,17 @@ class WikiPagesController < ApplicationController
head :not_found head :not_found
end end
def tag_reference
locale = resolve_locale!
description = TagDescription.find_by(id: params[:id])
return head :not_found unless description
tag = Tag.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.find(description.tag_id)
render json: TagRepr.base(tag, language_code: locale.language_code)
end
def exists def exists
locale = resolve_locale! locale = resolve_locale!
if TagDescription.exists?(id: params[:id], language_code: locale.language_code) if TagDescription.exists?(id: params[:id], language_code: locale.language_code)
@@ -214,6 +247,7 @@ class WikiPagesController < ApplicationController
base_revision_id = params[:base_revision_id].presence base_revision_id = params[:base_revision_id].presence
ApplicationRecord.transaction do ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock! tag.lock!
old_title = tag_name.name old_title = tag_name.name
if title != old_title if title != old_title
+1 -1
ファイルの表示
@@ -1,5 +1,5 @@
class Language < ApplicationRecord class Language < ApplicationRecord
has_many :locales, foreign_key: :language_code, primary_key: :code has_many :locales, foreign_key: :language_code, primary_key: :code
enum :code, ja: 'ja' enum :code, ja: 'ja', ain: 'ain'
end end
+25 -13
ファイルの表示
@@ -1,26 +1,38 @@
class Locale < ApplicationRecord class Locale < ApplicationRecord
after_create_commit :generate_tag_names! attr_accessor :certified_tag_names_path
around_save :save_with_tag_names!, if: :will_generate_tag_names?
belongs_to :language, foreign_key: :language_code, primary_key: :code belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :script, foreign_key: :script_code, primary_key: :code belongs_to :script, foreign_key: :script_code, primary_key: :code
enum :code, ja: 'ja', en: 'en', fr: 'fr' # Enum deserialisation uses the first key for each stored value.
# Keep the legacy key for existing predicates, scopes and query arguments.
enum :code, { ja: 'ja', 'ain-Kana' => 'ain-Kana', ain_kana: 'ain-Kana' }
def code= value
super(self.class.codes.fetch(value.to_s, value))
end
def self.nipponese = Locale.find('ja') def self.nipponese = Locale.find('ja')
private private
def generate_tag_names! def will_generate_tag_names?
ApplicationRecord.transaction do deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?)
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id) end
Tag.where.not(id: tag_ids).includes(:tag_names).find_each do
TagName.create!(tag_id: _1.id, def save_with_tag_names!
language_code:, completed = self.class.transaction(requires_new: true) do
name: TagName.generate_name(self, _1, _1.tag_name('ja')), SharedResourceIdAllocator.lock_space!(Tag, 'tags')
script_code:, saved = yield
primary_flg: true, raise ActiveRecord::RecordNotSaved, 'Locale save was halted' unless saved
auto_generated: true)
end TagNameCompletion.for_locale!(self)
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
end end
end end
end end
+7 -1
ファイルの表示
@@ -1,5 +1,11 @@
class Script < ApplicationRecord class Script < ApplicationRecord
has_many :locales, foreign_key: :script_code, primary_key: :code has_many :locales, foreign_key: :script_code, primary_key: :code
enum :code, Jpan: 'Jpan' # Canonical keys come first so reads and plucks retain ISO 15924 spelling.
# Legacy keys preserve the existing enum predicates, scopes and queries.
enum :code, { 'Jpan' => 'Jpan', 'Kana' => 'Kana', jpan: 'Jpan', kana: 'Kana' }
def code= value
super(self.class.codes.fetch(value.to_s, value))
end
end end
+14 -27
ファイルの表示
@@ -110,7 +110,12 @@ class Tag < ApplicationRecord
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def snapshot_aliases(language_code) def snapshot_aliases language_code
if tag_names.loaded?
return tag_names.select { _1.language_code == language_code && !_1.primary? }
.sort_by(&:name).map(&:name)
end
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name) tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
end end
@@ -259,20 +264,24 @@ class Tag < ApplicationRecord
name = name.to_s.strip name = name.to_s.strip
ApplicationRecord.transaction do ApplicationRecord.transaction do
tn = TagName.find_by(language_code:, name:) SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tn = TagName.lock.find_by(language_code:, name:)
next tn.tag if tn next tn.tag if tn
tag = Tag.create!(category:) tag = Tag.create!(category:)
tn = TagName.create!(tag:, language_code:, name:, TagName.create!(tag:, language_code:, name:,
script_code: locale.script_code, primary_flg: true, script_code: locale.script_code, primary_flg: true,
auto_generated: false) auto_generated: false)
generate_tag_names!(locale, tag, tn) TagNameCompletion.for_tag!(tag)
tag.association(:tag_names).reset tag.association(:tag_names).reset
tag tag
end end
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
retry existing = TagName.find_by(language_code:, name:)
raise unless existing
existing.tag
end end
def merge_tags! target_tag, source_tags, created_by_user: nil def merge_tags! target_tag, source_tags, created_by_user: nil
@@ -352,28 +361,6 @@ class Tag < ApplicationRecord
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end end
private
def generate_tag_names! locale, tag, base_tag_name
ApplicationRecord.transaction do
locales =
Locale
.where(deprecated_at: nil)
.where.not(language_code: locale.language_code)
.order(:language_code, :created_at, :script_code)
.uniq { _1.language_code }
locales.each do |locale|
language_code = locale.language_code
script_code = locale.script_code
TagName.create!(tag_id: tag.id,
language_code:,
name: TagName.generate_name(locale, tag, base_tag_name),
script_code:,
primary_flg: true,
auto_generated: true)
end
end
end
end end
private private
+18 -3
ファイルの表示
@@ -34,9 +34,22 @@ class TagName < ApplicationRecord
end end
def self.generate_name locale, tag, base_tag_name def self.generate_name locale, tag, base_tag_name
# TODO: 言語ごとの自動命名ロジック完成したら書く. fallback = "Tag_##{ tag.id }"
name =
"Tag_##{ tag.id }" case [locale.language_code, base_tag_name&.language_code]
when ['ain', 'ja']
TagNameTranscription.ainu(base_tag_name.name)
when ['ja', 'ain']
base_tag_name.name
else
return fallback
end
candidate = new(tag:, name:, language_code: locale.language_code,
script_code: locale.script_code,
primary_flg: true, auto_generated: true)
candidate.valid? ? name : fallback
rescue TagNameTranscription::Unconvertible
fallback
end end
private private
@@ -54,6 +67,8 @@ class TagName < ApplicationRecord
end end
def name_must_be_sanitised def name_must_be_sanitised
return if auto_generated && name == "Tag_##{ tag_id }"
if name? && name != TagNameSanitisationRule.sanitise(name) if name? && name != TagNameSanitisationRule.sanitise(name)
errors.add :name, '名前に使用できない文字が含まれてゐます.' errors.add :name, '名前に使用できない文字が含まれてゐます.'
end end
+22
ファイルの表示
@@ -9,6 +9,8 @@ module WikiPageRepr
{ 'id' => description.id, { 'id' => description.id,
'resource_type' => 'tag_description', 'resource_type' => 'tag_description',
'tag_id' => tag.id,
'language_code' => description.language_code,
'tag_name_id' => tag.tag_name(locale.language_code)&.id, 'tag_name_id' => tag.tag_name(locale.language_code)&.id,
'body' => description.body, 'body' => description.body,
'created_user_id' => description.created_by_user_id, 'created_user_id' => description.created_by_user_id,
@@ -24,4 +26,24 @@ module WikiPageRepr
def tag_group tag_group def tag_group tag_group
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group') TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
end end
def autolink_tag locale, tag_id:, description_id:, name:, language:
{ resource_type: 'tag_description',
resource_id: description_id,
tag_id:,
tag_description_id: description_id,
name:,
language_code: language,
locale_code: locale.code_before_type_cast }
end
def autolink_group locale, id:, name:, language:
{ resource_type: 'tag_group',
resource_id: id,
tag_id: nil,
tag_description_id: nil,
name: "#{ TagGroupRepr::PREFIX }#{ name }",
language_code: language,
locale_code: locale.code_before_type_cast }
end
end end
+105
ファイルの表示
@@ -0,0 +1,105 @@
require 'json'
require 'pathname'
class CertifiedTagNameTranslations
FORMAT = 'btrc-hub.certified-tag-name-translations.v1'.freeze
class Error < ArgumentError
;
end
def self.load! path, locale:
new(path, locale:).load!
end
def initialize path, locale:
@path = path
@locale = locale
end
def load!
document = read_document
unless document.is_a?(Hash) && document['format'] == FORMAT
fail!('invalid format or version')
end
source_code = document['source_locale']
target_code = document['target_locale']
unless target_code.is_a?(String) && target_code == Locale.codes.fetch(@locale.code)
fail!("target_locale #{ target_code.inspect } does not match the new Locale")
end
unless source_code.is_a?(String) && Locale.codes.value?(source_code)
fail!("invalid source_locale #{ source_code.inspect }")
end
source_locale = Locale.find_by(code: source_code)
fail!("source Locale #{ source_code.inspect } does not exist") unless source_locale
fail!('source_locale and target_locale must differ') if source_code == target_code
entries = validate_entries(document['entries'], source_code, target_code)
resolve_tags(entries, source_locale.language_code)
end
private
def read_document
unless (@path.is_a?(String) && !@path.include?("\0") &&
Pathname.new(@path).absolute?)
fail!('certified_tag_names_path must be an absolute file path')
end
fail!('JSON file does not exist or is not a regular file') unless File.file?(@path)
text = File.read(@path, encoding: Encoding::UTF_8)
fail!('JSON file is not valid UTF-8') unless text.valid_encoding?
JSON.parse(text)
rescue JSON::ParserError, SystemCallError, IOError, EncodingError => error
fail!("cannot read JSON: #{ error.message }")
end
def validate_entries entries, source_code, target_code
fail!('entries must be an array') unless entries.is_a?(Array)
sources = {}
targets = {}
entries.each_with_index.map do |entry, index|
fail!("entry #{ index + 1 } must be an object") unless entry.is_a?(Hash)
source = entry[source_code]
target = entry[target_code]
unless source.is_a?(String) && source.present? && target.is_a?(String) && target.present?
fail!("entry #{ index + 1 }: missing or empty #{ source_code }/#{ target_code } name")
end
fail!("duplicate source name #{ source.inspect }") if sources.key?(source)
fail!("duplicate target name #{ target.inspect }") if targets.key?(target)
sources[source] = true
targets[target] = true
[source, target]
end
end
def resolve_tags entries, source_language
source_names = entries.map(&:first)
# SQL may use a case/accent-insensitive collation; the Ruby keys are exact.
matches = TagName.where(language_code: source_language, primary_flg: true,
name: source_names).pluck(:name, :tag_id).group_by(&:first)
translations = {}
entries.each do |source, target|
rows = matches.fetch(source, [])
next if rows.empty?
unless rows.length == 1
fail!("source primary name #{ source.inspect } must match exactly one tag")
end
tag_id = rows.first.last
if translations.key?(tag_id)
fail!("multiple source primary names resolve to Tag##{ tag_id }: #{ source.inspect }")
end
translations[tag_id] = target
end
translations
end
def fail! message
raise Error, "Certified tag names #{@path.inspect}: #{ message }"
end
end
+76 -30
ファイルの表示
@@ -13,42 +13,26 @@ class SharedResourceIdAllocator
['tag_description_versions', 'tag_id']], ['tag_description_versions', 'tag_id']],
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze 'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
LOCK_TIMEOUT = 10 LOCK_TIMEOUT = 10
CONNECTION_LOCKS = ObjectSpace::WeakMap.new
class LockCleanup
def initialize &finish
@finish = finish
end
def trigger_transactional_callbacks? = false
def before_committed! = nil
def committed!(**) = @finish.call(self)
def rolledback!(**) = @finish.call(self)
end
private_constant :LockCleanup
def self.assign! record def self.assign! record
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) } space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
raise ArgumentError, 'Unknown shared ID resource' unless tables raise ArgumentError, 'Unknown shared ID resource' unless tables
connection = record.class.connection connection = record.class.connection
transaction = record.class.current_transaction lock_space!(record.class, space)
unless transaction.open?
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
end
database = connection.pool.db_config.database
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
quoted_name = connection.quote(name)
acquired = record.class.uncached do
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
end
unless acquired.to_i == 1
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
end
release = lambda do
result = record.class.uncached do
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
end
unless result.to_i == 1
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
end
end
begin
transaction.after_commit(&release)
transaction.after_rollback(&release)
rescue StandardError
release.call
raise
end
# 明示-tic な Id. 指定は許可する. # 明示-tic な Id. 指定は許可する.
return if record.id.present? return if record.id.present?
@@ -66,6 +50,68 @@ class SharedResourceIdAllocator
record.id = next_id record.id = next_id
end end
def self.lock_space! record_class, space
raise ArgumentError, 'Unknown shared ID resource' unless SPACES.key?(space)
connection = record_class.connection
transaction = connection.current_transaction
unless transaction.open?
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
end
database = connection.pool.db_config.database
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
held_locks = CONNECTION_LOCKS[connection] ||= {}
return if held_locks.key?(name)
quoted_name = connection.quote(name)
acquired = record_class.uncached do
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
end
unless acquired.to_i == 1
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
end
held_locks[name] = true
finish = lambda do |cleanup|
next unless held_locks.key?(name)
# Savepoint callbacks may run before the outer transaction ends.
# Keep one session lock and transfer its cleanup to the remaining parent.
parent = connection.current_transaction
if parent.open?
connection.add_transaction_record(cleanup)
next
end
result = record_class.uncached do
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
end
unless result.to_i == 1
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
end
held_locks.delete(name)
rescue StandardError
# Never return a session with an untracked advisory lock to the pool.
begin
connection.disconnect!
ensure
held_locks.clear
end
raise
end
begin
# Rails finalises remaining records in ensure if another callback raises.
connection.add_transaction_record(LockCleanup.new(&finish))
rescue StandardError
begin
connection.disconnect!
ensure
held_locks.clear
end
raise
end
end
def self.next_ids connection, tables def self.next_ids connection, tables
expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i
begin begin
+2
ファイルの表示
@@ -5,6 +5,7 @@ class TagDescriptionOperation
def self.create! locale, tag:, body:, created_by_user:, message: nil def self.create! locale, tag:, body:, created_by_user:, message: nil
ApplicationRecord.transaction do ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock! tag.lock!
raise ActiveRecord::RecordNotUnique if TagDescription.exists?( raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
tag_id: tag.id, language_code: locale.language_code) tag_id: tag.id, language_code: locale.language_code)
@@ -19,6 +20,7 @@ class TagDescriptionOperation
def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
ApplicationRecord.transaction do ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock! tag.lock!
description = TagDescription.lock.find_by!( description = TagDescription.lock.find_by!(
tag_id: tag.id, language_code: locale.language_code) tag_id: tag.id, language_code: locale.language_code)
+117
ファイルの表示
@@ -0,0 +1,117 @@
class TagNameCompletion
class << self
def for_locale! locale
completed = Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
certified_names =
if locale.certified_tag_names_path.nil?
{ }
else
CertifiedTagNameTranslations.load!(locale.certified_tag_names_path, locale:)
end
Tag.where(id: certified_names.keys).lock.find_each(order: :asc) do |tag|
for_tag!(tag, locales: [locale],
certified_names: { locale.language_code => certified_names.fetch(tag.id) })
end
named_tags =
TagName.where(language_code: locale.language_code, primary_flg: true)
.select(:tag_id)
Tag.where.not(id: named_tags).lock.find_each(order: :asc) do |tag|
for_tag!(tag, locales: [locale])
end
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
end
end
def for_tag! tag, locales: nil, certified_names: { }
locales ||=
Locale.where(deprecated_at: nil)
.order(:language_code, :created_at, :script_code).lock
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
completed = tag.with_lock do
primary_names = tag.tag_names.where(primary_flg: true).lock.index_by(&:language_code)
certified_names.each do |language, name|
primary = primary_names[language]
next unless primary
next if primary.name == name && !primary.auto_generated
raise CertifiedTagNameTranslations::Error,
"Tag##{ tag.id } already has a primary #{ language } name; " +
"cannot register certified name #{ name.inspect }"
end
missing_locales = locales.to_a.uniq(&:language_code).reject { |locale|
primary_names.key?(locale.language_code)
}
next true if missing_locales.empty?
TagVersioning.ensure_snapshot!(tag, created_by_user: nil)
missing_locales.each do |locale|
primary_names[locale.language_code] =
if certified_names.key?(locale.language_code)
create_certified_name!(locale, tag, certified_names.fetch(locale.language_code))
else
source_language = locale.language_code == 'ja' ? 'ain' : 'ja'
name = TagName.generate_name(locale, tag, primary_names[source_language])
create_name!(locale, tag, name)
end
end
tag.association(:tag_names).reset
version = TagVersioning.record!(tag, event_type: :update, created_by_user: nil)
unless version&.persisted?
raise ActiveRecord::RecordNotSaved, 'Tag version was not persisted'
end
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Tag completion was rolled back'
end
end
end
private
def create_certified_name! locale, tag, name
persist_name!(locale, tag, name, auto_generated: false)
rescue ActiveRecord::RecordInvalid => error
raise unless error.record.is_a?(TagName)
raise CertifiedTagNameTranslations::Error,
"Tag##{ tag.id } certified name #{ name.inspect }: " +
error.record.errors.full_messages.join(', ')
rescue ActiveRecord::RecordNotUnique => error
raise CertifiedTagNameTranslations::Error,
"Tag##{ tag.id } certified name #{ name.inspect } conflicts: #{ error.message }"
end
def create_name! locale, tag, name
persist_name!(locale, tag, name, auto_generated: true)
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
fallback = "Tag_##{ tag.id }"
invalid_name =
if error.is_a?(ActiveRecord::RecordNotUnique)
TagName.where(language_code: locale.language_code, name:).lock.exists?
else
error.record.errors[:name].present? &&
error.record.errors.attribute_names.all? { _1 == :name }
end
raise if name == fallback || !(invalid_name)
persist_name!(locale, tag, fallback, auto_generated: true)
end
def persist_name! locale, tag, name, auto_generated:
tag_name = TagName.transaction(requires_new: true) do
TagName.create!(tag:, name:, language_code: locale.language_code,
script_code: locale.script_code, primary_flg: true, auto_generated:)
end
unless tag_name&.persisted?
raise ActiveRecord::RecordNotSaved, 'Generated tag name was not persisted'
end
tag_name
end
end
end
+56
ファイルの表示
@@ -0,0 +1,56 @@
module TagNameTranscription
class Unconvertible < StandardError
;
end
def self.ainu name
previous_script = nil
parts = []
NipponeseSegments.split(name).each do |script, text|
output = transcribe_segment(script, text)
if (parts.last && output.first && previous_script && script != previous_script &&
NipponeseSegments.nipponese?(previous_script) && NipponeseSegments.nipponese?(script))
parts << '_'
end
parts.concat(output)
previous_script = script
end
replace_unconvertible(parts)
end
def self.transcribe_segment script, text
return [text] if script == :literal
return [nil] if script == :unconvertible
return transcribe_reading(text) unless script == :kanzi
text.each_char.flat_map do |character|
transcribe_reading(JisReadings.read(character))
rescue Unconvertible
[nil]
end
end
def self.transcribe_reading reading
Phonology.adapt(reading).map do |sound|
sound && Katakana.render([sound])
rescue Unconvertible
nil
end
end
def self.replace_unconvertible parts
grouped = parts.chunk { _1.nil? }.flat_map { |unconvertible, group|
unconvertible ? [nil] : group
}
first = grouped.index { _1&.match?(/[^_]/) }
last = grouped.rindex { _1&.match?(/[^_]/) }
grouped.each_with_index.map { |part, index|
part || (first && first < index && index < last ? '_' : '')
}.join
end
private_class_method :transcribe_segment, :transcribe_reading, :replace_unconvertible
end
+13
ファイルの表示
@@ -0,0 +1,13 @@
module TagNameTranscription
module JisReadings
SOURCE = Rails.root.join('config/tag_name_readings/jis_level_one.tsv')
READINGS = File.foreach(SOURCE, chomp: true).reject { _1.start_with?('#') }.to_h { |line|
character, reading, = line.split("\t")
[character, reading]
}.freeze
def self.read text
text.each_char.map { READINGS.fetch(_1) { raise Unconvertible } }.join
end
end
end
+26
ファイルの表示
@@ -0,0 +1,26 @@
module TagNameTranscription
module Katakana
ROWS = {
'' => 'アイウエオ',
'k' => 'カキクケコ',
's' => 'サシスセソ',
'c' => ['チャ', 'チ', 'チュ', 'チェ', 'チョ'],
't' => ['タ', 'ティ', 'トゥ', 'テ', 'ト'],
'n' => 'ナニヌネノ',
'h' => 'ハヒフヘホ',
'p' => 'パピプペポ',
'm' => 'マミムメモ',
'r' => 'ラリルレロ' }.freeze
SYLLABLES = ROWS.flat_map { |onset, letters|
kana = letters.is_a?(String) ? letters.chars : letters
['a', 'i', 'u', 'e', 'o'].zip(kana).map { |vowel, letter| [onset + vowel, letter] }
}.to_h.merge(
'ya' => 'ヤ', 'yu' => 'ユ', 'yo' => 'ヨ', 'wa' => 'ワ',
'k' => 'ㇰ', 's' => 'ㇱ', 't' => 'ッ', 'n' => 'ン', 'm' => 'ㇺ', 'p' => 'ㇷ゚',
'r:a' => 'ㇻ', 'r:i' => 'ㇼ', 'r:u' => 'ㇽ', 'r:e' => 'ㇾ', 'r:o' => 'ㇿ').freeze
def self.render sounds
sounds.map { SYLLABLES.fetch(_1) { raise Unconvertible } }.join
end
end
end
+30
ファイルの表示
@@ -0,0 +1,30 @@
module TagNameTranscription
module NipponeseSegments
NIPPONESE_SCRIPTS = [:kanzi, :xiragana, :katakana].freeze
def self.nipponese?(script) = NIPPONESE_SCRIPTS.include?(script)
def self.split name
segments = []
name.to_s.scan(/\X/).each do |character|
script = classify(character, segments.last&.first)
if segments.last&.first == script
segments.last[1] << character
else
segments << [script, character.dup]
end
end
segments
end
def self.classify character, previous_script
return previous_script if character == 'ー' && nipponese?(previous_script)
return :kanzi if character.match?(/\A\p{Han}\z/)
return :xiragana if character.match?(/\A\p{Hiragana}\p{M}*\z/)
return :katakana if character.match?(/\A\p{Katakana}\p{M}*\z/)
return :literal if character.match?(/\A(?:\p{Latin}\p{M}*|[!-~])\z/)
:unconvertible
end
end
end
+59
ファイルの表示
@@ -0,0 +1,59 @@
module TagNameTranscription
module Phonology
ROWS = {
'アイウエオ' => ['a', 'i', 'u', 'e', 'o'],
'カキクケコ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
'ガギグゲゴ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
'サシスセソ' => ['sa', 'si', 'su', 'se', 'so'],
'ザジズゼゾ' => ['ca', 'ci', 'cu', 'ce', 'co'],
'タチツテト' => ['ta', 'ci', 'cu', 'te', 'to'],
'ダヂヅデド' => ['ta', 'ci', 'cu', 'te', 'to'],
'ナニヌネノ' => ['na', 'ni', 'nu', 'ne', 'no'],
'ハヒフヘホ' => ['ha', 'hi', 'hu', 'he', 'ho'],
'バビブベボ' => ['pa', 'pi', 'pu', 'pe', 'po'],
'パピプペポ' => ['pa', 'pi', 'pu', 'pe', 'po'],
'マミムメモ' => ['ma', 'mi', 'mu', 'me', 'mo'],
'ヤユヨ' => ['ya', 'yu', 'yo'],
'ラリルレロ' => ['ra', 'ri', 'ru', 're', 'ro'],
'ワ' => ['wa'],
'ン' => ['n'],
'ッ' => ['t'],
'ㇰ' => ['k'],
'ㇱㇲ' => ['s', 's'],
'ㇳ' => ['t'],
'ㇴ' => ['n'],
'ㇺ' => ['m'],
'ㇻㇼㇽㇾㇿ' => ['r:a', 'r:i', 'r:u', 'r:e', 'r:o'] }.freeze
SOUNDS = ROWS.flat_map { |characters, sounds| characters.chars.zip(sounds) }.to_h.freeze
DIGRAPHS = {
'シャ' => 'sa', 'シュ' => 'su', 'シェ' => 'se', 'ショ' => 'so',
'チャ' => 'ca', 'チュ' => 'cu', 'チェ' => 'ce', 'チョ' => 'co',
'ジャ' => 'ca', 'ジュ' => 'cu', 'ジェ' => 'ce', 'ジョ' => 'co',
'ヂャ' => 'ca', 'ヂュ' => 'cu', 'ヂェ' => 'ce', 'ヂョ' => 'co' }.freeze
def self.adapt reading
kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').delete('ー')
characters = kana.scan(/\X/)
sounds = []
index = 0
while index < characters.length
pair = characters[index, 2].join
if DIGRAPHS.key?(pair)
sounds << DIGRAPHS.fetch(pair)
index += 2
next
end
character = characters[index].tr('ャュョ', 'ヤユヨ')
sounds << (character == 'ㇷ゚' ? 'p' : SOUNDS[character])
index += 1
end
sounds
end
end
end
+21 -5
ファイルの表示
@@ -1,7 +1,10 @@
class TagVersionRecorder < VersionRecorder class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:, **_options def self.record! tag:, event_type:, created_by_user:, **_options
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
new(tag:, event_type:, created_by_user:).record! new(tag:, event_type:, created_by_user:).record!
end end
end
def initialize tag:, event_type:, created_by_user: def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:) super(record: tag, event_type:, created_by_user:)
@@ -15,26 +18,39 @@ class TagVersionRecorder < VersionRecorder
def snapshot_attributes def snapshot_attributes
names = snapshot_names names = snapshot_names
compatibility_names = names.fetch('ja') compatibility_names = names['ja'] || names['ain'] || names.values.first
unless compatibility_names
@record.errors.add(:tag_names, :invalid)
raise ActiveRecord::RecordInvalid, @record
end
parent_ids =
TagImplication.where(tag_id: @record.id).order(:parent_tag_id)
.lock.pluck(:parent_tag_id)
{ name: compatibility_names.fetch('name'), { name: compatibility_names.fetch('name'),
category: @record.category, category: @record.category,
deprecated_at: @record.deprecated_at, deprecated_at: @record.deprecated_at,
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '), aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
names:, names:,
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } parent_tag_ids: parent_ids.join(' ') }
end end
def snapshot_names def snapshot_names
@record.tag_names.order(:language_code, :name).group_by(&:language_code) @record.tag_names.order(:language_code, :name).lock.group_by(&:language_code)
.transform_values do |tag_names| .transform_values do |tag_names|
primary = tag_names.find(&:primary?) primary = tag_names.find(&:primary?)
unless primary
@record.errors.add(:tag_names, :invalid,
language_code: tag_names.first.language_code)
raise ActiveRecord::RecordInvalid, @record
end
aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name| aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name|
{ 'name' => tag_name.name, 'script_code' => tag_name.script_code } { 'name' => tag_name.name,
'script_code' => Script.codes.fetch(tag_name.script_code, tag_name.script_code) }
} }
{ 'name' => primary.name, { 'name' => primary.name,
'script_code' => primary.script_code, 'script_code' => Script.codes.fetch(primary.script_code, primary.script_code),
'auto_generated' => primary.auto_generated, 'auto_generated' => primary.auto_generated,
'aliases' => aliases } 'aliases' => aliases }
end end
+13 -4
ファイルの表示
@@ -4,14 +4,23 @@ class TagVersioning
end end
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja' def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
return if tag.tag_versions.exists? Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
locked_tag = Tag.unscoped.lock.find(tag.id)
next if locked_tag.tag_versions.lock.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:, language_code:) TagVersionRecorder.record!(
tag: locked_tag, event_type: :create, created_by_user:, language_code:)
end
end end
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja' def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
event_type = tag.tag_versions.exists? ? :update : :create Tag.transaction do
record!(tag, event_type:, created_by_user:, language_code:) SharedResourceIdAllocator.lock_space!(Tag, 'tags')
locked_tag = Tag.unscoped.lock.find(tag.id)
event_type = locked_tag.tag_versions.lock.exists? ? :update : :create
record!(locked_tag, event_type:, created_by_user:, language_code:)
end
end end
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja' def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
+1 -1
ファイルの表示
@@ -35,7 +35,7 @@ class VersionRecorder
private private
def latest_version = versions.order(version_no: :desc).first def latest_version = versions.order(version_no: :desc).lock.first
def versions = @record.public_send(version_association) def versions = @record.public_send(version_association)
+9
ファイルの表示
@@ -31,5 +31,14 @@ module Backend
# Middleware like session, flash, cookies can be added back manually. # Middleware like session, flash, cookies can be added back manually.
# Skip views, helpers and assets when generating a new resource. # Skip views, helpers and assets when generating a new resource.
config.api_only = true config.api_only = true
config.middleware.use ActionDispatch::Cookies
config.session_store :cookie_store,
key: Rails.env.production? ? '__Host-btrc-session' : 'btrc-session',
path: '/',
secure: Rails.env.production?,
httponly: true,
same_site: :lax,
expire_after: 20.years
config.middleware.use ActionDispatch::Session::CookieStore, config.session_options
end end
end end
+2 -2
ファイルの表示
@@ -4,5 +4,5 @@
# Use this to limit dissemination of sensitive information. # Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [ Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate,
] :otp, :ssn, :cvv, :cvc, :code, :inheritance_code]
+8
ファイルの表示
@@ -1,4 +1,6 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :locales, only: [:index]
resources :tag_groups, only: [:index, :show, :create, :update] resources :tag_groups, only: [:index, :show, :create, :update]
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
@@ -43,6 +45,7 @@ Rails.application.routes.draw do
collection do collection do
get :search, action: :index get :search, action: :index
get :changes get :changes
get :autolink_candidates
scope :title do scope :title do
get ':title/exists', action: :exists_by_title get ':title/exists', action: :exists_by_title
@@ -53,12 +56,14 @@ Rails.application.routes.draw do
member do member do
get :exists get :exists
get :diff get :diff
get :tag, action: :tag_reference
end end
end end
resources :posts, only: [:index, :show, :create, :update] do resources :posts, only: [:index, :show, :create, :update] do
collection do collection do
get :metadata get :metadata
get :tag_filters
post :bulk post :bulk
get :random get :random
get :versions, to: 'post_versions#index' get :versions, to: 'post_versions#index'
@@ -97,6 +102,9 @@ Rails.application.routes.draw do
collection do collection do
post :verify post :verify
get :me get :me
get :session, action: :show_session
get :csrf
delete :session, action: :destroy_session
post 'code/renew', action: :renew post 'code/renew', action: :renew
end end
end end
ファイル差分が大きすぎるため省略します 差分を読み込み
+5
ファイルの表示
@@ -8,6 +8,11 @@
# MovieGenre.find_or_create_by!(name: genre_name) # MovieGenre.find_or_create_by!(name: genre_name)
# end # end
ApplicationRecord.transaction do
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
end
post_url_sanitisation_rules = [ post_url_sanitisation_rules = [
{ priority: 10, { priority: 10,
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z', source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
+1 -11
ファイルの表示
@@ -24,17 +24,7 @@ FactoryBot.define do
evaluator.primary_tag_name.update!(tag:, primary_flg: true) evaluator.primary_tag_name.update!(tag:, primary_flg: true)
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a TagNameCompletion.for_tag!(tag)
.uniq(&:language_code).each do |locale|
next if tag.tag_names.exists?(language_code: locale.language_code,
primary_flg: true)
create(:tag_name,
tag:, language_code: locale.language_code,
script_code: locale.script_code,
name: TagName.generate_name(locale, tag, evaluator.primary_tag_name),
primary_flg: true, auto_generated: true)
end
end end
end end
end end
+2 -3
ファイルの表示
@@ -6,9 +6,8 @@ RSpec.describe Language, type: :model do
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja']) expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
end end
it 'accepts English when multilingual support is enabled' do it 'accepts Ainu when multilingual support is enabled' do
pending '日本語以外の Language 有効化は別課題で実装する' expect { described_class.create!(code: 'ain', name: 'ain') }
expect { described_class.create!(code: 'en', name: 'English') }
.to change(described_class, :count).by(1) .to change(described_class, :count).by(1)
end end
+82
ファイルの表示
@@ -0,0 +1,82 @@
require 'rails_helper'
RSpec.describe 'Locale activation', type: :model do
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
let!(:first) { create(:tag, primary_name: 'activation_first') }
let!(:second) { create(:tag, primary_name: 'activation_second') }
def build_locale deprecated_at: nil
Locale.new(code: 'ain-Kana', language_code: language.code,
script_code: script.code, region_code: 392, variants: '',
name: 'ain-Kana', deprecated_at:)
end
def generated_names
TagName.where(language_code: 'ain', primary_flg: true)
end
def fail_second_generation!
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name)
.with(kind_of(Locale), second, second.tag_name('ja'))
.and_raise('backfill failed')
end
it 'rolls back a newly created locale and the preceding generated names' do
locale = build_locale
fail_second_generation!
expect { locale.save! }.to raise_error('backfill failed')
expect(Locale.exists?(code: 'ain-Kana')).to be(false)
expect(generated_names).to be_empty
expect(first.reload.name('ja')).to eq('activation_first')
expect(second.reload.name('ja')).to eq('activation_second')
end
it 'does not backfill while the locale is inactive' do
build_locale(deprecated_at: Time.current).save!
expect(generated_names).to be_empty
end
it 'backfills missing primary names when the locale becomes active' do
locale = build_locale(deprecated_at: Time.current)
locale.save!
locale.update!(deprecated_at: nil)
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
expect(generated_names).to all(have_attributes(
script_code: 'Kana', primary_flg: true, auto_generated: true))
end
it 'rolls back activation and all generated names when backfill fails' do
locale = build_locale(deprecated_at: Time.current)
locale.save!
previous_deprecated_at = locale.reload.deprecated_at
fail_second_generation!
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
end
it 'preserves a human primary through deactivation and repeated activation' do
locale = build_locale
locale.save!
primary = generated_names.find_by!(tag: first)
primary.update!(name: 'human_confirmed', auto_generated: false)
snapshot = generated_names.order(:id).map(&:attributes)
2.times do
locale.update!(deprecated_at: Time.current)
locale.update!(deprecated_at: nil)
end
expect(generated_names.order(:id).map(&:attributes)).to eq(snapshot)
expect(primary.reload).to have_attributes(name: 'human_confirmed', auto_generated: false)
end
end
+46 -56
ファイルの表示
@@ -1,13 +1,13 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Locale, type: :model do RSpec.describe Locale, type: :model do
include_context 'English locale' include_context 'Ainu locale'
def create_english_locale! def create_ainu_locale!
prepare_english_reference! prepare_ainu_reference!
described_class.create!(code: 'en', language_code: 'en', described_class.create!(code: 'ain-Kana', language_code: 'ain',
script_code: 'Latn', region_code: 826, script_code: 'Kana', region_code: 392,
variants: '', name: 'English') variants: '', name: 'Ainu')
end end
it 'keeps the active Nipponese locale consistent with its identity tuple' do it 'keeps the active Nipponese locale consistent with its identity tuple' do
@@ -27,7 +27,6 @@ RSpec.describe Locale, type: :model do
end end
it 'generates a primary name for every existing tag using the name generator' do it 'generates a primary name for every existing tag using the name generator' do
pending '日本語以外の Locale 有効化は別課題で実装する'
first = create(:tag, primary_name: 'first_existing_tag') first = create(:tag, primary_name: 'first_existing_tag')
second = create(:tag, primary_name: 'second_existing_tag') second = create(:tag, primary_name: 'second_existing_tag')
allow(TagName).to receive(:generate_name).and_call_original allow(TagName).to receive(:generate_name).and_call_original
@@ -35,63 +34,58 @@ RSpec.describe Locale, type: :model do
.with(kind_of(described_class), first, primary_tag_name_for(first, 'ja')) .with(kind_of(described_class), first, primary_tag_name_for(first, 'ja'))
.and_return('name_from_generator') .and_return('name_from_generator')
locale = create_english_locale! locale = create_ainu_locale!
expect(TagName).to have_received(:generate_name) expect(TagName).to have_received(:generate_name)
.with(locale, first, primary_tag_name_for(first, 'ja')) .with(locale, first, primary_tag_name_for(first, 'ja'))
expect(TagName).to have_received(:generate_name) expect(TagName).to have_received(:generate_name)
.with(locale, second, primary_tag_name_for(second, 'ja')) .with(locale, second, primary_tag_name_for(second, 'ja'))
expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true)) expect(first.tag_names.find_by!(language_code: 'ain', primary_flg: true))
.to have_attributes(name: 'name_from_generator', tag_id: first.id, .to have_attributes(name: 'name_from_generator', tag_id: first.id,
language_code: 'en', script_code: 'Latn', language_code: 'ain', script_code: 'Kana',
primary_flg: true, auto_generated: true) primary_flg: true, auto_generated: true)
expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true)) expect(second.tag_names.find_by!(language_code: 'ain', primary_flg: true))
.to have_attributes(tag_id: second.id, language_code: 'en', .to have_attributes(tag_id: second.id, language_code: 'ain',
script_code: 'Latn', primary_flg: true, script_code: 'Kana', primary_flg: true,
auto_generated: true) auto_generated: true)
expect(TagName.where(language_code: 'en', primary_flg: true, expect(TagName.where(language_code: 'ain', primary_flg: true,
tag_id: [first.id, second.id]).count).to eq(2) tag_id: [first.id, second.id]).count).to eq(2)
end end
it 'does not duplicate an existing primary name in the new language' do it 'does not duplicate an existing primary name in the new language' do
pending '日本語以外の Locale 有効化は別課題で実装する' prepare_ainu_reference!
prepare_english_reference!
tag = create(:tag, primary_name: 'already_named') tag = create(:tag, primary_name: 'already_named')
existing = create(:tag_name, name: 'existing_english', tag:, existing = create(:tag_name, name: 'existing_ainu', tag:,
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
create_english_locale! create_ainu_locale!
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true)) expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true))
.to contain_exactly(existing) .to contain_exactly(existing)
expect(existing.reload).to have_attributes( expect(existing.reload).to have_attributes(
tag_id: tag.id, language_code: 'en', script_code: 'Latn', tag_id: tag.id, language_code: 'ain', script_code: 'Kana',
primary_flg: true, auto_generated: false) primary_flg: true, auto_generated: false)
end end
it 'creates a primary name when the language has only an alias' do it 'adds the new language primary while preserving existing names and aliases' do
pending '日本語以外の Locale 有効化は別課題で実装する' tag = create(:tag, primary_name: 'named_tag')
prepare_english_reference! create(:tag_name, :alias, name: 'existing_alias', tag:,
tag = create(:tag, primary_name: 'alias_only_tag') language_code: 'ja', script_code: 'Jpan')
alias_name = create(:tag_name, :alias, name: 'alias_en', original_names = tag.tag_names.order(:id).map(&:attributes)
tag:,
language_code: 'en', script_code: 'Latn')
create_english_locale! create_ainu_locale!
expect(alias_name.reload).to have_attributes( expect(tag.tag_names.where(language_code: 'ja').order(:id).map(&:attributes))
tag_id: tag.id, .to eq(original_names)
language_code: 'en', script_code: 'Latn', primary_flg: false) generated = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true) expect(generated).to have_attributes(name: 'named_tag', tag_id: tag.id, language_code: 'ain',
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en', script_code: 'Kana', primary_flg: true,
script_code: 'Latn', primary_flg: true,
auto_generated: true) auto_generated: true)
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1) expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true).count).to eq(1)
end end
it 'rolls back the locale and all generated names when backfill fails' do it 'rolls back the locale and all generated names when backfill fails' do
pending '日本語以外の Locale 有効化は別課題で実装する' prepare_ainu_reference!
prepare_english_reference!
first = create(:tag, primary_name: 'atomic_first') first = create(:tag, primary_name: 'atomic_first')
second = create(:tag, primary_name: 'atomic_second') second = create(:tag, primary_name: 'atomic_second')
allow(TagName).to receive(:generate_name).and_call_original allow(TagName).to receive(:generate_name).and_call_original
@@ -99,31 +93,27 @@ RSpec.describe Locale, type: :model do
.with(kind_of(described_class), second, primary_tag_name_for(second, 'ja')) .with(kind_of(described_class), second, primary_tag_name_for(second, 'ja'))
.and_raise('backfill failed') .and_raise('backfill failed')
expect { create_english_locale! }.to raise_error('backfill failed') expect { create_ainu_locale! }.to raise_error('backfill failed')
expect(described_class.exists?(code: 'en')).to be(false) expect(described_class.exists?(code: 'ain-Kana')).to be(false)
expect(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty expect(TagName.where(language_code: 'ain', tag_id: [first.id, second.id])).to be_empty
end end
it 'accepts a stable locale code outside the historical enum values' do it 'rejects a private locale code outside the supported locale set' do
pending '日本語以外の Locale 有効化は別課題で実装する' prepare_ainu_reference!
prepare_english_reference!
locale = described_class.create!( expect {
code: 'en-x-rspec', language_code: 'en', described_class.create!(
script_code: 'Latn', region_code: 826, code: 'ain-Kana-x-rspec', language_code: 'ain',
variants: 'x-rspec', name: 'English') script_code: 'Kana', region_code: 392,
variants: 'x-rspec', name: 'Ainu')
expect(locale.reload).to have_attributes( }.to raise_error(ArgumentError)
code: 'en-x-rspec', language_code: 'en', script_code: 'Latn',
region_code: 826, variants: 'x-rspec')
end end
it 'registers the future British English locale tuple' do it 'registers the supported Ainu locale tuple' do
pending '日本語以外の Locale 有効化は別課題で実装する'
expect(locale).to have_attributes(code: 'en', language_code: 'en', expect(locale).to have_attributes(code: 'ain-Kana', language_code: 'ain',
script_code: 'Latn', region_code: 826, script_code: 'Kana', region_code: 392,
variants: '') variants: '')
end end
end end
+3 -4
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Script, type: :model do RSpec.describe Script, type: :model do
include_context 'English locale' include_context 'Ainu locale'
it 'selects locales by script code, not language code' do it 'selects locales by script code, not language code' do
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese) expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
@@ -25,11 +25,10 @@ RSpec.describe Script, type: :model do
} }
end end
it 'selects Latin locales when multilingual support is enabled' do it 'selects Kana locales when multilingual support is enabled' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
expect(described_class.find('Latn').locales).to contain_exactly(locale) expect(described_class.find('Kana').locales).to contain_exactly(locale)
expect(described_class.find('Jpan').locales).not_to include(locale) expect(described_class.find('Jpan').locales).not_to include(locale)
end end
end end
+14 -14
ファイルの表示
@@ -95,38 +95,38 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end end
context 'when only another language has the sanitised name' do context 'when only another language has the sanitised name' do
include_context 'English locale' include_context 'Ainu locale'
it 'keeps both names when sanitisation produces a cross-language match' do it 'keeps both names when sanitisation produces a cross-language match' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
nipponese = create(:tag_name, name: 'foobar') nipponese = create(:tag_name, name: 'foobar')
english = create(:tag_name, name: 'temporary', ainu = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
english.update_columns(name: 'foo_bar') ainu.update_columns(name: 'foo_bar')
expect { described_class.apply! }.not_to change(TagName, :count) expect { described_class.apply! }.not_to change(TagName, :count)
expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en') expect(ainu.reload).to have_attributes(name: 'foobar', language_code: 'ain')
expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja') expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
end end
it 'renames without merging tags or deleting either language identity' do it 'renames without merging tags or deleting either language identity' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
nipponese = create(:tag, primary_name: 'foobar') nipponese = create(:tag, primary_name: 'foobar')
english_name = create(:tag_name, name: 'temporary', primary_tag_name_for(nipponese, 'ain').update!(
language_code: 'en', script_code: 'Latn') name: 'different_ainu_name', auto_generated: false)
english = create(:tag, primary_tag_name: english_name) ainu_name = create(:tag_name, name: 'temporary',
english_name.update_columns(name: 'foo_bar') language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name)
ainu_name.update_columns(name: 'foo_bar')
described_class.apply! described_class.apply!
expect(english_name.reload).to have_attributes( expect(ainu_name.reload).to have_attributes(
name: 'foobar', language_code: 'en', tag_id: english.id) name: 'foobar', language_code: 'ain', tag_id: ainu.id)
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes( expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
name: 'foobar', language_code: 'ja', tag_id: nipponese.id) name: 'foobar', language_code: 'ja', tag_id: nipponese.id)
expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name) expect(primary_tag_name_for(ainu.reload, 'ain')).to eq(ainu_name)
end end
end end
+25 -29
ファイルの表示
@@ -1,17 +1,16 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe TagName, type: :model do RSpec.describe TagName, type: :model do
include_context 'English locale' include_context 'Ainu locale'
it 'allows the same name in different languages' do it 'allows the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
create(:tag_name, name: 'shared_name') create(:tag_name, name: 'shared_name')
english = build(:tag_name, name: 'shared_name', ainu = build(:tag_name, name: 'shared_name',
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
expect(english).to be_valid expect(ainu).to be_valid
expect { english.save! }.to change(described_class, :count).by(1) expect { ainu.save! }.to change(described_class, :count).by(1)
end end
it 'rejects a duplicate name within the same language' do it 'rejects a duplicate name within the same language' do
@@ -67,11 +66,10 @@ RSpec.describe TagName, type: :model do
end end
it 'completes an ordinary tag factory under future active languages' do it 'completes an ordinary tag factory under future active languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
tag = create(:tag, primary_name: 'future_factory') tag = create(:tag, primary_name: 'future_factory')
expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
.to contain_exactly(have_attributes(auto_generated: true)) .to contain_exactly(have_attributes(auto_generated: true))
end end
@@ -114,46 +112,44 @@ RSpec.describe TagName, type: :model do
end end
it 'resolves the primary name of the alias language on the same tag' do it 'resolves the primary name of the alias language on the same tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
tag = create(:tag, primary_name: '日本語正本') tag = create(:tag, primary_name: '日本語正本')
english_primary = primary_tag_name_for(tag, 'en') ainu_primary = primary_tag_name_for(tag, 'ain')
english_primary.update!(name: 'english_primary', auto_generated: false) ainu_primary.update!(name: 'ainu_primary', auto_generated: false)
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias', ainu_alias = create(:tag_name, :alias, tag:, name: 'ainu_alias',
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
representative_id = tag.tag_name('ja')&.id representative_id = tag.tag_name('ja')&.id
primary = described_class.find_by!(tag_id: english_alias.tag_id, primary = described_class.find_by!(tag_id: ainu_alias.tag_id,
language_code: english_alias.language_code, language_code: ainu_alias.language_code,
primary_flg: true) primary_flg: true)
expect(primary).to eq(english_primary) expect(primary).to eq(ainu_primary)
expect(described_class.canonicalise(locale, [english_alias.name])) expect(described_class.canonicalise(locale, [ainu_alias.name]))
.to eq([english_primary.name]) .to eq([ainu_primary.name])
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
end end
it 'resolves only aliases in the requested language' do it 'resolves only aliases in the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
nipponese = create(:tag, primary_name: 'nipponese_canonical') nipponese = create(:tag, primary_name: 'nipponese_canonical')
english_name = create(:tag_name, name: 'english_canonical', ainu_name = create(:tag_name, name: 'ainu_canonical',
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
english = create(:tag, primary_tag_name: english_name) ainu = create(:tag, primary_tag_name: ainu_name)
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese) tag: nipponese)
english_alias = create(:tag_name, :alias, name: 'shared_alias', ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english, tag: ainu,
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
expect(nipponese_alias.reload).to have_attributes( expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id, tag_id: nipponese.id,
primary_flg: false, language_code: 'ja') primary_flg: false, language_code: 'ja')
expect(english_alias.reload).to have_attributes( expect(ainu_alias.reload).to have_attributes(
tag_id: english.id, tag_id: ainu.id,
primary_flg: false, language_code: 'en') primary_flg: false, language_code: 'ain')
expect(described_class.canonicalise(locale, ['shared_alias'])) expect(described_class.canonicalise(locale, ['shared_alias']))
.to eq(['english_canonical']) .to eq(['ainu_canonical'])
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias'])) expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
.to eq(['nipponese_canonical']) .to eq(['nipponese_canonical'])
end end
+63 -74
ファイルの表示
@@ -8,7 +8,7 @@ RSpec.describe Tag, type: :model do
it 'checks the requested language and treats an empty description as present' do it 'checks the requested language and treats an empty description as present' do
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1) TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
expect(tag.has_description?(language_code: 'ja')).to be(true) expect(tag.has_description?(language_code: 'ja')).to be(true)
expect(tag.has_description?(language_code: 'en')).to be(false) expect(tag.has_description?(language_code: 'ain')).to be(false)
end end
it 'returns false without a description' do it 'returns false without a description' do
@@ -23,16 +23,16 @@ RSpec.describe Tag, type: :model do
subscriber = ->(*args) { queries << args.last.fetch(:sql) } subscriber = ->(*args) { queries << args.last.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
expect(loaded.has_description?(language_code: 'ja')).to be(true) expect(loaded.has_description?(language_code: 'ja')).to be(true)
expect(loaded.has_description?(language_code: 'en')).to be(false) expect(loaded.has_description?(language_code: 'ain')).to be(false)
end end
expect(queries).to be_empty expect(queries).to be_empty
end end
it 'keeps language selection generic without enabling another Language record' do it 'keeps language selection generic without enabling another Language record' do
tag.association(:tag_descriptions).target = [ tag.association(:tag_descriptions).target = [
TagDescription.new(tag:, language_code: 'en', body: '', version_no: 1)] TagDescription.new(tag:, language_code: 'ain', body: '', version_no: 1)]
tag.association(:tag_descriptions).loaded! tag.association(:tag_descriptions).loaded!
expect(tag.has_description?(language_code: 'en')).to be(true) expect(tag.has_description?(language_code: 'ain')).to be(true)
expect(tag.has_description?(language_code: 'ja')).to be(false) expect(tag.has_description?(language_code: 'ja')).to be(false)
end end
end end
@@ -61,33 +61,32 @@ RSpec.describe Tag, type: :model do
end end
describe '.normalise_tags!' do describe '.normalise_tags!' do
include_context 'English locale' include_context 'Ainu locale'
it 'canonicalises aliases in the supplied language only' do it 'canonicalises aliases in the supplied language only' do
pending '日本語以外の Locale 有効化は別課題で実装する' prepare_ainu_reference!
prepare_english_reference!
nipponese = create(:tag, primary_name: 'nipponese_canonical') nipponese = create(:tag, primary_name: 'nipponese_canonical')
english_name = create(:tag_name, name: 'english_canonical', ainu_name = build(:tag_name, tag: nil, name: 'ainu_canonical',
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
english = create(:tag, primary_tag_name: english_name) ainu = create(:tag, primary_tag_name: ainu_name)
primary_tag_name_for(english, 'ja').update!( primary_tag_name_for(ainu, 'ja').update!(
name: 'english_in_nipponese', auto_generated: false) name: 'ainu_in_nipponese', auto_generated: false)
english_locale = locale ainu_locale = locale
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese) tag: nipponese)
english_alias = create(:tag_name, :alias, name: 'shared_alias', ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english, tag: ainu,
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
expect(nipponese_alias.reload).to have_attributes( expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id, tag_id: nipponese.id,
primary_flg: false, language_code: 'ja') primary_flg: false, language_code: 'ja')
expect(english_alias.reload).to have_attributes( expect(ainu_alias.reload).to have_attributes(
tag_id: english.id, tag_id: ainu.id,
primary_flg: false, language_code: 'en') primary_flg: false, language_code: 'ain')
expect(described_class.normalise_tags!( expect(described_class.normalise_tags!(
english_locale, ['shared_alias'], ainu_locale, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([english]) with_tagme: false, with_no_deerjikist: false)).to eq([ainu])
expect(described_class.normalise_tags!( expect(described_class.normalise_tags!(
Locale.nipponese, ['shared_alias'], Locale.nipponese, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([nipponese]) with_tagme: false, with_no_deerjikist: false)).to eq([nipponese])
@@ -253,129 +252,120 @@ RSpec.describe Tag, type: :model do
describe '.find_or_create_by_tag_name!' do describe '.find_or_create_by_tag_name!' do
context 'with an explicit locale' do context 'with an explicit locale' do
include_context 'English locale' include_context 'Ainu locale'
it 'creates the representative name with locale attributes and ownership' do it 'creates the representative name with locale attributes and ownership' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
tag = described_class.find_or_create_by_tag_name!( tag = described_class.find_or_create_by_tag_name!(
locale, 'english_name', category: :character) locale, 'ainu_name', category: :character)
expect(primary_tag_name_for(tag, 'en')).to have_attributes( expect(primary_tag_name_for(tag, 'ain')).to have_attributes(
language_code: 'en', script_code: 'Latn', language_code: 'ain', script_code: 'Kana',
primary_flg: true, tag_id: tag.id) primary_flg: true, tag_id: tag.id)
expect(tag.category).to eq('character') expect(tag.category).to eq('character')
end end
it 'uses the English primary as the source for a generated Nipponese name' do it 'copies an Ainu-origin name to Japanese without reverse transcription' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
allow(TagName).to receive(:generate_name).and_return('generated_nipponese') allow(TagName).to receive(:generate_name).and_call_original
tag = described_class.find_or_create_by_tag_name!( tag = described_class.find_or_create_by_tag_name!(
locale, 'english_origin', category: :general) locale, 'ainu_origin', category: :general)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
.to contain_exactly(have_attributes( .to contain_exactly(have_attributes(
name: 'english_origin', auto_generated: false)) name: 'ainu_origin', auto_generated: false))
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(have_attributes( .to contain_exactly(have_attributes(
name: 'generated_nipponese', auto_generated: true)) name: 'ainu_origin', auto_generated: true))
source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true)
expect(TagName).to have_received(:generate_name) expect(TagName).to have_received(:generate_name)
.with(Locale.nipponese, tag, source_tag_name) .with(Locale.nipponese, tag, tag.tag_name('ain'))
end end
it 'gives a new tag one primary name for every active language' do it 'gives a new tag one primary name for every active language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
tag = described_class.find_or_create_by_tag_name!( tag = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'new_multilingual_tag', category: :general) Locale.nipponese, 'new_multilingual_tag', category: :general)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1) expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
end end
it 'creates one primary per language when multiple locales use English' do it 'creates one primary per language when multiple locales use Ainu' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
described_class.find_or_create_by_tag_name!( described_class.find_or_create_by_tag_name!(
locale, 'before_second_english_locale', category: :general) locale, 'before_second_ainu_locale', category: :general)
Locale.create!(code: 'en-x-rspec', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: 'x-rspec', name: 'English')
tag = described_class.find_or_create_by_tag_name!( tag = described_class.find_or_create_by_tag_name!(
locale, 'one_english_primary', category: :general) locale, 'one_ainu_primary', category: :general)
TagNameCompletion.for_tag!(tag, locales: [locale, locale.dup, Locale.nipponese])
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1) expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
end end
it 'rolls back a new tag and requested name if name generation fails' do it 'rolls back a new tag and requested name if name generation fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
original_tag_count = described_class.count original_tag_count = described_class.count
allow(TagName).to receive(:generate_name).and_raise('generation failed') allow(TagName).to receive(:generate_name).and_raise('generation failed')
expect { expect {
described_class.find_or_create_by_tag_name!( described_class.find_or_create_by_tag_name!(
locale, 'atomic_english_name', category: :general) locale, 'atomic_ainu_name', category: :general)
}.to raise_error('generation failed') }.to raise_error('generation failed')
expect(described_class.count).to eq(original_tag_count) expect(described_class.count).to eq(original_tag_count)
expect(TagName.where(language_code: 'en', name: 'atomic_english_name')).to be_empty expect(TagName.where(language_code: 'ain', name: 'atomic_ainu_name')).to be_empty
expect(described_class.joins(:tag_names) expect(described_class.joins(:tag_names)
.where(tag_names: { name: 'atomic_english_name' })).to be_empty .where(tag_names: { name: 'atomic_ainu_name' })).to be_empty
end end
it 'creates separate tag identities for the same name in different languages' do it 'creates separate tag identities for the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
nipponese = described_class.find_or_create_by_tag_name!( nipponese = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general) Locale.nipponese, 'same_name', category: :general)
english = described_class.find_or_create_by_tag_name!( primary_tag_name_for(nipponese, 'ain').update!(
name: 'other_ainu_name', auto_generated: false)
ainu = described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :character) locale, 'same_name', category: :character)
expect(english).not_to eq(nipponese) expect(ainu).not_to eq(nipponese)
expect(primary_tag_name_for(english, 'en')).to have_attributes( expect(primary_tag_name_for(ainu, 'ain')).to have_attributes(
name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true) name: 'same_name', tag_id: ainu.id, language_code: 'ain', primary_flg: true)
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes( expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true) name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
expect(described_class.find_or_create_by_tag_name!( expect(described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :general)).to eq(english) locale, 'same_name', category: :general)).to eq(ainu)
expect(described_class.find_or_create_by_tag_name!( expect(described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese) Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
end end
it 'preserves the V1 representative when looking up another primary language' do it 'preserves the V1 representative when looking up another primary language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
tag = create(:tag, primary_name: '日本語代表名') tag = create(:tag, primary_name: '日本語代表名')
representative_id = tag.tag_name('ja')&.id representative_id = tag.tag_name('ja')&.id
english = primary_tag_name_for(tag, 'en') ainu = primary_tag_name_for(tag, 'ain')
english.update!(name: 'english_primary', auto_generated: false) ainu.update!(name: 'ainu_primary', auto_generated: false)
found = described_class.find_or_create_by_tag_name!( found = described_class.find_or_create_by_tag_name!(
locale, english.name, category: :general) locale, ainu.name, category: :general)
expect(found).to eq(tag) expect(found).to eq(tag)
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
expect(english.reload.tag_id).to eq(tag.id) expect(ainu.reload.tag_id).to eq(tag.id)
end end
it 'normalises names using the supplied locale' do it 'normalises names using the supplied locale' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
tags = described_class.normalise_tags!( tags = described_class.normalise_tags!(
locale, ['character:normalised_english'], locale, ['character:normalised_ainu'],
with_tagme: false, with_no_deerjikist: false) with_tagme: false, with_no_deerjikist: false)
expect(tags.length).to eq(1) expect(tags.length).to eq(1)
expect(primary_tag_name_for(tags.first, 'en')).to have_attributes( expect(primary_tag_name_for(tags.first, 'ain')).to have_attributes(
name: 'normalised_english', language_code: 'en', script_code: 'Latn', name: 'normalised_ainu', language_code: 'ain', script_code: 'Kana',
primary_flg: true, tag_id: tags.first.id) primary_flg: true, tag_id: tags.first.id)
end end
end end
@@ -444,26 +434,25 @@ RSpec.describe Tag, type: :model do
end end
context 'with names in multiple languages' do context 'with names in multiple languages' do
include_context 'English locale' include_context 'Ainu locale'
it 'keeps the target primary and moves source names as aliases' do it 'keeps the target primary and moves source names as aliases' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
english_primary = primary_tag_name_for(target_tag, 'en') ainu_primary = primary_tag_name_for(target_tag, 'ain')
source_primary = primary_tag_name_for(source_tag, 'en') source_primary = primary_tag_name_for(source_tag, 'ain')
source_alias = create(:tag_name, :alias, tag: source_tag, source_alias = create(:tag_name, :alias, tag: source_tag,
name: 'source_english_alias', language_code: 'en', script_code: 'Latn') name: 'source_ainu_alias', language_code: 'ain', script_code: 'Kana')
expect([english_primary, source_primary]).to all(have_attributes( expect([ainu_primary, source_primary]).to all(have_attributes(
primary_flg: true, auto_generated: true)) primary_flg: true, auto_generated: true))
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary) expect(primary_tag_name_for(target_tag.reload, 'ain')).to eq(ainu_primary)
expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true)) expect(TagName.where(tag: target_tag, language_code: 'ain', primary_flg: true))
.to contain_exactly(english_primary) .to contain_exactly(ainu_primary)
expect([source_primary.reload, source_alias.reload]).to all(have_attributes( expect([source_primary.reload, source_alias.reload]).to all(have_attributes(
tag_id: target_tag.id, language_code: 'en', primary_flg: false)) tag_id: target_tag.id, language_code: 'ain', primary_flg: false))
expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true)) expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true))
.to contain_exactly(primary_tag_name_for(target_tag, 'ja')) .to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
expect(source_tag_name.reload).to have_attributes( expect(source_tag_name.reload).to have_attributes(
+8
ファイルの表示
@@ -53,6 +53,14 @@ RSpec.configure do |config|
config.include AuthHelper, type: :request config.include AuthHelper, type: :request
config.include JsonHelper, type: :request config.include JsonHelper, type: :request
config.around(:each, type: :request) do |example|
previous = ApplicationController.allow_forgery_protection
ApplicationController.allow_forgery_protection = example.metadata.fetch(:csrf, false)
example.run
ensure
ApplicationController.allow_forgery_protection = previous
end
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_paths = [ config.fixture_paths = [
Rails.root.join('spec/fixtures') Rails.root.join('spec/fixtures')
+54
ファイルの表示
@@ -0,0 +1,54 @@
require 'rails_helper'
RSpec.describe 'API locale resolution', type: :request do
let!(:tag) { create(:tag, primary_name: '日本語正本') }
it 'defaults an omitted locale to Japanese' do
get "/tags/#{ tag.id }"
expect(response).to have_http_status(:ok)
expect(json.fetch('name')).to eq('日本語正本')
end
it 'accepts an explicitly requested active Japanese locale' do
get "/tags/#{ tag.id }", params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
expect(json.fetch('name')).to eq('日本語正本')
end
['', 'en', 'ain', 'Jpan', 'JA', 'unknown', ['ja']].each do |code|
it "rejects invalid locale #{ code.inspect } instead of defaulting to Japanese" do
get "/tags/#{ tag.id }", params: { locale: code }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('日本語正本')
end
end
it 'rejects a supported locale that has not been registered' do
get "/tags/#{ tag.id }", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:not_found)
end
it 'rejects an inactive locale' do
Locale.nipponese.update!(deprecated_at: Time.current)
get "/tags/#{ tag.id }", params: { locale: 'ja' }
expect(response).to have_http_status(:not_found)
end
it 'does not write posts or tag names under an invalid locale' do
sign_in_as(create_member_user!)
expect do
post '/posts', params: {
locale: 'en', title: 'invalid locale', url: 'https://example.com/invalid-locale',
tags: 'unwritten_tag' }
end.to change(Post, :count).by(0).and change(TagName, :count).by(0)
expect(response).to have_http_status(:not_found)
end
end
+68 -80
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Locale propagation on write paths', type: :request do RSpec.describe 'Locale propagation on write paths', type: :request do
include_context 'English locale' include_context 'Ainu locale'
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
@@ -54,13 +54,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end end
end end
['en', nil].each do |requested_locale| ['ain-Kana', nil].each do |requested_locale|
context "with locale #{ requested_locale.inspect }" do context "with locale #{ requested_locale.inspect }" do
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } } let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
let(:expected_locale) { requested_locale ? locale : Locale.nipponese } let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
it 'creates post tags in the requested language or Nipponese fallback' do it 'creates post tags in the requested language or Nipponese fallback' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale locale if requested_locale
post '/posts', params: locale_params.merge( post '/posts', params: locale_params.merge(
title: 'Locale post', url: 'https://example.com/locale-post', title: 'Locale post', url: 'https://example.com/locale-post',
@@ -72,7 +71,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end end
it 'updates post tags in the requested language or Nipponese fallback' do it 'updates post tags in the requested language or Nipponese fallback' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale locale if requested_locale
record = create(:post) record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member) PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
@@ -87,7 +85,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end end
it 'passes the resolved locale to bulk creation' do it 'passes the resolved locale to bulk creation' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale locale if requested_locale
# Worker propagation is exercised in post_bulk_creator_spec. # Worker propagation is exercised in post_bulk_creator_spec.
expect_any_instance_of(PostBulkCreator).to receive(:run) expect_any_instance_of(PostBulkCreator).to receive(:run)
@@ -102,7 +99,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end end
it 'creates materials with tags in the resolved locale' do it 'creates materials with tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale locale if requested_locale
post '/materials', params: locale_params.merge( post '/materials', params: locale_params.merge(
tag: 'locale_material', url: 'https://example.com/material') tag: 'locale_material', url: 'https://example.com/material')
@@ -113,7 +109,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end end
it 'updates materials with tags in the resolved locale' do it 'updates materials with tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale locale if requested_locale
tag = create(:tag, category: :material) tag = create(:tag, category: :material)
material = Material.create!(tag:, url: 'https://example.com/material') material = Material.create!(tag:, url: 'https://example.com/material')
@@ -127,7 +122,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end end
it 'links Nico external tags to internal tags in the resolved locale' do it 'links Nico external tags to internal tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale locale if requested_locale
external = create(:external_tag) external = create(:external_tag)
NicoTagVersionRecorder.record!( NicoTagVersionRecorder.record!(
@@ -141,7 +135,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end end
it 'creates parent tags in the resolved locale' do it 'creates parent tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale locale if requested_locale
tag = create(:tag) tag = create(:tag)
@@ -157,184 +150,179 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end end
context 'with existing names in both languages' do context 'with existing names in both languages' do
let(:english_name) do let(:ainu_name) do
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn') create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana')
end
let(:ainu_tag) do
create(:tag, primary_tag_name: ainu_name, category: :material).tap do |tag|
primary_tag_name_for(tag, 'ja').update!(name: 'ainu_in_japanese', auto_generated: false)
end
end end
let(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) }
let(:nipponese_tag) do let(:nipponese_tag) do
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag| create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag|
# Isolate lookup from the separately tested uniqueness validation. primary_tag_name_for(tag, 'ja').update!(name: 'shared_name')
primary_tag_name_for(tag, 'ja').update_columns(name: 'shared_name')
end end
end end
it 'plans and creates the post using the English identity and category' do it 'plans and creates the post using the Ainu identity and category' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
english_tag ainu_tag
nipponese_tag nipponese_tag
post '/posts', params: { post '/posts', params: {
locale: 'en', title: 'English identity', locale: 'ain-Kana', title: 'Ainu identity',
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' } url: 'https://example.com/ainu-identity', tags: 'shared_name', parent_post_ids: '' }
expect(response).to have_http_status(:created), response.body expect(response).to have_http_status(:created), response.body
record = Post.find(json.fetch('id')) record = Post.find(json.fetch('id'))
expect(record.tags).to include(english_tag) expect(record.tags).to include(ainu_tag)
expect(record.tags).not_to include(nipponese_tag) expect(record.tags).not_to include(nipponese_tag)
expect(english_tag.reload.category).to eq('material') expect(ainu_tag.reload.category).to eq('material')
expect(nipponese_tag.reload.category).to eq('general') expect(nipponese_tag.reload.category).to eq('general')
end end
it 'updates the post using the English identity' do it 'updates the post using the Ainu identity' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
english_tag ainu_tag
nipponese_tag nipponese_tag
record = create(:post) record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member) PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
put "/posts/#{ record.id }", params: { put "/posts/#{ record.id }", params: {
locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '', locale: 'ain-Kana', title: record.title, tags: 'shared_name', parent_post_ids: '',
base_version_no: record.reload.version_no } base_version_no: record.reload.version_no }
expect(response).to have_http_status(:ok), response.body expect(response).to have_http_status(:ok), response.body
expect(record.reload.tags).to include(english_tag) expect(record.reload.tags).to include(ainu_tag)
expect(record.tags).not_to include(nipponese_tag) expect(record.tags).not_to include(nipponese_tag)
end end
it 'resolves the English material tag' do it 'resolves the Ainu material tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
english_tag ainu_tag
nipponese_tag nipponese_tag
post '/materials', params: { post '/materials', params: {
locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' } locale: 'ain-Kana', tag: 'shared_name', url: 'https://example.com/ainu-material' }
expect(response).to have_http_status(:created), response.body expect(response).to have_http_status(:created), response.body
expect(Material.find(json.fetch('id')).tag).to eq(english_tag) expect(Material.find(json.fetch('id')).tag).to eq(ainu_tag)
expect(Material.where(tag: nipponese_tag)).to be_empty expect(Material.where(tag: nipponese_tag)).to be_empty
end end
it 'resolves the English parent tag' do it 'resolves the Ainu parent tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
english_tag ainu_tag
nipponese_tag nipponese_tag
child = create(:tag) child = create(:tag)
put "/tags/#{ child.id }", params: { put "/tags/#{ child.id }", params: {
locale: 'en', name: primary_tag_name_for(child, 'ja').name, locale: 'ain-Kana', name: primary_tag_name_for(child, 'ja').name,
category: child.category, deprecated: false, category: child.category, deprecated: false,
aliases: '', parent_tags: 'shared_name' } aliases: '', parent_tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body expect(response).to have_http_status(:ok), response.body
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id]) expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([ainu_tag.id])
end end
it 'resolves the English internal tag for a Nico link' do it 'resolves the Ainu internal tag for a Nico link' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
english_tag ainu_tag
nipponese_tag nipponese_tag
external = create(:external_tag) external = create(:external_tag)
NicoTagVersionRecorder.record!( NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: member) external_tag: external, event_type: :create, created_by_user: member)
put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' } put "/tags/nico/#{ external.id }", params: { locale: 'ain-Kana', tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body expect(response).to have_http_status(:ok), response.body
expect(external.reload.linked_tags).to contain_exactly(english_tag) expect(external.reload.linked_tags).to contain_exactly(ainu_tag)
end end
end end
context 'when renaming or updating aliases in English' do context 'when renaming or updating aliases in Ainu' do
let(:english_name) do let(:ainu_name) do
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn') create(:tag_name, name: 'ainu_original', language_code: 'ain', script_code: 'Kana')
end end
let(:tag) { create(:tag, primary_tag_name: english_name) } let(:tag) { create(:tag, primary_tag_name: ainu_name) }
it 'marks a human-renamed generated primary as no longer auto-generated' do it 'marks a human-renamed generated primary as no longer auto-generated' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
generated_tag = create(:tag, primary_name: 'rename_generated_source') generated_tag = create(:tag, primary_name: 'rename_generated_source')
generated_name = primary_tag_name_for(generated_tag, 'en') generated_name = primary_tag_name_for(generated_tag, 'ain')
expect(generated_name.auto_generated).to be(true) expect(generated_name.auto_generated).to be(true)
patch "/tags/#{ generated_tag.id }", params: { patch "/tags/#{ generated_tag.id }", params: {
locale: 'en', name: 'human_english_name' } locale: 'ain-Kana', name: 'human_ainu_name' }
expect(response).to have_http_status(:ok), response.body expect(response).to have_http_status(:ok), response.body
expect(generated_name.reload).to have_attributes( expect(generated_name.reload).to have_attributes(
name: 'human_english_name', auto_generated: false) name: 'human_ainu_name', auto_generated: false)
end end
it 'does not steal another tag primary for an alias' do it 'does not steal another tag primary for an alias' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
other_tag = create(:tag, primary_name: 'other_nipponese') other_tag = create(:tag, primary_name: 'other_nipponese')
other_primary = primary_tag_name_for(other_tag, 'en') other_primary = primary_tag_name_for(other_tag, 'ain')
other_primary.update!(name: 'other_english_primary', auto_generated: false) other_primary.update!(name: 'other_ainu_primary', auto_generated: false)
create(:tag_name, :alias, tag:, name: 'existing_english_alias', create(:tag_name, :alias, tag:, name: 'existing_ainu_alias',
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
primary_name_before = primary_tag_name_for(tag, 'en').name primary_name_before = primary_tag_name_for(tag, 'ain').name
aliases_before = aliases_before =
tag.tag_names.where(language_code: 'en', primary_flg: false) tag.tag_names.where(language_code: 'ain', primary_flg: false)
.order(:name).pluck(:name) .order(:name).pluck(:name)
category_before = tag.category category_before = tag.category
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
locale: 'en', name: 'renamed_before_alias_collision', locale: 'ain-Kana', name: 'renamed_before_alias_collision',
category: 'character', deprecated: false, category: 'character', deprecated: false,
aliases: 'other_english_primary', parent_tags: '' } aliases: 'other_ainu_primary', parent_tags: '' }
aggregate_failures do aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(other_primary.reload).to have_attributes( expect(other_primary.reload).to have_attributes(
tag_id: other_tag.id, language_code: 'en', primary_flg: true) tag_id: other_tag.id, language_code: 'ain', primary_flg: true)
expect(TagName.where(tag: other_tag, language_code: 'en', primary_flg: true)) expect(TagName.where(tag: other_tag, language_code: 'ain', primary_flg: true))
.to contain_exactly(other_primary) .to contain_exactly(other_primary)
expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before) expect(primary_tag_name_for(tag.reload, 'ain').name).to eq(primary_name_before)
expect(tag.category).to eq(category_before) expect(tag.category).to eq(category_before)
aliases_after = aliases_after =
tag.tag_names.where(language_code: 'en', primary_flg: false) tag.tag_names.where(language_code: 'ain', primary_flg: false)
.order(:name).pluck(:name) .order(:name).pluck(:name)
expect(aliases_after).to eq(aliases_before) expect(aliases_after).to eq(aliases_before)
end end
end end
it 'allows a rename to a name already used in Nipponese' do it 'allows a rename to a name already used in Nipponese' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
nipponese = create(:tag, primary_name: 'rename_target') nipponese = create(:tag, primary_name: 'rename_target')
primary_tag_name_for(nipponese, 'ain').update!(
name: 'different_ainu_target', auto_generated: false)
patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' } patch "/tags/#{ tag.id }", params: { locale: 'ain-Kana', name: 'rename_target' }
expect(response).to have_http_status(:ok), response.body expect(response).to have_http_status(:ok), response.body
expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes( expect(primary_tag_name_for(tag.reload, 'ain')).to have_attributes(
name: 'rename_target', language_code: 'en', tag_id: tag.id) name: 'rename_target', language_code: 'ain', tag_id: tag.id)
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes( expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id) name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
end end
it 'creates aliases in the requested language' do it 'creates aliases in the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
locale: 'en', name: english_name.name, locale: 'ain-Kana', name: ainu_name.name,
category: tag.category, deprecated: false, category: tag.category, deprecated: false,
aliases: 'english_alias', parent_tags: '' } aliases: 'ainu_alias', parent_tags: '' }
expect(response).to have_http_status(:ok), response.body expect(response).to have_http_status(:ok), response.body
expect(TagName.find_by!(language_code: 'en', name: 'english_alias')) expect(TagName.find_by!(language_code: 'ain', name: 'ainu_alias'))
.to have_attributes( .to have_attributes(
script_code: 'Latn', script_code: 'Kana',
tag_id: tag.id, primary_flg: false, language_code: 'en') tag_id: tag.id, primary_flg: false, language_code: 'ain')
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true) primary = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn') expect(primary).to have_attributes(name: 'ainu_original', script_code: 'Kana')
end end
it 'does not take an alias from another language' do it 'does not take an alias from another language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
nipponese = create(:tag, primary_name: 'nipponese_owner') nipponese = create(:tag, primary_name: 'nipponese_owner')
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
@@ -344,7 +332,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
primary_flg: false, language_code: 'ja') primary_flg: false, language_code: 'ja')
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
locale: 'en', name: english_name.name, locale: 'ain-Kana', name: ainu_name.name,
category: tag.category, deprecated: false, category: tag.category, deprecated: false,
aliases: 'shared_alias', parent_tags: '' } aliases: 'shared_alias', parent_tags: '' }
@@ -352,12 +340,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
expect(nipponese_alias.reload).to have_attributes( expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id, tag_id: nipponese.id,
primary_flg: false, language_code: 'ja') primary_flg: false, language_code: 'ja')
expect(TagName.find_by!(language_code: 'en', name: 'shared_alias')) expect(TagName.find_by!(language_code: 'ain', name: 'shared_alias'))
.to have_attributes( .to have_attributes(
tag_id: tag.id, script_code: 'Latn', tag_id: tag.id, script_code: 'Kana',
primary_flg: false, language_code: 'en') primary_flg: false, language_code: 'ain')
expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id)) expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id))
.to contain_exactly(['ja', nipponese.id], ['en', tag.id]) .to contain_exactly(['ja', nipponese.id], ['ain', tag.id])
end end
end end
end end
+44
ファイルの表示
@@ -0,0 +1,44 @@
require 'rails_helper'
RSpec.describe 'Post filter identity across locales', type: :request do
include_context 'Ainu locale'
let!(:activated_locale) { locale }
let!(:tag) { create(:tag, primary_name: 'legacy_union_source') }
let!(:external) { create(:external_tag, name: 'shared') }
let!(:internal_post) { create(:post) }
let!(:external_post) { create(:post) }
let!(:other_post) { create(:post) }
before do
# Legacy data can predate the current prohibition on internal nico: names.
primary_tag_name_for(tag, 'ja').update_columns(name: 'nico:shared')
primary_tag_name_for(tag, 'ain').update!(name: 'シロシ', auto_generated: false)
create(:tag_name, :alias, tag:, name: 'union_alias', language_code: 'ja')
PostTag.create!(post: internal_post, tag:)
PostExternalTag.create!(post: external_post, external_tag: external)
end
['nico:shared', 'union_alias', 'not:nico:shared'].each do |literal|
it "preserves the complete match set for #{ literal }" do
get '/posts', params: { locale: 'ja', tags: literal }
expect(response).to have_http_status(:ok)
original_ids = json.fetch('posts').map { _1.fetch('id') }
expected = literal.start_with?('not:') ? [other_post.id] :
[internal_post.id, external_post.id]
expect(original_ids).to match_array(expected)
get '/posts/tag_filters', params: {
locale: 'ja', target_locale: 'ain-Kana', tags: literal }
expect(response).to have_http_status(:ok)
references = json.fetch('references')
expect(references.first).to include('tag_id' => tag.id, 'external_tag_id' => external.id)
get '/posts', params: {
locale: 'ain-Kana', tags: references.map { _1.fetch('name') }.join(' '),
tag_refs: references.to_json }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids)
end
end
end
+16 -8
ファイルの表示
@@ -900,7 +900,7 @@ RSpec.describe 'Posts API', type: :request do
query_count = query_count =
count_sql_queries do count_sql_queries do
get "/posts/#{ post_record.id }", get "/posts/#{ post_record.id }",
headers: { 'X-Transfer-Code' => user.inheritance_code } headers: cookie_headers(user)
end end
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -2323,8 +2323,10 @@ RSpec.describe 'Posts API', type: :request do
'prev' => nil 'prev' => nil
) )
expect(latest.fetch('tags')).to include( expect(latest.fetch('tags')).to include(
{ 'name' => 'spec_tag_2', 'type' => 'added' }, { 'name' => 'spec_tag_2', 'restore_name' => 'spec_tag_2',
{ 'name' => 'spec_tag', 'type' => 'removed' } 'external' => false, 'type' => 'added' },
{ 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
'external' => false, 'type' => 'removed' }
) )
expect(latest.fetch('original_created_from')).to eq( expect(latest.fetch('original_created_from')).to eq(
'current' => oc_from.iso8601, 'current' => oc_from.iso8601,
@@ -2351,7 +2353,8 @@ RSpec.describe 'Posts API', type: :request do
'prev' => nil 'prev' => nil
) )
expect(first.fetch('tags')).to include( expect(first.fetch('tags')).to include(
{ 'name' => 'spec_tag', 'type' => 'added' } { 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
'external' => false, 'type' => 'added' }
) )
expect(first.fetch('created_at')).to eq(t_v1.iso8601) expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end end
@@ -2435,7 +2438,9 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3) expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').first.fetch('tags')).to include( expect(json.fetch('versions').first.fetch('tags')).to include(
'name' => primary_tag_name_for(tag2, 'ja').name, 'type' => 'context') 'name' => primary_tag_name_for(tag2, 'ja').name,
'restore_name' => primary_tag_name_for(tag2, 'ja').name,
'external' => false, 'type' => 'context')
end end
it 'filters versions by tag when the current snapshot includes the tag' do it 'filters versions by tag when the current snapshot includes the tag' do
@@ -2449,7 +2454,8 @@ RSpec.describe 'Posts API', type: :request do
expect(versions[0]['post_id']).to eq(post_record.id) expect(versions[0]['post_id']).to eq(post_record.id)
expect(versions[0]['version_no']).to eq(2) expect(versions[0]['version_no']).to eq(2)
expect(versions[0]['tags']).to include( expect(versions[0]['tags']).to include(
{ 'name' => 'spec_tag_2', 'type' => 'added' } { 'name' => 'spec_tag_2', 'restore_name' => 'spec_tag_2',
'external' => false, 'type' => 'added' }
) )
end end
@@ -2467,10 +2473,12 @@ RSpec.describe 'Posts API', type: :request do
first = versions[1] first = versions[1]
expect(latest['tags']).to include( expect(latest['tags']).to include(
{ 'name' => 'spec_tag', 'type' => 'removed' } { 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
'external' => false, 'type' => 'removed' }
) )
expect(first['tags']).to include( expect(first['tags']).to include(
{ 'name' => 'spec_tag', 'type' => 'added' } { 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
'external' => false, 'type' => 'added' }
) )
end end
+1 -1
ファイルの表示
@@ -4,7 +4,7 @@ RSpec.describe 'Tag description Wiki facade writes', type: :request do
include WikiRecords include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag) { create(:tag, primary_name: 'description_write') } let!(:tag) { create(:tag, primary_name: 'description_write') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } let(:headers) { cookie_headers(user) }
def stored_state def stored_state
[Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model| [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model|
+3 -3
ファイルの表示
@@ -7,7 +7,7 @@ RSpec.describe 'Tag description native API', type: :request do
let(:editor) { create(:user, :member, name: 'description editor') } let(:editor) { create(:user, :member, name: 'description editor') }
let(:tag_name) { create(:tag_name, name: 'native_description') } let(:tag_name) { create(:tag_name, name: 'native_description') }
let(:tag) { create(:tag, primary_tag_name: tag_name) } let(:tag) { create(:tag, primary_tag_name: tag_name) }
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } } let(:headers) { cookie_headers(editor) }
let(:endpoint) { "/tags/#{ tag.id }/description" } let(:endpoint) { "/tags/#{ tag.id }/description" }
def stored_state def stored_state
@@ -285,7 +285,7 @@ RSpec.describe 'Tag description native API', type: :request do
guest = create(:user, :guest) guest = create(:user, :guest)
before_state = stored_state before_state = stored_state
native_write(body: 'forbidden', native_write(body: 'forbidden',
request_headers: { 'X-Transfer-Code' => guest.inheritance_code }) request_headers: cookie_headers(guest))
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
expect(stored_state).to eq(before_state) expect(stored_state).to eq(before_state)
end end
@@ -294,7 +294,7 @@ RSpec.describe 'Tag description native API', type: :request do
banned = create(:user, :member, :banned) banned = create(:user, :member, :banned)
before_state = stored_state before_state = stored_state
native_write(body: 'forbidden', native_write(body: 'forbidden',
request_headers: { 'X-Transfer-Code' => banned.inheritance_code }) request_headers: cookie_headers(banned))
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
expect(stored_state).to eq(before_state) expect(stored_state).to eq(before_state)
end end
+17 -16
ファイルの表示
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag) { create(:tag, primary_name: 'japanese_member') } let!(:tag) { create(:tag, primary_name: 'japanese_member') }
let!(:ainu_name) { add_ainu_name(tag, 'ainu_member') } let!(:ainu_name) { add_ainu_name(tag, 'ainu_member') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } let(:headers) { cookie_headers(user) }
let(:members) { [{ 'tag_id' => tag.id }] } let(:members) { [{ 'tag_id' => tag.id }] }
let(:placements) do let(:placements) do
[{ 'section_name' => 'section', 'description' => nil, 'children' => [ [{ 'section_name' => 'section', 'description' => nil, 'children' => [
@@ -22,7 +22,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
it 'uses selected-language names in native collection and nested placements' do it 'uses selected-language names in native collection and nested placements' do
["/tag_groups/#{ group.id }", '/tag_groups'].each do |path| ["/tag_groups/#{ group.id }", '/tag_groups'].each do |path|
get path, params: { locale: 'ain' } get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
representation = json.is_a?(Array) ? json.sole : json representation = json.is_a?(Array) ? json.sole : json
expect(representation.fetch('members').sole.fetch('tag').fetch('name')) expect(representation.fetch('members').sole.fetch('tag').fetch('name'))
@@ -37,14 +37,14 @@ RSpec.describe 'Tag group locale contracts', type: :request do
it 'uses snapshot names recursively for history and current names for the latest view' do it 'uses snapshot names recursively for history and current names for the latest view' do
version = group.versions.sole version = group.versions.sole
ainu_name.update!(name: 'renamed_ainu') ainu_name.update!(name: 'renamed_ainu')
get "/wiki/#{ group.id }", params: { locale: 'ain' } get "/wiki/#{ group.id }", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to include('renamed_ainu') expect(json.fetch('body')).to include('renamed_ainu')
get "/wiki/#{ group.id }", params: { locale: 'ain', version: version.id } get "/wiki/#{ group.id }", params: { locale: 'ain-Kana', version: version.id }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('body').scan('ainu_member').length).to eq(2) expect(json.fetch('body').scan('ainu_member').length).to eq(2)
expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member') expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member')
get "/wiki/#{ group.id }/diff", params: { locale: 'ain', from: version.id } get "/wiki/#{ group.id }/diff", params: { locale: 'ain-Kana', from: version.id }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context']) expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context'])
end end
@@ -52,23 +52,24 @@ RSpec.describe 'Tag group locale contracts', type: :request do
['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix| ['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix|
it "round trips #{ prefix } through create, lookup, search and update" do it "round trips #{ prefix } through create, lookup, search and update" do
post '/wiki', params: { post '/wiki', params: {
locale: 'ain', title: "#{ prefix }parent", body: '- tag_group:locale group' }, locale: 'ain-Kana', title: "#{ prefix }parent", body: '- tag_group:locale group' },
headers: headers headers: headers
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
id = json.fetch('id') id = json.fetch('id')
expect(json.fetch('body')).to include('タググループ:locale group') expect(json.fetch('body')).to include('タググループ:locale group')
path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }" path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }"
get path, params: { locale: 'ain' } get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('id')).to eq(id) expect(json.fetch('id')).to eq(id)
get "#{ path }/exists", params: { locale: 'ain' } get "#{ path }/exists", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
get '/wiki/search', params: { locale: 'ain', title: "#{ prefix }parent" } get '/wiki/search', params: { locale: 'ain-Kana', title: "#{ prefix }parent" }
expect(json.map { _1.fetch('id') }).to eq([id]) expect(json.map { _1.fetch('id') }).to eq([id])
put "/wiki/#{ id }", params: { put "/wiki/#{ id }", params: {
locale: 'ain', title: "#{ prefix }renamed", body: '- ainu_member' }, headers: headers locale: 'ain-Kana', title: "#{ prefix }renamed", body: '- ainu_member' },
headers: headers
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
get "/tag_groups/#{ id }", params: { locale: 'ain' } get "/tag_groups/#{ id }", params: { locale: 'ain-Kana' }
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member') expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
end end
end end
@@ -81,7 +82,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
end.to raise_error(TagGroupMarkdownParser::InvalidDocument) end.to raise_error(TagGroupMarkdownParser::InvalidDocument)
before_state = group_state before_state = group_state
put "/wiki/#{ group.id }", params: { put "/wiki/#{ group.id }", params: {
locale: 'ain', title: 'タググループ:partial rename', body: '- japanese_member' }, locale: 'ain-Kana', title: 'タググループ:partial rename', body: '- japanese_member' },
headers: headers headers: headers
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state) expect(group_state).to eq(before_state)
@@ -89,15 +90,15 @@ RSpec.describe 'Tag group locale contracts', type: :request do
it 'passes locale into native validation and facade compatibility checks' do it 'passes locale into native validation and facade compatibility checks' do
post '/tag_groups', params: { post '/tag_groups', params: {
locale: 'ain', name: 'native', description: nil, members:, placements: }, locale: 'ain-Kana', name: 'native', description: nil, members:, placements: },
headers:, as: :json headers:, as: :json
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
id = json.fetch('id') id = json.fetch('id')
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member') expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
get "/wiki/#{ id }", params: { locale: 'ain' } get "/wiki/#{ id }", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
body = json.fetch('body') body = json.fetch('body')
put "/wiki/#{ id }", params: { locale: 'ain', title: 'tag_group:native', body: }, put "/wiki/#{ id }", params: { locale: 'ain-Kana', title: 'tag_group:native', body: },
headers: headers headers: headers
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).version_no).to eq(1) expect(TagGroup.find(id).version_no).to eq(1)
@@ -115,7 +116,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
representation = TagGroupRepr.historical(ainu_locale, version) representation = TagGroupRepr.historical(ainu_locale, version)
expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil
expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil
get "/wiki/#{ old_group.id }", params: { locale: 'ain', version: version.id } get "/wiki/#{ old_group.id }", params: { locale: 'ain-Kana', version: version.id }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).not_to include('historical_japanese', 'current_ainu') expect(response.body).not_to include('historical_japanese', 'current_ainu')
end end
+1 -1
ファイルの表示
@@ -37,7 +37,7 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
markdown.lines.map(&:chomp).reject(&:empty?)) markdown.lines.map(&:chomp).reject(&:empty?))
post '/wiki', params: { title: 'タググループ:再解析', body: }, post '/wiki', params: { title: 'タググループ:再解析', body: },
headers: { 'X-Transfer-Code' => user.inheritance_code } headers: cookie_headers(user)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
parsed = group_rows('tag_groups', name: '再解析').sole parsed = group_rows('tag_groups', name: '再解析').sole
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:) expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
+4 -4
ファイルの表示
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag) { group_tag('洗澡鹿') } let!(:tag) { group_tag('洗澡鹿') }
let!(:other_tag) { group_tag('伊地知ニジカ') } let!(:other_tag) { group_tag('伊地知ニジカ') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } let(:headers) { cookie_headers(user) }
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] } let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] } let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
@@ -148,7 +148,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
body = group_markdown(page) body = group_markdown(page)
travel_to(1.hour.from_now) do travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id) do expect_noop_group_update(group_id: page.id) do
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) update_group(body:, request_headers: cookie_headers(editor))
end end
end end
end end
@@ -212,7 +212,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
it 'records the editor and update time' do it 'records the editor and update time' do
editor = create(:user, :member, name: '編輯者') editor = create(:user, :member, name: '編輯者')
travel_to(Time.current + 1.hour) do travel_to(Time.current + 1.hour) do
update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) update_group(request_headers: cookie_headers(editor))
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
current = group_rows('tag_groups', id: page.id).sole current = group_rows('tag_groups', id: page.id).sole
version = group_version(page.id, 2) version = group_version(page.id, 2)
@@ -271,7 +271,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
when :guest then create(:user, :guest) when :guest then create(:user, :guest)
when :banned then create(:user, :member, :banned) when :banned then create(:user, :member, :banned)
end end
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } request_headers = actor ? cookie_headers(actor) : { }
before_state = group_state before_state = group_state
write_group(operation, request_headers) write_group(operation, request_headers)
expect(response).to have_http_status(role ? :forbidden : :unauthorized) expect(response).to have_http_status(role ? :forbidden : :unauthorized)
+2 -2
ファイルの表示
@@ -8,7 +8,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag) { group_tag('洗澡鹿') } let!(:tag) { group_tag('洗澡鹿') }
let!(:other_tag) { group_tag('伊地知ニジカ') } let!(:other_tag) { group_tag('伊地知ニジカ') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } let(:headers) { cookie_headers(user) }
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] } let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] } let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
@@ -141,7 +141,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
end end
let!(:id) { seed_group(name: '更新', members:, placements:) } let!(:id) { seed_group(name: '更新', members:, placements:) }
let!(:editor) { create(:user, :member) } let!(:editor) { create(:user, :member) }
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } } let(:headers) { cookie_headers(editor) }
it 'keeps all membership and placement audit fields on a name-only edit' do it 'keeps all membership and placement audit fields on a name-only edit' do
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements') before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
+4 -4
ファイルの表示
@@ -47,7 +47,7 @@ RSpec.describe 'Tag group native API', type: :request do
when :guest then create(:user, :guest) when :guest then create(:user, :guest)
when :banned then create(:user, :member, :banned) when :banned then create(:user, :member, :banned)
end end
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } headers = actor ? cookie_headers(actor) : { }
before_state = group_state before_state = group_state
if operation == :create if operation == :create
post '/tag_groups', params: { }, headers:, as: :json post '/tag_groups', params: { }, headers:, as: :json
@@ -61,7 +61,7 @@ RSpec.describe 'Tag group native API', type: :request do
it "rejects a banned IP for native #{ operation }" do it "rejects a banned IP for native #{ operation }" do
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current) IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
headers = { 'X-Transfer-Code' => user.inheritance_code } headers = cookie_headers(user)
before_state = group_state before_state = group_state
if operation == :create if operation == :create
post '/tag_groups', params: { }, headers:, as: :json post '/tag_groups', params: { }, headers:, as: :json
@@ -77,7 +77,7 @@ RSpec.describe 'Tag group native API', type: :request do
let!(:tag) { group_tag('掲載タグ') } let!(:tag) { group_tag('掲載タグ') }
let!(:unplaced) { group_tag('未掲載タグ') } let!(:unplaced) { group_tag('未掲載タグ') }
let!(:child_id) { seed_group(name: '子', description: '子の説明') } let!(:child_id) { seed_group(name: '子', description: '子の説明') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } let(:headers) { cookie_headers(user) }
let(:members) do let(:members) do
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }] [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
end end
@@ -300,7 +300,7 @@ RSpec.describe 'Tag group native API', type: :request do
travel_to(1.hour.from_now) do travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) do expect_noop_group_update(group_id:) do
put "/tag_groups/#{ group_id }", params: synchronised_payload, put "/tag_groups/#{ group_id }", params: synchronised_payload,
headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json headers: cookie_headers(editor), as: :json
end end
end end
end end
+1 -1
ファイルの表示
@@ -3,7 +3,7 @@ require 'rails_helper'
RSpec.describe 'Tag resource ID allocation', type: :request do RSpec.describe 'Tag resource ID allocation', type: :request do
include WikiRecords include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } let(:headers) { cookie_headers(user) }
def create_description name def create_description name
tag = create(:tag, primary_name: name) tag = create(:tag, primary_name: name)
+113 -4
ファイルの表示
@@ -10,7 +10,31 @@ RSpec.describe 'Users', type: :request do
end end
def auth_headers(user) def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code } cookie_headers(user)
end
describe 'GET /users/session' do
it 'does not accept the retired transfer-code header' do
user = create(:user)
get '/users/session', headers: { 'X-Transfer-Code' => user.inheritance_code }
expect(response).to have_http_status(:unauthorized)
end
it 'requires authentication' do
get '/users/session'
expect(response).to have_http_status(:unauthorized)
end
it 'returns public user fields without recording a visit' do
user = create(:user, name: 'SSR user')
expect {
get '/users/session', headers: auth_headers(user)
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:ok)
expect(json).to eq('id' => user.id, 'name' => user.name, 'role' => user.role)
expect(response.body).not_to include(user.inheritance_code)
end
end end
describe 'POST /users' do describe 'POST /users' do
@@ -233,17 +257,102 @@ RSpec.describe 'Users', type: :request do
end end
end end
describe 'Cookie sessions with CSRF enabled', csrf: true do
def csrf_headers
get '/users/csrf'
expect(response).to have_http_status(:ok)
{ 'X-CSRF-Token' => json.fetch('token'), 'Origin' => 'http://www.example.com' }
end
def establish_session(user)
post '/users/verify', params: { code: user.inheritance_code }, headers: csrf_headers
expect(response).to have_http_status(:ok)
expect(json.fetch('valid')).to be(true)
end
it 'rejects a write without a token before creating an account' do
expect { post '/users' }.not_to change(User, :count)
expect(response).to have_http_status(:forbidden)
expect(response.headers['X-CSRF-Invalid']).to eq('1')
end
it 'uses the encrypted persistent Cookie for CSR and SSR session reads' do
user = create(:user)
establish_session(user)
cookie = Array(response.headers['set-cookie']).join
expect(cookie).to include('httponly', 'samesite=lax')
expect(cookie).not_to include(user.inheritance_code)
expires = Time.httpdate(cookie.match(/expires=([^;]+)/i)[1])
expect(expires).to be > 19.years.from_now
get '/users/session'
expect(json.fetch('id')).to eq(user.id)
expect(response.body).not_to include(user.inheritance_code)
get '/users/me'
expect(json.fetch('inheritance_code')).to eq(user.inheritance_code)
expect(response.headers['Cache-Control']).to include('no-store')
end
it 'rejects a cross-origin write with an otherwise valid token' do
headers = csrf_headers.merge('Origin' => 'https://other.invalid')
expect { post '/users', headers: }.not_to change(User, :count)
expect(response).to have_http_status(:forbidden)
end
it 'does not replace an existing Cookie identity during recovery' do
current = create(:user)
old = create(:user)
establish_session(current)
post '/users/verify', params: { code: old.inheritance_code, restore: true },
headers: csrf_headers
expect(json.fetch('user').fetch('id')).to eq(current.id)
get '/users/session'
expect(json.fetch('id')).to eq(current.id)
end
it 'allows explicit transfer and refreshes the active session' do
first = create(:user)
second = create(:user)
establish_session(first)
establish_session(second)
get '/users/session'
expect(json.fetch('id')).to eq(second.id)
end
it 'keeps the renewed session and rejects the previous credential' do
user = create(:user)
establish_session(user)
old_cookie = Array(response.headers['set-cookie']).first.split(';').first
old_code = user.inheritance_code
post '/users/code/renew', headers: csrf_headers
expect(response).to have_http_status(:ok)
expect(json.fetch('code')).not_to eq(old_code)
get '/users/session'
expect(json.fetch('id')).to eq(user.id)
other_browser = ActionDispatch::Integration::Session.new(Rails.application)
other_browser.get('/users/session', headers: { 'Cookie' => old_cookie })
expect(other_browser.response).to have_http_status(:unauthorized)
end
it 'clears browser authentication on session deletion' do
establish_session(create(:user))
delete '/users/session', headers: csrf_headers
expect(response).to have_http_status(:no_content)
get '/users/session'
expect(response).to have_http_status(:unauthorized)
end
end
describe 'GET /users/me' do describe 'GET /users/me' do
it 'returns 404 when code not found' do it 'requires a session even when a code is supplied in the query' do
get '/users/me', params: { code: 'nope' } get '/users/me', params: { code: 'nope' }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:unauthorized)
end end
it 'returns user slice when found' do it 'returns user slice when found' do
user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest') user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest')
get '/users/me', params: { code: user.inheritance_code } get '/users/me', headers: cookie_headers(user)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['id']).to eq(user.id) expect(json['id']).to eq(user.id)
+1 -1
ファイルの表示
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code } cookie_headers(user)
end end
it 'returns 409 when base_revision_id is stale' do it 'returns 409 when base_revision_id is stale' do
+2 -2
ファイルの表示
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do
include WikiRecords include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } let(:headers) { cookie_headers(user) }
context 'existing description write protection' do context 'existing description write protection' do
@@ -18,7 +18,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do
it "rejects #{ role || 'anonymous' } without changing either history" do it "rejects #{ role || 'anonymous' } without changing either history" do
actor = role && create(:user, role == :banned ? :member : role) actor = role && create(:user, role == :banned ? :member : role)
actor.update!(banned_at: Time.current) if role == :banned actor.update!(banned_at: Time.current) if role == :banned
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } request_headers = actor ? cookie_headers(actor) : { }
before_state = group_state before_state = group_state
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' }, put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
headers: request_headers headers: request_headers
+43 -25
ファイルの表示
@@ -8,7 +8,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag) { create(:tag, primary_name: 'japanese_title') } let!(:tag) { create(:tag, primary_name: 'japanese_title') }
let!(:ainu_name) { add_ainu_name(tag, 'ainu_title') } let!(:ainu_name) { add_ainu_name(tag, 'ainu_title') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } let(:headers) { cookie_headers(user) }
let!(:japanese) do let!(:japanese) do
TagDescriptionOperation.create!( TagDescriptionOperation.create!(
Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user) Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user)
@@ -20,12 +20,12 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
['/wiki', '/wiki/search'].each do |path| ['/wiki', '/wiki/search'].each do |path|
it "filters #{ path } by the selected language" do it "filters #{ path } by the selected language" do
get path, params: { locale: 'ain' } get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('id') }).to eq([ainu.id]) expect(json.map { _1.fetch('id') }).to eq([ainu.id])
expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body') expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body')
get path, params: { locale: 'ain', title: 'japanese' } get path, params: { locale: 'ain-Kana', title: 'japanese' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to eq([]) expect(json).to eq([])
end end
@@ -39,32 +39,50 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
it 'resolves titles and aliases only in the selected language in both APIs' do it 'resolves titles and aliases only in the selected language in both APIs' do
['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path| ['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path|
get path, params: { locale: 'ain' } get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path| ['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path|
get path, params: { locale: 'ain' } get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq('Ainu body') expect(json.fetch('body')).to eq('Ainu body')
end end
get '/wiki/title/ainu_title/exists', params: { locale: 'ain' } get '/wiki/title/ainu_title/exists', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
get '/wiki/title/japanese_title/exists', params: { locale: 'ain' } get '/wiki/title/japanese_title/exists', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'does not disclose another language through ID, existence, diff or history' do it 'resolves a foreign description ID to only the requested language body' do
revision = current_description_version(japanese) get "/wiki/#{ japanese.id }", params: { locale: 'ain-Kana' }
["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists", expect(response).to have_http_status(:ok)
"/wiki/#{ japanese.id }/diff"].each do |path| expect(json).to include('id' => ainu.id, 'body' => 'Ainu body')
get path, params: { locale: 'ain', from: revision.id } expect(response.body).not_to include('Japanese body')
end
it 'does not reuse foreign revision IDs when resolving a description' do
get "/wiki/#{ japanese.id }", params: {
locale: 'ain-Kana', version: current_description_version(japanese).id }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('Japanese body') expect(response.body).not_to include('Japanese body')
end end
get '/wiki/changes', params: { locale: 'ain', id: japanese.id }
it 'keeps existence and diff scoped to the specified language and description ID' do
revision = current_description_version(japanese)
["/wiki/#{ japanese.id }/exists", "/wiki/#{ japanese.id }/diff"].each do |path|
get path, params: { locale: 'ain-Kana', from: revision.id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('Japanese body')
end
get "/wiki/#{ ainu.id }/exists", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:no_content)
end
it 'keeps history scoped to the requested language without foreign revisions' do
get '/wiki/changes', params: { locale: 'ain-Kana', id: japanese.id }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to eq([]) expect(json).to eq([])
get '/wiki/changes', params: { locale: 'ain' } get '/wiki/changes', params: { locale: 'ain-Kana' }
expect(json.map { _1.fetch('revision_id') }) expect(json.map { _1.fetch('revision_id') })
.to eq([current_description_version(ainu).id]) .to eq([current_description_version(ainu).id])
end end
@@ -73,7 +91,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes] snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes]
expect do expect do
put "/wiki/#{ japanese.id }", params: { put "/wiki/#{ japanese.id }", params: {
locale: 'ain', title: 'renamed', body: 'wrong target' }, headers: headers locale: 'ain-Kana', title: 'renamed', body: 'wrong target' }, headers: headers
end.not_to change(TagDescriptionVersion, :count) end.not_to change(TagDescriptionVersion, :count)
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes]) expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes])
@@ -89,10 +107,10 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
snapshot = original.attributes snapshot = original.attributes
expect do expect do
if api == :wiki if api == :wiki
post '/wiki', params: { locale: 'ain', title: 'other_ainu', body: 'created' }, post '/wiki', params: { locale: 'ain-Kana', title: 'other_ainu', body: 'created' },
headers: headers headers: headers
else else
put "/tags/#{ other.id }/description", params: { locale: 'ain', body: 'created' }, put "/tags/#{ other.id }/description", params: { locale: 'ain-Kana', body: 'created' },
headers: headers headers: headers
end end
end.to change(TagDescription, :count).by(1) end.to change(TagDescription, :count).by(1)
@@ -108,20 +126,20 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
it 'shares selected-language edits and revision identity across both APIs' do it 'shares selected-language edits and revision identity across both APIs' do
snapshot = japanese.attributes snapshot = japanese.attributes
first = current_description_version(ainu) first = current_description_version(ainu)
put "/tags/#{ tag.id }/description", params: { locale: 'ain', body: 'native edit' }, put "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana', body: 'native edit' },
headers: headers headers: headers
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
second = current_description_version(ainu) second = current_description_version(ainu)
expect(second).to have_attributes(language_code: 'ain', version_no: 2) expect(second).to have_attributes(language_code: 'ain', version_no: 2)
expect(second.id).not_to eq(first.id) expect(second.id).not_to eq(first.id)
get "/wiki/#{ ainu.id }", params: { locale: 'ain' } get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana' }
expect(json).to include('body' => 'native edit', 'revision_id' => second.id) expect(json).to include('body' => 'native edit', 'revision_id' => second.id)
put "/wiki/#{ ainu.id }", params: { put "/wiki/#{ ainu.id }", params: {
locale: 'ain', title: 'ainu_title', body: 'facade edit', locale: 'ain-Kana', title: 'ainu_title', body: 'facade edit',
base_revision_id: second.id }, headers: headers base_revision_id: second.id }, headers: headers
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
get "/tags/#{ tag.id }/description", params: { locale: 'ain' } get "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana' }
expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3) expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3)
expect(japanese.reload.attributes).to eq(snapshot) expect(japanese.reload.attributes).to eq(snapshot)
end end
@@ -133,7 +151,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes] snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes]
expect do expect do
put "/wiki/#{ ainu.id }", params: { put "/wiki/#{ ainu.id }", params: {
locale: 'ain', title: 'conflicting_name', body: 'current', locale: 'ain-Kana', title: 'conflicting_name', body: 'current',
base_revision_id: stale.id }, headers: headers base_revision_id: stale.id }, headers: headers
end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0) end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0)
expect(response).to have_http_status(:conflict) expect(response).to have_http_status(:conflict)
@@ -145,14 +163,14 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
first = current_description_version(ainu) first = current_description_version(ainu)
TagDescriptionOperation.update!( TagDescriptionOperation.update!(
ainu_locale, tag:, body: 'later', updated_by_user: user) ainu_locale, tag:, body: 'later', updated_by_user: user)
get "/wiki/#{ ainu.id }", params: { locale: 'ain', version: first.id } get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana', version: first.id }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id) expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id)
get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain', from: first.id } get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain-Kana', from: first.id }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id) expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id)
get "/wiki/#{ ainu.id }/diff", params: { get "/wiki/#{ ainu.id }/diff", params: {
locale: 'ain', from: current_description_version(japanese).id } locale: 'ain-Kana', from: current_description_version(japanese).id }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
+3 -3
ファイルの表示
@@ -6,7 +6,7 @@ require 'securerandom'
RSpec.describe 'Wiki API', type: :request do RSpec.describe 'Wiki API', type: :request do
include WikiRecords include WikiRecords
def auth_headers(user) def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code } cookie_headers(user)
end end
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
@@ -80,7 +80,7 @@ RSpec.describe 'Wiki API', type: :request do
let(:guest) { create(:user, role: 'guest') } let(:guest) { create(:user, role: 'guest') }
def auth_headers(user) def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code } cookie_headers(user)
end end
context 'when not logged in' do context 'when not logged in' do
@@ -126,7 +126,7 @@ RSpec.describe 'Wiki API', type: :request do
let(:guest) { create(:user, role: 'guest', inheritance_code: SecureRandom.hex(16)) } let(:guest) { create(:user, role: 'guest', inheritance_code: SecureRandom.hex(16)) }
def auth_headers(user) def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code } cookie_headers(user)
end end
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') } let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
+1 -1
ファイルの表示
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki title collision', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code } cookie_headers(user)
end end
def create_wiki_page title:, body: def create_wiki_page title:, body:
+1 -1
ファイルの表示
@@ -17,7 +17,7 @@ RSpec.describe 'Removed standalone Wiki resources', type: :request do
user user
before_state = group_state before_state = group_state
post '/wiki', params: { title:, body: 'body' }, post '/wiki', params: { title:, body: 'body' },
headers: { 'X-Transfer-Code' => user.inheritance_code } headers: cookie_headers(user)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state) expect(group_state).to eq(before_state)
end end
+116
ファイルの表示
@@ -0,0 +1,116 @@
require 'rails_helper'
require 'tempfile'
RSpec.describe CertifiedTagNameTranslations do
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
let!(:tag) { create(:tag, primary_name: 'source') }
let(:entries) { [{ 'ja' => 'source', 'ain-Kana' => 'レ_ハイタ' }] }
let(:document) do
{ 'format' => described_class::FORMAT,
'source_locale' => 'ja', 'target_locale' => 'ain-Kana', 'entries' => entries }
end
let(:locale) do
Locale.new(code: 'ain-Kana', language_code: 'ain', script_code: 'Kana',
region_code: 392, variants: '', name: 'Ainu')
end
around do |example|
Tempfile.create(['certified-names', '.json']) do |file|
@path = file.path
example.run
end
end
def write_document
File.write(@path, JSON.generate(document))
locale.certified_tag_names_path = @path
end
it 'registers certified names before automatic names and records their provenance' do
other = create(:tag, primary_name: 'レ_ハイタ')
write_document
locale.save!
expect(tag.reload.tag_name('ain')).to have_attributes(
name: 'レ_ハイタ', primary_flg: true, auto_generated: false)
expect(other.reload.tag_name('ain')).to have_attributes(
name: "Tag_##{ other.id }", auto_generated: true)
version = tag.tag_versions.order(:version_no).last
expect(version.created_by_user_id).to be_nil
expect(version.names.fetch('ain')).to include(
'name' => 'レ_ハイタ', 'auto_generated' => false)
end
it 'skips missing sources without rolling back the locale or matching entries' do
entries.unshift({ 'ja' => 'missing', 'ain-Kana' => 'unused' })
write_document
locale.save!
expect(locale).to be_persisted
expect(tag.reload.name('ain')).to eq('レ_ハイタ')
expect(TagName.exists?(language_code: 'ain', name: 'unused')).to be(false)
end
it 'matches source names exactly rather than using a case-insensitive collation' do
entries.first['ja'] = 'SOURCE'
write_document
expect(described_class.load!(@path, locale:)).to eq({})
end
it 'rejects ambiguous primary matches' do
write_document
relation = instance_double(ActiveRecord::Relation)
allow(TagName).to receive(:where).with(
language_code: 'ja', primary_flg: true, name: ['source']).and_return(relation)
allow(relation).to receive(:pluck).with(:name, :tag_id)
.and_return([['source', tag.id], ['source', tag.id + 1]])
expect { described_class.load!(@path, locale:) }
.to raise_error(described_class::Error, /source.*exactly one tag/)
end
it 'rejects duplicate sources and identifies the offending name' do
entries.push({ 'ja' => 'source', 'ain-Kana' => 'another' })
write_document
expect { locale.save! }.to raise_error(described_class::Error, /duplicate source.*source/)
expect(Locale.exists?(code: 'ain-Kana')).to be(false)
end
it 'rejects duplicate targets even when a source is missing' do
entries.push({ 'ja' => 'missing', 'ain-Kana' => 'レ_ハイタ' })
write_document
expect { locale.save! }.to raise_error(described_class::Error, /duplicate target.*レ_ハイタ/)
expect(Locale.exists?(code: 'ain-Kana')).to be(false)
end
it 'rolls back names and histories if a later certified name is invalid' do
create(:tag, primary_name: 'second')
entries.push({ 'ja' => 'second', 'ain-Kana' => 'nico:invalid' })
write_document
original_versions = TagVersion.order(:id).map(&:attributes)
expect { locale.save! }.to raise_error(described_class::Error, /nico:invalid/)
expect(Locale.exists?(code: 'ain-Kana')).to be(false)
expect(TagName.where(language_code: 'ain')).to be_empty
expect(TagVersion.order(:id).map(&:attributes)).to eq(original_versions)
end
it 'does not add names or versions on repeated completion' do
write_document
locale.save!
original_names = TagName.order(:id).map(&:attributes)
original_versions = TagVersion.order(:id).map(&:attributes)
TagNameCompletion.for_locale!(locale)
expect(TagName.order(:id).map(&:attributes)).to eq(original_names)
expect(TagVersion.order(:id).map(&:attributes)).to eq(original_versions)
end
end
+2 -3
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe PostBulkCreator do RSpec.describe PostBulkCreator do
include_context 'English locale' include_context 'Ainu locale'
it 'limits workers to two and keeps failures in their request slots' do it 'limits workers to two and keeps failures in their request slots' do
locale = Locale.nipponese locale = Locale.nipponese
@@ -67,8 +67,7 @@ RSpec.describe PostBulkCreator do
expect(results[3].dig(:post, :id)).to eq(4) expect(results[3].dig(:post, :id)).to eq(4)
end end
it 'passes a future English locale to preflight and creation in every worker' do it 'passes a future Ainu locale to preflight and creation in every worker' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
actor = instance_double(User, id: 123) actor = instance_double(User, id: 123)
allow(User).to receive(:find).with(123).and_return(actor) allow(User).to receive(:find).with(123).and_return(actor)
+21 -23
ファイルの表示
@@ -11,23 +11,22 @@ RSpec.describe PostCreatePlan do
end end
context 'with an explicit locale' do context 'with an explicit locale' do
include_context 'English locale' include_context 'Ainu locale'
it 'resolves the matching language identity and its parents' do it 'resolves the matching language identity and its parents' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
english_name = create(:tag_name, name: 'shared_name', ainu_name = create(:tag_name, name: 'shared_name',
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
english = create(:tag, primary_tag_name: english_name, category: :character) ainu = create(:tag, primary_tag_name: ainu_name, category: :character)
primary_tag_name_for(ainu, 'ja').update!(name: 'ainu_in_japanese', auto_generated: false)
nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general) nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general)
# The DB permits this identity; validation has its own contract spec. primary_tag_name_for(nipponese, 'ja').update!(name: 'shared_name')
primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name') ainu_parent_name = create(:tag_name, name: 'ainu_parent',
english_parent_name = create(:tag_name, name: 'english_parent', language_code: 'ain', script_code: 'Kana')
language_code: 'en', script_code: 'Latn') ainu_parent = create(:tag, primary_tag_name: ainu_parent_name,
english_parent = create(:tag, primary_tag_name: english_parent_name,
category: :material) category: :material)
nipponese_parent = create(:tag, primary_name: 'nipponese_parent', category: :general) nipponese_parent = create(:tag, primary_name: 'nipponese_parent', category: :general)
TagImplication.create!(tag: english, parent_tag: english_parent) TagImplication.create!(tag: ainu, parent_tag: ainu_parent)
TagImplication.create!(tag: nipponese, parent_tag: nipponese_parent) TagImplication.create!(tag: nipponese, parent_tag: nipponese_parent)
plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale) plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale)
@@ -35,35 +34,34 @@ RSpec.describe PostCreatePlan do
expect(plan[:direct_tag_specs]).to eq( expect(plan[:direct_tag_specs]).to eq(
[{ name: 'shared_name', category: :character }]) [{ name: 'shared_name', category: :character }])
expect(plan[:post_tag_specs]).to include( expect(plan[:post_tag_specs]).to include(
{ name: primary_tag_name_for(english_parent, 'en').name, category: :material }) { name: primary_tag_name_for(ainu_parent, 'ain').name, category: :material })
expect(plan[:post_tag_specs].pluck(:name)) expect(plan[:post_tag_specs].pluck(:name))
.not_to include(primary_tag_name_for(nipponese_parent, 'ja').name) .not_to include(primary_tag_name_for(nipponese_parent, 'ja').name)
end end
it 'resolves aliases within the requested language' do it 'resolves aliases within the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
nipponese = create(:tag, primary_name: 'nipponese_canonical') nipponese = create(:tag, primary_name: 'nipponese_canonical')
english_name = create(:tag_name, name: 'english_canonical', ainu_name = create(:tag_name, name: 'ainu_canonical',
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
english = create(:tag, primary_tag_name: english_name, category: :character) ainu = create(:tag, primary_tag_name: ainu_name, category: :character)
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese) tag: nipponese)
english_alias = create(:tag_name, :alias, name: 'shared_alias', ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english, tag: ainu,
language_code: 'en', script_code: 'Latn') language_code: 'ain', script_code: 'Kana')
expect(nipponese_alias.reload).to have_attributes( expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id, tag_id: nipponese.id,
primary_flg: false, language_code: 'ja') primary_flg: false, language_code: 'ja')
expect(english_alias.reload).to have_attributes( expect(ainu_alias.reload).to have_attributes(
tag_id: english.id, tag_id: ainu.id,
primary_flg: false, language_code: 'en') primary_flg: false, language_code: 'ain')
plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale) plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale)
expect(plan[:direct_tag_specs]).to eq( expect(plan[:direct_tag_specs]).to eq(
[{ name: english_name.name, category: :character }]) [{ name: ainu_name.name, category: :character }])
end end
end end
+3 -4
ファイルの表示
@@ -77,11 +77,10 @@ RSpec.describe PostCreator do
end end
context 'with an explicit locale' do context 'with an explicit locale' do
include_context 'English locale' include_context 'Ainu locale'
[false, true].each do |planned| [false, true].each do |planned|
it "passes locale to tag creation with planned attributes: #{ planned }" do it "passes locale to tag creation with planned attributes: #{ planned }" do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale locale
attributes = { attributes = {
title: 'Locale propagation', title: 'Locale propagation',
@@ -97,8 +96,8 @@ RSpec.describe PostCreator do
expect(Tag).to have_received(:find_or_create_by_tag_name!) expect(Tag).to have_received(:find_or_create_by_tag_name!)
.with(locale, 'locale_character', category: :character).at_least(:once) .with(locale, 'locale_character', category: :character).at_least(:once)
tag_name = TagName.find_by!(language_code: 'en', name: 'locale_character') tag_name = TagName.find_by!(language_code: 'ain', name: 'locale_character')
expect(tag_name).to have_attributes(script_code: 'Latn', primary_flg: true) expect(tag_name).to have_attributes(script_code: 'Kana', primary_flg: true)
expect(post.tags).to include(tag_name.tag) expect(post.tags).to include(tag_name.tag)
end end
end end
+34
ファイルの表示
@@ -141,6 +141,40 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
result(first) result(first)
end end
[false, true].each do |inner_rollback|
[false, true].each do |outer_rollback|
it "keeps savepoint=#{ inner_rollback } lock through outer rollback=#{ outer_rollback }" do
connection = ApplicationRecord.connection
database = connection.pool.db_config.database
lock_name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:tags"
quoted_lock = connection.quote(lock_name)
owner = connection.select_value('SELECT CONNECTION_ID()').to_i
connection.transaction do
connection.transaction(requires_new: true) do
create_resource(:external)
raise ActiveRecord::Rollback if inner_rollback
end
expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i)
.to eq(owner)
contender = worker do |other|
other.select_value("SELECT GET_LOCK(#{ quoted_lock }, 0)").to_i
end
expect(result(contender)).to eq(0)
SharedResourceIdAllocator.lock_space!(ExternalTag, 'tags')
raise ActiveRecord::Rollback if outer_rollback
end
expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i)
.to eq(1)
expect(ExternalTag.exists?(name: "#{ @prefix }_external"))
.to eq(!inner_rollback && !outer_rollback)
end
end
end
it 'raises on a real lock acquisition failure without inserting a record' do it 'raises on a real lock acquisition failure without inserting a record' do
first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true) first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true)
stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0) stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0)
+30 -12
ファイルの表示
@@ -101,7 +101,7 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
let(:record) { build(:external_tag) } let(:record) { build(:external_tag) }
let(:connection) { double('connection') } let(:connection) { double('connection') }
let(:transaction) { double('transaction', open?: true) } let(:transaction) { double('transaction', open?: true) }
let(:callbacks) { { } } let(:callbacks) { [] }
let(:sql) { [] } let(:sql) { [] }
before do before do
@@ -128,19 +128,24 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
end end
end end
allow(ExternalTag).to receive(:connection).and_return(connection) allow(ExternalTag).to receive(:connection).and_return(connection)
allow(ExternalTag).to receive(:current_transaction).and_return(transaction) allow(connection).to receive(:current_transaction).and_return(transaction)
allow(connection).to receive(:add_transaction_record) { callbacks << _1 }
allow(connection).to receive(:disconnect!)
allow(ExternalTag).to receive(:uncached).and_yield allow(ExternalTag).to receive(:uncached).and_yield
[:after_commit, :after_rollback].each do |event|
allow(transaction).to receive(event) { |&callback| callbacks[event] = callback }
end
end end
[:after_commit, :after_rollback].each do |event| def finish_transaction event
allow(transaction).to receive(:open?).and_return(false)
callbacks.shift.public_send(event)
allow(transaction).to receive(:open?).and_return(true)
end
[:committed!, :rolledback!].each do |event|
it "holds the shared lock until #{ event }" do it "holds the shared lock until #{ event }" do
described_class.assign!(record) described_class.assign!(record)
expect(record.id).to eq(9) expect(record.id).to eq(9)
expect(sql.grep(/RELEASE_LOCK/)).to be_empty expect(sql.grep(/RELEASE_LOCK/)).to be_empty
callbacks.fetch(event).call finish_transaction(event)
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
end end
end end
@@ -155,7 +160,7 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
it 'releases a successfully acquired lock when allocation fails and rolls back' do it 'releases a successfully acquired lock when allocation fails and rolls back' do
allow(connection).to receive(:select_all).and_raise('query failed') allow(connection).to receive(:select_all).and_raise('query failed')
expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed') expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed')
callbacks.fetch(:after_rollback).call finish_transaction(:rolledback!)
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
end end
@@ -164,18 +169,17 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
described_class.assign!(record) described_class.assign!(record)
expect(record.id).to eq(50) expect(record.id).to eq(50)
expect(sql.grep(/FOR UPDATE/)).to be_empty expect(sql.grep(/FOR UPDATE/)).to be_empty
callbacks.fetch(:after_commit).call finish_transaction(:committed!)
end end
it 'uses the same named lock within each space and different locks between spaces' do it 'uses the same named lock within each space and different locks between spaces' do
locks = [Tag, ExternalTag, TagDescription, TagGroup, TagDescriptionVersion, TagGroupVersion].map do |model| locks = [Tag, ExternalTag, TagDescription, TagGroup, TagDescriptionVersion, TagGroupVersion].map do |model|
allow(model).to receive(:connection).and_return(connection) allow(model).to receive(:connection).and_return(connection)
allow(model).to receive(:current_transaction).and_return(transaction)
allow(model).to receive(:uncached).and_yield allow(model).to receive(:uncached).and_yield
record = model.new(id: 50) record = model.new(id: 50)
described_class.assign!(record) described_class.assign!(record)
statement = sql.grep(/GET_LOCK/).last statement = sql.grep(/GET_LOCK/).last
callbacks.fetch(:after_commit).call finish_transaction(:committed!)
statement statement
end end
expect(locks[0]).to eq(locks[1]) expect(locks[0]).to eq(locks[1])
@@ -187,7 +191,21 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
it 'reports lock release failures' do it 'reports lock release failures' do
described_class.assign!(record) described_class.assign!(record)
allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil) allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil)
expect { callbacks.fetch(:after_commit).call } expect { finish_transaction(:committed!) }
.to raise_error(ActiveRecord::ActiveRecordError, /lock release failed/) .to raise_error(ActiveRecord::ActiveRecordError, /lock release failed/)
expect(connection).to have_received(:disconnect!)
end
it 'keeps the lock and transfers cleanup when a savepoint rolls back' do
described_class.assign!(record)
callbacks.shift.rolledback!
expect(callbacks.size).to eq(1)
expect(sql.grep(/RELEASE_LOCK/)).to be_empty
described_class.assign!(record)
expect(sql.grep(/GET_LOCK/).size).to eq(1)
finish_transaction(:committed!)
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
end end
end end
+68
ファイルの表示
@@ -0,0 +1,68 @@
require 'rails_helper'
RSpec.describe TagNameCompletion do
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
let(:locale) do
Locale.create!(code: 'ain-Kana', language_code: 'ain', script_code: 'Kana',
region_code: 392, variants: '', name: 'ain-Kana')
end
it 'generates a phonetic primary with the required identity and provenance' do
tag = create(:tag, primary_name: '漢字あカ')
locale
expect(tag.reload.tag_name('ain')).to have_attributes(
name: 'カンチ_ア_カ', language_code: 'ain', script_code: 'Kana',
primary_flg: true, auto_generated: true, tag_id: tag.id)
end
it 'uses the exact fallback when the input cannot be converted' do
tag = create(:tag, primary_name: '😀')
locale
expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }")
end
it 'uses the exact fallback when a generated name collides with a human name' do
other = create(:tag, primary_name: 'other')
create(:tag_name, tag: other, name: 'AbC', language_code: 'ain', script_code: 'Kana')
tag = create(:tag, primary_name: 'AbC')
locale
expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }")
expect(other.reload.name('ain')).to eq('AbC')
end
it 'uses the exact fallback for a transcription that fails name validation' do
tag = create(:tag, primary_name: 'base')
source = TagName.new(name: 'nico:invalid', language_code: 'ja')
expect(TagName.generate_name(locale, tag, source)).to eq("Tag_##{ tag.id }")
end
it 'preserves human confirmation and is idempotent across repeated completion' do
locale
tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'あいぬ', category: :general)
primary = tag.tag_name('ain')
primary.update!(name: 'human_confirmed', auto_generated: false)
snapshot = tag.tag_names.order(:id).map(&:attributes)
2.times { described_class.for_tag!(tag) }
expect(tag.tag_names.order(:id).map(&:attributes)).to eq(snapshot)
end
it 'creates one primary per active language through the production creation path' do
locale
tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'アイヌ', category: :general)
expect(tag.tag_names.where(primary_flg: true).pluck(:language_code, :name))
.to contain_exactly(['ja', 'アイヌ'], ['ain', 'アイヌ'])
expect(Tag.find_or_create_by_tag_name!(locale, 'アイヌ', category: :general)).to eq(tag)
end
end
+37
ファイルの表示
@@ -0,0 +1,37 @@
require 'rails_helper'
RSpec.describe TagNameTranscription do
{
'AbCdXYZ' => 'AbCdXYZ',
'あいぬ' => 'アイヌ',
'アイヌ' => 'アイヌ',
'日本語' => 'ニチホンコ',
'漢字' => 'カンチ',
'漢あカ' => 'カン_ア_カ',
'漢字あいうカナ' => 'カンチ_アイウ_カナ',
'Ab漢あカZ' => 'Abカン_ア_カZ',
'りゃリュリョ' => 'リヤ_リユリヨ',
'キャット' => 'キヤット',
'コーヒー' => 'コヒ',
'コウコオケイケエカア' => 'コウコオケイケエカア',
'ガギグゲゴダヂヅデド' => 'カキクケコタチチュテト',
'バビブベボパピプペポ' => 'パピプペポパピプペポ',
'ザジズゼゾ' => 'チャチチュチェチョ',
'ジャジュジョ' => 'チャチュチョ',
'アㇰアㇱアッアンアㇺアㇷ゚' => 'アㇰアㇱアッアンアㇺアㇷ゚',
'アㇻイㇼウㇽエㇾオㇿ' => 'アㇻイㇼウㇽエㇾオㇿ',
'漢Aあ' => 'カンAア' }.each do |source, expected|
it "transcribes #{ source } as #{ expected }" do
expect(described_class.ainu(source)).to eq(expected)
end
end
{ '😀' => '', 'ヴ' => '', 'ティ' => 'テ', 'ㇵ' => '',
'魑' => '', '𠮷' => '', 'ー' => '', '々' => '',
'ア😀😀イ' => 'ア_イ', '😀アイ😀' => 'アイ',
'_😀_' => '__', 'ア_😀_イ' => 'ア___イ', '漢魑字' => 'カン_チ' }.each do |source, expected|
it "preserves convertible portions of #{ source }" do
expect(described_class.ainu(source)).to eq(expected)
end
end
end
+20 -23
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe TagVersionRecorder do RSpec.describe TagVersionRecorder do
include_context 'English locale' include_context 'Ainu locale'
let(:user) { create_member_user! } let(:user) { create_member_user! }
let!(:tag) { create(:tag, primary_name: '日本語名') } let!(:tag) { create(:tag, primary_name: '日本語名') }
@@ -17,11 +17,11 @@ RSpec.describe TagVersionRecorder do
{ 'name' => 'alias_z', 'script_code' => 'Jpan' }] } { 'name' => 'alias_z', 'script_code' => 'Jpan' }] }
end end
def prepare_english_names! def prepare_ainu_names!
locale locale
primary_tag_name_for(tag, 'en').update!(name: 'English name', auto_generated: false) primary_tag_name_for(tag, 'ain').update!(name: 'Ainu name', auto_generated: false)
create(:tag_name, :alias, tag:, language_code: 'en', script_code: 'Latn', create(:tag_name, :alias, tag:, language_code: 'ain', script_code: 'Kana',
name: 'English alias') name: 'Ainu alias')
end end
it 'snapshots the Nipponese primary and sorted aliases in grouped names' do it 'snapshots the Nipponese primary and sorted aliases in grouped names' do
@@ -62,40 +62,37 @@ RSpec.describe TagVersionRecorder do
end end
it 'snapshots future languages together without a request locale argument' do it 'snapshots future languages together without a request locale argument' do
pending '日本語以外の Locale 有効化は別課題で実装する' prepare_ainu_names!
prepare_english_names!
version = described_class.record!(tag:, event_type: :create, created_by_user: user) version = described_class.record!(tag:, event_type: :update, created_by_user: user)
expect(version.names).to eq( expect(version.names).to eq(
'ja' => nipponese_snapshot, 'ja' => nipponese_snapshot,
'en' => { 'name' => 'English name', 'script_code' => 'Latn', 'ain' => { 'name' => 'Ainu name', 'script_code' => 'Kana',
'auto_generated' => false, 'auto_generated' => false,
'aliases' => [{ 'name' => 'English alias', 'script_code' => 'Latn' }] }) 'aliases' => [{ 'name' => 'Ainu alias', 'script_code' => 'Kana' }] })
end end
it 'creates a version for an English-only change and preserves prior names' do it 'creates a version for an Ainu-only change and preserves prior names' do
pending '日本語以外の Locale 有効化は別課題で実装する' prepare_ainu_names!
prepare_english_names! original = described_class.record!(tag:, event_type: :update, created_by_user: user)
original = described_class.record!(tag:, event_type: :create, created_by_user: user) primary_tag_name_for(tag, 'ain').update!(name: 'Renamed')
primary_tag_name_for(tag, 'en').update!(name: 'Renamed')
updated = described_class.record!(tag:, event_type: :update, created_by_user: user) updated = described_class.record!(tag:, event_type: :update, created_by_user: user)
expect(updated.version_no).to eq(2) expect(updated.version_no).to eq(original.version_no + 1)
expect(original.reload.names.fetch('en')).to include('name' => 'English name') expect(original.reload.names.fetch('ain')).to include('name' => 'Ainu name')
expect(updated.names.fetch('en')).to include('name' => 'Renamed') expect(updated.names.fetch('ain')).to include('name' => 'Renamed')
expect(updated.names.fetch('ja')).to eq(nipponese_snapshot) expect(updated.names.fetch('ja')).to eq(nipponese_snapshot)
end end
it 'does not create a version for an unchanged multilingual snapshot' do it 'does not create a version for an unchanged multilingual snapshot' do
pending '日本語以外の Locale 有効化は別課題で実装する' prepare_ainu_names!
prepare_english_names! original = described_class.record!(tag:, event_type: :update, created_by_user: user)
described_class.record!(tag:, event_type: :create, created_by_user: user)
unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user) unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user)
expect(unchanged.version_no).to eq(1) expect(unchanged.version_no).to eq(original.version_no)
expect(tag.reload.tag_versions.count).to eq(1) expect(tag.reload.tag_versions.count).to eq(original.version_no)
end end
end end
+7 -4
ファイルの表示
@@ -1,19 +1,22 @@
RSpec.shared_context 'Ainu locale contract' do RSpec.shared_context 'Ainu locale contract' do
let(:ainu_locale) { instance_double(Locale, language_code: 'ain', script_code: 'Jpan') } let(:ainu_locale) do
instance_double(Locale, language_code: 'ain', script_code: 'Kana', deprecated_at: nil)
end
before do before do
# Only the foreign-key reference is seeded; no Locale or enum is enabled. # Keep description contracts independent of locale activation and backfill.
Language.connection.execute(<<~SQL) Language.connection.execute(<<~SQL)
INSERT IGNORE INTO languages (code, name, created_at) INSERT IGNORE INTO languages (code, name, created_at)
VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP) VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP)
SQL SQL
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
allow(Locale).to receive(:find_by).and_call_original allow(Locale).to receive(:find_by).and_call_original
allow(Locale).to receive(:find_by).with(code: 'ain').and_return(ainu_locale) allow(Locale).to receive(:find_by).with(code: 'ain-Kana').and_return(ainu_locale)
end end
def add_ainu_name tag, name def add_ainu_name tag, name
TagName.insert_all!([{ TagName.insert_all!([{
tag_id: tag.id, language_code: 'ain', script_code: 'Jpan', name:, tag_id: tag.id, language_code: 'ain', script_code: 'Kana', name:,
primary_flg: true, auto_generated: false, primary_flg: true, auto_generated: false,
created_at: Time.current, updated_at: Time.current }]) created_at: Time.current, updated_at: Time.current }])
tag.tag_names.reset tag.tag_names.reset
+12
ファイルの表示
@@ -1,4 +1,16 @@
module AuthHelper module AuthHelper
def cookie_headers(user)
env = Rails.application.env_config.merge(Rack::MockRequest.env_for('/'))
endpoint = lambda do |request_env|
ActionDispatch::Request.new(request_env).session[:inheritance_code] = user.inheritance_code
[200, {}, []]
end
store = ActionDispatch::Session::CookieStore.new(
endpoint, Rails.application.config.session_options)
headers = ActionDispatch::Cookies.new(store).call(env)[1]
{ 'Cookie' => Array(headers['set-cookie']).map { _1.split(';').first }.join('; ') }
end
def sign_in_as(user) def sign_in_as(user)
allow_any_instance_of(ApplicationController) allow_any_instance_of(ApplicationController)
.to receive(:current_user).and_return(user) .to receive(:current_user).and_return(user)
+10 -15
ファイルの表示
@@ -1,22 +1,17 @@
RSpec.shared_context 'English locale' do RSpec.shared_context 'Ainu locale' do
def prepare_english_reference! def prepare_ainu_reference!
Language.find_or_create_by!(code: 'en') { _1.name = 'English' } Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
region_class = Class.new(ApplicationRecord) { self.table_name = 'regions' }
region_class.find_or_create_by!(code: 826) do |record|
record.alpha_code = 'GB'
record.name = 'United Kingdom'
end
end end
let(:locale) do let(:locale) do
prepare_english_reference! prepare_ainu_reference!
Locale.find_or_create_by!(code: 'en') do |record| Locale.find_or_create_by!(code: 'ain-Kana') do |record|
record.language_code = 'en' record.language_code = 'ain'
record.script_code = 'Latn' record.script_code = 'Kana'
record.region_code = 826 record.region_code = 392
record.variants = '' record.variants = ''
record.name = 'English' record.name = 'Ainu'
end end
end end
end end
+53
ファイルの表示
@@ -37,3 +37,56 @@ npm run test:run
cd backend && bundle exec rspec cd backend && bundle exec rspec
cd ../frontend && npm run test:run && npm run build && npm run lint cd ../frontend && npm run test:run && npm run build && npm run lint
``` ```
### Help SSR in production
Build both `dist/client` and `dist/server` with `npm run build`, deploy them
together, and restart the Node process. `npm run dev` and `npm run preview`
remain CSR servers; use `npm start` for SSR. The old sitemap generator is not
part of this build.
The following environment is a deployment proposal, not an applied VPS change:
```sh
cd frontend
export NODE_ENV=production
export HOST=127.0.0.1
export PORT=5174
export PUBLIC_ORIGIN=https://hub.nizika.monster
export SSR_API_BASE_URL=http://127.0.0.1:3002
npm start
```
Run this command through the VPS's process supervisor. Port 5174 avoids the
Vite development port 5173. Rails remains on 127.0.0.1:3002. `PUBLIC_ORIGIN`
must match the public HTTPS origin, including any non-default port; redirect
alternate hostnames to that origin. `SSR_API_BASE_URL` configures server-side
public API requests only. Keep the browser's existing API configuration pointed
at the public `/api` endpoint.
Inside the existing Apache HTTPS virtual host, with `proxy`, `proxy_http` and
`headers` enabled, preserve the API rules before the catch-all frontend rule:
```apache
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass "/api/" "http://127.0.0.1:3002/"
ProxyPassReverse "/api/" "http://127.0.0.1:3002/"
ProxyPass "/" "http://127.0.0.1:5174/"
ProxyPassReverse "/" "http://127.0.0.1:5174/"
```
Retain existing TLS, authentication-header handling and any exact `/api` rule.
Replace the previous SPA catch-all rewrite instead of rewriting Help or missing
URLs to `index.html` before they reach Node. Do not override Node's 404/503
responses with an SPA fallback or cache its HTML. Hashed assets may be cached;
deployments must keep old hashed files available for already-open clients.
Only Help detail pages are server-rendered. Known CSR routes remain client
rendered, old known routes redirect to Japanese URLs, and unknown routes/assets
return 404. A failed Help preload keeps the readable server HTML, retries on
online/focus/pageshow, and permits one automatic reload per bundle and URL in
session storage. If storage is unavailable or the failure persists, the HTML
remains intact; native links and manual reload remain available.
+3 -25
ファイルの表示
@@ -4,35 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ぼざクリ ぼざろクリーチャーシリーズ 伊地知ニジカ リンク集 Wiki ニコニコ" /> <meta name="description" lang="ja" data-rh="true" content="ぼざクリ ぼざろクリーチャーシリーズ 伊地知ニジカ リンク集 Wiki ニコニコ" />
<title>ぼざクリ タグ広場 〜 ぼざろクリーチャーシリーズ綜合リンク集</title> <title>ぼざクリ タグ広場 〜 ぼざろクリーチャーシリーズ綜合リンク集</title>
<!-- helmet --> <!-- helmet -->
</head> </head>
<body> <body>
<div id="root"> <div id="root"><!-- outlet --></div>
<div class="flex flex-col h-screen w-screen"> <!-- ssr-state -->
<nav class="px-3 flex justify-between items-center w-full min-h-[48px]
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<div class="flex items-center gap-2 h-full">
<a class="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
href="/">
ぼざクリ タグ広場
</a>
<a class="hidden md:flex h-full items-center px-2" href="/posts">広場</a>
<a class="hidden md:flex h-full items-center px-2" href="/tags">タグ</a>
<a class="hidden md:flex h-full items-center px-2" href="/wiki/ヘルプ:ホーム">Wiki</a>
<a class="hidden md:flex h-full items-center px-2" href="/users">ユーザ</a>
</div>
<a href="#" class="md:hidden ml-auto pr-4
text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100">
Menu
</a>
</nav>
</div>
<!-- outlet -->
</div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
生成ファイル
+78 -3
ファイルの表示
@@ -25,12 +25,14 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"humps": "^2.0.1", "humps": "^2.0.1",
"i18next": "^26.4.7",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"path-to-regexp": "^8.3.0", "path-to-regexp": "^8.3.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-i18next": "^17.0.20",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4", "react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
@@ -350,9 +352,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.27.6", "version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -4872,6 +4874,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"funding": {
"url": "https://locize.com"
}
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -4916,6 +4927,34 @@
"integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==", "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/i18next": {
"version": "26.4.7",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.7.tgz",
"integrity": "sha512-p/3hqy6EcuqRs6wNwBYwGGC1fzBJSMxV9zMuN814ab/7QjdZbXfjXwuPZdQbngX6KDOTxM01uUiZyqmMB8ghZg==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -7258,6 +7297,33 @@
"react": "^16.6.0 || ^17.0.0 || ^18.0.0" "react": "^16.6.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/react-i18next": {
"version": "17.0.20",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.20.tgz",
"integrity": "sha512-rzSSirf0NP9vfcUXHZAksIuyMWl7L/T/7nm8S+sSIFuIzku54qcyyVAOZXbawPNpUxGkDx/eosSv4DrpzcxuOQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -8595,6 +8661,15 @@
} }
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+8 -3
ファイルの表示
@@ -5,12 +5,15 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && npm run build:client && npm run build:server",
"postbuild": "node scripts/generate-sitemap.js", "build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
"start": "node server.mjs",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"preview": "vite preview" "test:ssr": "node --test server.ssr.test.mjs",
"preview": "vite preview --outDir dist/client"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -29,12 +32,14 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"humps": "^2.0.1", "humps": "^2.0.1",
"i18next": "^26.4.7",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"path-to-regexp": "^8.3.0", "path-to-regexp": "^8.3.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-i18next": "^17.0.20",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4", "react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
+2 -1
ファイルの表示
@@ -11,7 +11,8 @@ const formatDate = (date = new Date) => {
return `${ year }-${ month }-${ day }` return `${ year }-${ month }-${ day }`
} }
const SITE_TITLE = 'ぼざクリ タグ広場' const SITE_TITLE = JSON.parse (fs.readFileSync (
new URL ('../src/locales/ja/shared.json', import.meta.url), 'utf8')).labels.brand
const DOMAIN = 'https://hub.nizika.monster' const DOMAIN = 'https://hub.nizika.monster'
const API_BASE_URL = 'https://hub.nizika.monster/api' const API_BASE_URL = 'https://hub.nizika.monster/api'
+122
ファイルの表示
@@ -0,0 +1,122 @@
import { createReadStream } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import { createServer, request as requestHTTP } from 'node:http'
import { request as requestHTTPS } from 'node:https'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { render } from './dist/server/entry-server.js'
const root = fileURLToPath (new URL ('./dist/client/', import.meta.url))
const template = await readFile (path.join (root, 'index.html'), 'utf8')
const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin
const apiBaseURL = process.env.SSR_API_BASE_URL ?? 'http://127.0.0.1:3002'
const types = {
'.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
'.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webp': 'image/webp',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.otf': 'font/otf',
'.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.txt': 'text/plain' }
if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN)
throw new Error ('PUBLIC_ORIGIN is required in production')
const server = createServer (async (request, response) => {
response.setHeader ('Cache-Control', 'private, no-store')
const forwardedFor = request.headers['x-forwarded-for'] ?? request.socket.remoteAddress
if (request.url === '/api' || request.url.startsWith ('/api/'))
{
const address = new URL (request.url, origin)
const target = new URL (apiBaseURL)
target.pathname = target.pathname.replace (/\/$/, '') + address.pathname.slice (4)
target.search = address.search
const send = target.protocol === 'https:' ? requestHTTPS : requestHTTP
const upstream = send (target, {
method: request.method,
headers: { ...request.headers, host: target.host,
'x-forwarded-host': new URL (origin).host,
'x-forwarded-proto': new URL (origin).protocol.replace (':', ''),
'x-forwarded-for': forwardedFor } }, result => {
response.writeHead (result.statusCode ?? 502, result.headers)
result.on ('error', () => response.destroy ())
result.pipe (response)
})
upstream.setTimeout (10_000, () => upstream.destroy (new Error ('API timeout')))
upstream.on ('error', () => {
if (response.headersSent)
response.destroy ()
else
response.writeHead (503).end ()
})
request.on ('aborted', () => upstream.destroy ())
request.pipe (upstream)
return
}
if (!['GET', 'HEAD'].includes (request.method))
{
response.writeHead (405, { Allow: 'GET, HEAD' }).end ()
return
}
try
{
const url = new URL (request.url, origin)
const pathname = decodeURIComponent (url.pathname)
const file = path.resolve (root, '.' + pathname)
const allowed = file.startsWith (root)
&& !pathname.split ('/').some (segment => segment.startsWith ('.'))
&& pathname !== '/index.html'
const info = allowed ? await stat (file).catch (() => null) : null
if (info?.isFile ())
{
response.writeHead (200, {
'Content-Type': types[path.extname (file)] ?? 'application/octet-stream',
'Content-Length': info.size,
'Cache-Control': /\/[^/]+-[\w-]{8}\.[^.]+$/.test (pathname)
? 'public, max-age=31536000, immutable' : 'public, max-age=3600' })
if (request.method === 'HEAD')
response.end ()
else
createReadStream (file).on ('error', () => response.destroy ()).pipe (response)
return
}
if (pathname.startsWith ('/assets/'))
{
response.writeHead (404).end ()
return
}
const result = await render (url.pathname + url.search, origin, apiBaseURL, {
cookie: request.headers.cookie, forwardedFor })
if (result?.setCookies?.length > 0)
response.setHeader ('Set-Cookie', result.setCookies)
if (result?.location != null)
{
response.writeHead (result.status, { Location: result.location }).end ()
return
}
const html = result?.html == null
? template.replace ('<!-- helmet -->', result?.status === 404
? '<meta name="robots" content="noindex" data-rh="true">' : '')
: template
.replace ('lang="ja"', `lang="${result.locale}"`)
.replace (/<title>[\s\S]*?<\/title>/, '')
.replace (/<meta name="description"[^>]*>/, '')
.replace ('<!-- helmet -->', () => result.head)
.replace ('<!-- outlet -->', () => result.html)
.replace ('<!-- ssr-state -->',
() => `<script id="ssr-state" type="application/json">${result.bootstrap}</script>`)
response.writeHead (result?.status ?? 200, {
'Content-Type': 'text/html; charset=utf-8' })
response.end (request.method === 'HEAD' ? undefined : html)
}
catch (error)
{
console.error ('[SSR]', error instanceof Error ? error.message : 'Rendering failed')
const denied = error?.response?.status === 401 || error?.response?.status === 403
response.writeHead (error instanceof URIError ? 400
: (denied ? error.response.status : 503)).end ()
}
})
server.listen (Number (process.env.PORT ?? 5173), process.env.HOST ?? '127.0.0.1', () => {
console.info ('[SSR] listening on', server.address ().port)
})
+478
ファイルの表示
@@ -0,0 +1,478 @@
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { after, before, test } from 'node:test'
import { createServer as createViteServer } from 'vite'
import { render } from './dist/server/entry-server.js'
const origin = 'https://ssr.example.invalid'
const date = '2026-09-15T00:00:00+09:00'
let api, apiURL, routes, vite, node, httpOrigin
let failAPI = false
const requests = []
// Rails owns encryption and verification; this upstream fixture tests Node's forwarding contract.
const recoveryCode = 'test-recovery-secret'
const users = new Map ([
[recoveryCode, { id: 1, name: 'SSR account', role: 'admin' }],
['second-recovery-secret', { id: 2, name: 'Second account', role: 'member' }]])
const sessions = new Map ()
let sessionSequence = 0
const cookieName = '__Host-btrc-session'
const cookieFor = value => `${cookieName}=${value}`
const sessionValue = request => request.headers.cookie?.split (';')
.map (part => part.trim ()).find (part => part.startsWith (`${cookieName}=`))
?.slice (cookieName.length + 1)
const issueSession = (response, code = null) => {
const value = `opaque-rails-session-${++sessionSequence}`
sessions.set (value, { code, token: `csrf-${sessionSequence}` })
refreshCookie (response, value)
return value
}
const refreshCookie = (response, value) => response.setHeader ('Set-Cookie',
`${cookieFor (value)}; Path=/; Max-Age=630720000; HttpOnly; Secure; SameSite=Lax`)
const tag = locale => ({
id: 1, name: locale === 'ja' ? 'SSR タグ' : 'SSR シロシ', category: 'general',
deprecated_at: null, aliases: [], parents: [], children: [], post_count: 1,
created_at: date, updated_at: date, has_wiki: true, material_id: null,
has_deerjikists: false, sections: [] })
const post = locale => ({
id: 1, version_no: 1, title: `SSR post ${locale}`, url: 'https://example.invalid/post',
thumbnail: null, thumbnail_base: null, video_ms: null, tags: [tag (locale)],
parent_posts: [], child_posts: [], sibling_posts: {}, viewed: false, related: [],
original_created_from: date, original_created_before: date,
created_at: date, updated_at: date, uploaded_user: null })
const wiki = locale => ({
id: 1, tag_id: 1, resource_type: 'tag_description', language_code: locale === 'ja'
? 'ja' : 'ain', title: tag (locale).name, body: `SSR wiki body ${locale}`,
created_at: date, updated_at: date, revision_id: 1, pred: null, succ: null,
deprecated_at: null })
const material = locale => ({
id: 1, version_no: 1, tag: tag (locale), file: null, url: 'https://example.invalid',
thumbnail: null, thumbnail_fallback_text: 'SSR material',
thumbnail_fallback_kind: 'tag_name', media_kind: 'url_only', content_type: null,
file_byte_size: null, export_paths: {}, export_items: [], created_at: date,
updated_at: date, created_by_user: { id: 1, name: 'SSR' },
updated_by_user: { id: 1, name: 'SSR' } })
const responseFor = url => {
const locale = url.searchParams.get ('locale') ?? 'ja'
const path = url.pathname
if (path === '/locales')
return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
if (path === '/users/theme_slots') return []
if (path === '/posts') return { posts: [post (locale)], count: 1 }
if (path === '/gekanator/posts') return { posts: [post (locale)] }
if (path === '/gekanator/questions') return { questions: [] }
if (path === '/posts/1') return post (locale)
if (path === '/tags') return { tags: [tag (locale)], count: 1 }
if (path === '/tags/1' || path.startsWith ('/tags/name/')) return tag (locale)
if (path === '/tags/1/deerjikists') return { tag: tag (locale), deerjikists: [] }
if (path === '/tags/with-depth') return []
if (path === '/tags/nico' || path === '/nico/tags') return { tags: [], count: 0 }
if (path.endsWith ('/versions')) return { versions: [], count: 0 }
if (path === '/wiki') return [wiki (locale)]
if (path === '/wiki/changes') return []
if (path.includes ('autolink')) return []
if (path === '/wiki/1/diff') return { title: 'SSR wiki diff', diff: [] }
if (path.startsWith ('/wiki/')) return wiki (locale)
if (path === '/materials') return { materials: [material (locale)], count: 1 }
if (path === '/materials/1') return material (locale)
if (path.includes ('suppressions')) return { suppressions: [] }
if (path === '/theatres/1') return { id: 1, name: 'SSR theatre', created_at: date }
if (path === '/theatres/1/programmes') return []
if (path === '/theatres/1/comments') return []
if (path === '/theatres/1/post_selection_weights')
return { tag_penalties: [], lightest_posts: [], heaviest_posts: [] }
if (path === '/theatres/1/watching')
return { host_flg: false, post_id: null, post_started_at: null, post_elapsed_ms: null,
watching_users: [], skip_vote: {
votes_count: 0, required_count: 1, watching_users_count: 0, voted: false } }
return null
}
before (async () => {
api = createServer (async (request, response) => {
requests.push ({ method: request.method, url: request.url,
cookie: request.headers.cookie, csrf: request.headers['x-csrf-token'],
transferCode: request.headers['x-transfer-code'] })
const url = new URL (request.url, 'http://localhost')
response.setHeader ('Content-Type', 'application/json')
response.setHeader ('Cache-Control', 'private, no-store')
if (failAPI) {
response.writeHead (503).end ()
return
}
if (url.pathname.startsWith ('/users')) {
const value = sessionValue (request)
const session = sessions.get (value)
const user = users.get (session?.code)
if (request.method === 'GET') {
if (url.pathname === '/users/csrf') {
const id = session != null ? value : issueSession (response)
response.end (JSON.stringify ({ token: sessions.get (id).token }))
return
}
if (user == null) {
response.writeHead (401).end ()
return
}
refreshCookie (response, value)
const data = url.pathname === '/users/session' ? user
: (url.pathname === '/users/me' ? { ...user, inheritance_code: session.code }
: responseFor (url))
response.writeHead (data == null ? 404 : 200)
response.end (JSON.stringify (data))
return
}
if (![origin, httpOrigin].includes (request.headers.origin)
|| session == null || request.headers['x-csrf-token'] !== session.token) {
response.writeHead (403, { 'X-CSRF-Invalid': '1' }).end ()
return
}
let body = ''
for await (const chunk of request) body += chunk
const params = JSON.parse (body || '{}')
if (request.method === 'POST' && url.pathname === '/users/verify') {
const code = params.restore && user != null ? session.code : params.code
const verified = users.get (code)
if (verified == null) {
response.end (JSON.stringify ({ valid: false }))
return
}
sessions.delete (value)
issueSession (response, code)
response.end (JSON.stringify ({ valid: true,
user: { ...verified, inheritance_code: code } }))
return
}
if (request.method === 'DELETE' && url.pathname === '/users/session') {
sessions.delete (value)
response.writeHead (204, { 'Set-Cookie':
`${cookieName}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax` }).end ()
return
}
if (request.method === 'POST' && url.pathname === '/users/code/renew' && user != null) {
const code = `rotated-recovery-${sessionSequence}`
users.delete (session.code)
users.set (code, user)
sessions.delete (value)
issueSession (response, code)
response.end (JSON.stringify ({ code }))
return
}
response.writeHead (404).end ()
return
}
const data = responseFor (url)
response.writeHead (data == null ? 404 : 200)
response.end (JSON.stringify (data))
})
await new Promise (resolve => api.listen (0, '127.0.0.1', resolve))
apiURL = `http://127.0.0.1:${api.address ().port}`
node = spawn (process.execPath, ['server.mjs'], { env: {
...process.env, NODE_ENV: 'production', PORT: '0', HOST: '127.0.0.1',
PUBLIC_ORIGIN: origin, SSR_API_BASE_URL: apiURL },
stdio: ['ignore', 'pipe', 'pipe'] })
httpOrigin = await new Promise ((resolve, reject) => {
node.stdout.on ('data', data => {
const match = data.toString ().match (/listening on (\d+)/)
if (match) resolve (`http://127.0.0.1:${match[1]}`)
})
node.on ('error', reject)
node.on ('exit', code => reject (new Error (`SSR server exited ${code}`)))
})
vite = await createViteServer ({ server: { middlewareMode: true }, appType: 'custom' })
const { pageRoutes } = await vite.ssrLoadModule ('/src/App.tsx')
const flatten = (entries, parent = '') => entries.flatMap (route => {
const path = [parent, route.path].filter (Boolean).join ('/')
return route.children ? flatten (route.children, path) : [{ ...route, path }]
})
routes = flatten (pageRoutes (null, () => {}))
})
const authenticatedCookie = async (code = recoveryCode) => {
const csrf = await fetch (`${httpOrigin}/api/users/csrf`)
assert.equal (csrf.status, 200)
const { token } = await csrf.json ()
const verified = await fetch (`${httpOrigin}/api/users/verify`, {
method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json',
Cookie: csrf.headers.get ('set-cookie').split (';')[0], 'X-CSRF-Token': token },
body: JSON.stringify ({ code, restore: true }) })
assert.equal (verified.status, 200)
assert.equal ((await verified.json ()).valid, true)
const cookie = verified.headers.get ('set-cookie')
assert.match (cookie, /HttpOnly; Secure; SameSite=Lax/)
assert.doesNotMatch (cookie, new RegExp (code))
return cookie.split (';')[0]
}
after (async () => {
await vite?.close ()
node?.kill ()
api?.closeAllConnections ()
await new Promise (resolve => api?.close (resolve))
})
test ('every canonical screen route returns page HTML without JavaScript', async t => {
const cookie = await authenticatedCookie ()
for (const route of routes) {
for (const locale of ['ja', 'ain-Kana']) {
const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
.replace ('*', 'unknown-screen')
const url = `/${locale}/${path}?from=2026-09-15&to=2026-09-15`
await t.test (`${locale} ${route.path}`, async () => {
const result = await render (url, origin, apiURL, { cookie })
assert.ok (result != null)
if (route.handle?.redirectTo) {
assert.equal (result.status, 308)
return
}
assert.ok (result.html?.length > 300, 'actual page HTML is required')
assert.equal (result.status, route.path === '*' ? 404 : 200)
assert.match (result.head, /<title/)
assert.match (result.head, /rel="canonical"/)
assert.equal (result.locale, locale)
assert.doesNotMatch (result.html + result.bootstrap, /test-recovery-secret|opaque-rails/)
if (route.path === 'wiki/:id/edit') assert.match (result.html, /<textarea/)
if (route.path === 'users/settings') assert.match (result.html, /SSR account/)
if (route.path === 'posts')
assert.match (result.html, new RegExp (`SSR post ${locale}`))
if (route.path === 'posts/:id')
assert.match (result.head, new RegExp (`SSR post ${locale}`))
if (route.path === 'theatres/:id')
assert.match (result.html.replace (/<[^>]*>/g, ''), /SSR[  ]theatre/)
if (route.path === 'wiki' || route.path === 'wiki/:title')
assert.match (result.html, new RegExp (`SSR wiki body ${locale}|SSR`))
assert.doesNotMatch (result.html, /Switched to client rendering|template id="B:/)
})
}
}
})
test ('parallel localized requests keep list data isolated', async () => {
const start = requests.length
const pages = await Promise.all (['ja', 'ain-Kana', 'ja', 'ain-Kana'].map (locale =>
render (`/${locale}/posts?limit=50&page=2`, origin, apiURL)))
for (const [index, page] of pages.entries ()) {
const locale = index % 2 === 0 ? 'ja' : 'ain-Kana'
assert.match (page.html, new RegExp (`SSR post ${locale}`))
assert.equal (page.status, 200)
const state = JSON.parse (page.bootstrap)
assert.equal (state.locale, locale)
}
assert.ok (requests.some (request => request.url.includes ('page=2')))
assert.ok (requests.slice (start).every (request => request.method === 'GET'))
})
test ('unknown resources and legacy URLs have correct status', async () => {
assert.equal ((await render ('/ja/unknown', origin, apiURL)).status, 404)
assert.equal ((await render ('/ja/help/unknown', origin, apiURL)).status, 404)
assert.equal ((await render ('/ja/posts/999', origin, apiURL)).status, 404)
const redirect = await render ('/help/home?edition=2026-09-15', origin, apiURL)
assert.equal (redirect.status, 308)
assert.equal (redirect.location, '/ja/help/home?edition=2026-09-15')
})
test ('HTTP forwards Rails cookies, CSRF, expiration and upstream errors', async () => {
const cookie = await authenticatedCookie ()
const settings = await fetch (`${httpOrigin}/ja/users/settings`, { headers: { Cookie: cookie } })
assert.equal (settings.status, 200)
const html = await settings.text ()
assert.match (html, /SSR account/)
assert.doesNotMatch (html, /test-recovery-secret|opaque-rails-session/)
assert.match (settings.headers.get ('cache-control'), /private, no-store/)
assert.match (settings.headers.get ('set-cookie'), /Max-Age=630720000/)
assert.ok (requests.some (request => request.url === '/users/session'
&& request.cookie === cookie))
assert.ok (requests.every (request => request.transferCode == null))
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`)).status, 401)
const invalid = cookie.replace ('=', '=invalid')
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: invalid } })).status, 401)
const publicPage = await fetch (`${httpOrigin}/ain-Kana/posts`)
assert.equal (publicPage.status, 200)
assert.match (await publicPage.text (), /SSR post ain-Kana/)
assert.equal ((await fetch (`${httpOrigin}/_ssr/session`, { method: 'POST' })).status, 405)
const csrf = await fetch (`${httpOrigin}/api/users/csrf`, { headers: { Cookie: cookie } })
const { token } = await csrf.json ()
const write = (csrfToken, requestOrigin = origin) => fetch (
`${httpOrigin}/api/users/verify`, { method: 'POST',
headers: { Cookie: cookie, Origin: requestOrigin, 'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken }, body: JSON.stringify ({ code: recoveryCode }) })
assert.equal ((await write ('invalid')).status, 403)
assert.equal ((await write (token, 'https://other.invalid')).status, 403)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: cookie } })).status, 200)
const renewed = await write (token)
assert.equal (renewed.status, 200)
const renewedCookie = renewed.headers.get ('set-cookie').split (';')[0]
assert.notEqual (renewedCookie, cookie)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: cookie } })).status, 401)
const renewedCSRF = await fetch (`${httpOrigin}/api/users/csrf`, {
headers: { Cookie: renewedCookie } })
const newToken = (await renewedCSRF.json ()).token
const logout = await fetch (`${httpOrigin}/api/users/session`, { method: 'DELETE',
headers: { Cookie: renewedCookie, Origin: origin, 'X-CSRF-Token': newToken } })
assert.equal (logout.status, 204)
assert.match (logout.headers.get ('set-cookie'), /Max-Age=0/)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: renewedCookie } })).status, 401)
failAPI = true
try {
assert.equal ((await fetch (`${httpOrigin}/ja/posts`)).status, 503)
} finally {
failAPI = false
}
})
test ('parallel user and Locale requests never share authentication or query state', async () => {
const cookies = await Promise.all ([authenticatedCookie (),
authenticatedCookie ('second-recovery-secret')])
const inputs = cookies.flatMap ((cookie, user) => ['ja', 'ain-Kana'].map (locale => ({
cookie, locale, name: user === 0 ? 'SSR account' : 'Second account',
other: user === 0 ? 'Second account' : 'SSR account' })))
const pages = await Promise.all (inputs.map (async input => {
const response = await fetch (`${httpOrigin}/${input.locale}/users/settings`, {
headers: { Cookie: input.cookie } })
assert.equal (response.status, 200)
return response.text ()
}))
for (const [index, html] of pages.entries ()) {
const input = inputs[index]
assert.match (html, new RegExp (input.name))
assert.doesNotMatch (html, new RegExp (input.other))
assert.match (html, new RegExp (`<html lang="${input.locale}"`))
assert.doesNotMatch (html, /recovery-secret|opaque-rails-session|csrf-/)
}
})
test ('renewed recovery codes invalidate old sessions and forward the new Rails cookie',
async () => {
const cookie = await authenticatedCookie ('second-recovery-secret')
const csrf = await fetch (`${httpOrigin}/api/users/csrf`, { headers: { Cookie: cookie } })
const { token } = await csrf.json ()
const renewed = await fetch (`${httpOrigin}/api/users/code/renew`, {
method: 'POST', headers: { Cookie: cookie, Origin: origin, 'X-CSRF-Token': token } })
assert.equal (renewed.status, 200)
const newCookie = renewed.headers.get ('set-cookie').split (';')[0]
assert.notEqual (newCookie, cookie)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: cookie } })).status, 401)
assert.equal ((await fetch (`${httpOrigin}/ain-Kana/users/settings`, {
headers: { Cookie: newCookie } })).status, 200)
})
test ('hydrate every route with stored browser preferences', {
skip: !process.env.SSR_CHROME_PATH, timeout: 180_000,
}, async t => {
const { default: WebSocket } = await import ('ws')
const chrome = spawn (process.env.SSR_CHROME_PATH, [
'--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
'--remote-debugging-port=0', `--user-data-dir=/tmp/btrc-ssr-browser-${process.pid}`],
{ stdio: ['ignore', 'ignore', 'pipe'] })
let ws
try {
const endpoint = await new Promise ((resolve, reject) => {
let output = ''
chrome.stderr.on ('data', data => {
output += data
const match = output.match (/DevTools listening on (ws:\/\/\S+)/)
if (match) resolve (match[1])
})
chrome.on ('error', reject)
})
ws = new WebSocket (endpoint)
await new Promise (resolve => ws.on ('open', resolve))
const pending = new Map ()
let sequence = 0, errors = []
const send = (method, params = {}, sessionId) => new Promise ((resolve, reject) => {
const id = ++sequence
pending.set (id, { resolve, reject })
ws.send (JSON.stringify ({ id, method, params, sessionId }))
})
ws.on ('message', raw => {
const message = JSON.parse (raw)
if (message.id) {
const entry = pending.get (message.id)
pending.delete (message.id)
if (message.error) entry?.reject (message.error)
else entry?.resolve (message.result)
} else if (message.method === 'Fetch.requestPaused') {
const { requestId, request } = message.params
const url = new URL (request.url)
if (url.origin === httpOrigin) {
void send ('Fetch.continueRequest', { requestId }, message.sessionId)
return
}
const data = responseFor (url)
void send ('Fetch.fulfillRequest', { requestId,
responseCode: request.method === 'OPTIONS' ? 204 : 200,
responseHeaders: [{ name: 'Content-Type', value: 'application/json' },
{ name: 'Access-Control-Allow-Origin', value: httpOrigin },
{ name: 'Access-Control-Allow-Headers', value: '*' },
{ name: 'Access-Control-Allow-Methods', value: '*' }],
body: Buffer.from (JSON.stringify (data ?? {})).toString ('base64') }, message.sessionId)
} else if (message.method === 'Runtime.exceptionThrown') {
errors.push (message.params.exceptionDetails.exception?.description
?? message.params.exceptionDetails.text)
} else if (message.method === 'Runtime.consoleAPICalled'
&& message.params.type === 'error') {
errors.push (message.params.args.map (arg => arg.value ?? arg.description).join (' '))
}
})
const { targetId } = await send ('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send ('Target.attachToTarget', { targetId, flatten: true })
await send ('Runtime.enable', {}, sessionId)
await send ('Page.enable', {}, sessionId)
await send ('Network.enable', {}, sessionId)
await send ('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId)
const cookie = await authenticatedCookie ()
await send ('Network.setExtraHTTPHeaders', { headers: {
Cookie: cookie } }, sessionId)
await send ('Page.addScriptToEvaluateOnNewDocument', { source: `
localStorage.setItem('user_code','test-recovery-secret');
localStorage.setItem('btrc_hub.client_settings',JSON.stringify({
lists:{postList:{limit:50},tagList:{limit:50},postSearch:{limit:50}},
behavior:{animation:'off'},theatre:{layoutMode:'tagsBottom',tagFlow:'horizontal'}
}));
new MutationObserver(()=>{
const child=document.getElementById('root')?.firstElementChild;
if(child&&!window.__initialSSRNode)window.__initialSSRNode=child;
}).observe(document,{childList:true,subtree:true});` }, sessionId)
const evaluate = async expression => (await send ('Runtime.evaluate', {
expression, returnByValue: true }, sessionId)).result.value
for (const route of routes.filter (entry => !entry.handle?.redirectTo)) {
for (const locale of ['ja', 'ain-Kana']) {
await t.test (`hydrate ${locale} ${route.path}`, async () => {
const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
.replace ('*', 'unknown-screen')
errors = []
await send ('Page.navigate', {
url: `${httpOrigin}/${locale}/${path}?from=2026-09-15&to=2026-09-15` }, sessionId)
let hydrated = false
for (let attempt = 0; attempt < 100; ++attempt) {
await new Promise (resolve => setTimeout (resolve, 75))
try {
hydrated = await evaluate (`Object.keys(document.getElementById('root')??{})
.some(key=>key.startsWith('__reactContainer'))`)
} catch {}
if (hydrated) break
}
assert.ok (hydrated, 'hydrateRoot must run')
await new Promise (resolve => setTimeout (resolve, 150))
assert.deepEqual (errors, [])
assert.ok (await evaluate (`document.getElementById('root').firstElementChild
===window.__initialSSRNode`), 'server DOM must be preserved')
assert.equal (await evaluate ('document.documentElement.lang'), locale)
})
}
}
} finally {
ws?.close ()
chrome.kill ()
}
})
+226 -116
ファイルの表示
@@ -1,18 +1,30 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react' import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createBrowserRouter, import { createBrowserRouter,
matchRoutes,
Navigate, Navigate,
Route,
RouterProvider, RouterProvider,
Routes, useLocation,
useLocation } from 'react-router-dom' useParams,
useRoutes } from 'react-router-dom'
import DevModeWatermark from '@/components/DevModeWatermark' import DevModeWatermark from '@/components/DevModeWatermark'
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider' import DialogueProvider from '@/components/dialogues/DialogueProvider'
import PageHead from '@/components/common/PageHead'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import { initialiseUserSession } from '@/lib/users'
import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki'
import { useLocale } from '@/lib/useLocale'
import { applyClientAnimationMode, import { applyClientAnimationMode,
applyClientAppearance, applyClientAppearance,
fetchUserThemeSlots, fetchUserThemeSlots,
@@ -25,98 +37,141 @@ import { applyClientAnimationMode,
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
import HelpDetailPage from '@/pages/help/HelpDetailPage' import HelpDetailPage from '@/pages/help/HelpDetailPage'
import HelpChangesPage from '@/pages/help/HelpChangesPage'
import HelpDiffPage from '@/pages/help/HelpDiffPage'
import HelpListPage from '@/pages/help/HelpListPage'
import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostNewPage from '@/pages/posts/PostNewPage'
import PostListPage from '@/pages/posts/PostListPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable' import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage'
import TagDetailPage from '@/pages/tags/TagDetailPage'
import TagHistoryPage from '@/pages/tags/TagHistoryPage'
import TagListPage from '@/pages/tags/TagListPage'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import WikiDiffPage from '@/pages/wiki/WikiDiffPage'
import WikiEditPage from '@/pages/wiki/WikiEditPage'
import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import type { Dispatch, FC, SetStateAction } from 'react' import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { RouteMatch } from 'react-router-dom'
import type { ClientAnimationMode } from '@/lib/settings' import type { ClientAnimationMode } from '@/lib/settings'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { PageRoute } from '@/lib/routeLocales'
import type { User } from '@/types' import type { User } from '@/types'
const GekanatorPage = lazy (() => import ('@/pages/GekanatorPage'))
const DeerjikistDetailPage = lazy (() => import ('@/pages/deerjikists/DeerjikistDetailPage'))
const MaterialBasePage = lazy (() => import ('@/pages/materials/MaterialBasePage'))
const MaterialDetailPage = lazy (() => import ('@/pages/materials/MaterialDetailPage'))
const MaterialHistoryPage = lazy (() => import ('@/pages/materials/MaterialHistoryPage'))
const MaterialListPage = lazy (() => import ('@/pages/materials/MaterialListPage'))
const MaterialNewPage = lazy (() => import ('@/pages/materials/MaterialNewPage'))
const MaterialSyncSuppressionsPage = lazy (
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
const MorePage = lazy (() => import ('@/pages/MorePage'))
const GekanatorUnavailablePage = lazy (() => import ('@/pages/GekanatorUnavailablePage'))
const NicoTagListPage = lazy (() => import ('@/pages/tags/NicoTagListPage'))
const TOSPage = lazy (() => import ('@/pages/TOSPage'))
const HelpChangesPage = lazy (() => import ('@/pages/help/HelpChangesPage'))
const HelpDiffPage = lazy (() => import ('@/pages/help/HelpDiffPage'))
const HelpListPage = lazy (() => import ('@/pages/help/HelpListPage'))
const PostDetailPage = lazy (() => import ('@/pages/posts/PostDetailPage'))
const PostHistoryPage = lazy (() => import ('@/pages/posts/PostHistoryPage'))
const PostNewPage = lazy (() => import ('@/pages/posts/PostNewPage'))
const PostListPage = lazy (() => import ('@/pages/posts/PostListPage'))
const PostSearchPage = lazy (() => import ('@/pages/posts/PostSearchPage'))
const SettingPage = lazy (() => import ('@/pages/users/SettingPage'))
const TagDetailPage = lazy (() => import ('@/pages/tags/TagDetailPage'))
const TagHistoryPage = lazy (() => import ('@/pages/tags/TagHistoryPage'))
const TagListPage = lazy (() => import ('@/pages/tags/TagListPage'))
const TheatreDetailPage = lazy (() => import ('@/pages/theatres/TheatreDetailPage'))
const WikiDetailPage = lazy (() => import ('@/pages/wiki/WikiDetailPage'))
const WikiDiffPage = lazy (() => import ('@/pages/wiki/WikiDiffPage'))
const WikiEditPage = lazy (() => import ('@/pages/wiki/WikiEditPage'))
const WikiHistoryPage = lazy (() => import ('@/pages/wiki/WikiHistoryPage'))
const WikiNewPage = lazy (() => import ('@/pages/wiki/WikiNewPage'))
const WikiSearchPage = lazy (() => import ('@/pages/wiki/WikiSearchPage'))
const GekanatorRoute = ({ user }: { user: User | null }) => {
const locale = useLocale ()
const location = useLocation ()
const pageLocale = resolvePageRoute (location.pathname)?.route.handle?.locale
if (pageLocale != null && pageLocale !== locale)
return <GekanatorUnavailablePage/>
return (
<Suspense fallback={null}>
<GekanatorPage user={user}/>
</Suspense>)
}
export const pageRoutes = (
user: User | null,
setUser: Dispatch<SetStateAction<User | null>>,
): PageRoute[] => [
{ path: '', element: <LocaleRedirect to="/posts"/>,
handle: { redirectTo: '/posts' } },
{ path: 'posts', element: <PostListPage/>,
handle: { switchLocale: switchPostFilterLocale } },
{ path: 'posts/new', element: <PostNewPage user={user}/>,
handle: { robots: 'noindex' } },
{ path: 'posts/search', element: <PostSearchPage/>,
handle: { switchLocale: switchPostFilterLocale } },
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> },
{ path: 'posts/changes', element: <PostHistoryPage/> },
{ path: 'tags', element: <TagListPage/> },
{ path: 'tags/:id', element: <TagDetailPage/> },
{ path: 'tags/:id/deerjikists', element: <DeerjikistDetailPage/> },
{ path: 'tags/nico', element: <NicoTagListPage user={user}/> },
{ path: 'nico/tags', element: <NicoTagListPage user={user}/> },
{ path: 'tags/changes', element: <TagHistoryPage/> },
{ path: 'theatres/:id', element: <TheatreDetailPage user={user}/> },
{ path: 'materials', element: <MaterialBasePage/>, children: [
{ index: true, element: <MaterialListPage/> },
{ path: 'changes', element: <MaterialHistoryPage/> },
{ path: 'new', element: <MaterialNewPage/>, handle: { robots: 'noindex' } },
{ path: 'suppressions', element: <MaterialSyncSuppressionsPage/>,
handle: { robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/> }] },
{ path: 'wiki', element: <WikiSearchPage/> },
{ path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { switchLocale: switchWikiLocale } },
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
handle: { robots: 'noindex' } },
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
handle: { robots: 'noindex',
switchLocale: request => switchWikiLocale (request, 'edit') } },
{ path: 'wiki/:id/diff', element: <WikiDiffPage/>,
handle: { switchLocale: request => switchWikiLocale (request, 'diff') } },
{ path: 'wiki/changes', element: <WikiHistoryPage/>,
handle: { switchLocale: request => switchWikiLocale (request, 'history') } },
{ path: 'help', element: <HelpListPage/> },
{ path: 'help/changes', element: <HelpChangesPage/> },
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> },
{ id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>,
handle: { prepare: prepareHelpPage } },
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/>,
handle: { robots: 'noindex', authentication: 'user' } },
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/>,
handle: { redirectTo: '/users/settings' } },
{ path: 'tos', element: <TOSPage/> },
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja' } },
{ path: 'more', element: <MorePage/> },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
export const resolvePageRoute = (
pathname: string,
): RouteMatch<string, PageRoute> | null => {
const match = matchRoutes<PageRoute> (
pageRoutes (null, () => { }), unlocalisedPath (pathname))?.at (-1)
return (match == null || match.route.handle?.notFound) ? null : match
}
const RouteTransitionWrapper = ({ animationMode, user, setUser }: { const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode animationMode: ClientAnimationMode
user: User | null user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => { setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation () const routes = useRoutes (pageRoutes (user, setUser))
const routes = (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/help" element={<HelpListPage/>}/>
<Route path="/help/changes" element={<HelpChangesPage/>}/>
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
if (animationMode === 'off')
return routes
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait" initial={animationMode !== 'off'}>
{routes} <Suspense fallback={null}>{routes}</Suspense>
</AnimatePresence>) </AnimatePresence>)
} }
@@ -128,12 +183,23 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
} }
const RoutedApp: FC = () => { const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const [user, setUser] = useState<User | null> (null) const client = useQueryClient ()
const location = useLocation ()
const locale = useLocale ()
const route = resolvePageRoute (location.pathname)
const robots = route?.route.handle?.robots
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
const resolve = route?.route.handle?.switchLocale
return (resolve != null && route != null)
? resolve ({ location, params: route.params, locale, targetLocale })
: switchLocalePath (location, targetLocale)
}
const [user, setUser] = useState<User | null> (
() => client.getQueryData<User> (sessionKeys.current) ?? null)
const [status, setStatus] = useState (200) const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
const appLayoutTransition = useMemo ( const appLayoutTransition = useMemo (
() => ( () => (
@@ -164,36 +230,21 @@ const RoutedApp: FC = () => {
}, [animationMode]) }, [animationMode])
useEffect (() => { useEffect (() => {
const createUser = async () => { let cancelled = false
const data = await apiPost<{ code: string; user: User }> ('/users')
if (data.code)
{
localStorage.setItem ('user_code', data.code)
setUser (data.user)
}
}
const code = localStorage.getItem ('user_code')
if (code)
{
void (async () => { void (async () => {
try try
{ {
const data = await apiPost<{ valid: boolean; user: User }> ('/users/verify', { code }) const user = await initialiseUserSession ()
if (data.valid) if (!cancelled)
setUser (data.user) setUser (user)
else
await createUser ()
} }
catch (err) catch (error)
{ {
if (isApiError (err)) if (!cancelled && isApiError (error))
setStatus (err.response?.status ?? 200) setStatus (error.response?.status ?? 503)
} }
}) () }) ()
} return () => { cancelled = true }
else
createUser ()
}, []) }, [])
useEffect (() => { useEffect (() => {
@@ -228,6 +279,9 @@ const RoutedApp: FC = () => {
return ( return (
<DialogueProvider> <DialogueProvider>
<PageHead>
{robots != null && <meta name="robots" content={robots}/>}
</PageHead>
<UnsavedChangesGuardProvider> <UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider> <KeyboardShortcutsProvider>
<MotionConfig <MotionConfig
@@ -238,18 +292,18 @@ const RoutedApp: FC = () => {
? 'user' ? 'user'
: 'always' : 'always'
}> }>
<LayoutWrapper> <LayoutGroup>
<motion.div <motion.div
layout={animationMode === 'off' ? false : 'position'} layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }} transition={{ layout: appLayoutTransition }}
className="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}/> <TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper <RouteTransitionWrapper
animationMode={animationMode} animationMode={animationMode}
user={user} user={user}
setUser={setUser}/> setUser={setUser}/>
</motion.div> </motion.div>
</LayoutWrapper> </LayoutGroup>
</MotionConfig> </MotionConfig>
<Toaster/> <Toaster/>
@@ -259,17 +313,73 @@ const RoutedApp: FC = () => {
} }
const router = createBrowserRouter ([{ const LocaleRoute: FC = () => {
path: '*', const { locale } = useParams ()
element: <RoutedApp/> }]) const location = useLocation ()
const { i18n } = useTranslation ()
const { data, isError } = useQuery ({
queryKey: localesKeys.index,
queryFn: fetchLocales,
staleTime: 60_000,
refetchInterval: 60_000,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true })
const activeLocale =
(isLocaleCode (locale)
&& data?.locales.some (entry => entry.code === locale))
? locale
: null
useLayoutEffect (() => {
if (activeLocale != null)
document.documentElement.lang = activeLocale
if (activeLocale != null && i18n.language !== activeLocale)
void i18n.changeLanguage (activeLocale)
}, [activeLocale, i18n])
const redirect = legacyLocalePath (location)
if (!(isLocaleCode (locale)) && redirect == null)
return <NotFound/>
if (isError && data == null)
return <ServiceUnavailable/>
if (data == null)
return null
if (isLocaleCode (locale))
{
return data.locales.some (entry => entry.code === locale)
? <RoutedApp locales={data.locales}/>
: <NotFound/>
}
return redirect == null ? <NotFound/> : <Navigate to={redirect} replace/>
}
const App: FC = () => ( const LocaleRedirect = ({ to }: { to: string }) => {
const locale = useLocale ()
const location = useLocation ()
return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/>
}
export const appRoutes = [
{ path: '/:locale/*', element: <LocaleRoute/> },
{ path: '*', element: <LocaleRoute/> }]
export const AppFrame = ({ children }: { children: ReactNode }) => (
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>} {import.meta.env.DEV && <DevModeWatermark/>}
<RouterProvider router={router}/> {children}
</>) </>)
const App: FC = () => {
const [router] = useState (() => createBrowserRouter (appRoutes))
return <AppFrame><RouterProvider router={router}/></AppFrame>
}
export default App export default App
+7 -1
ファイルの表示
@@ -1,4 +1,7 @@
import { useTranslation } from 'react-i18next'
import nikumaru from '@/assets/fonts/nikumaru.otf' import nikumaru from '@/assets/fonts/nikumaru.otf'
import { translateUIMessage } from '@/lib/i18n'
import type { FC } from 'react' import type { FC } from 'react'
@@ -8,6 +11,8 @@ const COLUMN_COUNT = 12
const DevModeWatermark: FC = () => { const DevModeWatermark: FC = () => {
const { i18n } = useTranslation ('shared')
const text = translateUIMessage ('labels.development', 'shared', i18n.language)
return ( return (
<div <div
aria-hidden="true" aria-hidden="true"
@@ -29,13 +34,14 @@ const DevModeWatermark: FC = () => {
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => ( {Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
<span <span
key={columnIndex} key={columnIndex}
lang={text.language}
className={ className={
'whitespace-nowrap text-3xl font-bold ' 'whitespace-nowrap text-3xl font-bold '
+ 'tracking-[0.3em] text-neutral-950/5 ' + 'tracking-[0.3em] text-neutral-950/5 '
+ 'dark:text-white/10' + 'dark:text-white/10'
} }
style={{ fontFamily: 'Nikumaru' }}> style={{ fontFamily: 'Nikumaru' }}>
開発モード {text.text}
</span>))} </span>))}
</div>))} </div>))}
</div> </div>
+5 -4
ファイルの表示
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async' import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import ErrorScreen from '@/components/ErrorScreen' import ErrorScreen from '@/components/ErrorScreen'
@@ -12,9 +13,9 @@ describe ('ErrorScreen', () => {
[503, '鯖死んでるよ(泣)'], [503, '鯖死んでるよ(泣)'],
]) ('renders status %s', (status, message) => { ]) ('renders status %s', (status, message) => {
render ( render (
<HelmetProvider> <MemoryRouter><HelmetProvider>
<ErrorScreen status={status}/> <ErrorScreen status={status}/>
</HelmetProvider>, </HelmetProvider></MemoryRouter>,
) )
expect (screen.getByText (String (status))).toBeInTheDocument () expect (screen.getByText (String (status))).toBeInTheDocument ()
@@ -24,9 +25,9 @@ describe ('ErrorScreen', () => {
it ('throws for unsupported statuses', () => { it ('throws for unsupported statuses', () => {
expect (() => render ( expect (() => render (
<HelmetProvider> <MemoryRouter><HelmetProvider>
<ErrorScreen status={418}/> <ErrorScreen status={418}/>
</HelmetProvider>, </HelmetProvider></MemoryRouter>,
)).toThrow () )).toThrow ()
}) })
}) })
+19 -24
ファイルの表示
@@ -1,9 +1,9 @@
import { Helmet } from 'react-helmet-async'
import nikumaru from '@/assets/fonts/nikumaru.otf' import nikumaru from '@/assets/fonts/nikumaru.otf'
import errorImg from '@/assets/images/not-found.gif' import errorImg from '@/assets/images/not-found.gif'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import PageHead from '@/components/common/PageHead'
import { useUITranslation } from '@/lib/useUITranslation'
import { currentRenderContext } from '@/lib/renderContext'
import type { FC } from 'react' import type { FC } from 'react'
@@ -11,17 +11,19 @@ type Props = { status: number }
const ErrorScreen: FC<Props> = ({ status }) => { const ErrorScreen: FC<Props> = ({ status }) => {
const [message, rightMsg, leftMsg]: [string, string, string] = (() => { const context = currentRenderContext ()
if (context != null)
context.status = status
const { message: translate, label } = useUITranslation ('errors')
const [message]: [string, string, string] = (() => {
switch (status) switch (status)
{ {
case 403: case 403:
return ['権限ないよ(笑)', '帰れ!', '帰れ!']
case 404: case 404:
return ['ページないよ(笑)', '帰れ!', '帰れ!']
case 500: case 500:
return ['鯖でエラー出たって(嘲笑)', '管理人に', '聯絡を!']
case 503: case 503:
return ['鯖死んでるよ(泣)', '管理人に', '聯絡を!'] return [translate (`${ status }.message`).text,
translate (`${ status }.right`).text, translate (`${ status }.left`).text]
default: default:
throw new Error throw new Error
} }
@@ -29,21 +31,14 @@ const ErrorScreen: FC<Props> = ({ status }) => {
const title = message.replaceAll ('(', ' (').replaceAll (')', ')') const title = message.replaceAll ('(', ' (').replaceAll (')', ')')
const style = document.createElement ('style')
style.innerHTML = `
@font-face
{
font-family: 'Nikumaru';
src: url(${ nikumaru }) format('opentype');
}`
document.head.appendChild (style)
return ( return (
<MainArea> <MainArea>
<Helmet> <PageHead title={{ text: title,
language: translate (`${status}.message`).language }}>
<meta name="robots" content="noindex"/> <meta name="robots" content="noindex"/>
<title>{title} | {SITE_TITLE}</title> <style>{`@font-face { font-family: 'Nikumaru';
</Helmet> src: url(${nikumaru}) format('opentype'); }`}</style>
</PageHead>
<div className="text-6xl font-bold text-transparent <div className="text-6xl font-bold text-transparent
bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)] bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)]
bg-clip-text bg-[length:200%_100%] animate-rainbow-scroll drop-shadow-[0_0_6px_black] bg-clip-text bg-[length:200%_100%] animate-rainbow-scroll drop-shadow-[0_0_6px_black]
@@ -51,11 +46,11 @@ const ErrorScreen: FC<Props> = ({ status }) => {
style={{ fontFamily: 'Nikumaru' }}> style={{ fontFamily: 'Nikumaru' }}>
<p>{status}</p> <p>{status}</p>
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4"> <div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4">
<p style={{ writingMode: 'vertical-rl' }}>{leftMsg}</p> <p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.left`)}</p>
<img className="max-w-[70vw]" src={errorImg} alt="逃げたギター"/> <img className="max-w-[70vw]" src={errorImg} alt={translate ('imageAlt').text}/>
<p style={{ writingMode: 'vertical-rl' }}>{rightMsg}</p> <p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.right`)}</p>
</div> </div>
<p className="mr-[-.5em]">{message}</p> <p className="mr-[-.5em]">{label (`${ status }.message`)}</p>
</div> </div>
</MainArea>) </MainArea>)
} }
+47
ファイルの表示
@@ -0,0 +1,47 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import LocalisedText from '@/components/LocalisedText'
describe ('LocalisedText', () => {
it.each ([
['ネコ チャペ', ['ネコ', 'チャペ']],
['ネコ、チャペ', ['ネコ、', 'チャペ']],
['著作者人格権、商標権、肖像権、パブリシティー権',
['著作者人格権、', '商標権、', '肖像権、', 'パブリシティー権']],
['ネコ、チャペ。', ['ネコ、', 'チャペ。']],
['ネコ、「チャペ」。', ['ネコ、', '「チャペ」。']],
['ネコ,、」チャペ', ['ネコ,、」', 'チャペ']]]) (
'adds word boundaries without changing %s', (text, words) => {
const { container } = render (<LocalisedText language="ain">{text}</LocalisedText>)
const wrapper = container.firstElementChild!
expect (wrapper).toHaveAttribute ('lang', 'ain-Kana')
expect (wrapper.textContent).toBe (text)
expect (Array.from (wrapper.querySelectorAll ('.ainu-ui-word'), node => node.textContent))
.toEqual (words)
expect (wrapper.querySelectorAll ('wbr')).toHaveLength (words.length - 1)
})
it ('preserves Japanese fallback and links and code inside an Ainu paragraph', () => {
const { container } = render (
<LocalisedText language="ain">
<span lang="ja">日本語、本文</span>
<a href="https://example.com">ネコ チャペ</a>
<code>ネコ チャペ</code>
<strong>ネコ チャペ</strong>
</LocalisedText>)
expect (container.querySelector ('[lang="ja"] .ainu-ui-word')).toBeNull ()
expect (container.querySelector ('a .ainu-ui-word')).toBeNull ()
expect (container.querySelector ('code .ainu-ui-word')).toBeNull ()
expect (container.querySelectorAll ('strong .ainu-ui-word')).toHaveLength (2)
})
it ('does not apply Ainu word splitting to Japanese text', () => {
const { container } = render (
<LocalisedText language="ja">ネコ、チャペ。</LocalisedText>)
expect (container.firstElementChild).toHaveAttribute ('lang', 'ja')
expect (container.querySelector ('.ainu-ui-word')).toBeNull ()
expect (container.textContent).toBe ('ネコ、チャペ。')
})
})
+89
ファイルの表示
@@ -0,0 +1,89 @@
import { Children, cloneElement, Fragment, isValidElement } from 'react'
import { cn } from '@/lib/utils'
import type { ReactNode } from 'react'
export const formatUIString = (text: string, language: string): string =>
(language === 'ain' || language === 'ain-Kana')
? text.replace (/[ \t\r\n]+/g, '\u3000')
: text
const splitAinuText = (text: string): string[] => {
const parts: string[] = []
let part = ''
let boundary = false
for (const character of text)
{
// Keep punctuation runs and closing brackets/quotes with the preceding word.
if (boundary && !/[\u3000、,,。.\p{Pe}\p{Pf}]/u.test (character))
{
parts.push (part)
part = ''
boundary = false
}
part += character
if (/[\u3000、,,。.]/u.test (character))
boundary = true
}
if (part !== '')
parts.push (part)
return parts
}
export const localiseUIChildren = (children: ReactNode, language: string): ReactNode => {
if (language !== 'ain' && language !== 'ain-Kana')
return children
return Children.map (children, child => {
if (typeof child === 'string')
{
const parts = splitAinuText (formatUIString (child, language))
return parts.map ((part, index) => {
const spaces = part.match (/\u3000+$/u)?.[0] ?? ''
const word = part.slice (0, part.length - spaces.length)
return (
<Fragment key={index}>
{word !== '' && <span className="ainu-ui-word">{word}</span>}
{spaces}
{index < parts.length - 1 && <wbr/>}
</Fragment>)
})
}
if (isValidElement<{ children?: ReactNode; lang?: string }> (child))
{
// Only traverse inline formatting owned by the renderer. Components,
// links and code retain their own children and behaviour.
const inline = child.type === Fragment
|| (typeof child.type === 'string'
&& ['span', 'em', 'strong', 'del', 's', 'b', 'i', 'u',
'small', 'mark', 'sup', 'sub'].includes (child.type))
if (!inline
|| (child.props.lang != null && !(child.props.lang.startsWith ('ain'))))
return child
return cloneElement (child, { }, localiseUIChildren (child.props.children, language))
}
return child
})
}
const LocalisedText = (
{ children, language, className }: {
children: ReactNode
language: string
className?: string },
) => (
<span lang={language === 'ain' ? 'ain-Kana' : language}
className={cn ((language === 'ain' || language === 'ain-Kana') && 'ainu-ui-text',
className)}>
{localiseUIChildren (children, language)}
</span>)
export default LocalisedText
+25 -15
ファイルの表示
@@ -1,19 +1,20 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials' import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types' import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any'] const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
const px = (value: string): number => { const px = (value: string): number => {
@@ -83,7 +84,7 @@ const tagSelectionShellClass = (selected: boolean): string =>
const updateMaterialFilterQuery = ( const updateMaterialFilterQuery = (
pathname: string, pathname: string,
locationSearch: string, locationSearch: string,
navigate: ReturnType<typeof useNavigate>, navigate: ReturnType<typeof useLocaleNavigate>,
materialFilter: MaterialFilter, materialFilter: MaterialFilter,
) => { ) => {
const qs = new URLSearchParams (locationSearch) const qs = new URLSearchParams (locationSearch)
@@ -95,9 +96,11 @@ const updateMaterialFilterQuery = (
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
onChange: (materialFilter: MaterialFilter) => void }> = ( onChange: (materialFilter: MaterialFilter) => void }> = (
{ materialFilter, onChange }, { materialFilter, onChange },
) => ( ) => {
const { label } = useUITranslation ('materials')
return (
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center"> <div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
<label className="my-auto text-sm font-bold">素材:</label> <label className="my-auto text-sm font-bold">{label ('sidebar.label')}</label>
{FILTERS.map (value => ( {FILTERS.map (value => (
<button <button
key={value} key={value}
@@ -110,9 +113,10 @@ const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
'dark:bg-sky-950 dark:text-sky-100'] 'dark:bg-sky-950 dark:text-sky-100']
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700', : ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-200']))}> 'dark:bg-stone-900 dark:text-stone-200']))}>
{FILTER_LABELS[value]} {label (`sidebar.${value}`)}
</button>))} </button>))}
</div>) </div>)
}
const MaterialTreeNode: FC<{ const MaterialTreeNode: FC<{
@@ -125,10 +129,11 @@ const MaterialTreeNode: FC<{
tag: MaterialSidebarTag tag: MaterialSidebarTag
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId, }> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
setOpenTags, tag }) => { setOpenTags, tag }) => {
const locale = useLocale ()
const open = Boolean (openTags[tag.id]) const open = Boolean (openTags[tag.id])
const { data } = useQuery ({ const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
enabled: open && tag.hasChildren && tag.children.length === 0}) enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => { useEffect (() => {
@@ -200,6 +205,7 @@ const MobileMaterialTreeNode: FC<{ depth?: number
tag, tag,
}, },
) => { ) => {
const locale = useLocale ()
const open = Boolean (openTags[tag.id]) const open = Boolean (openTags[tag.id])
const tagColumnRef = useRef<HTMLDivElement | null> (null) const tagColumnRef = useRef<HTMLDivElement | null> (null)
const chipRef = useRef<HTMLDivElement | null> (null) const chipRef = useRef<HTMLDivElement | null> (null)
@@ -210,8 +216,8 @@ const MobileMaterialTreeNode: FC<{ depth?: number
const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null) const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null)
const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null) const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
const { data } = useQuery ({ const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
enabled: open && tag.hasChildren && tag.children.length === 0}) enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => { useEffect (() => {
@@ -382,8 +388,10 @@ const MobileMaterialTreeNode: FC<{ depth?: number
const MaterialSidebar: FC = () => { const MaterialSidebar: FC = () => {
const { label } = useUITranslation ('materials')
const locale = useLocale ()
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useLocaleNavigate ()
const qs = new URLSearchParams (location.search) const qs = new URLSearchParams (location.search)
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any') const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
@@ -395,8 +403,8 @@ const MaterialSidebar: FC = () => {
useState<number | null> (null) useState<number | null> (null)
const { data: rootTags = [], isLoading, isError } = useQuery ({ const { data: rootTags = [], isLoading, isError } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }), queryKey: materialsKeys.tree ({ parentId: null, materialFilter }, locale),
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })}) queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }, locale)})
useEffect (() => { useEffect (() => {
setDesktopTags (rootTags) setDesktopTags (rootTags)
@@ -494,10 +502,12 @@ const MaterialSidebar: FC = () => {
materialFilter={materialFilter} materialFilter={materialFilter}
onChange={handleFilterChange}/> onChange={handleFilterChange}/>
{isLoading && ( {isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)} <p className="text-sm text-neutral-500 dark:text-stone-400">
{label ('shared:messages.loadingEllipsis')}
</p>)}
{isError && ( {isError && (
<p className="text-sm text-red-600 dark:text-red-300"> <p className="text-sm text-red-600 dark:text-red-300">
タグ一覧の取得に失敗しました. {label ('sidebar.error')}
</p>)} </p>)}
{(!isLoading && !isError) && ( {(!isLoading && !isError) && (
<ul> <ul>
+2 -2
ファイルの表示
@@ -3,8 +3,8 @@ import type { FC } from 'react'
const MenuSeparator: FC = () => ( const MenuSeparator: FC = () => (
<> <>
<span className="hidden md:inline flex items-center px-2">|</span> <span className="top-nav-separator-vertical items-center px-2">|</span>
<hr className="block md:hidden w-full opacity-25 <hr className="top-nav-separator-horizontal w-full opacity-25
border-t border-black dark:border-white"/> border-t border-black dark:border-white"/>
</>) </>)
+10 -6
ファイルの表示
@@ -1,8 +1,10 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import PostEditForm from '@/components/PostEditForm' import PostEditForm from '@/components/PostEditForm'
import { buildPost, buildTag } from '@/test/factories' import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const postsApi = vi.hoisted (() => ({ const postsApi = vi.hoisted (() => ({
updatePost: vi.fn (), updatePost: vi.fn (),
@@ -19,7 +21,8 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/dialogues/useDialogue', () => ({ vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/dialogues/useDialogue')> (),
default: () => ({ default: () => ({
choice: vi.fn (), choice: vi.fn (),
}), }),
@@ -60,11 +63,12 @@ describe ('PostEditForm', () => {
parentPostIds: '3 4', parentPostIds: '3 4',
tags: 'general-tag', tags: 'general-tag',
}), }),
{ baseVersionNo: 4 }, { baseVersionNo: 4 }, 'ja',
) )
}) })
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].description)).toBe ('更新しました.')
}) })
it ('preserves duration while the video tag is temporarily removed', () => { it ('preserves duration while the video tag is temporarily removed', () => {
@@ -78,7 +82,7 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>) render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input')) expect (screen.getByDisplayValue ('180.5'))
.toHaveValue ('180.5') .toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2] const tags = screen.getAllByRole ('textbox')[2]
@@ -88,7 +92,7 @@ describe ('PostEditForm', () => {
fireEvent.change (tags, { fireEvent.change (tags, {
target: { value: '動画 general-tag' }, target: { value: '動画 general-tag' },
}) })
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input')) expect (screen.getByDisplayValue ('180.5'))
.toHaveValue ('180.5') .toHaveValue ('180.5')
}) })
+25 -14
ファイルの表示
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import PostDurationField from '@/components/posts/PostDurationField' import PostDurationField from '@/components/posts/PostDurationField'
@@ -12,6 +13,7 @@ import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
import { msToTime } from '@/lib/utils' import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -51,6 +53,8 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => { const PostEditForm: FC<Props> = ({ post, onSave }) => {
const locale = useLocale ()
const { label } = useUITranslation ('posts')
const [disabled, setDisabled] = useState (false) const [disabled, setDisabled] = useState (false)
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs)) const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
@@ -61,7 +65,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
useState<string | null> (post.originalCreatedFrom) useState<string | null> (post.originalCreatedFrom)
const [parentPostIds, setParentPostIds] = const [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' ')) useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
const [tags, setTags] = useState<string> ('') const [tags, setTags] = useState<string> (() => tagsToStr (post.tags))
const [title, setTitle] = useState (post.title) const [title, setTitle] = useState (post.title)
const videoFlg = const videoFlg =
@@ -70,12 +74,15 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => { const update = async (
postData: Parameters<typeof updatePost>[0],
options: Parameters<typeof updatePost>[1],
) => {
clearValidationErrors () clearValidationErrors ()
try try
{ {
const data = await updatePost (...args) const data = await updatePost (postData, options, locale)
onSave ({ ...post, onSave ({ ...post,
versionNo: data.versionNo, versionNo: data.versionNo,
title: data.title, title: data.title,
@@ -86,7 +93,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
siblingPosts: data.siblingPosts, siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom, originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post) originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' }) toast ({ description: label ('shared:messages.updated') })
} }
catch (e) catch (e)
{ {
@@ -96,24 +103,28 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{ {
if (applyValidationError (e)) if (applyValidationError (e))
{ {
toast ({ description: '更新はできなかったよ……' }) toast ({ description: label ('edit.validationFailed') })
return return
} }
toast ({ title: '失敗……', description: '入力を確認してください.' }) toast ({ title: label ('shared:messages.failed'),
description: label ('shared:messages.checkInput') })
return return
} }
const action = await dialogue.choice ({ const action = await dialogue.choice ({
title: '競合が発生しました.', title: label ('edit.conflictTitle'),
description: ( description: (
<div> <div>
<p>ほかの耕作員が先に更新してゐます.</p> <p>{label ('edit.conflictDescription')}</p>
<p>現在の変更をどう扱ひますか?</p> <p>{label ('edit.conflictQuestion')}</p>
</div>), </div>),
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []), cancelText: label ('shared:actions.cancel'),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] }) closeLabel: label ('settings:actions.close'),
choices: [...(response?.data?.mergeable
? [{ value: 'merge', label: label ('edit.merge') }] : []),
{ value: 'overwrite', label: label ('edit.overwrite'), variant: 'danger' }] })
if (action === 'merge') if (action === 'merge')
{ {
@@ -163,13 +174,13 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
<PostTextField <PostTextField
label="タイトル" label={label ('shared:labels.title')}
value={title ?? ''} value={title ?? ''}
disabled={disabled} disabled={disabled}
onChange={setTitle}/> onChange={setTitle}/>
<PostTextField <PostTextField
label="親投稿" label={label ('shared:labels.parentPosts')}
value={parentPostIds} value={parentPostIds}
disabled={disabled} disabled={disabled}
errors={fieldErrors.parentPostIds} errors={fieldErrors.parentPostIds}
@@ -202,7 +213,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{/* 送信 */} {/* 送信 */}
<Button type="submit" disabled={disabled}> <Button type="submit" disabled={disabled}>
更新 {label ('shared:actions.update')}
</Button> </Button>
</form>) </form>)
} }
+5 -3
ファイルの表示
@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostEmbed from '@/components/PostEmbed' import PostEmbed from '@/components/PostEmbed'
import { buildPost } from '@/test/factories' import { buildPost } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const dialogue = vi.hoisted (() => ({ const dialogue = vi.hoisted (() => ({
confirm: vi.fn (), confirm: vi.fn (),
@@ -12,8 +13,9 @@ const nicoViewer = vi.hoisted (() => ({
props: vi.fn (), props: vi.fn (),
})) }))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
useDialogue: () => dialogue, ...await importOriginal<typeof import ('@/lib/dialogues/useDialogue')> (),
default: () => dialogue,
})) }))
vi.mock ('@/components/NicoViewer', () => ({ vi.mock ('@/components/NicoViewer', () => ({
+10 -8
ファイルの表示
@@ -3,9 +3,10 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed' import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import useDialogue from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, RefObject } from 'react' import type { FC, RefObject } from 'react'
@@ -36,6 +37,7 @@ const PostEmbed: FC<Props> = (
onError }, onError },
) => { ) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const { label } = useUITranslation ('posts')
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto' const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
const [manualLoadRequested, setManualLoadRequested] = useState (false) const [manualLoadRequested, setManualLoadRequested] = useState (false)
@@ -135,7 +137,7 @@ const PostEmbed: FC<Props> = (
const externalLink = ( const externalLink = (
<a href={post.url} target="_blank" rel="noreferrer"> <a href={post.url} target="_blank" rel="noreferrer">
外部ページを開く {label ('embed.openExternal')}
</a>) </a>)
const manualLoadControl = ( const manualLoadControl = (
@@ -144,7 +146,7 @@ const PostEmbed: FC<Props> = (
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setManualLoadRequested (true)}> onClick={() => setManualLoadRequested (true)}>
埋め込みを読込 {label ('embed.load')}
</Button> </Button>
{externalLink} {externalLink}
</div>) </div>)
@@ -239,15 +241,15 @@ const PostEmbed: FC<Props> = (
e.preventDefault () e.preventDefault ()
setFramed (await dialogue.confirm ({ setFramed (await dialogue.confirm ({
title: '未確認の外部ページを表示します。', title: label ('embed.confirmTitle'),
description: ( description: (
<div> <div>
<p>悪意のあるスクリプトが実行される可能性があります。</p> <p>{label ('embed.risk')}</p>
<p>表示しますか?</p> <p>{label ('embed.question')}</p>
</div>), </div>),
confirmText: '表示' })) confirmText: label ('shared:actions.show') }))
}}> }}>
外部ページを表示 {label ('embed.showExternal')}
</a>)} </a>)}
</div>)} </div>)}
</>) </>)
+2 -1
ファイルの表示
@@ -26,7 +26,8 @@ describe ('PostFormTagsArea', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete', '/tags/autocomplete',
{ params: { q: '虹', nico: '0' } }, { locale: 'ja', params: { q: '虹', nico: '0' },
signal: expect.any (AbortSignal) },
) )
}) })
expect (setTags).toHaveBeenCalledWith ('虹夏') expect (setTags).toHaveBeenCalledWith ('虹夏')
+54 -14
ファイルの表示
@@ -1,13 +1,15 @@
// TODO: TagSearch と共通化する. // TODO: TagSearch と共通化する.
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react' import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -34,19 +36,29 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & { type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
tags: string tags: string
setTags: (tags: string) => void setTags: (tags: string) => void
errors?: string[] } errors?: ReactNode[] }
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => { const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
const { label } = useUITranslation ('navigation')
const locale = useLocale ()
const ref = useRef<HTMLTextAreaElement> (null) const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
const [focused, setFocused] = useState (false) const [focused, setFocused] = useState (false)
const [suggestions, setSuggestions] = useState<Tag[]> ([]) const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
request: { query: string }
tags: Tag[] }> ({ locale, request, tags: [] })
const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token
const suggestions = (result.locale === locale && result.request === request
&& request.query === currentToken) ? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const handleTagSelect = (tag: Tag) => { const handleTagSelect = (tag: Tag) => {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
setRequest ({ query: '' })
const textarea = ref.current! const textarea = ref.current!
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ') const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
setTags (newValue) setTags (newValue)
@@ -58,8 +70,10 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
}) })
} }
const recompute = async (pos: number, v: string = tags) => { const recompute = (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos) const { start, end, token } = getTokenAt (v, pos)
setRequest ({ query: token })
setSuggestionsVsbl (token.trim () !== '')
if (!(token.trim ())) if (!(token.trim ()))
{ {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
@@ -67,17 +81,43 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
} }
setBounds ({ start, end }) setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q: token,
nico: '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
} }
useEffect (() => {
if (request.query.trim () === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, params: { q: request.query, nico: '0' } })
if (active)
{
setResult ({ locale, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale])
useEffect (() => {
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}, [locale])
return ( return (
<FormField className="relative w-full" label="タグ" messages={errors}> <FormField className="relative w-full" label={label ('tags')} messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<TextArea <TextArea
@@ -89,7 +129,7 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
onChange={ev => setTags (ev.target.value)} onChange={ev => setTags (ev.target.value)}
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => { onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos) await recompute (pos, (ev.target as HTMLTextAreaElement).value)
}} }}
onFocus={() => setFocused (true)} onFocus={() => setFocused (true)}
onBlur={() => { onBlur={() => {
+2 -2
ファイルの表示
@@ -26,11 +26,11 @@ describe ('PostList', () => {
expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute (
'href', 'href',
'/posts/1', '/ja/posts/1',
) )
expect ( expect (
screen.getByRole ('link', { name: 'https://example.com/second' }), screen.getByRole ('link', { name: 'https://example.com/second' }),
).toHaveAttribute ('href', '/posts/2') ).toHaveAttribute ('href', '/ja/posts/2')
}) })
it ('calls the optional click handler', () => { it ('calls the optional click handler', () => {
+3 -1
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { useUITranslation } from '@/lib/useUITranslation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => { const PostList: FC<Props> = ({ posts, onClick }) => {
const { label } = useUITranslation ('shared')
const location = useLocation () const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal' const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
@@ -75,7 +77,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
? ( ? (
<div className="flex h-full w-full items-center justify-center <div className="flex h-full w-full items-center justify-center
bg-muted text-center text-xs text-muted-foreground"> bg-muted text-center text-xs text-muted-foreground">
サムネイルなし {label ('labels.noThumbnail')}
</div>) </div>)
: ( : (
<img <img
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import { renderWithProviders as render } from '@/test/render'
describe ('PostOriginalCreatedTimeField', () => { describe ('PostOriginalCreatedTimeField', () => {
it ('updates from and before values', () => { it ('updates from and before values', () => {
+23 -22
ファイルの表示
@@ -1,8 +1,9 @@
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField' import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react' import type { FC, ReactNode } from 'react'
type Props = { type Props = {
disabled?: boolean disabled?: boolean
@@ -10,7 +11,7 @@ type Props = {
setOriginalCreatedFrom: (x: string | null) => void setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void setOriginalCreatedBefore: (x: string | null) => void
errors?: string[] } errors?: ReactNode[] }
const PostOriginalCreatedTimeField: FC<Props> = ( const PostOriginalCreatedTimeField: FC<Props> = (
@@ -19,15 +20,16 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore, setOriginalCreatedBefore,
errors }: Props) => { errors }: Props,
) => {
const { label } = useUITranslation ('shared')
return ( return (
<FormField label="オリジナルの作成日時" messages={errors}> <FormField label={label ('labels.originalCreatedAt')} messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<div className="my-1 flex"> <div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
<div className="w-80">
<DateTimeField <DateTimeField
className="mr-2" className="min-w-0 max-w-full"
disabled={disabled ?? false} disabled={disabled ?? false}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
@@ -46,45 +48,44 @@ const PostOriginalCreatedTimeField: FC<Props> = (
d.setMinutes (d.getMinutes () + 1) d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ())) setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/> }}/>
以降 <span className="ainu-ui-label shrink-0 whitespace-nowrap">
</div> {label ('labels.since')}
<div> </span>
<Button <Button
type="button" type="button"
className="bg-gray-600 text-white rounded" className="ainu-ui-label shrink-0 rounded bg-gray-600 text-white
dark:bg-gray-600 dark:text-white"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
setOriginalCreatedFrom (null) setOriginalCreatedFrom (null)
}}> }}>
リセット {label ('actions.reset')}
</Button> </Button>
</div> </div>
</div>
<div className="my-1 flex"> <div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
<div className="w-80">
<DateTimeField <DateTimeField
className="mr-2" className="min-w-0 max-w-full"
disabled={disabled} disabled={disabled}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
invalid={invalid} invalid={invalid}
value={originalCreatedBefore ?? undefined} value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/> onChange={setOriginalCreatedBefore}/>
より前 <span className="ainu-ui-label shrink-0 whitespace-nowrap">
</div> {label ('labels.before')}
<div> </span>
<Button <Button
type="button" type="button"
className="bg-gray-600 text-white rounded" className="ainu-ui-label shrink-0 rounded bg-gray-600 text-white
dark:bg-gray-600 dark:text-white"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
setOriginalCreatedBefore (null) setOriginalCreatedBefore (null)
}}> }}>
リセット {label ('actions.reset')}
</Button> </Button>
</div> </div>
</div>
</>)} </>)}
</FormField>) </FormField>)
} }
+13 -6
ファイルの表示
@@ -1,12 +1,15 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { forwardRef, useMemo } from 'react' import { forwardRef, useMemo } from 'react'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { createPath, useNavigate } from 'react-router-dom' import { createPath } from 'react-router-dom'
import { localiseTo } from '@/lib/locales'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { currentRenderContext } from '@/lib/renderContext'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
import type { To } from 'react-router-dom' import type { To } from 'react-router-dom'
@@ -32,16 +35,20 @@ export default forwardRef<HTMLAnchorElement, Props> (({
if ('onClick' in rest) if ('onClick' in rest)
delete rest['onClick'] delete rest['onClick']
const navigate = useNavigate () const navigate = useLocaleNavigate ()
const locale = useLocale ()
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo ( const path = useMemo (
() => typeof to === 'string' ? to : createPath (to), () => {
[to], const localised = localiseTo (to, locale)
return typeof localised === 'string' ? localised : createPath (localised)
},
[to, locale],
) )
const url = useMemo (() => { const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString () return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
}, [path]) }, [path])
const setOverlay = useOverlayStore (s => s.setActive) const setOverlay = useOverlayStore (s => s.setActive)
@@ -98,7 +105,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
if (!(ok) && cancelOnError) if (!(ok) && cancelOnError)
return return
navigate (to, { replace, ...(state && { state }) }) navigate (path, { replace, ...(state && { state }) })
} }
catch (ex) catch (ex)
{ {
+10 -2
ファイルの表示
@@ -1,6 +1,10 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { create } from 'zustand' import { create } from 'zustand'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import type { FC } from 'react' import type { FC } from 'react'
type OverlayStore = { type OverlayStore = {
@@ -14,6 +18,9 @@ export const useOverlayStore = create<OverlayStore> (set => ({
const RouteBlockerOverlay: FC = () => { const RouteBlockerOverlay: FC = () => {
const { i18n } = useTranslation ('common')
const loading = translateUIMessage ('loading', 'common', i18n.language)
const loadingLabel = translateUIMessage ('loadingLabel', 'common', i18n.language)
const active = useOverlayStore (s => s.active) const active = useOverlayStore (s => s.active)
useEffect (() => { useEffect (() => {
@@ -35,11 +42,12 @@ const RouteBlockerOverlay: FC = () => {
return ( return (
<div <div
role="progressbar" role="progressbar"
aria-label="Loading" aria-label={loadingLabel.text}
lang={loadingLabel.language}
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto"> className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto">
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm"> <div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm">
Loading... <LocalisedText language={loading.language}>{loading.text}</LocalisedText>
</div> </div>
</div> </div>
</div>) </div>)
+2 -2
ファイルの表示
@@ -17,7 +17,7 @@ describe ('SortHeader', () => {
expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute (
'href', 'href',
'/posts?tags=x&page=1&order=title%3Adesc', '/ja/posts?tags=x&page=1&order=title%3Adesc',
) )
}) })
@@ -33,7 +33,7 @@ describe ('SortHeader', () => {
expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute (
'href', 'href',
'/posts?page=1&order=updated_at%3Adesc', '/ja/posts?page=1&order=updated_at%3Adesc',
) )
}) })
}) })

変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示