Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -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
|
||||
@@ -255,7 +255,8 @@ class MaterialsController < ApplicationController
|
||||
end
|
||||
|
||||
def resolve_material_tag! locale, tag_name_raw
|
||||
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material)
|
||||
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material,
|
||||
created_by_user: current_user)
|
||||
end
|
||||
|
||||
def material_index_needs_tag_name? filters
|
||||
|
||||
@@ -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
|
||||
@@ -98,7 +98,8 @@ class NicoTagsController < ApplicationController
|
||||
ApplicationRecord.transaction do
|
||||
linked_tags = Tag.normalise_tags!(locale, linked_tag_names,
|
||||
with_tagme: false,
|
||||
with_no_deerjikist: false)
|
||||
with_no_deerjikist: false,
|
||||
created_by_user: current_user)
|
||||
|
||||
TagVersioning.record_tag_snapshots!(
|
||||
linked_tags,
|
||||
@@ -140,10 +141,23 @@ class NicoTagsController < ApplicationController
|
||||
end
|
||||
|
||||
def render_nico_tag_form_record_invalid record
|
||||
if record.is_a?(TagName) || record.is_a?(Tag)
|
||||
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
|
||||
"タグ名 “#{ record.name }”: #{ message }"
|
||||
} }
|
||||
names =
|
||||
case record
|
||||
when TagName
|
||||
[record]
|
||||
when Tag
|
||||
record.tag_names.to_a
|
||||
else
|
||||
[]
|
||||
end
|
||||
messages = names.flat_map do |tag_name|
|
||||
tag_name.errors.full_messages.map do |message|
|
||||
"タグ名 “#{ tag_name.name }”: #{ message }"
|
||||
end
|
||||
end
|
||||
|
||||
if messages.any?
|
||||
render_validation_error fields: { tags: messages }
|
||||
else
|
||||
render_validation_error record
|
||||
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)
|
||||
@@ -728,7 +818,8 @@ class PostsController < ApplicationController
|
||||
with_tagme: false,
|
||||
with_no_deerjikist: false,
|
||||
deny_deprecated: true,
|
||||
with_sections: true) => { tags:, sections: }
|
||||
with_sections: true,
|
||||
created_by_user: current_user) => { tags:, sections: }
|
||||
|
||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
|
||||
@@ -874,7 +965,8 @@ class PostsController < ApplicationController
|
||||
Tag.normalise_tags!(locale, snapshot[:tag_names],
|
||||
with_tagme: false,
|
||||
deny_deprecated: true,
|
||||
with_sections: true) => { tags:, sections: }
|
||||
with_sections: true,
|
||||
created_by_user: current_user) => { tags:, sections: }
|
||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user,
|
||||
language_code: locale.language_code)
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
class SitemapEntriesController < ApplicationController
|
||||
PAGE_SIZE = 1_000
|
||||
RESOURCES = [
|
||||
'posts', 'tags', 'deerjikists', 'materials',
|
||||
'descriptions', 'tag_groups', 'theatres'].freeze
|
||||
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
resources = RESOURCES.map { |resource| { resource:, count: entries(resource, locale).count } }
|
||||
render json: { resources:, page_size: PAGE_SIZE }
|
||||
end
|
||||
|
||||
def show
|
||||
locale = resolve_locale!
|
||||
resource = params[:resource]
|
||||
return head :not_found unless RESOURCES.include?(resource)
|
||||
|
||||
page = Integer(params.fetch(:page, '1'), exception: false)
|
||||
return head :bad_request unless page && page.positive?
|
||||
|
||||
scope = entries(resource, locale)
|
||||
return head :not_found if page > [(scope.count.to_f / PAGE_SIZE).ceil, 1].max
|
||||
|
||||
ids = scope.order(:id).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).pluck(:id)
|
||||
render json: { ids: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def entries resource, locale
|
||||
named_tags = TagName.where(language_code: locale.language_code, primary_flg: true)
|
||||
.select(:tag_id)
|
||||
case resource
|
||||
when 'posts'
|
||||
Post.all
|
||||
when 'tags'
|
||||
Tag.where(id: named_tags)
|
||||
when 'deerjikists'
|
||||
Tag.where(id: named_tags).where(id: Deerjikist.select(:tag_id))
|
||||
when 'materials'
|
||||
Material.all
|
||||
when 'descriptions'
|
||||
TagDescription.where(language_code: locale.language_code, tag_id: named_tags)
|
||||
.where.not(body: [nil, ''])
|
||||
.where(TagDescriptionVersion
|
||||
.where('tag_description_versions.tag_id = tag_descriptions.tag_id')
|
||||
.where('tag_description_versions.language_code = tag_descriptions.language_code')
|
||||
.where('tag_description_versions.version_no = tag_descriptions.version_no')
|
||||
.arel.exists)
|
||||
when 'tag_groups'
|
||||
TagGroup.joins(:versions)
|
||||
.where('tag_group_versions.version_no = tag_groups.version_no')
|
||||
when 'theatres'
|
||||
Theatre.kept
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -835,7 +844,8 @@ class TagsController < ApplicationController
|
||||
def update_parent_tags! locale, tag, parent_names
|
||||
parent_tags = Tag.normalise_tags!(locale, parent_names,
|
||||
with_tagme: false,
|
||||
with_no_deerjikist: false)
|
||||
with_no_deerjikist: false,
|
||||
created_by_user: current_user)
|
||||
|
||||
old_parent_tags = tag.parents.to_a
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-13
@@ -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)
|
||||
end
|
||||
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
|
||||
|
||||
+21
-33
@@ -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
|
||||
|
||||
@@ -137,7 +142,8 @@ class Tag < ApplicationRecord
|
||||
with_tagme: true,
|
||||
with_no_deerjikist: true,
|
||||
deny_deprecated: false,
|
||||
with_sections: false
|
||||
with_sections: false,
|
||||
created_by_user: nil
|
||||
if tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
||||
raise NicoTagNormalisationError
|
||||
end
|
||||
@@ -166,7 +172,8 @@ class Tag < ApplicationRecord
|
||||
|
||||
name = TagName.canonicalise(locale, name).first
|
||||
|
||||
find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag|
|
||||
find_or_create_by_tag_name!(locale, name, category: (cat || :general),
|
||||
created_by_user:).tap do |tag|
|
||||
if deny_deprecated && tag.deprecated?
|
||||
raise DeprecatedTagNormalisationError, [tag.name(locale.language_code)]
|
||||
end
|
||||
@@ -254,25 +261,28 @@ class Tag < ApplicationRecord
|
||||
[left_end_ms, right_end_ms].max
|
||||
end
|
||||
|
||||
def find_or_create_by_tag_name! locale, name, category:
|
||||
def find_or_create_by_tag_name! locale, name, category:, created_by_user: nil
|
||||
language_code = locale.language_code
|
||||
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:,
|
||||
script_code: locale.script_code, primary_flg: true,
|
||||
auto_generated: false)
|
||||
generate_tag_names!(locale, tag, tn)
|
||||
tag = Tag.new(category:)
|
||||
tag.tag_names.build(language_code:, name:, script_code: locale.script_code,
|
||||
primary_flg: true, auto_generated: false)
|
||||
TagNameCompletion.for_tag!(tag, created_by_user:)
|
||||
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 +362,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
|
||||
@@ -105,7 +105,7 @@ class PostCreator
|
||||
category = spec[:category] || spec['category']
|
||||
next if name.blank? || category.blank?
|
||||
|
||||
tag = Tag.find_or_create_by_tag_name!(locale, name, category:)
|
||||
tag = Tag.find_or_create_by_tag_name!(locale, name, category:, created_by_user: @actor)
|
||||
tag.update!(category:) if tag.category.to_sym != category.to_sym
|
||||
tags[name] ||= tag
|
||||
end.values
|
||||
|
||||
@@ -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,122 @@
|
||||
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) },
|
||||
created_by_user: nil)
|
||||
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], created_by_user: nil)
|
||||
end
|
||||
true
|
||||
end
|
||||
unless completed
|
||||
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
|
||||
end
|
||||
end
|
||||
|
||||
def for_tag! tag, locales: nil, certified_names: { }, created_by_user: nil
|
||||
locales ||=
|
||||
Locale.where(deprecated_at: nil)
|
||||
.order(:language_code, :created_at, :script_code).lock
|
||||
Tag.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
# Creation includes every language in its first snapshot.
|
||||
creating = tag.new_record?
|
||||
tag.save! if creating
|
||||
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? && !creating
|
||||
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user:) unless creating
|
||||
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
|
||||
event_type = creating ? :create : :update
|
||||
version = TagVersioning.record!(tag, event_type:, created_by_user:)
|
||||
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,6 +1,9 @@
|
||||
class TagVersionRecorder < VersionRecorder
|
||||
def self.record! tag:, event_type:, created_by_user:, **_options
|
||||
new(tag:, event_type:, created_by_user:).record!
|
||||
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:
|
||||
@@ -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)
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする