コミットを比較
39
コミット
main
..
feature/428
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
83563e36ef | ||
|
|
4ab13c4244 | ||
|
|
d365406019 | ||
|
|
4e656bad55 | ||
|
|
0622d5ba0b | ||
|
|
716428b047 | ||
|
|
aa11d09fc3 | ||
|
|
d5046a17c9 | ||
|
|
a86129006d | ||
|
|
112d75c3bb | ||
|
|
8bcc61c0f8 | ||
|
|
e0970a81a4 | ||
|
|
22f4a0af40 | ||
|
|
912b941f90 | ||
|
|
569b9234c6 | ||
|
|
e187331af4 | ||
|
|
b3b00e170f | ||
|
|
c9628a120d | ||
|
|
525f6883c9 | ||
|
|
20085d5017 | ||
|
|
bbee83e35b | ||
|
|
1dcb790f44 | ||
|
|
b3c42b8eb3 | ||
|
|
a7e350d28e | ||
|
|
d9bfac8392 | ||
|
|
4c1178dab5 | ||
|
|
2d1a8b4730 | ||
|
|
61d7e6f44e | ||
|
|
753a150128 | ||
|
|
cd948611af | ||
|
|
34a4ffefb4 | ||
|
|
eda81cfa89 | ||
|
|
0c1f459d4e | ||
|
|
9be0c7b1d9 | ||
|
|
63860c94af | ||
|
|
851d5e8e09 | ||
|
|
91296f7477 | ||
|
|
f063fc54bb | ||
|
|
b945ceef60 |
@@ -1,22 +1,52 @@
|
||||
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::RecordNotUnique, with: :render_record_not_unique
|
||||
rescue_from(UnsupportedLocale) { head :not_found }
|
||||
|
||||
before_action :reject_banned_ip_address!
|
||||
before_action :authenticate_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
|
||||
|
||||
private
|
||||
|
||||
def reject_invalid_csrf
|
||||
response.headers['X-CSRF-Invalid'] = '1'
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def authenticate_user
|
||||
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
|
||||
code = session[:inheritance_code]
|
||||
return if code.blank?
|
||||
|
||||
@current_user = User.find_by(inheritance_code: code)
|
||||
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
|
||||
return default if params[key].nil?
|
||||
|
||||
@@ -93,5 +123,21 @@ class ApplicationController < ActionController::API
|
||||
value
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -24,7 +24,7 @@ class NicoTagsController < ApplicationController
|
||||
|
||||
q =
|
||||
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')
|
||||
.includes(linked_tags: [:tag_names, :tag_descriptions])
|
||||
if name
|
||||
@@ -37,7 +37,7 @@ class NicoTagsController < ApplicationController
|
||||
linked_tag_ids =
|
||||
Tag
|
||||
.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 }%")
|
||||
.pluck(:id)
|
||||
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
|
||||
@@ -45,7 +45,7 @@ class NicoTagsController < ApplicationController
|
||||
end
|
||||
if link_status.in?(['linked', 'unlinked'])
|
||||
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)'
|
||||
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class PostVersionsController < ApplicationController
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
post_id = params[:post].presence
|
||||
tag_id = params[:tag].presence&.to_i
|
||||
external_tag_id = params[:external_tag].presence&.to_i
|
||||
@@ -45,12 +46,12 @@ class PostVersionsController < ApplicationController
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
render json: { versions: serialise_versions(versions), count: }
|
||||
render json: { versions: serialise_versions(versions, locale.language_code), count: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialise_versions rows
|
||||
def serialise_versions rows, language_code
|
||||
rows = rows.to_a
|
||||
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||
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.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)
|
||||
|
||||
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(
|
||||
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,
|
||||
version_no: row.version_no,
|
||||
@@ -75,6 +80,7 @@ class PostVersionsController < ApplicationController
|
||||
prev: row.attributes['prev_thumbnail_base'] },
|
||||
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
|
||||
tags: build_version_tags(cur_tags, prev_tags),
|
||||
parent_post_ids: row.parent_post_ids,
|
||||
original_created_from: {
|
||||
current: row.original_created_from&.iso8601,
|
||||
prev: row.attributes['prev_original_created_from']&.iso8601 },
|
||||
@@ -106,17 +112,27 @@ class PostVersionsController < ApplicationController
|
||||
names
|
||||
end
|
||||
|
||||
def snapshot_tag_literals snapshots, external_tag_names
|
||||
def snapshot_tag_literals snapshots, tag_names, external_tag_names
|
||||
snapshots
|
||||
.filter_map { |snapshot|
|
||||
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']),
|
||||
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']]
|
||||
[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
|
||||
}
|
||||
.sort_by { |order, name| [order, name.downcase] }
|
||||
.sort_by { |order, tag| [order, tag[:name].downcase] }
|
||||
.map(&:second)
|
||||
end
|
||||
|
||||
@@ -125,17 +141,22 @@ class PostVersionsController < ApplicationController
|
||||
end
|
||||
|
||||
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 =
|
||||
if cur_tags.include?(name) && prev_tags.include?(name)
|
||||
if current.key?(identity) && previous.key?(identity)
|
||||
'context'
|
||||
elsif cur_tags.include?(name)
|
||||
elsif current.key?(identity)
|
||||
'added'
|
||||
else
|
||||
'removed'
|
||||
end
|
||||
|
||||
{ name:, type: }
|
||||
entry = current[identity] || previous[identity]
|
||||
{ name: entry[:name], restore_name: entry[:restore_name],
|
||||
external: entry[:external], type: }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -400,8 +400,76 @@ class PostsController < ApplicationController
|
||||
render_post_form_record_invalid e.record
|
||||
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
|
||||
|
||||
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
|
||||
tag_names = params[:tags].to_s.split
|
||||
match_type = params[:match]
|
||||
@@ -413,20 +481,29 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def filter_posts_by_tags locale, tag_names, match_type
|
||||
literals = tag_names.map do |raw_name|
|
||||
{ name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first,
|
||||
negative: raw_name.downcase.start_with?('not:') }
|
||||
references = tag_filter_references(tag_names)
|
||||
literals = tag_names.each_with_index.map do |raw_name, index|
|
||||
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
|
||||
|
||||
return Post.all if literals.empty?
|
||||
|
||||
if match_type == 'any'
|
||||
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
|
||||
else
|
||||
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]
|
||||
posts.where.not(id: ids)
|
||||
else
|
||||
@@ -436,8 +513,8 @@ class PostsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def tag_literal_relation locale, name, negative:
|
||||
ids = tagged_post_ids_for(locale, name)
|
||||
def tag_literal_relation locale, name, negative:, reference: nil
|
||||
ids = tagged_post_ids_for(locale, name, reference:)
|
||||
if negative
|
||||
Post.where.not(id: ids)
|
||||
else
|
||||
@@ -445,7 +522,20 @@ class PostsController < ApplicationController
|
||||
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 =
|
||||
Post
|
||||
.joins(tags: :tag_names)
|
||||
|
||||
@@ -35,8 +35,23 @@ class TagVersionsController < ApplicationController
|
||||
private
|
||||
|
||||
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
|
||||
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|
|
||||
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)
|
||||
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 =
|
||||
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
|
||||
.map do |h|
|
||||
{ tag: TagRepr.base(tags_by_id[h[:tag_id]], language_code:),
|
||||
{ tag: parent_json.fetch(h[:tag_id]),
|
||||
type: h[:type] }
|
||||
end
|
||||
|
||||
@@ -66,6 +73,8 @@ class TagVersionsController < ApplicationController
|
||||
version_no: row.version_no,
|
||||
event_type: row.event_type,
|
||||
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'] },
|
||||
category: { current: row.category, prev: row.attributes['prev_category'] },
|
||||
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 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&.fetch(language_code, {}) || {}
|
||||
values.is_a?(Hash) ? values : {}
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -175,7 +175,8 @@ class TagsController < ApplicationController
|
||||
tags_by_id =
|
||||
Tag
|
||||
.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 },
|
||||
id: tag_ids)
|
||||
.index_by(&:id)
|
||||
@@ -238,7 +239,8 @@ class TagsController < ApplicationController
|
||||
base =
|
||||
Tag
|
||||
.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 },
|
||||
deprecated_at: nil)
|
||||
base = base.where('tags.post_count > 0') if present_only
|
||||
@@ -282,7 +284,8 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.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],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||
@@ -302,8 +305,9 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
|
||||
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
|
||||
|
||||
platform, external_name = name.split(':', 2)
|
||||
@@ -320,7 +324,8 @@ class TagsController < ApplicationController
|
||||
|
||||
tag =
|
||||
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],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
@@ -340,7 +345,8 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
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(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
|
||||
@@ -359,7 +365,8 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
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],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
@@ -368,6 +375,7 @@ class TagsController < ApplicationController
|
||||
return if performed?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
|
||||
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
|
||||
@@ -424,7 +432,8 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.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 })
|
||||
return head :not_found unless tag
|
||||
|
||||
|
||||
@@ -1,39 +1,69 @@
|
||||
class UsersController < ApplicationController
|
||||
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.transaction do
|
||||
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
|
||||
attach_ip_address!(user)
|
||||
end
|
||||
|
||||
establish_user_session(user)
|
||||
render json: { code: user.inheritance_code,
|
||||
user: user.slice(:id, :name, :inheritance_code, :role) },
|
||||
status: :created
|
||||
end
|
||||
|
||||
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 head :forbidden if user.banned?
|
||||
|
||||
attach_ip_address!(user)
|
||||
establish_user_session(user)
|
||||
|
||||
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
||||
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
|
||||
return head :unauthorized unless current_user
|
||||
|
||||
user = current_user
|
||||
user.inheritance_code = SecureRandom.uuid
|
||||
user.save!
|
||||
establish_user_session(user)
|
||||
render json: { code: user.inheritance_code }
|
||||
end
|
||||
|
||||
def me
|
||||
user = User.find_by(inheritance_code: params[:code])
|
||||
return head :not_found unless user
|
||||
user = current_user
|
||||
return head :unauthorized unless user
|
||||
|
||||
attach_ip_address!(user)
|
||||
render json: user.slice(:id, :name, :inheritance_code, :role)
|
||||
end
|
||||
|
||||
|
||||
@@ -34,11 +34,33 @@ class WikiPagesController < ApplicationController
|
||||
render json:
|
||||
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
|
||||
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 head :not_found if reference
|
||||
|
||||
tag_group = TagGroup.find_by(id: params[:id])
|
||||
return render_tag_group locale, tag_group if tag_group
|
||||
@@ -63,6 +85,17 @@ class WikiPagesController < ApplicationController
|
||||
head :not_found
|
||||
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
|
||||
locale = resolve_locale!
|
||||
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
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
old_title = tag_name.name
|
||||
if title != old_title
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Language < ApplicationRecord
|
||||
has_many :locales, foreign_key: :language_code, primary_key: :code
|
||||
|
||||
enum :code, ja: 'ja'
|
||||
enum :code, ja: 'ja', ain: 'ain'
|
||||
end
|
||||
|
||||
+24
-12
@@ -1,26 +1,38 @@
|
||||
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 :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')
|
||||
|
||||
private
|
||||
|
||||
def generate_tag_names!
|
||||
ApplicationRecord.transaction do
|
||||
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
|
||||
Tag.where.not(id: tag_ids).includes(:tag_names).find_each do
|
||||
TagName.create!(tag_id: _1.id,
|
||||
language_code:,
|
||||
name: TagName.generate_name(self, _1, _1.tag_name('ja')),
|
||||
script_code:,
|
||||
primary_flg: true,
|
||||
auto_generated: true)
|
||||
def will_generate_tag_names?
|
||||
deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?)
|
||||
end
|
||||
|
||||
def save_with_tag_names!
|
||||
completed = self.class.transaction(requires_new: true) do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
saved = yield
|
||||
raise ActiveRecord::RecordNotSaved, 'Locale save was halted' unless saved
|
||||
|
||||
TagNameCompletion.for_locale!(self)
|
||||
true
|
||||
end
|
||||
unless completed
|
||||
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
class Script < ApplicationRecord
|
||||
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
|
||||
|
||||
+14
-27
@@ -110,7 +110,12 @@ class Tag < ApplicationRecord
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -259,20 +264,24 @@ class Tag < ApplicationRecord
|
||||
name = name.to_s.strip
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
auto_generated: false)
|
||||
generate_tag_names!(locale, tag, tn)
|
||||
TagNameCompletion.for_tag!(tag)
|
||||
tag.association(:tag_names).reset
|
||||
|
||||
tag
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
retry
|
||||
existing = TagName.find_by(language_code:, name:)
|
||||
raise unless existing
|
||||
|
||||
existing.tag
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
@@ -34,9 +34,22 @@ class TagName < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.generate_name locale, tag, base_tag_name
|
||||
# TODO: 言語ごとの自動命名ロジック完成したら書く.
|
||||
|
||||
"Tag_##{ tag.id }"
|
||||
fallback = "Tag_##{ tag.id }"
|
||||
name =
|
||||
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
|
||||
|
||||
private
|
||||
@@ -54,6 +67,8 @@ class TagName < ApplicationRecord
|
||||
end
|
||||
|
||||
def name_must_be_sanitised
|
||||
return if auto_generated && name == "Tag_##{ tag_id }"
|
||||
|
||||
if name? && name != TagNameSanitisationRule.sanitise(name)
|
||||
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
||||
end
|
||||
|
||||
@@ -9,6 +9,8 @@ module WikiPageRepr
|
||||
|
||||
{ 'id' => description.id,
|
||||
'resource_type' => 'tag_description',
|
||||
'tag_id' => tag.id,
|
||||
'language_code' => description.language_code,
|
||||
'tag_name_id' => tag.tag_name(locale.language_code)&.id,
|
||||
'body' => description.body,
|
||||
'created_user_id' => description.created_by_user_id,
|
||||
@@ -24,4 +26,24 @@ module WikiPageRepr
|
||||
def tag_group tag_group
|
||||
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -13,42 +13,26 @@ class SharedResourceIdAllocator
|
||||
['tag_description_versions', 'tag_id']],
|
||||
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
|
||||
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
|
||||
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
|
||||
raise ArgumentError, 'Unknown shared ID resource' unless tables
|
||||
|
||||
connection = record.class.connection
|
||||
transaction = record.class.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 }"
|
||||
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
|
||||
lock_space!(record.class, space)
|
||||
|
||||
# 明示-tic な Id. 指定は許可する.
|
||||
return if record.id.present?
|
||||
@@ -66,6 +50,68 @@ class SharedResourceIdAllocator
|
||||
record.id = next_id
|
||||
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
|
||||
expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i
|
||||
begin
|
||||
|
||||
@@ -5,6 +5,7 @@ class TagDescriptionOperation
|
||||
|
||||
def self.create! locale, tag:, body:, created_by_user:, message: nil
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
|
||||
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
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
description = TagDescription.lock.find_by!(
|
||||
tag_id: tag.id, language_code: locale.language_code)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,7 +1,10 @@
|
||||
class TagVersionRecorder < VersionRecorder
|
||||
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!
|
||||
end
|
||||
end
|
||||
|
||||
def initialize tag:, event_type:, created_by_user:
|
||||
super(record: tag, event_type:, created_by_user:)
|
||||
@@ -15,26 +18,39 @@ class TagVersionRecorder < VersionRecorder
|
||||
|
||||
def snapshot_attributes
|
||||
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'),
|
||||
category: @record.category,
|
||||
deprecated_at: @record.deprecated_at,
|
||||
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
|
||||
names:,
|
||||
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
||||
parent_tag_ids: parent_ids.join(' ') }
|
||||
end
|
||||
|
||||
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|
|
||||
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|
|
||||
{ '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,
|
||||
'script_code' => primary.script_code,
|
||||
'script_code' => Script.codes.fetch(primary.script_code, primary.script_code),
|
||||
'auto_generated' => primary.auto_generated,
|
||||
'aliases' => aliases }
|
||||
end
|
||||
|
||||
@@ -4,14 +4,23 @@ class TagVersioning
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
|
||||
event_type = tag.tag_versions.exists? ? :update : :create
|
||||
record!(tag, event_type:, created_by_user:, language_code:)
|
||||
Tag.transaction do
|
||||
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
|
||||
|
||||
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
|
||||
|
||||
@@ -35,7 +35,7 @@ class VersionRecorder
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -31,5 +31,14 @@ module Backend
|
||||
# Middleware like session, flash, cookies can be added back manually.
|
||||
# Skip views, helpers and assets when generating a new resource.
|
||||
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
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# Use this to limit dissemination of sensitive information.
|
||||
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
||||
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]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Rails.application.routes.draw do
|
||||
resources :locales, only: [:index]
|
||||
|
||||
resources :tag_groups, only: [:index, :show, :create, :update]
|
||||
|
||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||
@@ -43,6 +45,7 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
get :search, action: :index
|
||||
get :changes
|
||||
get :autolink_candidates
|
||||
|
||||
scope :title do
|
||||
get ':title/exists', action: :exists_by_title
|
||||
@@ -53,12 +56,14 @@ Rails.application.routes.draw do
|
||||
member do
|
||||
get :exists
|
||||
get :diff
|
||||
get :tag, action: :tag_reference
|
||||
end
|
||||
end
|
||||
|
||||
resources :posts, only: [:index, :show, :create, :update] do
|
||||
collection do
|
||||
get :metadata
|
||||
get :tag_filters
|
||||
post :bulk
|
||||
get :random
|
||||
get :versions, to: 'post_versions#index'
|
||||
@@ -97,6 +102,9 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
post :verify
|
||||
get :me
|
||||
get :session, action: :show_session
|
||||
get :csrf
|
||||
delete :session, action: :destroy_session
|
||||
post 'code/renew', action: :renew
|
||||
end
|
||||
end
|
||||
|
||||
ファイル差分が大きすぎるため省略します
差分を読み込み
@@ -8,6 +8,11 @@
|
||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||
# 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 = [
|
||||
{ priority: 10,
|
||||
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
|
||||
|
||||
@@ -24,17 +24,7 @@ FactoryBot.define do
|
||||
|
||||
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
|
||||
|
||||
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
|
||||
.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
|
||||
TagNameCompletion.for_tag!(tag)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,9 +6,8 @@ RSpec.describe Language, type: :model do
|
||||
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
|
||||
end
|
||||
|
||||
it 'accepts English when multilingual support is enabled' do
|
||||
pending '日本語以外の Language 有効化は別課題で実装する'
|
||||
expect { described_class.create!(code: 'en', name: 'English') }
|
||||
it 'accepts Ainu when multilingual support is enabled' do
|
||||
expect { described_class.create!(code: 'ain', name: 'ain') }
|
||||
.to change(described_class, :count).by(1)
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,13 +1,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Locale, type: :model do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
def create_english_locale!
|
||||
prepare_english_reference!
|
||||
described_class.create!(code: 'en', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
variants: '', name: 'English')
|
||||
def create_ainu_locale!
|
||||
prepare_ainu_reference!
|
||||
described_class.create!(code: 'ain-Kana', language_code: 'ain',
|
||||
script_code: 'Kana', region_code: 392,
|
||||
variants: '', name: 'Ainu')
|
||||
end
|
||||
|
||||
it 'keeps the active Nipponese locale consistent with its identity tuple' do
|
||||
@@ -27,7 +27,6 @@ RSpec.describe Locale, type: :model do
|
||||
end
|
||||
|
||||
it 'generates a primary name for every existing tag using the name generator' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
first = create(:tag, primary_name: 'first_existing_tag')
|
||||
second = create(:tag, primary_name: 'second_existing_tag')
|
||||
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'))
|
||||
.and_return('name_from_generator')
|
||||
|
||||
locale = create_english_locale!
|
||||
locale = create_ainu_locale!
|
||||
|
||||
expect(TagName).to have_received(:generate_name)
|
||||
.with(locale, first, primary_tag_name_for(first, 'ja'))
|
||||
expect(TagName).to have_received(:generate_name)
|
||||
.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,
|
||||
language_code: 'en', script_code: 'Latn',
|
||||
language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, auto_generated: true)
|
||||
expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true))
|
||||
.to have_attributes(tag_id: second.id, language_code: 'en',
|
||||
script_code: 'Latn', primary_flg: true,
|
||||
expect(second.tag_names.find_by!(language_code: 'ain', primary_flg: true))
|
||||
.to have_attributes(tag_id: second.id, language_code: 'ain',
|
||||
script_code: 'Kana', primary_flg: 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)
|
||||
end
|
||||
|
||||
it 'does not duplicate an existing primary name in the new language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
tag = create(:tag, primary_name: 'already_named')
|
||||
existing = create(:tag_name, name: 'existing_english', tag:,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
existing = create(:tag_name, name: 'existing_ainu', tag:,
|
||||
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)
|
||||
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)
|
||||
end
|
||||
|
||||
it 'creates a primary name when the language has only an alias' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
tag = create(:tag, primary_name: 'alias_only_tag')
|
||||
alias_name = create(:tag_name, :alias, name: 'alias_en',
|
||||
tag:,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
it 'adds the new language primary while preserving existing names and aliases' do
|
||||
tag = create(:tag, primary_name: 'named_tag')
|
||||
create(:tag_name, :alias, name: 'existing_alias', tag:,
|
||||
language_code: 'ja', script_code: 'Jpan')
|
||||
original_names = tag.tag_names.order(:id).map(&:attributes)
|
||||
|
||||
create_english_locale!
|
||||
create_ainu_locale!
|
||||
|
||||
expect(alias_name.reload).to have_attributes(
|
||||
tag_id: tag.id,
|
||||
language_code: 'en', script_code: 'Latn', primary_flg: false)
|
||||
generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en',
|
||||
script_code: 'Latn', primary_flg: true,
|
||||
expect(tag.tag_names.where(language_code: 'ja').order(:id).map(&:attributes))
|
||||
.to eq(original_names)
|
||||
generated = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
|
||||
expect(generated).to have_attributes(name: 'named_tag', tag_id: tag.id, language_code: 'ain',
|
||||
script_code: 'Kana', primary_flg: 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
|
||||
|
||||
it 'rolls back the locale and all generated names when backfill fails' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
first = create(:tag, primary_name: 'atomic_first')
|
||||
second = create(:tag, primary_name: 'atomic_second')
|
||||
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'))
|
||||
.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(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty
|
||||
expect(described_class.exists?(code: 'ain-Kana')).to be(false)
|
||||
expect(TagName.where(language_code: 'ain', tag_id: [first.id, second.id])).to be_empty
|
||||
end
|
||||
|
||||
it 'accepts a stable locale code outside the historical enum values' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
it 'rejects a private locale code outside the supported locale set' do
|
||||
prepare_ainu_reference!
|
||||
|
||||
locale = described_class.create!(
|
||||
code: 'en-x-rspec', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
variants: 'x-rspec', name: 'English')
|
||||
|
||||
expect(locale.reload).to have_attributes(
|
||||
code: 'en-x-rspec', language_code: 'en', script_code: 'Latn',
|
||||
region_code: 826, variants: 'x-rspec')
|
||||
expect {
|
||||
described_class.create!(
|
||||
code: 'ain-Kana-x-rspec', language_code: 'ain',
|
||||
script_code: 'Kana', region_code: 392,
|
||||
variants: 'x-rspec', name: 'Ainu')
|
||||
}.to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it 'registers the future British English locale tuple' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'registers the supported Ainu locale tuple' do
|
||||
|
||||
expect(locale).to have_attributes(code: 'en', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
expect(locale).to have_attributes(code: 'ain-Kana', language_code: 'ain',
|
||||
script_code: 'Kana', region_code: 392,
|
||||
variants: '')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Script, type: :model do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'selects locales by script code, not language code' do
|
||||
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
|
||||
@@ -25,11 +25,10 @@ RSpec.describe Script, type: :model do
|
||||
}
|
||||
end
|
||||
|
||||
it 'selects Latin locales when multilingual support is enabled' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'selects Kana locales when multilingual support is enabled' do
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -95,38 +95,38 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
||||
end
|
||||
|
||||
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
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag_name, name: 'foobar')
|
||||
english = create(:tag_name, name: 'temporary',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english.update_columns(name: 'foo_bar')
|
||||
ainu = create(:tag_name, name: 'temporary',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu.update_columns(name: 'foo_bar')
|
||||
|
||||
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')
|
||||
end
|
||||
|
||||
it 'renames without merging tags or deleting either language identity' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'foobar')
|
||||
english_name = create(:tag_name, name: 'temporary',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name)
|
||||
english_name.update_columns(name: 'foo_bar')
|
||||
primary_tag_name_for(nipponese, 'ain').update!(
|
||||
name: 'different_ainu_name', auto_generated: false)
|
||||
ainu_name = create(:tag_name, name: 'temporary',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||
ainu_name.update_columns(name: 'foo_bar')
|
||||
|
||||
described_class.apply!
|
||||
|
||||
expect(english_name.reload).to have_attributes(
|
||||
name: 'foobar', language_code: 'en', tag_id: english.id)
|
||||
expect(ainu_name.reload).to have_attributes(
|
||||
name: 'foobar', language_code: 'ain', tag_id: ainu.id)
|
||||
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
||||
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
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagName, type: :model do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'allows the same name in different languages' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
create(:tag_name, name: 'shared_name')
|
||||
english = build(:tag_name, name: 'shared_name',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu = build(:tag_name, name: 'shared_name',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(english).to be_valid
|
||||
expect { english.save! }.to change(described_class, :count).by(1)
|
||||
expect(ainu).to be_valid
|
||||
expect { ainu.save! }.to change(described_class, :count).by(1)
|
||||
end
|
||||
|
||||
it 'rejects a duplicate name within the same language' do
|
||||
@@ -67,11 +66,10 @@ RSpec.describe TagName, type: :model do
|
||||
end
|
||||
|
||||
it 'completes an ordinary tag factory under future active languages' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
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))
|
||||
end
|
||||
|
||||
@@ -114,46 +112,44 @@ RSpec.describe TagName, type: :model do
|
||||
end
|
||||
|
||||
it 'resolves the primary name of the alias language on the same tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = create(:tag, primary_name: '日本語正本')
|
||||
english_primary = primary_tag_name_for(tag, 'en')
|
||||
english_primary.update!(name: 'english_primary', auto_generated: false)
|
||||
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_primary = primary_tag_name_for(tag, 'ain')
|
||||
ainu_primary.update!(name: 'ainu_primary', auto_generated: false)
|
||||
ainu_alias = create(:tag_name, :alias, tag:, name: 'ainu_alias',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
representative_id = tag.tag_name('ja')&.id
|
||||
|
||||
primary = described_class.find_by!(tag_id: english_alias.tag_id,
|
||||
language_code: english_alias.language_code,
|
||||
primary = described_class.find_by!(tag_id: ainu_alias.tag_id,
|
||||
language_code: ainu_alias.language_code,
|
||||
primary_flg: true)
|
||||
|
||||
expect(primary).to eq(english_primary)
|
||||
expect(described_class.canonicalise(locale, [english_alias.name]))
|
||||
.to eq([english_primary.name])
|
||||
expect(primary).to eq(ainu_primary)
|
||||
expect(described_class.canonicalise(locale, [ainu_alias.name]))
|
||||
.to eq([ainu_primary.name])
|
||||
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
||||
end
|
||||
|
||||
it 'resolves only aliases in the requested language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||
english_name = create(:tag_name, name: 'english_canonical',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name)
|
||||
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: nipponese)
|
||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: english,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: ainu,
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(english_alias.reload).to have_attributes(
|
||||
tag_id: english.id,
|
||||
primary_flg: false, language_code: 'en')
|
||||
expect(ainu_alias.reload).to have_attributes(
|
||||
tag_id: ainu.id,
|
||||
primary_flg: false, language_code: 'ain')
|
||||
expect(described_class.canonicalise(locale, ['shared_alias']))
|
||||
.to eq(['english_canonical'])
|
||||
.to eq(['ainu_canonical'])
|
||||
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
|
||||
.to eq(['nipponese_canonical'])
|
||||
end
|
||||
|
||||
@@ -8,7 +8,7 @@ RSpec.describe Tag, type: :model do
|
||||
it 'checks the requested language and treats an empty description as present' do
|
||||
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: 'en')).to be(false)
|
||||
expect(tag.has_description?(language_code: 'ain')).to be(false)
|
||||
end
|
||||
|
||||
it 'returns false without a description' do
|
||||
@@ -23,16 +23,16 @@ RSpec.describe Tag, type: :model do
|
||||
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
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
|
||||
expect(queries).to be_empty
|
||||
end
|
||||
|
||||
it 'keeps language selection generic without enabling another Language record' do
|
||||
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!
|
||||
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)
|
||||
end
|
||||
end
|
||||
@@ -61,33 +61,32 @@ RSpec.describe Tag, type: :model do
|
||||
end
|
||||
|
||||
describe '.normalise_tags!' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'canonicalises aliases in the supplied language only' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||
english_name = create(:tag_name, name: 'english_canonical',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name)
|
||||
primary_tag_name_for(english, 'ja').update!(
|
||||
name: 'english_in_nipponese', auto_generated: false)
|
||||
english_locale = locale
|
||||
ainu_name = build(:tag_name, tag: nil, name: 'ainu_canonical',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||
primary_tag_name_for(ainu, 'ja').update!(
|
||||
name: 'ainu_in_nipponese', auto_generated: false)
|
||||
ainu_locale = locale
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: nipponese)
|
||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: english,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: ainu,
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(english_alias.reload).to have_attributes(
|
||||
tag_id: english.id,
|
||||
primary_flg: false, language_code: 'en')
|
||||
expect(ainu_alias.reload).to have_attributes(
|
||||
tag_id: ainu.id,
|
||||
primary_flg: false, language_code: 'ain')
|
||||
expect(described_class.normalise_tags!(
|
||||
english_locale, ['shared_alias'],
|
||||
with_tagme: false, with_no_deerjikist: false)).to eq([english])
|
||||
ainu_locale, ['shared_alias'],
|
||||
with_tagme: false, with_no_deerjikist: false)).to eq([ainu])
|
||||
expect(described_class.normalise_tags!(
|
||||
Locale.nipponese, ['shared_alias'],
|
||||
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
|
||||
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
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
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(
|
||||
language_code: 'en', script_code: 'Latn',
|
||||
expect(primary_tag_name_for(tag, 'ain')).to have_attributes(
|
||||
language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, tag_id: tag.id)
|
||||
expect(tag.category).to eq('character')
|
||||
end
|
||||
|
||||
it 'uses the English primary as the source for a generated Nipponese name' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'copies an Ainu-origin name to Japanese without reverse transcription' do
|
||||
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!(
|
||||
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(
|
||||
name: 'english_origin', auto_generated: false))
|
||||
name: 'ainu_origin', auto_generated: false))
|
||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
|
||||
.to contain_exactly(have_attributes(
|
||||
name: 'generated_nipponese', auto_generated: true))
|
||||
source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true)
|
||||
name: 'ainu_origin', auto_generated: true))
|
||||
expect(TagName).to have_received(:generate_name)
|
||||
.with(Locale.nipponese, tag, source_tag_name)
|
||||
.with(Locale.nipponese, tag, tag.tag_name('ain'))
|
||||
end
|
||||
|
||||
it 'gives a new tag one primary name for every active language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = described_class.find_or_create_by_tag_name!(
|
||||
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: 'en', primary_flg: true).count).to eq(1)
|
||||
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'creates one primary per language when multiple locales use English' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'creates one primary per language when multiple locales use Ainu' do
|
||||
locale
|
||||
described_class.find_or_create_by_tag_name!(
|
||||
locale, 'before_second_english_locale', category: :general)
|
||||
Locale.create!(code: 'en-x-rspec', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
variants: 'x-rspec', name: 'English')
|
||||
locale, 'before_second_ainu_locale', category: :general)
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
it 'rolls back a new tag and requested name if name generation fails' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
original_tag_count = described_class.count
|
||||
allow(TagName).to receive(:generate_name).and_raise('generation failed')
|
||||
|
||||
expect {
|
||||
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')
|
||||
|
||||
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)
|
||||
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
|
||||
.where(tag_names: { name: 'atomic_ainu_name' })).to be_empty
|
||||
end
|
||||
|
||||
|
||||
|
||||
it 'creates separate tag identities for the same name in different languages' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = described_class.find_or_create_by_tag_name!(
|
||||
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)
|
||||
|
||||
expect(english).not_to eq(nipponese)
|
||||
expect(primary_tag_name_for(english, 'en')).to have_attributes(
|
||||
name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true)
|
||||
expect(ainu).not_to eq(nipponese)
|
||||
expect(primary_tag_name_for(ainu, 'ain')).to have_attributes(
|
||||
name: 'same_name', tag_id: ainu.id, language_code: 'ain', primary_flg: true)
|
||||
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
|
||||
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
|
||||
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!(
|
||||
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
|
||||
end
|
||||
|
||||
it 'preserves the V1 representative when looking up another primary language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = create(:tag, primary_name: '日本語代表名')
|
||||
representative_id = tag.tag_name('ja')&.id
|
||||
english = primary_tag_name_for(tag, 'en')
|
||||
english.update!(name: 'english_primary', auto_generated: false)
|
||||
ainu = primary_tag_name_for(tag, 'ain')
|
||||
ainu.update!(name: 'ainu_primary', auto_generated: false)
|
||||
|
||||
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(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
|
||||
|
||||
it 'normalises names using the supplied locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tags = described_class.normalise_tags!(
|
||||
locale, ['character:normalised_english'],
|
||||
locale, ['character:normalised_ainu'],
|
||||
with_tagme: false, with_no_deerjikist: false)
|
||||
|
||||
expect(tags.length).to eq(1)
|
||||
expect(primary_tag_name_for(tags.first, 'en')).to have_attributes(
|
||||
name: 'normalised_english', language_code: 'en', script_code: 'Latn',
|
||||
expect(primary_tag_name_for(tags.first, 'ain')).to have_attributes(
|
||||
name: 'normalised_ainu', language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, tag_id: tags.first.id)
|
||||
end
|
||||
end
|
||||
@@ -444,26 +434,25 @@ RSpec.describe Tag, type: :model do
|
||||
end
|
||||
|
||||
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
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
english_primary = primary_tag_name_for(target_tag, 'en')
|
||||
source_primary = primary_tag_name_for(source_tag, 'en')
|
||||
ainu_primary = primary_tag_name_for(target_tag, 'ain')
|
||||
source_primary = primary_tag_name_for(source_tag, 'ain')
|
||||
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))
|
||||
|
||||
described_class.merge_tags!(target_tag, [source_tag])
|
||||
|
||||
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary)
|
||||
expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true))
|
||||
.to contain_exactly(english_primary)
|
||||
expect(primary_tag_name_for(target_tag.reload, 'ain')).to eq(ainu_primary)
|
||||
expect(TagName.where(tag: target_tag, language_code: 'ain', primary_flg: true))
|
||||
.to contain_exactly(ainu_primary)
|
||||
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))
|
||||
.to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
|
||||
expect(source_tag_name.reload).to have_attributes(
|
||||
|
||||
@@ -53,6 +53,14 @@ RSpec.configure do |config|
|
||||
config.include AuthHelper, 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
|
||||
config.fixture_paths = [
|
||||
Rails.root.join('spec/fixtures')
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
@@ -54,13 +54,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
['en', nil].each do |requested_locale|
|
||||
['ain-Kana', nil].each do |requested_locale|
|
||||
context "with locale #{ requested_locale.inspect }" do
|
||||
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
|
||||
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
|
||||
|
||||
it 'creates post tags in the requested language or Nipponese fallback' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
post '/posts', params: locale_params.merge(
|
||||
title: 'Locale post', url: 'https://example.com/locale-post',
|
||||
@@ -72,7 +71,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'updates post tags in the requested language or Nipponese fallback' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
record = create(:post)
|
||||
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
|
||||
|
||||
it 'passes the resolved locale to bulk creation' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
# Worker propagation is exercised in post_bulk_creator_spec.
|
||||
expect_any_instance_of(PostBulkCreator).to receive(:run)
|
||||
@@ -102,7 +99,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'creates materials with tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
post '/materials', params: locale_params.merge(
|
||||
tag: 'locale_material', url: 'https://example.com/material')
|
||||
@@ -113,7 +109,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'updates materials with tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
tag = create(:tag, category: :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
|
||||
|
||||
it 'links Nico external tags to internal tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
external = create(:external_tag)
|
||||
NicoTagVersionRecorder.record!(
|
||||
@@ -141,7 +135,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'creates parent tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
tag = create(:tag)
|
||||
|
||||
@@ -157,184 +150,179 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
context 'with existing names in both languages' do
|
||||
let(:english_name) do
|
||||
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn')
|
||||
let(:ainu_name) do
|
||||
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
|
||||
let(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) }
|
||||
let(:nipponese_tag) do
|
||||
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_columns(name: 'shared_name')
|
||||
primary_tag_name_for(tag, 'ja').update!(name: 'shared_name')
|
||||
end
|
||||
end
|
||||
|
||||
it 'plans and creates the post using the English identity and category' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'plans and creates the post using the Ainu identity and category' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
post '/posts', params: {
|
||||
locale: 'en', title: 'English identity',
|
||||
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' }
|
||||
locale: 'ain-Kana', title: 'Ainu identity',
|
||||
url: 'https://example.com/ainu-identity', tags: 'shared_name', parent_post_ids: '' }
|
||||
|
||||
expect(response).to have_http_status(:created), response.body
|
||||
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(english_tag.reload.category).to eq('material')
|
||||
expect(ainu_tag.reload.category).to eq('material')
|
||||
expect(nipponese_tag.reload.category).to eq('general')
|
||||
end
|
||||
|
||||
it 'updates the post using the English identity' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'updates the post using the Ainu identity' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
record = create(:post)
|
||||
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
||||
|
||||
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 }
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
it 'resolves the English material tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu material tag' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
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(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
|
||||
end
|
||||
|
||||
it 'resolves the English parent tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu parent tag' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
child = create(:tag)
|
||||
|
||||
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,
|
||||
aliases: '', parent_tags: 'shared_name' }
|
||||
|
||||
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
|
||||
|
||||
it 'resolves the English internal tag for a Nico link' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu internal tag for a Nico link' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
external = create(:external_tag)
|
||||
NicoTagVersionRecorder.record!(
|
||||
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(external.reload.linked_tags).to contain_exactly(english_tag)
|
||||
expect(external.reload.linked_tags).to contain_exactly(ainu_tag)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when renaming or updating aliases in English' do
|
||||
let(:english_name) do
|
||||
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn')
|
||||
context 'when renaming or updating aliases in Ainu' do
|
||||
let(:ainu_name) do
|
||||
create(:tag_name, name: 'ainu_original', language_code: 'ain', script_code: 'Kana')
|
||||
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
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
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)
|
||||
|
||||
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(generated_name.reload).to have_attributes(
|
||||
name: 'human_english_name', auto_generated: false)
|
||||
name: 'human_ainu_name', auto_generated: false)
|
||||
end
|
||||
|
||||
it 'does not steal another tag primary for an alias' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
other_tag = create(:tag, primary_name: 'other_nipponese')
|
||||
other_primary = primary_tag_name_for(other_tag, 'en')
|
||||
other_primary.update!(name: 'other_english_primary', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, name: 'existing_english_alias',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
primary_name_before = primary_tag_name_for(tag, 'en').name
|
||||
other_primary = primary_tag_name_for(other_tag, 'ain')
|
||||
other_primary.update!(name: 'other_ainu_primary', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, name: 'existing_ainu_alias',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
primary_name_before = primary_tag_name_for(tag, 'ain').name
|
||||
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)
|
||||
category_before = tag.category
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: 'renamed_before_alias_collision',
|
||||
locale: 'ain-Kana', name: 'renamed_before_alias_collision',
|
||||
category: 'character', deprecated: false,
|
||||
aliases: 'other_english_primary', parent_tags: '' }
|
||||
aliases: 'other_ainu_primary', parent_tags: '' }
|
||||
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(other_primary.reload).to have_attributes(
|
||||
tag_id: other_tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(TagName.where(tag: other_tag, 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: 'ain', primary_flg: true))
|
||||
.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)
|
||||
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)
|
||||
expect(aliases_after).to eq(aliases_before)
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows a rename to a name already used in Nipponese' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
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(primary_tag_name_for(tag.reload, 'en')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'en', tag_id: tag.id)
|
||||
expect(primary_tag_name_for(tag.reload, 'ain')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'ain', tag_id: tag.id)
|
||||
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
|
||||
end
|
||||
|
||||
it 'creates aliases in the requested language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: english_name.name,
|
||||
locale: 'ain-Kana', name: ainu_name.name,
|
||||
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(TagName.find_by!(language_code: 'en', name: 'english_alias'))
|
||||
expect(TagName.find_by!(language_code: 'ain', name: 'ainu_alias'))
|
||||
.to have_attributes(
|
||||
script_code: 'Latn',
|
||||
tag_id: tag.id, primary_flg: false, language_code: 'en')
|
||||
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn')
|
||||
script_code: 'Kana',
|
||||
tag_id: tag.id, primary_flg: false, language_code: 'ain')
|
||||
primary = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
|
||||
expect(primary).to have_attributes(name: 'ainu_original', script_code: 'Kana')
|
||||
end
|
||||
|
||||
it 'does not take an alias from another language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'nipponese_owner')
|
||||
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')
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: english_name.name,
|
||||
locale: 'ain-Kana', name: ainu_name.name,
|
||||
category: tag.category, deprecated: false,
|
||||
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(
|
||||
tag_id: nipponese.id,
|
||||
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(
|
||||
tag_id: tag.id, script_code: 'Latn',
|
||||
primary_flg: false, language_code: 'en')
|
||||
tag_id: tag.id, script_code: 'Kana',
|
||||
primary_flg: false, language_code: 'ain')
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -900,7 +900,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
query_count =
|
||||
count_sql_queries do
|
||||
get "/posts/#{ post_record.id }",
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
headers: cookie_headers(user)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@@ -2323,8 +2323,10 @@ RSpec.describe 'Posts API', type: :request do
|
||||
'prev' => nil
|
||||
)
|
||||
expect(latest.fetch('tags')).to include(
|
||||
{ 'name' => 'spec_tag_2', 'type' => 'added' },
|
||||
{ 'name' => 'spec_tag', 'type' => 'removed' }
|
||||
{ 'name' => 'spec_tag_2', 'restore_name' => 'spec_tag_2',
|
||||
'external' => false, 'type' => 'added' },
|
||||
{ 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
|
||||
'external' => false, 'type' => 'removed' }
|
||||
)
|
||||
expect(latest.fetch('original_created_from')).to eq(
|
||||
'current' => oc_from.iso8601,
|
||||
@@ -2351,7 +2353,8 @@ RSpec.describe 'Posts API', type: :request do
|
||||
'prev' => nil
|
||||
)
|
||||
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)
|
||||
end
|
||||
@@ -2435,7 +2438,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(3)
|
||||
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
|
||||
|
||||
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]['version_no']).to eq(2)
|
||||
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
|
||||
|
||||
@@ -2467,10 +2473,12 @@ RSpec.describe 'Posts API', type: :request do
|
||||
first = versions[1]
|
||||
|
||||
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(
|
||||
{ 'name' => 'spec_tag', 'type' => 'added' }
|
||||
{ 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
|
||||
'external' => false, 'type' => 'added' }
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ RSpec.describe 'Tag description Wiki facade writes', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'description_write') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
|
||||
def stored_state
|
||||
[Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model|
|
||||
|
||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
let(:editor) { create(:user, :member, name: 'description editor') }
|
||||
let(:tag_name) { create(:tag_name, name: 'native_description') }
|
||||
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" }
|
||||
|
||||
def stored_state
|
||||
@@ -285,7 +285,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
guest = create(:user, :guest)
|
||||
before_state = stored_state
|
||||
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(stored_state).to eq(before_state)
|
||||
end
|
||||
@@ -294,7 +294,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
banned = create(:user, :member, :banned)
|
||||
before_state = stored_state
|
||||
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(stored_state).to eq(before_state)
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'japanese_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(:placements) do
|
||||
[{ '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
|
||||
["/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)
|
||||
representation = json.is_a?(Array) ? json.sole : json
|
||||
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
|
||||
version = group.versions.sole
|
||||
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(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(json.fetch('body').scan('ainu_member').length).to eq(2)
|
||||
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(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context'])
|
||||
end
|
||||
@@ -52,23 +52,24 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix|
|
||||
it "round trips #{ prefix } through create, lookup, search and update" do
|
||||
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
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
expect(json.fetch('body')).to include('タググループ:locale group')
|
||||
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(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)
|
||||
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])
|
||||
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)
|
||||
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')
|
||||
end
|
||||
end
|
||||
@@ -81,7 +82,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
end.to raise_error(TagGroupMarkdownParser::InvalidDocument)
|
||||
before_state = group_state
|
||||
put "/wiki/#{ group.id }", params: {
|
||||
locale: 'ain', title: 'タググループ:partial rename', body: '- japanese_member' },
|
||||
locale: 'ain-Kana', title: 'タググループ:partial rename', body: '- japanese_member' },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
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
|
||||
post '/tag_groups', params: {
|
||||
locale: 'ain', name: 'native', description: nil, members:, placements: },
|
||||
locale: 'ain-Kana', name: 'native', description: nil, members:, placements: },
|
||||
headers:, as: :json
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
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)
|
||||
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
|
||||
expect(response).to have_http_status(:ok)
|
||||
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)
|
||||
expect(representation.fetch('members').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.body).not_to include('historical_japanese', 'current_ainu')
|
||||
end
|
||||
|
||||
@@ -37,7 +37,7 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
|
||||
markdown.lines.map(&:chomp).reject(&:empty?))
|
||||
|
||||
post '/wiki', params: { title: 'タググループ:再解析', body: },
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
headers: cookie_headers(user)
|
||||
expect(response).to have_http_status(:created)
|
||||
parsed = group_rows('tag_groups', name: '再解析').sole
|
||||
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
|
||||
|
||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(: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(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
@@ -148,7 +148,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
body = group_markdown(page)
|
||||
travel_to(1.hour.from_now) 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
|
||||
@@ -212,7 +212,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
it 'records the editor and update time' do
|
||||
editor = create(:user, :member, name: '編輯者')
|
||||
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)
|
||||
current = group_rows('tag_groups', id: page.id).sole
|
||||
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 :banned then create(:user, :member, :banned)
|
||||
end
|
||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
request_headers = actor ? cookie_headers(actor) : { }
|
||||
before_state = group_state
|
||||
write_group(operation, request_headers)
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
|
||||
@@ -8,7 +8,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(: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(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
@@ -141,7 +141,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
end
|
||||
let!(:id) { seed_group(name: '更新', members:, placements:) }
|
||||
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
|
||||
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||
|
||||
@@ -47,7 +47,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
headers = actor ? cookie_headers(actor) : { }
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
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
|
||||
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
|
||||
if operation == :create
|
||||
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!(:unplaced) { group_tag('未掲載タグ') }
|
||||
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let(:members) do
|
||||
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
|
||||
end
|
||||
@@ -300,7 +300,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:) do
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe 'Tag resource ID allocation', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
|
||||
def create_description name
|
||||
tag = create(:tag, primary_name: name)
|
||||
|
||||
@@ -10,7 +10,31 @@ RSpec.describe 'Users', type: :request do
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
describe 'POST /users' do
|
||||
@@ -233,17 +257,102 @@ RSpec.describe 'Users', type: :request do
|
||||
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
|
||||
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' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns user slice when found' do
|
||||
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(json['id']).to eq(user.id)
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
it 'returns 409 when base_revision_id is stale' do
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do
|
||||
include WikiRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
|
||||
|
||||
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
|
||||
actor = role && create(:user, role == :banned ? :member : role)
|
||||
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
|
||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
|
||||
headers: request_headers
|
||||
|
||||
@@ -8,7 +8,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'japanese_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
|
||||
TagDescriptionOperation.create!(
|
||||
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|
|
||||
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(json.map { _1.fetch('id') }).to eq([ainu.id])
|
||||
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(json).to eq([])
|
||||
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
|
||||
['/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)
|
||||
end
|
||||
['/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(json.fetch('body')).to eq('Ainu body')
|
||||
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)
|
||||
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)
|
||||
end
|
||||
|
||||
it 'does not disclose another language through ID, existence, diff or history' do
|
||||
revision = current_description_version(japanese)
|
||||
["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists",
|
||||
"/wiki/#{ japanese.id }/diff"].each do |path|
|
||||
get path, params: { locale: 'ain', from: revision.id }
|
||||
it 'resolves a foreign description ID to only the requested language body' do
|
||||
get "/wiki/#{ japanese.id }", params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('id' => ainu.id, 'body' => 'Ainu body')
|
||||
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.body).not_to include('Japanese body')
|
||||
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(json).to eq([])
|
||||
get '/wiki/changes', params: { locale: 'ain' }
|
||||
get '/wiki/changes', params: { locale: 'ain-Kana' }
|
||||
expect(json.map { _1.fetch('revision_id') })
|
||||
.to eq([current_description_version(ainu).id])
|
||||
end
|
||||
@@ -73,7 +91,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes]
|
||||
expect do
|
||||
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)
|
||||
expect(response).to have_http_status(:not_found)
|
||||
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
|
||||
expect do
|
||||
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
|
||||
else
|
||||
put "/tags/#{ other.id }/description", params: { locale: 'ain', body: 'created' },
|
||||
put "/tags/#{ other.id }/description", params: { locale: 'ain-Kana', body: 'created' },
|
||||
headers: headers
|
||||
end
|
||||
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
|
||||
snapshot = japanese.attributes
|
||||
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
|
||||
expect(response).to have_http_status(:ok)
|
||||
second = current_description_version(ainu)
|
||||
expect(second).to have_attributes(language_code: 'ain', version_no: 2)
|
||||
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)
|
||||
|
||||
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
|
||||
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(japanese.reload.attributes).to eq(snapshot)
|
||||
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]
|
||||
expect do
|
||||
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
|
||||
end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0)
|
||||
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)
|
||||
TagDescriptionOperation.update!(
|
||||
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(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(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id)
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ require 'securerandom'
|
||||
RSpec.describe 'Wiki API', type: :request do
|
||||
include WikiRecords
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
@@ -80,7 +80,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
let(:guest) { create(:user, role: 'guest') }
|
||||
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
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)) }
|
||||
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki title collision', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
def create_wiki_page title:, body:
|
||||
|
||||
@@ -17,7 +17,7 @@ RSpec.describe 'Removed standalone Wiki resources', type: :request do
|
||||
user
|
||||
before_state = group_state
|
||||
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(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
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
|
||||
locale = Locale.nipponese
|
||||
@@ -67,8 +67,7 @@ RSpec.describe PostBulkCreator do
|
||||
expect(results[3].dig(:post, :id)).to eq(4)
|
||||
end
|
||||
|
||||
it 'passes a future English locale to preflight and creation in every worker' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'passes a future Ainu locale to preflight and creation in every worker' do
|
||||
locale
|
||||
actor = instance_double(User, id: 123)
|
||||
allow(User).to receive(:find).with(123).and_return(actor)
|
||||
|
||||
@@ -11,23 +11,22 @@ RSpec.describe PostCreatePlan do
|
||||
end
|
||||
|
||||
context 'with an explicit locale' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'resolves the matching language identity and its parents' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
english_name = create(:tag_name, name: 'shared_name',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name, category: :character)
|
||||
ainu_name = create(:tag_name, name: 'shared_name',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
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)
|
||||
# The DB permits this identity; validation has its own contract spec.
|
||||
primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name')
|
||||
english_parent_name = create(:tag_name, name: 'english_parent',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english_parent = create(:tag, primary_tag_name: english_parent_name,
|
||||
primary_tag_name_for(nipponese, 'ja').update!(name: 'shared_name')
|
||||
ainu_parent_name = create(:tag_name, name: 'ainu_parent',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu_parent = create(:tag, primary_tag_name: ainu_parent_name,
|
||||
category: :material)
|
||||
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)
|
||||
|
||||
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(
|
||||
[{ name: 'shared_name', category: :character }])
|
||||
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))
|
||||
.not_to include(primary_tag_name_for(nipponese_parent, 'ja').name)
|
||||
end
|
||||
|
||||
it 'resolves aliases within the requested language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||
english_name = create(:tag_name, name: 'english_canonical',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name, category: :character)
|
||||
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name, category: :character)
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: nipponese)
|
||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: english,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: ainu,
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(english_alias.reload).to have_attributes(
|
||||
tag_id: english.id,
|
||||
primary_flg: false, language_code: 'en')
|
||||
expect(ainu_alias.reload).to have_attributes(
|
||||
tag_id: ainu.id,
|
||||
primary_flg: false, language_code: 'ain')
|
||||
|
||||
plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale)
|
||||
|
||||
expect(plan[:direct_tag_specs]).to eq(
|
||||
[{ name: english_name.name, category: :character }])
|
||||
[{ name: ainu_name.name, category: :character }])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -77,11 +77,10 @@ RSpec.describe PostCreator do
|
||||
end
|
||||
|
||||
context 'with an explicit locale' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
[false, true].each do |planned|
|
||||
it "passes locale to tag creation with planned attributes: #{ planned }" do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
attributes = {
|
||||
title: 'Locale propagation',
|
||||
@@ -97,8 +96,8 @@ RSpec.describe PostCreator do
|
||||
|
||||
expect(Tag).to have_received(:find_or_create_by_tag_name!)
|
||||
.with(locale, 'locale_character', category: :character).at_least(:once)
|
||||
tag_name = TagName.find_by!(language_code: 'en', name: 'locale_character')
|
||||
expect(tag_name).to have_attributes(script_code: 'Latn', primary_flg: true)
|
||||
tag_name = TagName.find_by!(language_code: 'ain', name: 'locale_character')
|
||||
expect(tag_name).to have_attributes(script_code: 'Kana', primary_flg: true)
|
||||
expect(post.tags).to include(tag_name.tag)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -141,6 +141,40 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
||||
result(first)
|
||||
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
|
||||
first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true)
|
||||
stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0)
|
||||
|
||||
@@ -101,7 +101,7 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
|
||||
let(:record) { build(:external_tag) }
|
||||
let(:connection) { double('connection') }
|
||||
let(:transaction) { double('transaction', open?: true) }
|
||||
let(:callbacks) { { } }
|
||||
let(:callbacks) { [] }
|
||||
let(:sql) { [] }
|
||||
|
||||
before do
|
||||
@@ -128,19 +128,24 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
|
||||
end
|
||||
end
|
||||
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
|
||||
[:after_commit, :after_rollback].each do |event|
|
||||
allow(transaction).to receive(event) { |&callback| callbacks[event] = callback }
|
||||
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
|
||||
described_class.assign!(record)
|
||||
expect(record.id).to eq(9)
|
||||
expect(sql.grep(/RELEASE_LOCK/)).to be_empty
|
||||
callbacks.fetch(event).call
|
||||
finish_transaction(event)
|
||||
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
|
||||
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
|
||||
allow(connection).to receive(:select_all).and_raise('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)
|
||||
end
|
||||
|
||||
@@ -164,18 +169,17 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
|
||||
described_class.assign!(record)
|
||||
expect(record.id).to eq(50)
|
||||
expect(sql.grep(/FOR UPDATE/)).to be_empty
|
||||
callbacks.fetch(:after_commit).call
|
||||
finish_transaction(:committed!)
|
||||
end
|
||||
|
||||
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|
|
||||
allow(model).to receive(:connection).and_return(connection)
|
||||
allow(model).to receive(:current_transaction).and_return(transaction)
|
||||
allow(model).to receive(:uncached).and_yield
|
||||
record = model.new(id: 50)
|
||||
described_class.assign!(record)
|
||||
statement = sql.grep(/GET_LOCK/).last
|
||||
callbacks.fetch(:after_commit).call
|
||||
finish_transaction(:committed!)
|
||||
statement
|
||||
end
|
||||
expect(locks[0]).to eq(locks[1])
|
||||
@@ -187,7 +191,21 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
|
||||
it 'reports lock release failures' do
|
||||
described_class.assign!(record)
|
||||
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/)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagVersionRecorder do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
let(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: '日本語名') }
|
||||
@@ -17,11 +17,11 @@ RSpec.describe TagVersionRecorder do
|
||||
{ 'name' => 'alias_z', 'script_code' => 'Jpan' }] }
|
||||
end
|
||||
|
||||
def prepare_english_names!
|
||||
def prepare_ainu_names!
|
||||
locale
|
||||
primary_tag_name_for(tag, 'en').update!(name: 'English name', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, language_code: 'en', script_code: 'Latn',
|
||||
name: 'English alias')
|
||||
primary_tag_name_for(tag, 'ain').update!(name: 'Ainu name', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, language_code: 'ain', script_code: 'Kana',
|
||||
name: 'Ainu alias')
|
||||
end
|
||||
|
||||
it 'snapshots the Nipponese primary and sorted aliases in grouped names' do
|
||||
@@ -62,40 +62,37 @@ RSpec.describe TagVersionRecorder do
|
||||
end
|
||||
|
||||
it 'snapshots future languages together without a request locale argument' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_names!
|
||||
prepare_ainu_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(
|
||||
'ja' => nipponese_snapshot,
|
||||
'en' => { 'name' => 'English name', 'script_code' => 'Latn',
|
||||
'ain' => { 'name' => 'Ainu name', 'script_code' => 'Kana',
|
||||
'auto_generated' => false,
|
||||
'aliases' => [{ 'name' => 'English alias', 'script_code' => 'Latn' }] })
|
||||
'aliases' => [{ 'name' => 'Ainu alias', 'script_code' => 'Kana' }] })
|
||||
end
|
||||
|
||||
it 'creates a version for an English-only change and preserves prior names' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_names!
|
||||
original = described_class.record!(tag:, event_type: :create, created_by_user: user)
|
||||
primary_tag_name_for(tag, 'en').update!(name: 'Renamed')
|
||||
it 'creates a version for an Ainu-only change and preserves prior names' do
|
||||
prepare_ainu_names!
|
||||
original = described_class.record!(tag:, event_type: :update, created_by_user: user)
|
||||
primary_tag_name_for(tag, 'ain').update!(name: 'Renamed')
|
||||
|
||||
updated = described_class.record!(tag:, event_type: :update, created_by_user: user)
|
||||
|
||||
expect(updated.version_no).to eq(2)
|
||||
expect(original.reload.names.fetch('en')).to include('name' => 'English name')
|
||||
expect(updated.names.fetch('en')).to include('name' => 'Renamed')
|
||||
expect(updated.version_no).to eq(original.version_no + 1)
|
||||
expect(original.reload.names.fetch('ain')).to include('name' => 'Ainu name')
|
||||
expect(updated.names.fetch('ain')).to include('name' => 'Renamed')
|
||||
expect(updated.names.fetch('ja')).to eq(nipponese_snapshot)
|
||||
end
|
||||
|
||||
it 'does not create a version for an unchanged multilingual snapshot' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_names!
|
||||
described_class.record!(tag:, event_type: :create, created_by_user: user)
|
||||
prepare_ainu_names!
|
||||
original = 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(tag.reload.tag_versions.count).to eq(1)
|
||||
expect(unchanged.version_no).to eq(original.version_no)
|
||||
expect(tag.reload.tag_versions.count).to eq(original.version_no)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
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
|
||||
# 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)
|
||||
INSERT IGNORE INTO languages (code, name, created_at)
|
||||
VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP)
|
||||
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).with(code: 'ain').and_return(ainu_locale)
|
||||
allow(Locale).to receive(:find_by).with(code: 'ain-Kana').and_return(ainu_locale)
|
||||
end
|
||||
|
||||
def add_ainu_name tag, name
|
||||
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,
|
||||
created_at: Time.current, updated_at: Time.current }])
|
||||
tag.tag_names.reset
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
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)
|
||||
allow_any_instance_of(ApplicationController)
|
||||
.to receive(:current_user).and_return(user)
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
RSpec.shared_context 'English locale' do
|
||||
def prepare_english_reference!
|
||||
Language.find_or_create_by!(code: 'en') { _1.name = 'English' }
|
||||
Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' }
|
||||
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
|
||||
RSpec.shared_context 'Ainu locale' do
|
||||
def prepare_ainu_reference!
|
||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||
end
|
||||
|
||||
let(:locale) do
|
||||
prepare_english_reference!
|
||||
Locale.find_or_create_by!(code: 'en') do |record|
|
||||
record.language_code = 'en'
|
||||
record.script_code = 'Latn'
|
||||
record.region_code = 826
|
||||
prepare_ainu_reference!
|
||||
Locale.find_or_create_by!(code: 'ain-Kana') do |record|
|
||||
record.language_code = 'ain'
|
||||
record.script_code = 'Kana'
|
||||
record.region_code = 392
|
||||
record.variants = ''
|
||||
record.name = 'English'
|
||||
record.name = 'Ainu'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,3 +37,56 @@ npm run test:run
|
||||
cd backend && bundle exec rspec
|
||||
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" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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>
|
||||
<!-- helmet -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div class="flex flex-col h-screen w-screen">
|
||||
<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>
|
||||
<div id="root"><!-- outlet --></div>
|
||||
<!-- ssr-state -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
生成ファイル
+78
-3
@@ -25,12 +25,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
"humps": "^2.0.1",
|
||||
"i18next": "^26.4.7",
|
||||
"lucide-react": "^0.511.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"path-to-regexp": "^8.3.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^17.0.20",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-markdown-editor-lite": "^1.3.4",
|
||||
"react-router-dom": "^6.30.1",
|
||||
@@ -350,9 +352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -4872,6 +4874,15 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -4916,6 +4927,34 @@
|
||||
"integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==",
|
||||
"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": {
|
||||
"version": "0.6.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "16.13.1",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
+8
-3
@@ -5,12 +5,15 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"postbuild": "node scripts/generate-sitemap.js",
|
||||
"build": "tsc -b && npm run build:client && npm run build:server",
|
||||
"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 .",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"preview": "vite preview"
|
||||
"test:ssr": "node --test server.ssr.test.mjs",
|
||||
"preview": "vite preview --outDir dist/client"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -29,12 +32,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
"humps": "^2.0.1",
|
||||
"i18next": "^26.4.7",
|
||||
"lucide-react": "^0.511.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"path-to-regexp": "^8.3.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^17.0.20",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-markdown-editor-lite": "^1.3.4",
|
||||
"react-router-dom": "^6.30.1",
|
||||
|
||||
@@ -11,7 +11,8 @@ const formatDate = (date = new Date) => {
|
||||
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 API_BASE_URL = 'https://hub.nizika.monster/api'
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createBrowserRouter,
|
||||
matchRoutes,
|
||||
Navigate,
|
||||
Route,
|
||||
RouterProvider,
|
||||
Routes,
|
||||
useLocation } from 'react-router-dom'
|
||||
useLocation,
|
||||
useParams,
|
||||
useRoutes } from 'react-router-dom'
|
||||
|
||||
import DevModeWatermark from '@/components/DevModeWatermark'
|
||||
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||
import TopNav from '@/components/TopNav'
|
||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
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,
|
||||
applyClientAppearance,
|
||||
fetchUserThemeSlots,
|
||||
@@ -25,98 +37,141 @@ import { applyClientAnimationMode,
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
|
||||
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 TOSPage from '@/pages/TOSPage.mdx'
|
||||
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 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 { AvailableLocale, LocaleCode } from '@/lib/locales'
|
||||
import type { PageRoute } from '@/lib/routeLocales'
|
||||
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 }: {
|
||||
animationMode: ClientAnimationMode
|
||||
user: User | null
|
||||
setUser: Dispatch<SetStateAction<User | null>> }) => {
|
||||
const location = useLocation ()
|
||||
|
||||
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
|
||||
const routes = useRoutes (pageRoutes (user, setUser))
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{routes}
|
||||
<AnimatePresence mode="wait" initial={animationMode !== 'off'}>
|
||||
<Suspense fallback={null}>{routes}</Suspense>
|
||||
</AnimatePresence>)
|
||||
}
|
||||
|
||||
@@ -128,12 +183,23 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
|
||||
}
|
||||
|
||||
|
||||
const RoutedApp: FC = () => {
|
||||
const [user, setUser] = useState<User | null> (null)
|
||||
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
||||
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 behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
|
||||
|
||||
const appLayoutTransition = useMemo (
|
||||
() => (
|
||||
@@ -164,36 +230,21 @@ const RoutedApp: FC = () => {
|
||||
}, [animationMode])
|
||||
|
||||
useEffect (() => {
|
||||
const createUser = async () => {
|
||||
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)
|
||||
{
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const data = await apiPost<{ valid: boolean; user: User }> ('/users/verify', { code })
|
||||
if (data.valid)
|
||||
setUser (data.user)
|
||||
else
|
||||
await createUser ()
|
||||
const user = await initialiseUserSession ()
|
||||
if (!cancelled)
|
||||
setUser (user)
|
||||
}
|
||||
catch (err)
|
||||
catch (error)
|
||||
{
|
||||
if (isApiError (err))
|
||||
setStatus (err.response?.status ?? 200)
|
||||
if (!cancelled && isApiError (error))
|
||||
setStatus (error.response?.status ?? 503)
|
||||
}
|
||||
}) ()
|
||||
}
|
||||
else
|
||||
createUser ()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
@@ -228,6 +279,9 @@ const RoutedApp: FC = () => {
|
||||
|
||||
return (
|
||||
<DialogueProvider>
|
||||
<PageHead>
|
||||
{robots != null && <meta name="robots" content={robots}/>}
|
||||
</PageHead>
|
||||
<UnsavedChangesGuardProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
<MotionConfig
|
||||
@@ -238,18 +292,18 @@ const RoutedApp: FC = () => {
|
||||
? 'user'
|
||||
: 'always'
|
||||
}>
|
||||
<LayoutWrapper>
|
||||
<LayoutGroup>
|
||||
<motion.div
|
||||
layout={animationMode === 'off' ? false : 'position'}
|
||||
transition={{ layout: appLayoutTransition }}
|
||||
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
|
||||
<RouteTransitionWrapper
|
||||
animationMode={animationMode}
|
||||
user={user}
|
||||
setUser={setUser}/>
|
||||
</motion.div>
|
||||
</LayoutWrapper>
|
||||
</LayoutGroup>
|
||||
</MotionConfig>
|
||||
|
||||
<Toaster/>
|
||||
@@ -259,17 +313,73 @@ const RoutedApp: FC = () => {
|
||||
}
|
||||
|
||||
|
||||
const router = createBrowserRouter ([{
|
||||
path: '*',
|
||||
element: <RoutedApp/> }])
|
||||
const LocaleRoute: FC = () => {
|
||||
const { locale } = useParams ()
|
||||
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/>
|
||||
{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
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||
import { translateUIMessage } from '@/lib/i18n'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -8,6 +11,8 @@ const COLUMN_COUNT = 12
|
||||
|
||||
|
||||
const DevModeWatermark: FC = () => {
|
||||
const { i18n } = useTranslation ('shared')
|
||||
const text = translateUIMessage ('labels.development', 'shared', i18n.language)
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
@@ -29,13 +34,14 @@ const DevModeWatermark: FC = () => {
|
||||
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
|
||||
<span
|
||||
key={columnIndex}
|
||||
lang={text.language}
|
||||
className={
|
||||
'whitespace-nowrap text-3xl font-bold '
|
||||
+ 'tracking-[0.3em] text-neutral-950/5 '
|
||||
+ 'dark:text-white/10'
|
||||
}
|
||||
style={{ fontFamily: 'Nikumaru' }}>
|
||||
開発モード
|
||||
{text.text}
|
||||
</span>))}
|
||||
</div>))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import ErrorScreen from '@/components/ErrorScreen'
|
||||
@@ -12,9 +13,9 @@ describe ('ErrorScreen', () => {
|
||||
[503, '鯖死んでるよ(泣)'],
|
||||
]) ('renders status %s', (status, message) => {
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter><HelmetProvider>
|
||||
<ErrorScreen status={status}/>
|
||||
</HelmetProvider>,
|
||||
</HelmetProvider></MemoryRouter>,
|
||||
)
|
||||
|
||||
expect (screen.getByText (String (status))).toBeInTheDocument ()
|
||||
@@ -24,9 +25,9 @@ describe ('ErrorScreen', () => {
|
||||
|
||||
it ('throws for unsupported statuses', () => {
|
||||
expect (() => render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter><HelmetProvider>
|
||||
<ErrorScreen status={418}/>
|
||||
</HelmetProvider>,
|
||||
</HelmetProvider></MemoryRouter>,
|
||||
)).toThrow ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||
import errorImg from '@/assets/images/not-found.gif'
|
||||
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'
|
||||
|
||||
@@ -11,17 +11,19 @@ type Props = { status: number }
|
||||
|
||||
|
||||
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)
|
||||
{
|
||||
case 403:
|
||||
return ['権限ないよ(笑)', '帰れ!', '帰れ!']
|
||||
case 404:
|
||||
return ['ページないよ(笑)', '帰れ!', '帰れ!']
|
||||
case 500:
|
||||
return ['鯖でエラー出たって(嘲笑)', '管理人に', '聯絡を!']
|
||||
case 503:
|
||||
return ['鯖死んでるよ(泣)', '管理人に', '聯絡を!']
|
||||
return [translate (`${ status }.message`).text,
|
||||
translate (`${ status }.right`).text, translate (`${ status }.left`).text]
|
||||
default:
|
||||
throw new Error
|
||||
}
|
||||
@@ -29,21 +31,14 @@ const ErrorScreen: FC<Props> = ({ status }) => {
|
||||
|
||||
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 (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<PageHead title={{ text: title,
|
||||
language: translate (`${status}.message`).language }}>
|
||||
<meta name="robots" content="noindex"/>
|
||||
<title>{title} | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
<style>{`@font-face { font-family: 'Nikumaru';
|
||||
src: url(${nikumaru}) format('opentype'); }`}</style>
|
||||
</PageHead>
|
||||
<div className="text-6xl font-bold text-transparent
|
||||
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]
|
||||
@@ -51,11 +46,11 @@ const ErrorScreen: FC<Props> = ({ status }) => {
|
||||
style={{ fontFamily: 'Nikumaru' }}>
|
||||
<p>{status}</p>
|
||||
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4">
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{leftMsg}</p>
|
||||
<img className="max-w-[70vw]" src={errorImg} alt="逃げたギター"/>
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{rightMsg}</p>
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.left`)}</p>
|
||||
<img className="max-w-[70vw]" src={errorImg} alt={translate ('imageAlt').text}/>
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.right`)}</p>
|
||||
</div>
|
||||
<p className="mr-[-.5em]">{message}</p>
|
||||
<p className="mr-[-.5em]">{label (`${ status }.message`)}</p>
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
@@ -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 ('ネコ、チャペ。')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 TagLink from '@/components/TagLink'
|
||||
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
|
||||
import { materialsKeys } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||||
|
||||
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
|
||||
|
||||
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
|
||||
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
|
||||
|
||||
|
||||
const px = (value: string): number => {
|
||||
@@ -83,7 +84,7 @@ const tagSelectionShellClass = (selected: boolean): string =>
|
||||
const updateMaterialFilterQuery = (
|
||||
pathname: string,
|
||||
locationSearch: string,
|
||||
navigate: ReturnType<typeof useNavigate>,
|
||||
navigate: ReturnType<typeof useLocaleNavigate>,
|
||||
materialFilter: MaterialFilter,
|
||||
) => {
|
||||
const qs = new URLSearchParams (locationSearch)
|
||||
@@ -95,9 +96,11 @@ const updateMaterialFilterQuery = (
|
||||
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
|
||||
onChange: (materialFilter: MaterialFilter) => void }> = (
|
||||
{ materialFilter, onChange },
|
||||
) => (
|
||||
) => {
|
||||
const { label } = useUITranslation ('materials')
|
||||
return (
|
||||
<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 => (
|
||||
<button
|
||||
key={value}
|
||||
@@ -110,9 +113,10 @@ const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
|
||||
'dark:bg-sky-950 dark:text-sky-100']
|
||||
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
|
||||
'dark:bg-stone-900 dark:text-stone-200']))}>
|
||||
{FILTER_LABELS[value]}
|
||||
{label (`sidebar.${value}`)}
|
||||
</button>))}
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
const MaterialTreeNode: FC<{
|
||||
@@ -125,10 +129,11 @@ const MaterialTreeNode: FC<{
|
||||
tag: MaterialSidebarTag
|
||||
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
|
||||
setOpenTags, tag }) => {
|
||||
const locale = useLocale ()
|
||||
const open = Boolean (openTags[tag.id])
|
||||
const { data } = useQuery ({
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
|
||||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||||
|
||||
useEffect (() => {
|
||||
@@ -200,6 +205,7 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
||||
tag,
|
||||
},
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const open = Boolean (openTags[tag.id])
|
||||
const tagColumnRef = 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 [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
|
||||
const { data } = useQuery ({
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
|
||||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||||
|
||||
useEffect (() => {
|
||||
@@ -382,8 +388,10 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
||||
|
||||
|
||||
const MaterialSidebar: FC = () => {
|
||||
const { label } = useUITranslation ('materials')
|
||||
const locale = useLocale ()
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const qs = new URLSearchParams (location.search)
|
||||
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
|
||||
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
|
||||
@@ -395,8 +403,8 @@ const MaterialSidebar: FC = () => {
|
||||
useState<number | null> (null)
|
||||
|
||||
const { data: rootTags = [], isLoading, isError } = useQuery ({
|
||||
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
|
||||
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }, locale),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }, locale)})
|
||||
|
||||
useEffect (() => {
|
||||
setDesktopTags (rootTags)
|
||||
@@ -494,10 +502,12 @@ const MaterialSidebar: FC = () => {
|
||||
materialFilter={materialFilter}
|
||||
onChange={handleFilterChange}/>
|
||||
{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 && (
|
||||
<p className="text-sm text-red-600 dark:text-red-300">
|
||||
タグ一覧の取得に失敗しました.
|
||||
{label ('sidebar.error')}
|
||||
</p>)}
|
||||
{(!isLoading && !isError) && (
|
||||
<ul>
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { FC } from 'react'
|
||||
|
||||
const MenuSeparator: FC = () => (
|
||||
<>
|
||||
<span className="hidden md:inline flex items-center px-2">|</span>
|
||||
<hr className="block md:hidden w-full opacity-25
|
||||
<span className="top-nav-separator-vertical items-center px-2">|</span>
|
||||
<hr className="top-nav-separator-horizontal w-full opacity-25
|
||||
border-t border-black dark:border-white"/>
|
||||
</>)
|
||||
|
||||
|
||||
@@ -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 { textContentOf } from '@/test/text'
|
||||
import PostEditForm from '@/components/PostEditForm'
|
||||
import { buildPost, buildTag } from '@/test/factories'
|
||||
import { renderWithProviders as render } from '@/test/render'
|
||||
|
||||
const postsApi = vi.hoisted (() => ({
|
||||
updatePost: vi.fn (),
|
||||
@@ -19,7 +21,8 @@ const toastApi = vi.hoisted (() => ({
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
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: () => ({
|
||||
choice: vi.fn (),
|
||||
}),
|
||||
@@ -60,11 +63,12 @@ describe ('PostEditForm', () => {
|
||||
parentPostIds: '3 4',
|
||||
tags: 'general-tag',
|
||||
}),
|
||||
{ baseVersionNo: 4 },
|
||||
{ baseVersionNo: 4 }, 'ja',
|
||||
)
|
||||
})
|
||||
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', () => {
|
||||
@@ -78,7 +82,7 @@ describe ('PostEditForm', () => {
|
||||
|
||||
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
|
||||
|
||||
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
|
||||
expect (screen.getByDisplayValue ('180.5'))
|
||||
.toHaveValue ('180.5')
|
||||
|
||||
const tags = screen.getAllByRole ('textbox')[2]
|
||||
@@ -88,7 +92,7 @@ describe ('PostEditForm', () => {
|
||||
fireEvent.change (tags, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
|
||||
expect (screen.getByDisplayValue ('180.5'))
|
||||
.toHaveValue ('180.5')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import PostDurationField from '@/components/posts/PostDurationField'
|
||||
@@ -12,6 +13,7 @@ import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { updatePost } from '@/lib/posts'
|
||||
import { msToTime } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
|
||||
@@ -51,6 +53,8 @@ type Props = { post: Post
|
||||
|
||||
|
||||
const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
const locale = useLocale ()
|
||||
const { label } = useUITranslation ('posts')
|
||||
const [disabled, setDisabled] = useState (false)
|
||||
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
|
||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||
@@ -61,7 +65,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
useState<string | null> (post.originalCreatedFrom)
|
||||
const [parentPostIds, setParentPostIds] =
|
||||
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 videoFlg =
|
||||
@@ -70,12 +74,15 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
|
||||
const dialogue = useDialogue ()
|
||||
|
||||
const update = async (...args: Parameters<typeof updatePost>) => {
|
||||
const update = async (
|
||||
postData: Parameters<typeof updatePost>[0],
|
||||
options: Parameters<typeof updatePost>[1],
|
||||
) => {
|
||||
clearValidationErrors ()
|
||||
|
||||
try
|
||||
{
|
||||
const data = await updatePost (...args)
|
||||
const data = await updatePost (postData, options, locale)
|
||||
onSave ({ ...post,
|
||||
versionNo: data.versionNo,
|
||||
title: data.title,
|
||||
@@ -86,7 +93,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
siblingPosts: data.siblingPosts,
|
||||
originalCreatedFrom: data.originalCreatedFrom,
|
||||
originalCreatedBefore: data.originalCreatedBefore } as Post)
|
||||
toast ({ description: '更新しました.' })
|
||||
toast ({ description: label ('shared:messages.updated') })
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
@@ -96,24 +103,28 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
{
|
||||
if (applyValidationError (e))
|
||||
{
|
||||
toast ({ description: '更新はできなかったよ……' })
|
||||
toast ({ description: label ('edit.validationFailed') })
|
||||
return
|
||||
}
|
||||
|
||||
toast ({ title: '失敗……', description: '入力を確認してください.' })
|
||||
toast ({ title: label ('shared:messages.failed'),
|
||||
description: label ('shared:messages.checkInput') })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const action = await dialogue.choice ({
|
||||
title: '競合が発生しました.',
|
||||
title: label ('edit.conflictTitle'),
|
||||
description: (
|
||||
<div>
|
||||
<p>ほかの耕作員が先に更新してゐます.</p>
|
||||
<p>現在の変更をどう扱ひますか?</p>
|
||||
<p>{label ('edit.conflictDescription')}</p>
|
||||
<p>{label ('edit.conflictQuestion')}</p>
|
||||
</div>),
|
||||
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
|
||||
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })
|
||||
cancelText: label ('shared:actions.cancel'),
|
||||
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')
|
||||
{
|
||||
@@ -163,13 +174,13 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
<FieldError messages={baseErrors}/>
|
||||
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
label={label ('shared:labels.title')}
|
||||
value={title ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={setTitle}/>
|
||||
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
label={label ('shared:labels.parentPosts')}
|
||||
value={parentPostIds}
|
||||
disabled={disabled}
|
||||
errors={fieldErrors.parentPostIds}
|
||||
@@ -202,7 +213,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
|
||||
{/* 送信 */}
|
||||
<Button type="submit" disabled={disabled}>
|
||||
更新
|
||||
{label ('shared:actions.update')}
|
||||
</Button>
|
||||
</form>)
|
||||
}
|
||||
|
||||
@@ -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 PostEmbed from '@/components/PostEmbed'
|
||||
import { buildPost } from '@/test/factories'
|
||||
import { renderWithProviders as render } from '@/test/render'
|
||||
|
||||
const dialogue = vi.hoisted (() => ({
|
||||
confirm: vi.fn (),
|
||||
@@ -12,8 +13,9 @@ const nicoViewer = vi.hoisted (() => ({
|
||||
props: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
useDialogue: () => dialogue,
|
||||
vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
|
||||
...await importOriginal<typeof import ('@/lib/dialogues/useDialogue')> (),
|
||||
default: () => dialogue,
|
||||
}))
|
||||
|
||||
vi.mock ('@/components/NicoViewer', () => ({
|
||||
|
||||
@@ -3,9 +3,10 @@ import YoutubeEmbed from 'react-youtube'
|
||||
|
||||
import NicoViewer from '@/components/NicoViewer'
|
||||
import TwitterEmbed from '@/components/TwitterEmbed'
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC, RefObject } from 'react'
|
||||
|
||||
@@ -36,6 +37,7 @@ const PostEmbed: FC<Props> = (
|
||||
onError },
|
||||
) => {
|
||||
const dialogue = useDialogue ()
|
||||
const { label } = useUITranslation ('posts')
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
|
||||
const [manualLoadRequested, setManualLoadRequested] = useState (false)
|
||||
@@ -135,7 +137,7 @@ const PostEmbed: FC<Props> = (
|
||||
|
||||
const externalLink = (
|
||||
<a href={post.url} target="_blank" rel="noreferrer">
|
||||
外部ページを開く
|
||||
{label ('embed.openExternal')}
|
||||
</a>)
|
||||
|
||||
const manualLoadControl = (
|
||||
@@ -144,7 +146,7 @@ const PostEmbed: FC<Props> = (
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setManualLoadRequested (true)}>
|
||||
埋め込みを読込
|
||||
{label ('embed.load')}
|
||||
</Button>
|
||||
{externalLink}
|
||||
</div>)
|
||||
@@ -239,15 +241,15 @@ const PostEmbed: FC<Props> = (
|
||||
e.preventDefault ()
|
||||
|
||||
setFramed (await dialogue.confirm ({
|
||||
title: '未確認の外部ページを表示します。',
|
||||
title: label ('embed.confirmTitle'),
|
||||
description: (
|
||||
<div>
|
||||
<p>悪意のあるスクリプトが実行される可能性があります。</p>
|
||||
<p>表示しますか?</p>
|
||||
<p>{label ('embed.risk')}</p>
|
||||
<p>{label ('embed.question')}</p>
|
||||
</div>),
|
||||
confirmText: '表示' }))
|
||||
confirmText: label ('shared:actions.show') }))
|
||||
}}>
|
||||
外部ページを表示
|
||||
{label ('embed.showExternal')}
|
||||
</a>)}
|
||||
</div>)}
|
||||
</>)
|
||||
|
||||
@@ -26,7 +26,8 @@ describe ('PostFormTagsArea', () => {
|
||||
await waitFor (() => {
|
||||
expect (api.apiGet).toHaveBeenCalledWith (
|
||||
'/tags/autocomplete',
|
||||
{ params: { q: '虹', nico: '0' } },
|
||||
{ locale: 'ja', params: { q: '虹', nico: '0' },
|
||||
signal: expect.any (AbortSignal) },
|
||||
)
|
||||
})
|
||||
expect (setTags).toHaveBeenCalledWith ('虹夏')
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// 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 FormField from '@/components/common/FormField'
|
||||
import TextArea from '@/components/common/TextArea'
|
||||
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'
|
||||
|
||||
@@ -34,19 +36,29 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
|
||||
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
|
||||
tags: string
|
||||
setTags: (tags: string) => void
|
||||
errors?: string[] }
|
||||
errors?: ReactNode[] }
|
||||
|
||||
|
||||
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||
const { label } = useUITranslation ('navigation')
|
||||
const locale = useLocale ()
|
||||
const ref = useRef<HTMLTextAreaElement> (null)
|
||||
|
||||
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
||||
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 handleTagSelect = (tag: Tag) => {
|
||||
setSuggestionsVsbl (false)
|
||||
setRequest ({ query: '' })
|
||||
const textarea = ref.current!
|
||||
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
|
||||
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)
|
||||
setRequest ({ query: token })
|
||||
setSuggestionsVsbl (token.trim () !== '')
|
||||
if (!(token.trim ()))
|
||||
{
|
||||
setSuggestionsVsbl (false)
|
||||
@@ -67,17 +81,43 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||
}
|
||||
|
||||
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 (
|
||||
<FormField className="relative w-full" label="タグ" messages={errors}>
|
||||
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<TextArea
|
||||
@@ -89,7 +129,7 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||
onChange={ev => setTags (ev.target.value)}
|
||||
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const pos = (ev.target as HTMLTextAreaElement).selectionStart
|
||||
await recompute (pos)
|
||||
await recompute (pos, (ev.target as HTMLTextAreaElement).value)
|
||||
}}
|
||||
onFocus={() => setFocused (true)}
|
||||
onBlur={() => {
|
||||
|
||||
@@ -26,11 +26,11 @@ describe ('PostList', () => {
|
||||
|
||||
expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute (
|
||||
'href',
|
||||
'/posts/1',
|
||||
'/ja/posts/1',
|
||||
)
|
||||
expect (
|
||||
screen.getByRole ('link', { name: 'https://example.com/second' }),
|
||||
).toHaveAttribute ('href', '/posts/2')
|
||||
).toHaveAttribute ('href', '/ja/posts/2')
|
||||
})
|
||||
|
||||
it ('calls the optional click handler', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
||||
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
|
||||
|
||||
|
||||
const PostList: FC<Props> = ({ posts, onClick }) => {
|
||||
const { label } = useUITranslation ('shared')
|
||||
const location = useLocation ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
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
|
||||
bg-muted text-center text-xs text-muted-foreground">
|
||||
サムネイルなし
|
||||
{label ('labels.noThumbnail')}
|
||||
</div>)
|
||||
: (
|
||||
<img
|
||||
|
||||
@@ -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 PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import { renderWithProviders as render } from '@/test/render'
|
||||
|
||||
describe ('PostOriginalCreatedTimeField', () => {
|
||||
it ('updates from and before values', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
@@ -10,7 +11,7 @@ type Props = {
|
||||
setOriginalCreatedFrom: (x: string | null) => void
|
||||
originalCreatedBefore: string | null
|
||||
setOriginalCreatedBefore: (x: string | null) => void
|
||||
errors?: string[] }
|
||||
errors?: ReactNode[] }
|
||||
|
||||
|
||||
const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
@@ -19,15 +20,16 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
setOriginalCreatedFrom,
|
||||
originalCreatedBefore,
|
||||
setOriginalCreatedBefore,
|
||||
errors }: Props) => {
|
||||
errors }: Props,
|
||||
) => {
|
||||
const { label } = useUITranslation ('shared')
|
||||
return (
|
||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||
<FormField label={label ('labels.originalCreatedAt')} messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="my-1 flex">
|
||||
<div className="w-80">
|
||||
<div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
|
||||
<DateTimeField
|
||||
className="mr-2"
|
||||
className="min-w-0 max-w-full"
|
||||
disabled={disabled ?? false}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
@@ -46,45 +48,44 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
d.setMinutes (d.getMinutes () + 1)
|
||||
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
|
||||
}}/>
|
||||
以降
|
||||
</div>
|
||||
<div>
|
||||
<span className="ainu-ui-label shrink-0 whitespace-nowrap">
|
||||
{label ('labels.since')}
|
||||
</span>
|
||||
<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}
|
||||
onClick={() => {
|
||||
setOriginalCreatedFrom (null)
|
||||
}}>
|
||||
リセット
|
||||
{label ('actions.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-1 flex">
|
||||
<div className="w-80">
|
||||
<div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
|
||||
<DateTimeField
|
||||
className="mr-2"
|
||||
className="min-w-0 max-w-full"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
invalid={invalid}
|
||||
value={originalCreatedBefore ?? undefined}
|
||||
onChange={setOriginalCreatedBefore}/>
|
||||
より前
|
||||
</div>
|
||||
<div>
|
||||
<span className="ainu-ui-label shrink-0 whitespace-nowrap">
|
||||
{label ('labels.before')}
|
||||
</span>
|
||||
<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}
|
||||
onClick={() => {
|
||||
setOriginalCreatedBefore (null)
|
||||
}}>
|
||||
リセット
|
||||
{label ('actions.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</FormField>)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { forwardRef, useMemo } from 'react'
|
||||
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 { prefetchForURL } from '@/lib/prefetchers'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { currentRenderContext } from '@/lib/renderContext'
|
||||
|
||||
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
|
||||
import type { To } from 'react-router-dom'
|
||||
@@ -32,16 +35,20 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
if ('onClick' in rest)
|
||||
delete rest['onClick']
|
||||
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const locale = useLocale ()
|
||||
const qc = useQueryClient ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
||||
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 (() => {
|
||||
return (new URL (path, window.location.origin)).toString ()
|
||||
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
|
||||
}, [path])
|
||||
const setOverlay = useOverlayStore (s => s.setActive)
|
||||
|
||||
@@ -98,7 +105,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
if (!(ok) && cancelOnError)
|
||||
return
|
||||
|
||||
navigate (to, { replace, ...(state && { state }) })
|
||||
navigate (path, { replace, ...(state && { state }) })
|
||||
}
|
||||
catch (ex)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { create } from 'zustand'
|
||||
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { translateUIMessage } from '@/lib/i18n'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type OverlayStore = {
|
||||
@@ -14,6 +18,9 @@ export const useOverlayStore = create<OverlayStore> (set => ({
|
||||
|
||||
|
||||
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)
|
||||
|
||||
useEffect (() => {
|
||||
@@ -35,11 +42,12 @@ const RouteBlockerOverlay: FC = () => {
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<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">
|
||||
Loading...
|
||||
<LocalisedText language={loading.language}>{loading.text}</LocalisedText>
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
|
||||
@@ -17,7 +17,7 @@ describe ('SortHeader', () => {
|
||||
|
||||
expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute (
|
||||
'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 (
|
||||
'href',
|
||||
'/posts?page=1&order=updated_at%3Adesc',
|
||||
'/ja/posts?page=1&order=updated_at%3Adesc',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示
新しいイシューから参照
ユーザーをブロックする