コミットを比較
22
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
30b5fe5f05 | ||
|
|
53ea010f82 | ||
|
|
5493673996 | ||
|
|
1154babe45 | ||
|
|
2563b3fef3 | ||
|
|
d5bb9c8508 | ||
|
|
f44a436e20 | ||
|
|
c61d9d76e3 | ||
|
|
88711d2bd8 | ||
|
|
b4139f8370 | ||
|
|
f91f9d0eb5 | ||
|
|
3e8f209542 | ||
|
|
8642225b94 | ||
|
|
96a5bb8601 | ||
|
|
e71663bd22 | ||
|
|
52a8d62be5 | ||
|
|
3e05757820 | ||
|
|
d411b3b40f | ||
|
|
304090f717 | ||
|
|
084a090cab | ||
|
|
75d7dac999 | ||
|
|
e44887cf15 |
@@ -1,5 +1,18 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Mandatory security and URL contracts
|
||||
|
||||
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
|
||||
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
|
||||
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
|
||||
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
|
||||
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
|
||||
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
|
||||
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
||||
サーバーが確定した current_user の role のみを認可に使用する。
|
||||
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
||||
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
||||
|
||||
## Project overview
|
||||
|
||||
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
|
||||
@@ -573,10 +586,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
||||
- When new user-facing wording appears necessary, ask the user for the exact
|
||||
wording and placement before implementing it.
|
||||
- Do not invent replacement copy when removing unrequested wording.
|
||||
- Do not create, modify, or run tests unless the user explicitly asks for
|
||||
test work. When the user asks for tests, keep working within the permitted
|
||||
test-file scope and rerun them until they pass or the remaining failure is
|
||||
clearly blocked.
|
||||
- 明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
|
||||
fixture、mock、snapshot、テスト補助コードも対象とする。
|
||||
既存のテスト差分を勝手に修正・差戻ししない。
|
||||
この規約はディレクトリ固有の自動的なテスト実行推奨より優先する。
|
||||
- Test-only work includes adding, updating, deleting, reorganising, or fixing
|
||||
SyntaxError in tests. During test-only work, do not modify production code.
|
||||
- During test-only work, do not change production constants, behaviour, API
|
||||
@@ -631,6 +644,19 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
||||
|
||||
## Frontend rules
|
||||
|
||||
- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。
|
||||
新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。
|
||||
不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。
|
||||
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
|
||||
`_untranslated` 規約を使い、language metadata を維持する。
|
||||
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
|
||||
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
|
||||
SSR / hydration の整合及び翻訳キーの非露出を検証する。
|
||||
i18n の検証を理由にテスト関連作業を開始しない。
|
||||
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
|
||||
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
|
||||
アクセシビリティ用ラベルも UI 文言として扱う。
|
||||
|
||||
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
|
||||
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
|
||||
avoid ad hoc query key arrays.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# backend/AGENTS.md
|
||||
|
||||
## Mandatory security and URL contracts
|
||||
|
||||
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
|
||||
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
|
||||
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
|
||||
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
|
||||
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
|
||||
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
|
||||
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
||||
サーバーが確定した current_user の role のみを認可に使用する。
|
||||
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
||||
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
||||
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
|
||||
export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。
|
||||
|
||||
## Scope
|
||||
|
||||
These rules apply to work under `backend/`.
|
||||
|
||||
@@ -1,20 +1,63 @@
|
||||
class ApplicationController < ActionController::API
|
||||
include RequestAccess
|
||||
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(ActiveRecord::RecordNotFound) { head :not_found }
|
||||
rescue_from(UnsupportedLocale) { head :not_found }
|
||||
|
||||
before_action :reject_banned_ip_address!
|
||||
before_action :authenticate_user
|
||||
before_action :reject_banned_user!
|
||||
|
||||
def current_user = @current_user
|
||||
before_action :validate_explicit_locale!
|
||||
before_action :enforce_query_contract!
|
||||
around_action :with_history_authority
|
||||
rescue_from(HistoryAccess::Forbidden) { head :not_found }
|
||||
protect_from_forgery with: :exception
|
||||
self.forgery_protection_origin_check = true
|
||||
rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf
|
||||
after_action :prevent_authenticated_caching
|
||||
|
||||
private
|
||||
|
||||
def authenticate_user
|
||||
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
|
||||
return if code.blank?
|
||||
def enforce_query_contract!
|
||||
pairs = URI.decode_www_form(request.query_string)
|
||||
return head :bad_request if pairs.length > 1000 || pairs.map(&:first).uniq.length != pairs.length
|
||||
return head :bad_request if request.query_parameters.values.any? { !_1.is_a?(String) }
|
||||
if (controller_name.start_with?('counts_') &&
|
||||
(request.query_parameters.keys & ['headers', 'labels']).any?)
|
||||
return head :bad_request
|
||||
end
|
||||
rescue ArgumentError
|
||||
head :bad_request
|
||||
end
|
||||
|
||||
@current_user = User.find_by(inheritance_code: code)
|
||||
def with_history_authority(&action)
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
HistoryAccess.set(user: current_user, &action)
|
||||
end
|
||||
|
||||
def reject_invalid_csrf
|
||||
response.headers['X-CSRF-Invalid'] = '1'
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
head :forbidden
|
||||
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
|
||||
@@ -49,19 +92,6 @@ class ApplicationController < ActionController::API
|
||||
render_validation_error base: ['すでに存在してゐます.']
|
||||
end
|
||||
|
||||
def reject_banned_ip_address!
|
||||
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
|
||||
return unless ip_address&.banned?
|
||||
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def reject_banned_user!
|
||||
return unless current_user&.banned?
|
||||
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
|
||||
errors = { }
|
||||
|
||||
@@ -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,21 @@
|
||||
module CountsListing
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
|
||||
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
|
||||
.merge('locale' => locale.code))
|
||||
result = { population_count_scope: 'all_recorded_versions', population_count: HistoryAccess.population_count(query.relation.klass), count: query.count, page: query.page, limit: query.limit,
|
||||
at: request.query_parameters['at'].presence || Time.current.utc.iso8601(6) }
|
||||
if query.mode == 'summary'
|
||||
result.merge!(query.summary)
|
||||
else
|
||||
result[:versions] = CountsVersionRepr.many(
|
||||
query.details, resource:, language_code: locale.language_code)
|
||||
end
|
||||
render json: result
|
||||
rescue ArgumentError, TypeError
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
# Permanent storage URLs must not bypass a resource's later discard boundary.
|
||||
module HistoryStorageAccess
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include RequestAccess
|
||||
prepend_before_action :authorise_history_storage!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorise_history_storage!
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return unless request.get? || request.head?
|
||||
|
||||
reject_banned_ip_address!
|
||||
return if performed?
|
||||
|
||||
authenticate_user
|
||||
reject_banned_user!
|
||||
return if performed?
|
||||
|
||||
signed = params[:signed_blob_id] || params[:signed_id]
|
||||
blob = if signed
|
||||
ActiveStorage::Blob.find_signed(signed)
|
||||
elsif params[:encoded_key]
|
||||
payload = ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
|
||||
key = payload && (payload[:key] || payload['key'])
|
||||
if key
|
||||
original = key.start_with?('variants/') ? key.split('/')[1] : key
|
||||
ActiveStorage::Blob.find_by(key: original)
|
||||
end
|
||||
end
|
||||
return head :not_found unless blob
|
||||
|
||||
allowed = HistoryAccess.set(user: current_user) { HistoryAccess.blob_visible?(blob) }
|
||||
head :not_found unless allowed
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
head :not_found
|
||||
end
|
||||
|
||||
# Override the engine's public-forever caching before a streaming response commits.
|
||||
def http_cache_forever(**_options)
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
yield
|
||||
end
|
||||
|
||||
def expires_in(*arguments, **options)
|
||||
super
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# Canonical read-side session and BAN boundary, shared by API and storage controllers.
|
||||
module RequestAccess
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def current_user = @current_user
|
||||
|
||||
private
|
||||
|
||||
def authenticate_user
|
||||
code = session[:inheritance_code]
|
||||
return if code.blank?
|
||||
|
||||
@current_user = User.find_by(inheritance_code: code)
|
||||
end
|
||||
|
||||
def reject_banned_ip_address!
|
||||
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
|
||||
return unless ip_address&.banned?
|
||||
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def reject_banned_user!
|
||||
return unless current_user&.banned?
|
||||
|
||||
head :forbidden
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
class CountsExportsController < ApplicationController
|
||||
wrap_parameters false
|
||||
|
||||
def create_job
|
||||
locale = resolve_locale!
|
||||
payload = request.request_parameters.merge(request.query_parameters)
|
||||
session[:counts_export_owner] ||= SecureRandom.hex(32)
|
||||
job = CountsExportJobs.start(payload, session[:counts_export_owner], locale.language_code)
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
render json: { job: }, status: :accepted
|
||||
rescue CountsExportDownload::Busy
|
||||
head :too_many_requests
|
||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||
head :bad_request
|
||||
end
|
||||
|
||||
def job_status
|
||||
job_state(cancel: false)
|
||||
end
|
||||
|
||||
def cancel_job
|
||||
job_state(cancel: true)
|
||||
end
|
||||
|
||||
def prepare
|
||||
locale = resolve_locale!
|
||||
payload = request.request_parameters.merge(request.query_parameters)
|
||||
exporter = CountsExporter.new(payload, language_code: locale.language_code)
|
||||
session[:counts_export_owner] ||= SecureRandom.hex(32)
|
||||
token = CountsExportDownload.prepare(exporter, session[:counts_export_owner])
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
render json: { token: }
|
||||
rescue CountsExportDownload::Busy
|
||||
head :too_many_requests
|
||||
rescue CountsExportDownload::TooLarge
|
||||
head 413
|
||||
rescue CountsExportDownload::TimedOut
|
||||
head :gateway_timeout
|
||||
rescue CountsExportDownload::StorageFull, Errno::ENOSPC
|
||||
head :insufficient_storage
|
||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||
head :bad_request
|
||||
end
|
||||
|
||||
def download
|
||||
file = CountsExportDownload.find(params[:token], session[:counts_export_owner])
|
||||
return head :not_found unless file
|
||||
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
send_file file.fetch('path'),
|
||||
filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
|
||||
type: file['format'] == 'json' ? 'application/json' : 'text/csv',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
def job_state(cancel:)
|
||||
result = CountsExportJobs.status(params[:token], session[:counts_export_owner], cancel:)
|
||||
return head :not_found unless result
|
||||
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
render json: result
|
||||
end
|
||||
private :job_state
|
||||
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
payload = request.request_parameters.except('authenticity_token')
|
||||
.merge(request.query_parameters)
|
||||
exporter = CountsExporter.new(payload,
|
||||
language_code: locale.language_code)
|
||||
session[:counts_export_owner] ||= SecureRandom.hex(32)
|
||||
token = CountsExportDownload.prepare(exporter, session[:counts_export_owner])
|
||||
file = CountsExportDownload.find(token, session[:counts_export_owner])
|
||||
return head :not_found unless file
|
||||
|
||||
send_file file.fetch('path'),
|
||||
filename: "counts-#{ exporter.resource }.#{ exporter.format }",
|
||||
type: exporter.format == 'json' ? 'application/json' : 'text/csv',
|
||||
disposition: 'attachment'
|
||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class CountsPostsController < PostVersionsController
|
||||
include CountsListing
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
class CountsStatesController < ApplicationController
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
result = CountsStateQuery.new(request.query_parameters,
|
||||
language_code: locale.language_code).call
|
||||
render json: result
|
||||
rescue ArgumentError, TypeError
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class CountsTablesController < ApplicationController
|
||||
def definition
|
||||
resolve_locale!
|
||||
definition = CountsResourceDefinition.new(params[:resource])
|
||||
render json: { fields: definition.metadata,
|
||||
output_fields: definition.output_fields,
|
||||
filter_fields: definition.filter_metadata,
|
||||
axes: { latest: definition.axes(false), history: definition.axes(true) },
|
||||
grains: CountsAxes::GRAINS, defaults: CountsDefaults.metadata(params[:resource]) }
|
||||
rescue ArgumentError
|
||||
head :bad_request
|
||||
end
|
||||
|
||||
def index
|
||||
resolve_locale!
|
||||
render json: CountsTableQuery.new(request.query_parameters).call
|
||||
rescue CountsTableQuery::TooManyColumns
|
||||
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
|
||||
status: :unprocessable_entity
|
||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class CountsTagsController < TagVersionsController
|
||||
include CountsListing
|
||||
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
|
||||
@@ -8,7 +8,7 @@ class MaterialVersionsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
q = MaterialVersion.includes(:created_by_user)
|
||||
q = HistoryAccess.visible(MaterialVersion.includes(:created_by_user))
|
||||
q = q.where(material_id: params[:material_id]) if params[:material_id].present?
|
||||
q = q.where(tag_name: params[:tag].to_s.strip) if params[:tag].present?
|
||||
q = q.where(event_type: params[:event_type]) if params[:event_type].present?
|
||||
@@ -20,7 +20,7 @@ class MaterialVersionsController < ApplicationController
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
render json: { versions: serialise_versions(versions), count: }
|
||||
render json: { versions: serialise_versions(versions), count:, population_count: HistoryAccess.population_count(MaterialVersion) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -70,7 +70,10 @@ class MaterialsController < ApplicationController
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless material
|
||||
|
||||
wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.body
|
||||
wiki_page_body =
|
||||
TagDescription
|
||||
.find_by(tag_id: material.tag_id, language_code: locale.language_code)
|
||||
&.body
|
||||
|
||||
render json: MaterialRepr.base(material, host: request.base_url,
|
||||
language_code: locale.language_code).merge(wiki_page_body:)
|
||||
@@ -252,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,9 +24,9 @@ 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: :wiki_page })
|
||||
.includes(linked_tags: [:tag_names, :tag_descriptions])
|
||||
if name
|
||||
q = q.where(('external_tags.name LIKE ? ' +
|
||||
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
|
||||
@@ -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
|
||||
@@ -13,21 +14,7 @@ class PostVersionsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
q = PostVersion.joins(<<~SQL.squish)
|
||||
LEFT JOIN
|
||||
post_versions prev
|
||||
ON
|
||||
prev.post_id = post_versions.post_id
|
||||
AND prev.version_no = post_versions.version_no - 1
|
||||
SQL
|
||||
.select('post_versions.*',
|
||||
'prev.title AS prev_title',
|
||||
'prev.url AS prev_url',
|
||||
'prev.thumbnail_base AS prev_thumbnail_base',
|
||||
'prev.tags_json AS prev_tags_json',
|
||||
'prev.video_ms AS prev_video_ms',
|
||||
'prev.original_created_from AS prev_original_created_from',
|
||||
'prev.original_created_before AS prev_original_created_before')
|
||||
q = HistoryAccess.visible(version_scope)
|
||||
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
||||
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
|
||||
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
|
||||
@@ -45,12 +32,24 @@ class PostVersionsController < ApplicationController
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
render json: { versions: serialise_versions(versions), count: }
|
||||
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialise_versions rows
|
||||
def version_scope
|
||||
PostVersion.with_previous_version
|
||||
.select('post_versions.*',
|
||||
'prev.title AS prev_title',
|
||||
'prev.url AS prev_url',
|
||||
'prev.thumbnail_base AS prev_thumbnail_base',
|
||||
'prev.tags_json AS prev_tags_json',
|
||||
'prev.video_ms AS prev_video_ms',
|
||||
'prev.original_created_from AS prev_original_created_from',
|
||||
'prev.original_created_before AS prev_original_created_before')
|
||||
end
|
||||
|
||||
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 +57,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 +78,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 },
|
||||
@@ -97,7 +101,7 @@ class PostVersionsController < ApplicationController
|
||||
}
|
||||
missing_ids = ids - names.keys
|
||||
|
||||
NicoTagVersion
|
||||
HistoryAccess.visible(NicoTagVersion.all)
|
||||
.where(tag_id: missing_ids)
|
||||
.order(:tag_id, version_no: :desc)
|
||||
.pluck(:tag_id, :name)
|
||||
@@ -106,17 +110,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 +139,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
|
||||
|
||||
@@ -38,7 +38,7 @@ class PostsController < ApplicationController
|
||||
offset = (page - 1) * limit
|
||||
|
||||
pt_max_sql =
|
||||
PostVersion
|
||||
HistoryAccess.visible(PostVersion.all)
|
||||
.select('post_id, MAX(created_at) AS max_updated_at')
|
||||
.group('post_id')
|
||||
.to_sql
|
||||
@@ -53,7 +53,7 @@ class PostsController < ApplicationController
|
||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
||||
.preload(:external_tags, :uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
|
||||
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
||||
@@ -111,7 +111,7 @@ class PostsController < ApplicationController
|
||||
filtered_posts(locale)
|
||||
.preload(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
.order('RAND()')
|
||||
.first
|
||||
@@ -198,7 +198,7 @@ class PostsController < ApplicationController
|
||||
Post
|
||||
.includes(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless post
|
||||
@@ -344,6 +344,7 @@ class PostsController < ApplicationController
|
||||
unless force
|
||||
base_version = post.post_versions.find_by!(version_no: base_version_no)
|
||||
|
||||
HistoryAccess.require!(base_version)
|
||||
base_snapshot = post_snapshot_from_version(base_version)
|
||||
current_snapshot = post_snapshot_from_record(post, locale)
|
||||
end
|
||||
@@ -400,8 +401,60 @@ 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
|
||||
PostTagSearch.parse_references(params.to_unsafe_h, literals)
|
||||
rescue ArgumentError, JSON::ParserError, TypeError
|
||||
raise ActionController::BadRequest
|
||||
end
|
||||
|
||||
def filtered_posts locale
|
||||
tag_names = params[:tags].to_s.split
|
||||
match_type = params[:match]
|
||||
@@ -413,20 +466,19 @@ 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:') }
|
||||
end
|
||||
references = tag_filter_references(tag_names)
|
||||
literals = PostTagSearch.literals(locale, tag_names.join(' '), references:)
|
||||
|
||||
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 +488,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,19 +497,11 @@ class PostsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def tagged_post_ids_for locale, name
|
||||
posts_by_internal_tags =
|
||||
Post
|
||||
.joins(tags: :tag_names)
|
||||
.where(tag_names: { language_code: locale.language_code, name: })
|
||||
.select(:id)
|
||||
|
||||
posts_by_external_tags =
|
||||
Post
|
||||
.joins(:external_tags)
|
||||
.where("CONCAT(external_tags.platform, ':', external_tags.name) = ?", name)
|
||||
|
||||
(posts_by_internal_tags + posts_by_external_tags).uniq(&:id)
|
||||
def tagged_post_ids_for locale, name, reference: nil
|
||||
internal, external = PostTagSearch.references(locale, { name:, reference: })
|
||||
Post.where(id: PostTag.where(tag_id: Array(internal)).select(:post_id))
|
||||
.or(Post.where(id: PostExternalTag.where(external_tag_id: Array(external))
|
||||
.select(:post_id))).select(:id)
|
||||
end
|
||||
|
||||
def sync_post_tags! post, desired_tags, sections
|
||||
@@ -728,7 +772,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 +919,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,58 @@
|
||||
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(HistoryAccess.visible(TagDescriptionVersion.all)
|
||||
.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')
|
||||
.where(Arel.sql(HistoryAccess.sql(TagGroupVersion)))
|
||||
when 'theatres'
|
||||
Theatre.kept
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,14 +1,18 @@
|
||||
class TagDescriptionsController < ApplicationController
|
||||
def show
|
||||
description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja')
|
||||
locale = resolve_locale!
|
||||
description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code)
|
||||
return head :not_found unless description
|
||||
|
||||
render json: representation(description)
|
||||
end
|
||||
|
||||
def show_by_title
|
||||
tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip)
|
||||
description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja')
|
||||
locale = resolve_locale!
|
||||
tag_name = TagName.find_by(
|
||||
language_code: locale.language_code, name: params[:name].to_s.strip)
|
||||
description = tag_name&.tag && TagDescription.find_by(
|
||||
tag: tag_name.tag, language_code: locale.language_code)
|
||||
return head :not_found unless description
|
||||
|
||||
render json: representation(description)
|
||||
@@ -21,18 +25,19 @@ class TagDescriptionsController < ApplicationController
|
||||
body = params[:body].to_s
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
locale = resolve_locale!
|
||||
tag = Tag.find_by(id: params[:id])
|
||||
return head :not_found unless tag
|
||||
|
||||
description = TagDescription.find_by(tag:, language_code: 'ja')
|
||||
description = TagDescription.find_by(tag:, language_code: locale.language_code)
|
||||
|
||||
if description
|
||||
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user)
|
||||
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
|
||||
description.reload
|
||||
render json: representation(description)
|
||||
else
|
||||
TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user)
|
||||
description = TagDescription.find_by!(tag:, language_code: 'ja')
|
||||
TagDescriptionOperation.create!(locale, tag:, body:, created_by_user: current_user)
|
||||
description = TagDescription.find_by!(tag:, language_code: locale.language_code)
|
||||
render json: representation(description), status: :created
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@@ -44,6 +49,7 @@ class TagDescriptionsController < ApplicationController
|
||||
private
|
||||
|
||||
def representation description
|
||||
HistoryAccess.require_current!(description, TagDescriptionVersion)
|
||||
description.as_json(only: [
|
||||
:tag_id,
|
||||
:language_code,
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
class TagGroupsController < ApplicationController
|
||||
def index
|
||||
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
|
||||
render json: TagGroupRepr.many(groups.to_a)
|
||||
locale = resolve_locale!
|
||||
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
|
||||
render json: TagGroupRepr.many(locale, groups.to_a)
|
||||
end
|
||||
|
||||
def show
|
||||
locale = resolve_locale!
|
||||
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
render json: TagGroupRepr.one(group)
|
||||
render json: TagGroupRepr.one(locale, group)
|
||||
end
|
||||
|
||||
def create
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
group = TagGroupOperation.create!(**write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(group), status: :created
|
||||
locale = resolve_locale!
|
||||
group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(locale, group), status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
locale = resolve_locale!
|
||||
group = TagGroup.find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
TagGroupOperation.update!(group:, **write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(group.reload)
|
||||
TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(locale, group.reload)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -10,17 +10,7 @@ class TagVersionsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
q = TagVersion.joins(<<~SQL.squish)
|
||||
LEFT JOIN
|
||||
tag_versions prev
|
||||
ON
|
||||
prev.tag_id = tag_versions.tag_id
|
||||
AND prev.version_no = tag_versions.version_no - 1
|
||||
SQL
|
||||
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
||||
'prev.deprecated_at AS prev_deprecated_at',
|
||||
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
|
||||
'prev.parent_tag_ids AS prev_parent_tag_ids')
|
||||
q = HistoryAccess.visible(version_scope)
|
||||
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
||||
|
||||
count = q.except(:select, :order, :limit, :offset).count
|
||||
@@ -29,14 +19,44 @@ class TagVersionsController < ApplicationController
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
render json: { versions: serialise_versions(versions, locale.language_code), count: }
|
||||
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(TagVersion) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def version_scope
|
||||
TagVersion.joins(<<~SQL.squish)
|
||||
LEFT JOIN
|
||||
tag_versions prev
|
||||
ON
|
||||
prev.tag_id = tag_versions.tag_id
|
||||
AND prev.version_no = tag_versions.version_no - 1
|
||||
AND #{ HistoryAccess.sql(TagVersion, 'prev') }
|
||||
SQL
|
||||
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
||||
'prev.deprecated_at AS prev_deprecated_at',
|
||||
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
|
||||
'prev.parent_tag_ids AS prev_parent_tag_ids')
|
||||
end
|
||||
|
||||
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 +67,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_names: :wiki_page)
|
||||
.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 +78,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 +111,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_names: :wiki_page)
|
||||
.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_names: :wiki_page)
|
||||
.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_names: :wiki_page)
|
||||
.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_names: :wiki_page)
|
||||
.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_names: :wiki_page)
|
||||
.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_names: :wiki_page)
|
||||
.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_names: :wiki_page)
|
||||
.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_names: :wiki_page)
|
||||
.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
|
||||
|
||||
@@ -770,31 +779,15 @@ class TagsController < ApplicationController
|
||||
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
|
||||
end
|
||||
|
||||
current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id)
|
||||
current_tag_name.association(:wiki_page).reset
|
||||
|
||||
current_tag_name.update!(canonical_id: promoted_tag_name.id,
|
||||
primary_flg: false, auto_generated: false)
|
||||
promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true, auto_generated: false)
|
||||
|
||||
TagName.where(tag:,
|
||||
language_code: current_tag_name.language_code,
|
||||
primary_flg: false)
|
||||
.where.not(id: promoted_tag_name.id)
|
||||
.find_each do |alias_tag_name|
|
||||
alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
|
||||
end
|
||||
|
||||
tag.update!(tag_name_id: promoted_tag_name.id)
|
||||
current_tag_name.association(:wiki_page).reset
|
||||
current_tag_name.association(:tag).reset
|
||||
current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
|
||||
current_tag_name&.update!(primary_flg: false, auto_generated: false)
|
||||
promoted_tag_name.update!(tag:, primary_flg: true, auto_generated: false)
|
||||
tag.association(:tag_names).reset
|
||||
|
||||
return unless old_owner_tag && old_owner_tag != tag
|
||||
|
||||
record_tag_version!(old_owner_tag.reload, event_type: :update,
|
||||
created_by_user: current_user,
|
||||
language_code: current_tag_name.language_code)
|
||||
language_code: promoted_tag_name.language_code)
|
||||
end
|
||||
|
||||
def update_aliases! locale, tag, alias_names
|
||||
@@ -802,20 +795,15 @@ class TagsController < ApplicationController
|
||||
|
||||
affected_tags = [tag]
|
||||
|
||||
primary_tag_name = TagName.find_by!(tag:,
|
||||
language_code: locale.language_code,
|
||||
primary_flg: true)
|
||||
current_aliases = tag.tag_names.where(language_code: locale.language_code,
|
||||
primary_flg: false).to_a
|
||||
|
||||
current_aliases.each do |alias_tag_name|
|
||||
next if alias_names.include?(alias_tag_name.name)
|
||||
|
||||
affected_tags << alias_tag_name.tag
|
||||
end
|
||||
requested_aliases = TagName.where(
|
||||
language_code: locale.language_code, name: alias_names)
|
||||
.includes(:tag).index_by(&:name)
|
||||
|
||||
alias_names.each do |alias_name|
|
||||
alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name)
|
||||
alias_tag_name = requested_aliases[alias_name]
|
||||
if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id
|
||||
alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.')
|
||||
raise ActiveRecord::RecordInvalid, alias_tag_name
|
||||
@@ -823,7 +811,8 @@ class TagsController < ApplicationController
|
||||
affected_tags << alias_tag_name&.tag
|
||||
end
|
||||
|
||||
affected_tags.compact.uniq.each do |affected_tag|
|
||||
affected_tags = affected_tags.compact.uniq(&:id)
|
||||
affected_tags.each do |affected_tag|
|
||||
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user,
|
||||
language_code: locale.language_code)
|
||||
end
|
||||
@@ -831,19 +820,21 @@ class TagsController < ApplicationController
|
||||
current_aliases.each do |alias_tag_name|
|
||||
next if alias_names.include?(alias_tag_name.name)
|
||||
|
||||
alias_tag_name.update!(canonical_id: nil, tag_id: nil)
|
||||
alias_tag_name.destroy!
|
||||
end
|
||||
|
||||
alias_names.each do |alias_name|
|
||||
alias_tag_name = TagName.find_or_initialize_by(
|
||||
alias_tag_name = requested_aliases[alias_name] || TagName.new(
|
||||
language_code: locale.language_code,
|
||||
name: alias_name)
|
||||
alias_tag_name.script_code = locale.script_code
|
||||
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false,
|
||||
canonical_id: primary_tag_name.id)
|
||||
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false)
|
||||
end
|
||||
|
||||
affected_tags.compact.uniq.each do |affected_tag|
|
||||
affected_tags.each do |affected_tag|
|
||||
# update_all records this Tag after its parent relationships are updated.
|
||||
next if affected_tag.id == tag.id
|
||||
|
||||
record_tag_version!(affected_tag, event_type: :update,
|
||||
created_by_user: current_user,
|
||||
language_code: locale.language_code)
|
||||
@@ -853,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
|
||||
|
||||
|
||||
@@ -1,104 +1,139 @@
|
||||
class WikiPagesController < ApplicationController
|
||||
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
|
||||
# 互換性のための Facade API
|
||||
|
||||
rescue_from TagDescriptionOperation::Conflict, with: :render_wiki_conflict
|
||||
|
||||
def index
|
||||
# 互換性のための化石リソース
|
||||
locale = resolve_locale!
|
||||
|
||||
title = params[:title].to_s.strip
|
||||
descriptions =
|
||||
TagDescription.where(language_code: 'ja')
|
||||
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
|
||||
.where(language_code: locale.language_code)
|
||||
.includes(tag: :tag_names)
|
||||
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
|
||||
|
||||
tag_groups = TagGroup.all
|
||||
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
||||
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
|
||||
if title.present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||
descriptions =
|
||||
descriptions.joins(tag: :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 ?', pattern).limit(20)
|
||||
tag_groups = tag_groups.where(
|
||||
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
|
||||
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||
'CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?',
|
||||
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
|
||||
end
|
||||
|
||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
||||
json = descriptions.map { |description| WikiPageRepr.tag_description(locale, description) }
|
||||
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||
json.concat(WikiPageRepr.base(pages))
|
||||
json.sort_by! do |row|
|
||||
[-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')]
|
||||
end
|
||||
json = json.first(20) if title.present?
|
||||
render json:
|
||||
end
|
||||
|
||||
def show
|
||||
tag_description = TagDescription.find_by(id: params[:id])
|
||||
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)
|
||||
|
||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||
names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).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(HistoryAccess.current(TagGroup.all, TagGroupVersion).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!
|
||||
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 tag_group if tag_group
|
||||
return render_tag_group locale, tag_group if tag_group
|
||||
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(id: params[:id])
|
||||
|
||||
render_wiki_page_or_404 page
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def show_by_title
|
||||
locale = resolve_locale!
|
||||
title = params[:title].to_s.strip
|
||||
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||
if (name = TagGroupRepr.name_from_title(title))
|
||||
tag_group = TagGroup.find_by(name:)
|
||||
return render_tag_group tag_group if tag_group
|
||||
return render_tag_group locale, tag_group if tag_group
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
tag_description = tag_description_by_title(title)
|
||||
tag_description = tag_description_by_title(locale, title)
|
||||
|
||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||
return render_wiki_page_by_tag_description locale, tag_description if tag_description
|
||||
|
||||
page =
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(tag_name: { name: title })
|
||||
head :not_found
|
||||
end
|
||||
|
||||
render_wiki_page_or_404 page
|
||||
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
|
||||
return head :no_content if TagDescription.exists?(id: params[:id])
|
||||
locale = resolve_locale!
|
||||
if TagDescription.exists?(id: params[:id], language_code: locale.language_code)
|
||||
return head :no_content
|
||||
end
|
||||
return head :no_content if TagGroup.exists?(id: params[:id])
|
||||
|
||||
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def exists_by_title
|
||||
locale = resolve_locale!
|
||||
title = params[:title].to_s.strip
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX))
|
||||
if (name = TagGroupRepr.name_from_title(title))
|
||||
exists = TagGroup.exists?(name:)
|
||||
return head exists ? :no_content : :not_found
|
||||
end
|
||||
return head :no_content if tag_description_by_title(title)
|
||||
|
||||
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
||||
head exists ? :no_content : :not_found
|
||||
return head :no_content if tag_description_by_title(locale, title)
|
||||
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def diff
|
||||
id = params[:id]
|
||||
return render_bad_request('id は必須です.') if id.blank?
|
||||
|
||||
locale = resolve_locale!
|
||||
|
||||
from = params[:from].presence
|
||||
to = params[:to].presence
|
||||
|
||||
description = TagDescription.find_by(id:)
|
||||
description = TagDescription.find_by(id:, language_code: locale.language_code)
|
||||
if description
|
||||
versions = description_versions(description)
|
||||
from_rev = from && versions.find(from)
|
||||
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
|
||||
resource = WikiPageRepr.tag_description(description)
|
||||
resource = WikiPageRepr.tag_description(locale, description)
|
||||
else
|
||||
tag_group = TagGroup.find_by(id:)
|
||||
if tag_group
|
||||
@@ -106,21 +141,13 @@ class WikiPagesController < ApplicationController
|
||||
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
|
||||
return head :not_found if (from && !from_rev) || !to_rev
|
||||
|
||||
from_body = from_rev && render_group_version(from_rev)
|
||||
to_body = render_group_version(to_rev)
|
||||
from_body = from_rev && render_group_version(locale, from_rev)
|
||||
to_body = render_group_version(locale, to_rev)
|
||||
resource = { 'id' => tag_group.id,
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||
'deprecated_at' => nil }
|
||||
else
|
||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
||||
from_rev = from && page.wiki_revisions.find(from)
|
||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
|
||||
return render_unprocessable_entity('差分を表示できない版です.')
|
||||
end
|
||||
from_body = from_rev&.body
|
||||
to_body = to_rev.body
|
||||
resource = WikiPageRepr.base(page)
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -149,37 +176,31 @@ class WikiPagesController < ApplicationController
|
||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||
parsed = TagGroupMarkdownParser.parse(body:, group: nil)
|
||||
group = TagGroupOperation.create!(name:, **parsed, actor: current_user,
|
||||
require_compatibility: true)
|
||||
locale = resolve_locale!
|
||||
|
||||
if (name = TagGroupRepr.name_from_title(title))
|
||||
parsed = TagGroupMarkdownParser.parse(locale, body:, group: nil)
|
||||
group = TagGroupOperation.create!(
|
||||
locale, name:, **parsed.except(:compatibility_body), actor: current_user,
|
||||
require_compatibility: true)
|
||||
version = current_group_version(group)
|
||||
return render_group_representation(TagGroupRepr.one(group), version,
|
||||
body: parsed.fetch(:compatibility_body),
|
||||
return render_group_representation(TagGroupRepr.one(locale, group), version,
|
||||
status: :created)
|
||||
end
|
||||
|
||||
page = if title.include?(':')
|
||||
ApplicationRecord.transaction do
|
||||
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
|
||||
name.script_code = 'Jpan'
|
||||
name.primary_flg = true
|
||||
name.auto_generated = false
|
||||
description =
|
||||
if title.include?(':')
|
||||
return render_unprocessable_entity field: :title
|
||||
else
|
||||
tag_name = TagName.find_by(language_code: locale.language_code, name: title)
|
||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||
|
||||
TagDescriptionOperation.create!(
|
||||
locale,
|
||||
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
|
||||
Wiki::Commit.create_legacy_content!(
|
||||
tag_name:, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
else
|
||||
tag_name = TagName.find_by(language_code: 'ja', name: title)
|
||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||
|
||||
TagDescriptionOperation.create!(
|
||||
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
|
||||
render json: WikiPageRepr.base(page), status: :created
|
||||
render json: WikiPageRepr.tag_description(locale, description), status: :created
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_validation_error e.record
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
@@ -198,55 +219,48 @@ class WikiPagesController < ApplicationController
|
||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
locale = resolve_locale!
|
||||
|
||||
group = TagGroup.find_by(id: params[:id])
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
if (name = TagGroupRepr.name_from_title(title))
|
||||
return head :not_found unless group
|
||||
|
||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||
parsed = TagGroupMarkdownParser.parse(body:, group:)
|
||||
TagGroupOperation.update!(group:, name:, **parsed, actor: current_user,
|
||||
require_compatibility: true)
|
||||
parsed = TagGroupMarkdownParser.parse(locale, body:, group:)
|
||||
TagGroupOperation.update!(
|
||||
locale, group:, name:, **parsed.except(:compatibility_body), actor: current_user,
|
||||
require_compatibility: true)
|
||||
return head :ok
|
||||
end
|
||||
|
||||
page = WikiPage.find_by(id: params[:id])
|
||||
if legacy_group_page?(page)
|
||||
return render_unprocessable_entity field: :title
|
||||
return render_unprocessable_entity field: :title if title.include?(':')
|
||||
description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
|
||||
unless description
|
||||
return render_unprocessable_entity field: :title if group
|
||||
return head :not_found
|
||||
end
|
||||
if group && !legacy_update_identified?(page, title, group)
|
||||
return render_unprocessable_entity field: :title
|
||||
end
|
||||
return head :not_found unless page
|
||||
|
||||
tag = description.tag
|
||||
tag_name = tag.tag_name(locale.language_code)
|
||||
return render_unprocessable_entity field: :title unless tag_name
|
||||
return render_unprocessable_entity field: :title if tag_name.name.include?(':')
|
||||
|
||||
base_revision_id = params[:base_revision_id].presence
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
page.lock!
|
||||
|
||||
old_title = page.title
|
||||
|
||||
tag = page.tag_name.tag
|
||||
|
||||
if tag && title != old_title
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
old_title = tag_name.name
|
||||
if title != old_title
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||
end
|
||||
|
||||
page.tag_name.update!(name: title) if title != old_title
|
||||
tag_name.update!(name: title) if title != old_title
|
||||
|
||||
message = params[:message].presence
|
||||
description = TagDescription.find_by(id: page.id)
|
||||
description_page = description && (
|
||||
tag&.id == description.tag_id ||
|
||||
(tag.nil? && !old_title.include?(':')))
|
||||
if description && !description_page
|
||||
Wiki::Commit.legacy_content!(page:, body:, created_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
else
|
||||
Wiki::Commit.content!(page:, body:, created_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
end
|
||||
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
|
||||
if tag && title != old_title
|
||||
if title != old_title
|
||||
tag.reload
|
||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
@@ -262,37 +276,24 @@ class WikiPagesController < ApplicationController
|
||||
end
|
||||
|
||||
def changes
|
||||
locale = resolve_locale!
|
||||
|
||||
id = params[:id].presence
|
||||
description = id && TagDescription.find_by(id:)
|
||||
description = id && TagDescription.find_by(id:, language_code: locale.language_code)
|
||||
if description
|
||||
return render json: description_changes(TagDescription.where(id: description.id))
|
||||
return render json: description_changes(locale, TagDescription.where(id: description.id))
|
||||
end
|
||||
|
||||
tag_group = id && TagGroup.find_by(id:)
|
||||
if tag_group
|
||||
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
||||
return render json: tag_group_changes(HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: tag_group.id))
|
||||
end
|
||||
|
||||
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
|
||||
revisions = WikiRevision.joins(wiki_page: :tag_name)
|
||||
.where(wiki_page_id: pages.select(:id))
|
||||
.includes(:created_user, wiki_page: { tag_name: :tag })
|
||||
.order(id: :desc).limit(200)
|
||||
json = revisions.map { |rev|
|
||||
{ revision_id: rev.id,
|
||||
pred: rev.base_revision_id,
|
||||
succ: nil,
|
||||
wiki_page: { id: rev.wiki_page_id,
|
||||
title: rev.wiki_page.title,
|
||||
deprecated_at: rev.wiki_page.deprecated_at },
|
||||
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
||||
kind: rev.kind,
|
||||
message: rev.message,
|
||||
timestamp: rev.created_at }
|
||||
}
|
||||
json = []
|
||||
unless id
|
||||
json.concat(tag_group_changes(TagGroupVersion.all))
|
||||
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
||||
json.concat(tag_group_changes(HistoryAccess.visible(TagGroupVersion.all)))
|
||||
json.concat(description_changes(locale,
|
||||
TagDescription.where(language_code: locale.language_code)))
|
||||
json = json.each_with_index.sort_by do |row, index|
|
||||
[-row.fetch(:timestamp).to_f, index]
|
||||
end.map(&:first)
|
||||
@@ -302,27 +303,7 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def legacy_group_page? page
|
||||
return false unless page
|
||||
return true if page.title.start_with?(TagGroupRepr::PREFIX)
|
||||
|
||||
prefix = ApplicationRecord.sanitize_sql_like(TagGroupRepr::PREFIX)
|
||||
page.wiki_versions.where('title LIKE ?', "#{ prefix }%").exists?
|
||||
end
|
||||
|
||||
def legacy_update_identified? page, title, group
|
||||
return false unless page
|
||||
return true if page.title == title
|
||||
return false if title == group.name
|
||||
|
||||
namespace = page.title.split(':', 2)
|
||||
return true if namespace.size == 2 && title.start_with?("#{ namespace.first }:")
|
||||
|
||||
base = params[:base_revision_id].presence
|
||||
base && page.wiki_revisions.exists?(id: base)
|
||||
end
|
||||
|
||||
def render_tag_group group
|
||||
def render_tag_group locale, group
|
||||
version = if params[:version].present?
|
||||
group_version(group, params[:version])
|
||||
else
|
||||
@@ -330,27 +311,29 @@ class WikiPagesController < ApplicationController
|
||||
end
|
||||
return head :not_found unless version
|
||||
|
||||
representation = if params[:version].present?
|
||||
TagGroupRepr.historical(version)
|
||||
else
|
||||
TagGroupRepr.one(group)
|
||||
end
|
||||
representation =
|
||||
if params[:version].present?
|
||||
TagGroupRepr.historical(locale, version)
|
||||
else
|
||||
TagGroupRepr.one(locale, group)
|
||||
end
|
||||
render_group_representation(representation, version)
|
||||
end
|
||||
|
||||
def render_group_version version
|
||||
representation = TagGroupRepr.historical(version)
|
||||
def render_group_version locale, version
|
||||
representation = TagGroupRepr.historical(locale, version)
|
||||
TagGroupMarkdownRenderer.render(representation)
|
||||
end
|
||||
|
||||
def render_group_representation representation, version, body: nil, status: :ok
|
||||
body ||= TagGroupMarkdownRenderer.render(representation)
|
||||
versions = TagGroupVersion.where(tag_group_id: version.tag_group_id)
|
||||
versions = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: version.tag_group_id)
|
||||
pred = versions.find_by(version_no: version.version_no - 1)&.id
|
||||
succ = versions.find_by(version_no: version.version_no + 1)&.id
|
||||
updated_at = version.created_at
|
||||
render json: {
|
||||
'id' => version.tag_group_id,
|
||||
'resource_type' => 'tag_group',
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
||||
'deprecated_at' => nil,
|
||||
'body' => body,
|
||||
@@ -363,11 +346,11 @@ class WikiPagesController < ApplicationController
|
||||
end
|
||||
|
||||
def current_group_version group
|
||||
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||
end
|
||||
|
||||
def group_version group, version_id
|
||||
TagGroupVersion.find_by(tag_group_id: group.id, id: version_id)
|
||||
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, id: version_id)
|
||||
end
|
||||
|
||||
def wiki_diff from_body, to_body
|
||||
@@ -390,7 +373,7 @@ class WikiPagesController < ApplicationController
|
||||
def tag_group_changes versions
|
||||
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
|
||||
groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id)
|
||||
predecessors = TagGroupVersion.where(tag_group_id: groups.keys,
|
||||
predecessors = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: groups.keys,
|
||||
version_no: rows.map { _1.version_no - 1 })
|
||||
.pluck(:tag_group_id, :version_no, :id)
|
||||
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
|
||||
@@ -407,28 +390,7 @@ class WikiPagesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def render_wiki_page_or_404 page
|
||||
return head :not_found unless page
|
||||
|
||||
rev = find_revision(page)
|
||||
return head :not_found unless rev
|
||||
|
||||
if rev.redirect?
|
||||
return (
|
||||
redirect_to wiki_page_by_title_path(title: rev.redirect_page.title),
|
||||
status: :moved_permanently)
|
||||
end
|
||||
|
||||
body = rev.body
|
||||
revision_id = rev.id
|
||||
pred = page.pred_revision_id(revision_id)
|
||||
succ = page.succ_revision_id(revision_id)
|
||||
updated_at = rev.created_at
|
||||
|
||||
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
|
||||
end
|
||||
|
||||
def render_wiki_page_by_tag_description description
|
||||
def render_wiki_page_by_tag_description locale, description
|
||||
versions = description_versions(description)
|
||||
historical = params[:version].present?
|
||||
rev = historical ? versions.find_by(id: params[:version]) :
|
||||
@@ -439,32 +401,23 @@ class WikiPagesController < ApplicationController
|
||||
succ = versions.find_by(version_no: rev.version_no + 1)&.id
|
||||
body = historical ? rev.body : description.body
|
||||
updated_at = historical ? rev.created_at : description.updated_at
|
||||
render json: WikiPageRepr.tag_description(description).merge(
|
||||
render json: WikiPageRepr.tag_description(locale, description).merge(
|
||||
body:, revision_id: rev.id, pred:, succ:, updated_at:)
|
||||
end
|
||||
|
||||
def description_versions description
|
||||
TagDescriptionVersion.where(tag_id: description.tag_id,
|
||||
HistoryAccess.visible(TagDescriptionVersion.all).where(tag_id: description.tag_id,
|
||||
language_code: description.language_code)
|
||||
end
|
||||
|
||||
def tag_description_by_title title
|
||||
TagDescription.joins(tag: :tag_names)
|
||||
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
|
||||
def tag_description_by_title locale, title
|
||||
HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
|
||||
.find_by(language_code: locale.language_code,
|
||||
tag_names: { language_code: locale.language_code, name: title })
|
||||
end
|
||||
|
||||
def unmigrated_wiki_pages
|
||||
descriptions = TagDescription.where(language_code: 'ja')
|
||||
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
|
||||
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
||||
descriptions.select(:tag_id))
|
||||
end
|
||||
|
||||
def description_changes descriptions
|
||||
versions = TagDescriptionVersion.where(language_code: 'ja',
|
||||
def description_changes locale, descriptions
|
||||
versions = HistoryAccess.visible(TagDescriptionVersion.all).where(language_code: locale.language_code,
|
||||
tag_id: descriptions.select(:tag_id))
|
||||
.order(id: :desc).limit(200).to_a
|
||||
return [] if versions.empty?
|
||||
@@ -472,8 +425,8 @@ class WikiPagesController < ApplicationController
|
||||
by_tag = descriptions.where(tag_id: versions.map(&:tag_id))
|
||||
.includes(tag: :tag_names).index_by(&:tag_id)
|
||||
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
|
||||
predecessors = TagDescriptionVersion.where(
|
||||
language_code: 'ja', tag_id: by_tag.keys,
|
||||
predecessors = HistoryAccess.visible(TagDescriptionVersion.all).where(
|
||||
language_code: locale.language_code, tag_id: by_tag.keys,
|
||||
version_no: versions.map { |version| version.version_no - 1 })
|
||||
.pluck(:tag_id, :version_no, :id)
|
||||
.to_h { |tag_id, version_no, id| [[tag_id, version_no], id] }
|
||||
@@ -483,7 +436,7 @@ class WikiPagesController < ApplicationController
|
||||
{ revision_id: version.id,
|
||||
pred: predecessors[[version.tag_id, version.version_no - 1]],
|
||||
succ: nil,
|
||||
wiki_page: { id: description.id, title: description.tag.name('ja'),
|
||||
wiki_page: { id: description.id, title: description.tag.name(locale.language_code),
|
||||
deprecated_at: description.tag.deprecated_at },
|
||||
user: user && { id: user.id, name: user.name },
|
||||
kind: 'content',
|
||||
@@ -492,14 +445,6 @@ class WikiPagesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def find_revision page
|
||||
if params[:version].present?
|
||||
page.wiki_revisions.find_by(id: params[:version])
|
||||
else
|
||||
page.current_revision
|
||||
end
|
||||
end
|
||||
|
||||
def render_wiki_conflict err
|
||||
render json: { error: 'conflict', message: err.message }, status: :conflict
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class ExternalTag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
enum :platform, nico: 'nico'
|
||||
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
require 'digest'
|
||||
|
||||
# Request/worker authority comes only from a server-resolved user. Default is public.
|
||||
class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
class Forbidden < StandardError; end
|
||||
|
||||
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
||||
'nico_tag_versions' => ['tag_id'],
|
||||
'tag_description_versions' => ['tag_id', 'language_code'],
|
||||
'tag_group_versions' => ['tag_group_id'], 'material_versions' => ['material_id'] }.freeze
|
||||
|
||||
def self.revision
|
||||
values = KEYS.keys.map do |table|
|
||||
table.classify.constantize.unscoped.where(event_type: 'discard').maximum(:id)
|
||||
end
|
||||
Digest::SHA256.hexdigest(values.to_json)
|
||||
end
|
||||
|
||||
def self.authority
|
||||
{ 'policy_revision' => revision, 'admin' => admin?, 'actor_id' => user&.id }
|
||||
end
|
||||
|
||||
def self.authorised_artifact?(metadata)
|
||||
return false unless metadata['policy_revision'].is_a?(String)
|
||||
if metadata['admin']
|
||||
admin? && user.id == metadata['actor_id']
|
||||
else
|
||||
metadata['policy_revision'] == revision
|
||||
end
|
||||
end
|
||||
|
||||
def self.admin? = user&.admin? == true
|
||||
|
||||
def self.sql(model, row = model.table_name, references: true)
|
||||
return 'TRUE' if admin?
|
||||
|
||||
table = model.table_name
|
||||
keys = KEYS.fetch(table)
|
||||
matches = keys.map { "boundary.#{ _1 } = #{ row }.#{ _1 }" }.join(' AND ')
|
||||
clauses = ["NOT EXISTS (SELECT 1 FROM #{ table } boundary WHERE #{ matches } " +
|
||||
"AND boundary.event_type = 'discard' AND boundary.version_no >= #{ row }.version_no)"]
|
||||
clauses += reference_sql(table, row) if references
|
||||
clauses.map { "(#{ _1 })" }.join(' AND ')
|
||||
end
|
||||
|
||||
def self.discard_reference(table, key, expression)
|
||||
"NOT EXISTS (SELECT 1 FROM #{ table } blocked WHERE blocked.event_type = 'discard' " +
|
||||
"AND #{ expression.call("blocked.#{ key }") })"
|
||||
end
|
||||
|
||||
def self.reference_sql(table, row)
|
||||
case table
|
||||
when 'post_versions'
|
||||
[['tag_versions', 'tag_id', 'tag_id'],
|
||||
['nico_tag_versions', 'tag_id', 'external_tag_id']].map do |target, key, field|
|
||||
discard_reference(target, key, ->(id) {
|
||||
"JSON_CONTAINS(#{ row }.tags_json, JSON_OBJECT('#{ field }', #{ id }))"
|
||||
})
|
||||
end + [discard_reference('post_versions', 'post_id', ->(id) {
|
||||
"FIND_IN_SET(#{ id }, REPLACE(#{ row }.parent_post_ids, ' ', ',')) > 0"
|
||||
})]
|
||||
when 'tag_versions'
|
||||
[discard_reference('tag_versions', 'tag_id', ->(id) {
|
||||
"FIND_IN_SET(#{ id }, REPLACE(#{ row }.parent_tag_ids, ' ', ',')) > 0"
|
||||
})]
|
||||
when 'tag_description_versions'
|
||||
[discard_reference('tag_versions', 'tag_id', ->(id) { "#{ row }.tag_id = #{ id }" })]
|
||||
when 'material_versions'
|
||||
[discard_reference('tag_versions', 'tag_id', ->(id) { "#{ row }.tag_id = #{ id }" }),
|
||||
discard_reference('material_versions', 'material_id', ->(id) {
|
||||
"#{ row }.parent_id = #{ id }"
|
||||
})]
|
||||
when 'tag_group_versions'
|
||||
[['tag_versions', 'tag_id'], ['tag_group_versions', 'tag_group_id']].map do |target, field|
|
||||
discard_reference(target, field, ->(id) {
|
||||
['members', 'placements'].map do |column|
|
||||
"JSON_CONTAINS(COALESCE(JSON_EXTRACT(#{ row }.#{ column }, " +
|
||||
"'$**.#{ field }'), JSON_ARRAY()), CAST(#{ id } AS CHAR))"
|
||||
end.join(' OR ')
|
||||
})
|
||||
end
|
||||
else []
|
||||
end
|
||||
end
|
||||
|
||||
def self.visible(scope, row = scope.klass.table_name)
|
||||
scope.where(Arel.sql(sql(scope.klass, row)))
|
||||
end
|
||||
|
||||
def self.current(scope, version_model)
|
||||
return scope if admin?
|
||||
|
||||
table = scope.klass.table_name
|
||||
keys = KEYS.fetch(version_model.table_name)
|
||||
matches = keys.map do |key|
|
||||
column = scope.klass.column_names.include?(key) ? key : 'id'
|
||||
"current_snapshot.#{ key } = #{ table }.#{ column }"
|
||||
end.join(' AND ')
|
||||
boundary = "#{ matches } AND current_snapshot.version_no >= #{ table }.version_no " +
|
||||
"AND current_snapshot.event_type = 'discard'"
|
||||
scope = scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
|
||||
"current_snapshot WHERE #{ boundary })"))
|
||||
condition = "#{ matches } AND current_snapshot.version_no = #{ table }.version_no"
|
||||
scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
|
||||
"current_snapshot WHERE #{ condition } AND NOT (#{ sql(version_model, 'current_snapshot') }))"))
|
||||
end
|
||||
|
||||
def self.require_current!(record, version_model)
|
||||
return if admin?
|
||||
raise Forbidden unless current(record.class.where(id: record.id), version_model).exists?
|
||||
end
|
||||
|
||||
def self.blob_visible?(blob, seen = [])
|
||||
return true if admin?
|
||||
return false if seen.include?(blob.id)
|
||||
seen = seen + [blob.id]
|
||||
private_versions = MaterialVersion.where(file_blob_id: blob.id)
|
||||
.where(Arel.sql("NOT (#{ sql(MaterialVersion) })"))
|
||||
return false if private_versions.exists?
|
||||
|
||||
blob.attachments.each do |attachment|
|
||||
model, key = { 'Post' => [PostVersion, 'post_id'],
|
||||
'Material' => [MaterialVersion, 'material_id'] }[attachment.record_type]
|
||||
if model && model.where(key => attachment.record_id, event_type: 'discard').exists?
|
||||
return false
|
||||
end
|
||||
if attachment.record_type == 'ActiveStorage::VariantRecord'
|
||||
original = ActiveStorage::VariantRecord.find_by(id: attachment.record_id)&.blob
|
||||
return false unless original && blob_visible?(original, seen)
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def self.require_many!(records)
|
||||
return if admin? || records.empty?
|
||||
|
||||
ids = records.map(&:id).uniq
|
||||
raise Forbidden unless visible(records.first.class.where(id: ids)).count == ids.length
|
||||
end
|
||||
|
||||
def self.require!(record)
|
||||
return record if admin? || visible(record.class.where(id: record.id)).exists?
|
||||
|
||||
raise Forbidden
|
||||
end
|
||||
|
||||
# This number deliberately ignores time, identity and content filters to prevent count probes.
|
||||
def self.population_count(model) = model.unscoped.count
|
||||
|
||||
# Only the resource-wide denied count is public, never a condition-dependent denied count.
|
||||
def self.population_counts(model)
|
||||
total, excluded = model.unscoped.optimizer_hints('MAX_EXECUTION_TIME(10000)').pick(
|
||||
Arel.sql('COUNT(*)'),
|
||||
Arel.sql("COALESCE(SUM(CASE WHEN #{ sql(model) } THEN 0 ELSE 1 END), 0)"))
|
||||
{ population_count: total.to_i, excluded_count: excluded.to_i,
|
||||
excluded_count_scope: 'all_recorded_versions' }
|
||||
end
|
||||
end
|
||||
@@ -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
-14
@@ -1,27 +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).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,
|
||||
canonical_id: nil)
|
||||
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,6 +1,12 @@
|
||||
class PostVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
scope :with_previous_version, -> {
|
||||
joins('LEFT JOIN post_versions prev ON prev.post_id = post_versions.post_id ' +
|
||||
'AND prev.version_no = post_versions.version_no - 1 AND ' +
|
||||
HistoryAccess.sql(PostVersion, 'prev'))
|
||||
}
|
||||
|
||||
belongs_to :post
|
||||
belongs_to :parent, class_name: 'Post', optional: true
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+39
-49
@@ -2,6 +2,8 @@ require 'set'
|
||||
|
||||
|
||||
class Tag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
class NicoTagNormalisationError < ArgumentError
|
||||
;
|
||||
end
|
||||
@@ -51,6 +53,7 @@ class Tag < ApplicationRecord
|
||||
has_many :tag_versions
|
||||
|
||||
has_many :tag_names
|
||||
has_many :tag_descriptions
|
||||
|
||||
attribute :version_no, :integer, default: 1
|
||||
|
||||
@@ -88,7 +91,6 @@ class Tag < ApplicationRecord
|
||||
end
|
||||
end
|
||||
def name(language_code) = tag_name(language_code)&.name
|
||||
def wiki_page = tag_name('ja')&.wiki_page
|
||||
|
||||
def system?
|
||||
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
|
||||
@@ -96,13 +98,24 @@ class Tag < ApplicationRecord
|
||||
|
||||
def deprecated? = deprecated_at?
|
||||
|
||||
def has_wiki = wiki_page.present?
|
||||
def has_description? language_code:
|
||||
if tag_descriptions.loaded?
|
||||
tag_descriptions.any? { _1.language_code == language_code }
|
||||
else
|
||||
tag_descriptions.exists?(language_code:)
|
||||
end
|
||||
end
|
||||
|
||||
def material_id = materials.first&.id
|
||||
|
||||
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
|
||||
|
||||
@@ -129,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
|
||||
@@ -158,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
|
||||
@@ -246,28 +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_or_create_by!(language_code:, name:) do
|
||||
_1.script_code = locale.script_code
|
||||
_1.primary_flg = true
|
||||
end
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tn = TagName.lock.find_by(language_code:, name:)
|
||||
next tn.tag if tn
|
||||
|
||||
tag = tn.tag
|
||||
next tag if tag
|
||||
|
||||
tag = Tag.create!(tag_name_id: tn.id, category:)
|
||||
tn.update!(tag:, primary_flg: true, canonical_id: nil, 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
|
||||
@@ -294,20 +309,18 @@ class Tag < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
source_tag_name = source_tag.tag_name('ja')
|
||||
|
||||
if source_tag_name.wiki_page.present?
|
||||
raise ActiveRecord::RecordInvalid.new(source_tag_name)
|
||||
if source_tag.tag_descriptions.exists?
|
||||
source_tag.errors.add(:base, :invalid)
|
||||
raise ActiveRecord::RecordInvalid, source_tag
|
||||
end
|
||||
|
||||
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
|
||||
source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false,
|
||||
updated_at: Time.current)
|
||||
target_languages = target_tag.tag_names.where(primary_flg: true).pluck(:language_code)
|
||||
source_tag.tag_names.where(language_code: target_languages)
|
||||
.update_all(primary_flg: false, auto_generated: false, updated_at: Time.current)
|
||||
source_tag.tag_names.update_all(tag_id: target_tag.id, updated_at: Time.current)
|
||||
source_tag.destroy!
|
||||
|
||||
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
|
||||
updated_at: Time.current)
|
||||
|
||||
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
||||
end
|
||||
|
||||
@@ -349,29 +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,
|
||||
canonical_id: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class TagDescription < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
belongs_to :tag
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class TagDescriptionVersion < ApplicationRecord
|
||||
;
|
||||
include VersionRecord
|
||||
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
end
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class TagGroup < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
before_destroy :delete_current_structure!
|
||||
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
|
||||
@@ -9,8 +12,16 @@ class TagGroup < ApplicationRecord
|
||||
has_many :placements, -> { order(:position) },
|
||||
class_name: 'TagGroupPlacement', dependent: :delete_all
|
||||
has_many :versions, -> { order(:version_no) },
|
||||
class_name: 'TagGroupVersion', dependent: :delete_all
|
||||
class_name: 'TagGroupVersion'
|
||||
|
||||
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
|
||||
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
|
||||
|
||||
private
|
||||
|
||||
def delete_current_structure!
|
||||
placements.reorder(position: :desc).each(&:delete)
|
||||
tag_group_tags.delete_all
|
||||
tag_group_groups.delete_all
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class TagGroupVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
belongs_to :tag_group
|
||||
|
||||
scope :ordered, -> { order(:version_no) }
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
class TagName < ApplicationRecord
|
||||
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
||||
belongs_to :tag, optional: true
|
||||
has_one :wiki_page
|
||||
belongs_to :tag
|
||||
|
||||
validates :name, presence: true,
|
||||
length: { maximum: 255 },
|
||||
uniqueness: { scope: :language_code }
|
||||
|
||||
validate :alias_name_must_not_have_prefix
|
||||
validate :alias_must_not_have_wiki_page
|
||||
validate :name_must_be_sanitised
|
||||
validate :name_mustnt_start_with_nico
|
||||
validate :generated_name_must_be_reserved
|
||||
@@ -21,22 +19,37 @@ class TagName < ApplicationRecord
|
||||
|
||||
tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name)
|
||||
|
||||
primary_names = TagName.where(
|
||||
language_code: locale.language_code, tag_id: tns.values.map(&:tag_id),
|
||||
primary_flg: true).pluck(:tag_id, :name).to_h
|
||||
|
||||
names.map { |name|
|
||||
tag_name = tns[name]
|
||||
if !tag_name || tag_name.primary? || tag_name.tag_id.nil?
|
||||
if !tag_name || tag_name.primary?
|
||||
name
|
||||
else
|
||||
TagName.find_by(language_code: locale.language_code,
|
||||
tag_id: tag_name.tag_id,
|
||||
primary_flg: true)&.name || name
|
||||
primary_names[tag_name.tag_id] || name
|
||||
end
|
||||
}.uniq
|
||||
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
|
||||
@@ -53,13 +66,9 @@ class TagName < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def alias_must_not_have_wiki_page
|
||||
if !(primary?) && wiki_page
|
||||
errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.'
|
||||
end
|
||||
end
|
||||
|
||||
def name_must_be_sanitised
|
||||
return if auto_generated && name == "Tag_##{ tag_id }"
|
||||
|
||||
if name? && name != TagNameSanitisationRule.sanitise(name)
|
||||
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
||||
end
|
||||
|
||||
@@ -16,37 +16,36 @@ class TagNameSanitisationRule < ApplicationRecord
|
||||
|
||||
def apply!
|
||||
Language.find_each do |language|
|
||||
TagName.where(language:).find_each do |tn|
|
||||
name = sanitise(tn.name)
|
||||
next if name == tn.name
|
||||
|
||||
TagName.where(language:).find_each do |candidate|
|
||||
TagName.transaction do
|
||||
tn = TagName.find_by(id: candidate.id)
|
||||
next unless tn
|
||||
|
||||
name = sanitise(tn.name)
|
||||
next if name == tn.name
|
||||
|
||||
existing_tn = TagName.find_by(language:, name:)
|
||||
if existing_tn
|
||||
unless existing_tn.primary_flg
|
||||
existing_tn = TagName.find_by!(language:,
|
||||
tag_id: existing_tn.tag_id,
|
||||
primary_flg: true)
|
||||
source_tag = tn.tag
|
||||
target_tag = existing_tn ? existing_tn.tag : source_tag
|
||||
[source_tag, target_tag].uniq(&:id).sort_by(&:id).each(&:lock!)
|
||||
TagVersioning.ensure_snapshot!(source_tag, created_by_user: nil)
|
||||
|
||||
if existing_tn && target_tag != source_tag
|
||||
Tag.merge_tags!(target_tag, source_tag)
|
||||
tn.reload.destroy!
|
||||
elsif existing_tn
|
||||
if tn.primary?
|
||||
existing_tn.destroy!
|
||||
tn.update_columns(name:, updated_at: Time.current)
|
||||
else
|
||||
tn.destroy!
|
||||
end
|
||||
next if existing_tn.id == tn.id
|
||||
|
||||
existing_tag = existing_tn.tag
|
||||
source_tag = tn.tag
|
||||
|
||||
if existing_tag
|
||||
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
|
||||
elsif source_tag
|
||||
tn.update_columns(tag_id: nil, updated_at: Time.current)
|
||||
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
|
||||
existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current)
|
||||
end
|
||||
tn.destroy!
|
||||
|
||||
next
|
||||
else
|
||||
# TagName 側の自動サニタイズを回避
|
||||
tn.update_columns(name:, updated_at: Time.current)
|
||||
end
|
||||
|
||||
# TagName 側の自動サニタイズを回避
|
||||
tn.update_columns(name:, updated_at: Time.current)
|
||||
TagVersioning.record!(target_tag, event_type: :update, created_by_user: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,10 +13,10 @@ class User < ApplicationRecord
|
||||
has_many :ip_addresses, through: :user_ips
|
||||
has_many :user_post_views, dependent: :destroy
|
||||
has_many :viewed_posts, through: :user_post_views, source: :post
|
||||
has_many :created_wiki_pages,
|
||||
class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify
|
||||
has_many :updated_wiki_pages,
|
||||
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
|
||||
has_many :created_tag_descriptions, class_name: 'TagDescription',
|
||||
foreign_key: :created_by_user_id, dependent: :nullify
|
||||
has_many :updated_tag_descriptions, class_name: 'TagDescription',
|
||||
foreign_key: :updated_by_user_id, dependent: :nullify
|
||||
|
||||
def viewed?(post) = user_post_views.exists?(post_id: post.id)
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ module VersionRecord
|
||||
discard: 'discard',
|
||||
restore: 'restore' }.freeze
|
||||
|
||||
def require_history_access! = HistoryAccess.require!(self)
|
||||
|
||||
def readonly? = persisted?
|
||||
|
||||
included do
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
class WikiLine < ApplicationRecord
|
||||
has_many :wiki_revision_lines, dependent: :restrict_with_exception
|
||||
|
||||
validates :sha256, presence: true, uniqueness: true, length: { is: 64 }
|
||||
validates :body, presence: true
|
||||
|
||||
def self.upsert_by_body! body
|
||||
sha = Digest::SHA256.hexdigest(body)
|
||||
now = Time.current
|
||||
|
||||
upsert(sha256: sha, body:, created_at: now, updated_at: now)
|
||||
|
||||
find_by!(sha256: sha)
|
||||
end
|
||||
end
|
||||
@@ -1,58 +0,0 @@
|
||||
require 'set'
|
||||
|
||||
|
||||
class WikiPage < ApplicationRecord
|
||||
include MyDiscard
|
||||
|
||||
has_many :wiki_revisions, dependent: :destroy
|
||||
belongs_to :created_user, class_name: 'User'
|
||||
belongs_to :updated_user, class_name: 'User'
|
||||
|
||||
has_many :redirected_from_revisions,
|
||||
class_name: 'WikiRevision',
|
||||
foreign_key: :redirect_page_id,
|
||||
dependent: :nullify
|
||||
|
||||
has_many :wiki_versions
|
||||
|
||||
attribute :version_no, :integer, default: 1
|
||||
|
||||
belongs_to :tag_name
|
||||
validates :tag_name, presence: true
|
||||
validates :body, presence: true
|
||||
|
||||
def title = tag_name.name
|
||||
def deprecated_at = tag_name.tag&.deprecated_at
|
||||
|
||||
def title= val
|
||||
(self.tag_name ||= build_tag_name).name = val
|
||||
end
|
||||
|
||||
def current_revision = wiki_revisions.order(id: :desc).first
|
||||
|
||||
def resolve_redirect limit: 10
|
||||
page = self
|
||||
visited = Set.new
|
||||
|
||||
limit.times do
|
||||
return page if visited.include?(page.id)
|
||||
|
||||
visited.add(page.id)
|
||||
|
||||
rev = page.current_revision
|
||||
return page if !(rev&.redirect?) || !(rev.redirect_page)
|
||||
|
||||
page = rev.redirect_page
|
||||
end
|
||||
|
||||
page
|
||||
end
|
||||
|
||||
def pred_revision_id revision_id
|
||||
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
|
||||
end
|
||||
|
||||
def succ_revision_id revision_id
|
||||
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
|
||||
end
|
||||
end
|
||||
@@ -1,55 +0,0 @@
|
||||
class WikiRevision < ApplicationRecord
|
||||
belongs_to :wiki_page
|
||||
belongs_to :base_revision, class_name: 'WikiRevision', optional: true
|
||||
belongs_to :created_user, class_name: 'User'
|
||||
belongs_to :redirect_page, class_name: 'WikiPage', optional: true
|
||||
|
||||
has_many :wiki_revision_lines, dependent: :delete_all
|
||||
has_many :wiki_lines, through: :wiki_revision_lines
|
||||
|
||||
enum :kind, content: 0, redirect: 1
|
||||
|
||||
validates :kind, presence: true
|
||||
validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validates :tree_sha256, length: { is: 64 }, allow_nil: true
|
||||
|
||||
validate :kind_consistency
|
||||
|
||||
def body
|
||||
return unless content?
|
||||
|
||||
wiki_revision_lines
|
||||
.includes(:wiki_line)
|
||||
.order(:position)
|
||||
.map { |rev| rev.wiki_line.body }
|
||||
.join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def kind_consistency
|
||||
if content?
|
||||
if tree_sha256.blank?
|
||||
errors.add(:tree_sha256, '種類がページの場合は必須です.')
|
||||
end
|
||||
|
||||
if redirect_page_id.present?
|
||||
errors.add(:redirect_page_id, '種類がページの場合は空である必要があります.')
|
||||
end
|
||||
end
|
||||
|
||||
if redirect?
|
||||
if redirect_page_id.blank?
|
||||
errors.add(:redirect_page_id, '種類がリダイレクトの場合は必須です.')
|
||||
end
|
||||
|
||||
if tree_sha256.present?
|
||||
errors.add(:tree_sha256, '種類がリダイレクトの場合は空である必要があります.')
|
||||
end
|
||||
|
||||
if lines_count.to_i > 0
|
||||
errors.add(:lines_count, '種類がリダイレクトの場合は 0 である必要があります.')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
class WikiRevisionLine < ApplicationRecord
|
||||
belongs_to :wiki_revision
|
||||
belongs_to :wiki_line
|
||||
|
||||
validates :position, presence: true,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validates :position, uniqueness: { scope: :wiki_revision_id }
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
class WikiVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
belongs_to :wiki_page
|
||||
|
||||
validates :title, presence: true
|
||||
validates :body, presence: true
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
# Counts uses recorded values only; history/diff endpoints retain their own contract.
|
||||
class CountsVersionRepr
|
||||
def self.many(rows, resource:, language_code:, at: nil, inclusive: true)
|
||||
new(rows.to_a, resource:, language_code:, at:, inclusive:).serialise
|
||||
end
|
||||
|
||||
def initialize(rows, resource:, language_code:, at:, inclusive:)
|
||||
@rows = rows
|
||||
HistoryAccess.require_many!(rows)
|
||||
@at = at
|
||||
@inclusive = inclusive
|
||||
@resource = resource
|
||||
@language = language_code
|
||||
@users = User.where(id: rows.map(&:created_by_user_id).compact).pluck(:id, :name).to_h
|
||||
@languages = master_names(Language)
|
||||
@scripts = master_names(Script)
|
||||
@tag_versions = {}
|
||||
load_tag_versions if resource == 'posts'
|
||||
load_parent_versions if resource == 'tags'
|
||||
end
|
||||
|
||||
def serialise
|
||||
@rows.map do |row|
|
||||
resource_id = row.public_send(@resource == 'posts' ? :post_id : :tag_id)
|
||||
common = { id: row.id, resource_id:,
|
||||
version_no: row.version_no, is_latest: row.attributes['counts_latest'].to_i == 1,
|
||||
created_at: row.created_at.iso8601,
|
||||
created_by_user: row.created_by_user_id &&
|
||||
{ id: row.created_by_user_id, name: @users[row.created_by_user_id] } }
|
||||
common.merge(@resource == 'posts' ? post(row) : tag(row))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def master_names(model)
|
||||
model.select('code AS counts_code', :name).to_h do |record|
|
||||
[record.attributes['counts_code'], record.name]
|
||||
end
|
||||
end
|
||||
|
||||
def json(value)
|
||||
value.is_a?(String) ? JSON.parse(value) : value
|
||||
end
|
||||
|
||||
def load_tag_versions
|
||||
pairs = @rows.flat_map { Array(json(_1.tags_json)) }
|
||||
.filter_map { |entry| [entry['tag_id'], entry['version_no']] if entry['tag_id'] }
|
||||
.uniq
|
||||
pairs.each_slice(500) do |batch|
|
||||
placeholders = batch.map { '(?, ?)' }.join(', ')
|
||||
scope = HistoryAccess.visible(TagVersion.all)
|
||||
if @at
|
||||
scope = scope.where("created_at #{ @inclusive ? '<=' : '<' } ?", @at)
|
||||
end
|
||||
scope.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten])
|
||||
.select(:tag_id, :version_no, :name, :category, :names).each do |version|
|
||||
@tag_versions[[version.tag_id, version.version_no]] = version
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def names(value)
|
||||
(json(value) || {}).sort.map do |language, entry|
|
||||
aliases = entry.fetch('aliases', []).map do |alias_entry|
|
||||
alias_entry.merge('script_name' => @scripts[alias_entry['script_code']])
|
||||
end
|
||||
{ language_code: language, language_name: @languages[language],
|
||||
script_code: entry['script_code'], script_name: @scripts[entry['script_code']],
|
||||
name: entry['name'], auto_generated: entry['auto_generated'], aliases: }
|
||||
end
|
||||
end
|
||||
|
||||
def post(row)
|
||||
snapshots = Array(json(row.tags_json)).map do |entry|
|
||||
version = @tag_versions[[entry['tag_id'], entry['version_no']]]
|
||||
{ id: entry['tag_id'] || entry['external_tag_id'],
|
||||
kind: entry['tag_id'] ? 'internal' : 'external',
|
||||
tag_id: entry['tag_id'], external_tag_id: entry['external_tag_id'],
|
||||
version_no: entry['version_no'], name: entry['name'] || version&.name,
|
||||
category: entry['category'] || version&.category,
|
||||
sections: entry.fetch('sections', []), names: version ? names(version.names) : [] }
|
||||
end
|
||||
{ title: row.title, url: row.url, thumbnail_base: row.thumbnail_base,
|
||||
tags: snapshots, parents: row.parent_post_ids.to_s.split.map { { id: _1.to_i } },
|
||||
video_ms: row.video_ms,
|
||||
original_created_from: row.original_created_from&.iso8601,
|
||||
original_created_before: row.original_created_before&.iso8601 }
|
||||
end
|
||||
|
||||
def parent_point(row) = @at || row.created_at
|
||||
|
||||
def load_parent_versions
|
||||
@parent_versions = {}
|
||||
requests = @rows.flat_map do |row|
|
||||
row.parent_tag_ids.to_s.split.map { [_1.to_i, parent_point(row)] }
|
||||
end.uniq
|
||||
requests.each_slice(100) do |batch|
|
||||
queries = batch.map do |id, point|
|
||||
scope = HistoryAccess.visible(TagVersion.where(tag_id: id))
|
||||
.where('created_at <= ?', point)
|
||||
.where('NOT EXISTS (SELECT 1 FROM tag_versions newer ' +
|
||||
'WHERE newer.tag_id = tag_versions.tag_id AND newer.created_at <= ? ' +
|
||||
'AND newer.version_no > tag_versions.version_no)', point)
|
||||
quoted = TagVersion.connection.quote(point.iso8601(6))
|
||||
scope.select("tag_id, name, names, #{ quoted } AS reference_point").to_sql
|
||||
end
|
||||
next if queries.empty?
|
||||
|
||||
TagVersion.connection.select_all(queries.map { "(#{ _1 })" }.join(' UNION ALL ')).each do |value|
|
||||
@parent_versions[[value['tag_id'], value['reference_point']]] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def tag(row)
|
||||
recorded_names = names(row.names)
|
||||
selected = recorded_names.find { _1[:language_code] == @language }
|
||||
aliases = recorded_names.flat_map do |entry|
|
||||
entry[:aliases].map {
|
||||
_1.merge('language_code' => entry[:language_code],
|
||||
'language_name' => entry[:language_name])
|
||||
}
|
||||
end
|
||||
{ name: selected&.fetch(:name) || row.name, names: recorded_names,
|
||||
category: row.category, aliases:,
|
||||
parents: row.parent_tag_ids.to_s.split.map do |id|
|
||||
value = @parent_versions[[id.to_i, parent_point(row).iso8601(6)]]
|
||||
snapshots = value && json(value['names'])
|
||||
name = snapshots&.dig(@language, 'name') || value&.fetch('name')
|
||||
{ id: id.to_i, name: }
|
||||
end,
|
||||
deprecated: row.deprecated_at&.iso8601 }
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,12 @@ module TagGroupRepr
|
||||
|
||||
module_function
|
||||
|
||||
def many groups
|
||||
def name_from_title title
|
||||
prefix = [PREFIX, 'tag_group:'].find { title.downcase.start_with?(_1) }
|
||||
title[prefix.length..] if prefix
|
||||
end
|
||||
|
||||
def many locale, groups
|
||||
ids = groups.map(&:id)
|
||||
return [] if ids.empty?
|
||||
|
||||
@@ -14,14 +19,20 @@ module TagGroupRepr
|
||||
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
|
||||
.group_by(&:tag_group_id)
|
||||
|
||||
target_ids = tag_members.values.flatten.map(&:tag_id) +
|
||||
group_members.values.flatten.map(&:child_tag_group_id) +
|
||||
target_ids =
|
||||
tag_members.values.flatten.map(&:tag_id) +
|
||||
placements.values.flatten.filter_map(&:tag_id)
|
||||
child_ids = group_members.values.flatten.map(&:child_tag_group_id) +
|
||||
child_ids =
|
||||
group_members.values.flatten.map(&:child_tag_group_id) +
|
||||
placements.values.flatten.filter_map(&:child_tag_group_id)
|
||||
tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id)
|
||||
names = TagName.where(tag_id: target_ids.uniq, language_code: 'ja', primary_flg: true)
|
||||
.pluck(:tag_id, :name).to_h
|
||||
names =
|
||||
TagName
|
||||
.where(tag_id: target_ids.uniq,
|
||||
language_code: locale.language_code,
|
||||
primary_flg: true)
|
||||
.pluck(:tag_id, :name)
|
||||
.to_h
|
||||
child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id)
|
||||
|
||||
groups.map do |group|
|
||||
@@ -32,8 +43,9 @@ module TagGroupRepr
|
||||
end
|
||||
end
|
||||
|
||||
def one group
|
||||
many([group]).first
|
||||
def one locale, group
|
||||
HistoryAccess.require_current!(group, TagGroupVersion)
|
||||
many(locale, [group]).first
|
||||
end
|
||||
|
||||
def tag_group wiki_page
|
||||
@@ -45,10 +57,11 @@ module TagGroupRepr
|
||||
end
|
||||
|
||||
def current_version group
|
||||
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||
end
|
||||
|
||||
def historical version
|
||||
def historical locale, version
|
||||
HistoryAccess.require!(version)
|
||||
members = Array(version.members)
|
||||
placements = Array(version.placements)
|
||||
tag_references = members.filter_map { |member| member['tag_id'] } +
|
||||
@@ -68,10 +81,10 @@ module TagGroupRepr
|
||||
[member.fetch('tag_group_id'), member.fetch('version_no')]
|
||||
end.to_h
|
||||
historical_members = members.map do |member|
|
||||
historical_member(member, tag_versions, group_versions)
|
||||
historical_member(locale, member, tag_versions, group_versions)
|
||||
end
|
||||
historical_placements = placements.map do |node|
|
||||
historical_placement(node, tag_versions, group_versions,
|
||||
historical_placement(locale, node, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers)
|
||||
end
|
||||
{ 'id' => version.tag_group_id,
|
||||
@@ -89,7 +102,7 @@ module TagGroupRepr
|
||||
def group_json group, tag_members, group_members, rows, tags, names, child_groups
|
||||
tree = placement_tree(rows, tags, names, child_groups)
|
||||
members = tag_members.sort_by(&:tag_id).map do |member|
|
||||
{ 'tag' => tag_json(tags.fetch(member.tag_id), names.fetch(member.tag_id)) }
|
||||
{ 'tag' => tag_json(tags.fetch(member.tag_id), names[member.tag_id]) }
|
||||
end
|
||||
members.concat(group_members.sort_by(&:child_tag_group_id).map do |member|
|
||||
{ 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) }
|
||||
@@ -115,7 +128,7 @@ module TagGroupRepr
|
||||
build = lambda do |parent|
|
||||
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
|
||||
node = if row.tag_id
|
||||
{ 'tag' => tag_json(tags.fetch(row.tag_id), names.fetch(row.tag_id)) }
|
||||
{ 'tag' => tag_json(tags.fetch(row.tag_id), names[row.tag_id]) }
|
||||
elsif row.child_tag_group_id
|
||||
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
|
||||
else
|
||||
@@ -142,22 +155,22 @@ module TagGroupRepr
|
||||
end
|
||||
|
||||
def historical_tag_versions ids
|
||||
TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version|
|
||||
HistoryAccess.visible(TagVersion.all).where(tag_id: ids.uniq).to_a.index_by do |version|
|
||||
[version.tag_id, version.version_no]
|
||||
end
|
||||
end
|
||||
|
||||
def historical_group_versions ids
|
||||
TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version|
|
||||
HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: ids.uniq).to_a.index_by do |version|
|
||||
[version.tag_group_id, version.version_no]
|
||||
end
|
||||
end
|
||||
|
||||
def historical_member member, tag_versions, group_versions
|
||||
def historical_member locale, member, tag_versions, group_versions
|
||||
if member['tag_id']
|
||||
id = member.fetch('tag_id')
|
||||
version = tag_versions.fetch([id, member.fetch('version_no')])
|
||||
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
|
||||
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
|
||||
'category' => version.category } }
|
||||
else
|
||||
id = member.fetch('tag_group_id')
|
||||
@@ -166,13 +179,13 @@ module TagGroupRepr
|
||||
end
|
||||
end
|
||||
|
||||
def historical_placement(node, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers)
|
||||
def historical_placement locale, node, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers
|
||||
result = if node['tag_id']
|
||||
id = node.fetch('tag_id')
|
||||
version_no = tag_version_numbers.fetch(id)
|
||||
version = tag_versions.fetch([id, version_no])
|
||||
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
|
||||
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
|
||||
'category' => version.category } }
|
||||
elsif node['tag_group_id']
|
||||
id = node.fetch('tag_group_id')
|
||||
@@ -183,12 +196,12 @@ module TagGroupRepr
|
||||
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
|
||||
end
|
||||
result.merge('children' => Array(node['children']).map do |child|
|
||||
historical_placement(child, tag_versions, group_versions,
|
||||
historical_placement(locale, child, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers)
|
||||
end)
|
||||
end
|
||||
|
||||
def japanese_version_name version
|
||||
version.names.fetch('ja').fetch('name')
|
||||
def version_name locale, version
|
||||
version.names[locale.language_code]&.fetch('name')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,21 +3,28 @@
|
||||
|
||||
module TagRepr
|
||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
||||
methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze
|
||||
methods: [:material_id, :has_deerjikists] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def base tag, language_code: 'ja'
|
||||
tag.as_json(BASE).merge(name: name_for(tag, language_code),
|
||||
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||
name: name_for(tag, language_code),
|
||||
aliases: tag.snapshot_aliases(language_code),
|
||||
parents: tag.parents.map { inline(_1, language_code:) })
|
||||
end
|
||||
|
||||
def inline tag, language_code: 'ja'
|
||||
tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: [])
|
||||
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||
name: name_for(tag, language_code), aliases: [], parents: [])
|
||||
end
|
||||
|
||||
def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) }
|
||||
def many tags, language_code: 'ja'
|
||||
records = tags.to_a
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records:, associations: [:tag_descriptions, { parents: :tag_descriptions }]).call
|
||||
records.map { |tag| base(tag, language_code:) }
|
||||
end
|
||||
|
||||
def name_for tag, language_code
|
||||
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
|
||||
|
||||
module WikiPageRepr
|
||||
BASE = { methods: [:title, :deprecated_at] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def base wiki_page
|
||||
wiki_page.as_json(BASE)
|
||||
end
|
||||
|
||||
def tag_description description
|
||||
def tag_description locale, description
|
||||
HistoryAccess.require_current!(description, TagDescriptionVersion)
|
||||
tag = description.tag
|
||||
|
||||
{ 'id' => description.id,
|
||||
'tag_name_id' => tag.tag_name('ja')&.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,
|
||||
'updated_user_id' => description.updated_by_user_id,
|
||||
@@ -21,15 +20,31 @@ module WikiPageRepr
|
||||
'updated_at' => description.updated_at,
|
||||
'discarded_at' => nil,
|
||||
'version_no' => description.version_no,
|
||||
'title' => tag.name('ja'),
|
||||
'title' => tag.name(locale.language_code),
|
||||
'deprecated_at' => tag.deprecated_at }
|
||||
end
|
||||
|
||||
def tag_group tag_group
|
||||
TagGroupRepr.tag_group(tag_group)
|
||||
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||
end
|
||||
|
||||
def many wiki_pages
|
||||
wiki_pages.map { |p| base(p) }
|
||||
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
|
||||
@@ -0,0 +1,100 @@
|
||||
class CountsAxes
|
||||
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
|
||||
|
||||
def self.time_expression(expression, grain, timezone)
|
||||
raise ArgumentError unless GRAINS.include?(grain)
|
||||
|
||||
connection = ApplicationRecord.connection
|
||||
# Use the installed TZInfo database, including DST, rather than MySQL timezone tables.
|
||||
first = Time.utc(1900)
|
||||
last = Time.utc(2101)
|
||||
offset = timezone.period_for_utc(first).utc_total_offset
|
||||
clauses = timezone.transitions_up_to(last, first).filter_map do |transition|
|
||||
next if transition.offset.utc_total_offset == offset
|
||||
|
||||
previous = offset
|
||||
offset = transition.offset.utc_total_offset
|
||||
"WHEN #{ expression } < #{ connection.quote(transition.at.to_time) } THEN #{ previous }"
|
||||
end
|
||||
seconds = clauses.empty? ? offset.to_s : "CASE #{ clauses.join(' ') } ELSE #{ offset } END"
|
||||
local = "DATE_ADD(#{ expression }, INTERVAL (#{ seconds }) SECOND)"
|
||||
case grain
|
||||
when 'year' then "DATE_FORMAT(#{ local }, '%Y')"
|
||||
when 'quarter' then "CONCAT(YEAR(#{ local }), '-Q', QUARTER(#{ local }))"
|
||||
when 'month' then "DATE_FORMAT(#{ local }, '%Y-%m')"
|
||||
when 'week' then "DATE_FORMAT(#{ local }, '%x-W%v')"
|
||||
when 'day' then "DATE_FORMAT(#{ local }, '%Y-%m-%d')"
|
||||
else "DATE_FORMAT(#{ local }, '%Y-%m-%d %H:00')"
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :rows, :columns, :all
|
||||
|
||||
def initialize(population)
|
||||
@population = population
|
||||
@definition = population.definition
|
||||
@rows = parse(population.params.fetch('rows', ''))
|
||||
@columns = parse(population.params.fetch('cols', ''))
|
||||
@all = rows + columns
|
||||
raise ArgumentError if all.length > 8 || all.map { _1['field'] }.uniq.length != all.length
|
||||
end
|
||||
|
||||
def expressions
|
||||
@expressions ||= all.map do |axis|
|
||||
field = axis.fetch('field')
|
||||
case field
|
||||
when 'tags'
|
||||
"CASE WHEN tag_entry.tag_id IS NOT NULL " +
|
||||
"THEN CONCAT('internal:', tag_entry.tag_id) " +
|
||||
"ELSE CONCAT('external:', tag_entry.external_tag_id) END"
|
||||
when 'changed_field' then 'changed.field_key'
|
||||
else
|
||||
expression = @definition.expression(field)
|
||||
@definition.fields[field] == :datetime ? self.class.time_expression(
|
||||
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def joins(fields = all.map { _1['field'] })
|
||||
result = ''
|
||||
if fields.include?('tags')
|
||||
result += <<~SQL.squish
|
||||
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
|
||||
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
||||
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
||||
|
||||
SQL
|
||||
end
|
||||
if fields.include?('changed_field')
|
||||
fields = @definition.changes.map do |field|
|
||||
"SELECT #{ ApplicationRecord.connection.quote(field) } AS field_key"
|
||||
end.join(' UNION ALL ')
|
||||
condition = @definition.changes.map do |field|
|
||||
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
||||
end.join(' OR ')
|
||||
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse(value)
|
||||
axes = QueryState.axes(value)
|
||||
raise ArgumentError unless axes.is_a?(Array)
|
||||
|
||||
axes.each do |axis|
|
||||
raise ArgumentError unless axis.is_a?(Hash) && (axis.keys - ['field', 'grain']).empty?
|
||||
raise ArgumentError unless @definition.axes(@population.history).include?(axis['field'])
|
||||
if @definition.fields[axis['field']] == :datetime
|
||||
raise ArgumentError unless GRAINS.include?(axis.fetch('grain', 'month'))
|
||||
|
||||
axis['grain'] ||= 'month'
|
||||
elsif axis.key?('grain')
|
||||
raise ArgumentError
|
||||
end
|
||||
end
|
||||
axes
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,330 @@
|
||||
# Typed predicates over the same recorded fields used by Counts output.
|
||||
# Multi-valued comparisons use EXISTS; inequality means no matching element.
|
||||
class CountsCondition
|
||||
CHILD_TYPES = { 'id' => :integer, 'versionNo' => :integer, 'tagId' => :integer,
|
||||
'tagGroupId' => :integer, 'autoGenerated' => :boolean,
|
||||
'category' => :enum, 'kind' => :enum, 'sections' => :interval }.freeze
|
||||
|
||||
def self.fields(definition)
|
||||
result = definition.metadata.map { _1.merge(labels: [definition.field_label(_1[:key])]) }
|
||||
visit = lambda do |fields, prefix, labels|
|
||||
fields.each do |field|
|
||||
key = [prefix, field[:key]].compact.join('.')
|
||||
path_labels = labels + [field[:label]]
|
||||
type = if prefix
|
||||
CHILD_TYPES.fetch(field[:key], :string)
|
||||
elsif key == 'is_latest'
|
||||
:boolean
|
||||
elsif key == 'parents'
|
||||
:integer
|
||||
elsif key == 'deprecated'
|
||||
:datetime
|
||||
elsif key == 'thumbnail'
|
||||
:string
|
||||
else
|
||||
definition.fields.fetch(key, :string)
|
||||
end
|
||||
type = :string if field[:children] || type == :json
|
||||
type = :integer if ['parents', 'members'].include?(key)
|
||||
physical = result.find { _1[:key] == key }
|
||||
nullable = prefix.present? || (physical ? physical[:nullable] : key != 'is_latest')
|
||||
nullable = false if field[:children]
|
||||
entry = { key:, type:, labels: path_labels, nullable:,
|
||||
multiple: prefix.present? || field[:children].present?,
|
||||
collection: field[:children].present? || type == :interval,
|
||||
operators: CountsResourceDefinition::OPERATORS.fetch(type) }
|
||||
result.reject! { _1[:key] == key }
|
||||
result << entry
|
||||
visit.call(field[:children] || [], key, path_labels)
|
||||
end
|
||||
end
|
||||
visit.call(definition.output_fields, nil, [])
|
||||
# The raw JSON alias has the same membership semantics as the public tag field.
|
||||
if definition.resource == 'posts'
|
||||
result.find { _1[:key] == 'tags_json' }.merge!(type: :string, multiple: true, collection: true,
|
||||
operators: CountsResourceDefinition::OPERATORS.fetch(:string))
|
||||
end
|
||||
result + [{ key: 'user.id', type: :integer, nullable: true,
|
||||
labels: ['history:actor', 'counts:columns.resourceId'],
|
||||
operators: CountsResourceDefinition::OPERATORS.fetch(:integer) },
|
||||
{ key: 'user.name', type: :string, nullable: true,
|
||||
labels: ['history:actor', 'history:name'],
|
||||
operators: CountsResourceDefinition::OPERATORS.fetch(:string) }]
|
||||
end
|
||||
|
||||
def initialize(population)
|
||||
@population = population
|
||||
@definition = population.definition
|
||||
@connection = @definition.model.connection
|
||||
@language = Locale.find_by!(code: population.params.fetch('locale', 'ja')).language_code
|
||||
end
|
||||
|
||||
def apply(scope, entry)
|
||||
raise ArgumentError unless entry.is_a?(Hash)
|
||||
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
|
||||
|
||||
field = @definition.filter_metadata.find { _1[:key] == entry.fetch('field') }
|
||||
raise ArgumentError unless field && field[:operators].include?(entry.fetch('operator'))
|
||||
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
|
||||
values = [entry['value'], entry['end']]
|
||||
raise ArgumentError unless values.all? { _1.nil? || (_1.is_a?(String) && _1.bytesize <= 8192) }
|
||||
if entry['operator'] != 'range' && !values[1].nil? && values[1] != ''
|
||||
raise ArgumentError
|
||||
end
|
||||
|
||||
source, expression = source_for(field[:key])
|
||||
type = field[:type]
|
||||
blank = blank_sql(expression, type)
|
||||
if entry['blank']
|
||||
sql = if source && field[:collection]
|
||||
"NOT EXISTS (SELECT 1 FROM #{ source })"
|
||||
elsif source
|
||||
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
||||
else blank
|
||||
end
|
||||
return scope.where(Arel.sql(sql))
|
||||
end
|
||||
return scope if values.all? { _1.nil? || _1 == '' }
|
||||
|
||||
parsed = values.map { parse(_1, type) }
|
||||
validate_enum!(field[:key], parsed.first) if type == :enum
|
||||
operator = entry['operator']
|
||||
if type == :interval
|
||||
return interval(scope, source, expression, parsed)
|
||||
end
|
||||
clauses = []
|
||||
binds = []
|
||||
if operator == 'range'
|
||||
['>=', '<='].each_with_index do |comparison, index|
|
||||
next if parsed[index].nil?
|
||||
|
||||
clauses << "#{ expression } #{ comparison } ?"
|
||||
binds << parsed[index]
|
||||
end
|
||||
raise ArgumentError if parsed.all? && parsed.first > parsed.last
|
||||
elsif !parsed.first.nil?
|
||||
comparison = { 'eq' => '=', 'ne' => '=', 'gt' => '>', 'lt' => '<',
|
||||
'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator)
|
||||
clauses << "#{ expression } #{ comparison } ?"
|
||||
binds << parsed.first
|
||||
end
|
||||
return scope if clauses.empty?
|
||||
|
||||
predicate = clauses.join(' AND ')
|
||||
if source
|
||||
predicate = "EXISTS (SELECT 1 FROM #{ source } WHERE #{ predicate })"
|
||||
predicate = "NOT (#{ predicate })" if operator == 'ne'
|
||||
elsif operator == 'ne'
|
||||
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
||||
end
|
||||
scope.where(predicate, *binds)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse(value, type)
|
||||
return nil if value.nil? || value == ''
|
||||
|
||||
case type
|
||||
when :integer, :interval
|
||||
raise ArgumentError unless value.match?(/\A-?[0-9]{1,19}\z/)
|
||||
|
||||
number = Integer(value, 10)
|
||||
raise ArgumentError unless number.between?(-9_223_372_036_854_775_808, 9_223_372_036_854_775_807)
|
||||
|
||||
number
|
||||
when :boolean
|
||||
raise ArgumentError unless ['true', 'false'].include?(value)
|
||||
|
||||
value == 'true' ? 1 : 0
|
||||
when :datetime then @population.timestamp(value)
|
||||
else value
|
||||
end
|
||||
end
|
||||
|
||||
def validate_enum!(key, value)
|
||||
allowed = case key.split('.').last
|
||||
when 'event_type' then VersionRecord::DEFAULT_EVENT_TYPE_MAP.values
|
||||
when 'category', 'tag_category' then TagVersion.categories.keys
|
||||
when 'kind' then ['internal', 'external']
|
||||
when 'source_kind' then Material::SOURCE_KINDS
|
||||
end
|
||||
raise ArgumentError if value && allowed && !allowed.include?(value)
|
||||
end
|
||||
|
||||
def blank_sql(expression, type)
|
||||
if [:string, :enum].include?(type)
|
||||
"(#{ expression } IS NULL OR #{ expression } = '')"
|
||||
else
|
||||
"#{ expression } IS NULL"
|
||||
end
|
||||
end
|
||||
|
||||
def scalar(document, key, type = :string)
|
||||
path = @connection.quote("$.#{ key }")
|
||||
value = "JSON_EXTRACT(#{ document }, #{ path })"
|
||||
text = "JSON_UNQUOTE(NULLIF(#{ value }, CAST('null' AS JSON)))"
|
||||
return "CAST(#{ text } AS SIGNED)" if type == :integer
|
||||
return "CASE #{ text } WHEN 'true' THEN 1 WHEN 'false' THEN 0 END" if type == :boolean
|
||||
|
||||
text
|
||||
end
|
||||
|
||||
def json_table(document, path, name)
|
||||
"JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), #{ @connection.quote(path) } " +
|
||||
"COLUMNS (item JSON PATH '$')) #{ name }"
|
||||
end
|
||||
|
||||
def names_source(document, alias_name)
|
||||
source = "JSON_TABLE(COALESCE(JSON_KEYS(#{ document }), JSON_ARRAY()), '$[*]' " +
|
||||
"COLUMNS (language_code TEXT PATH '$')) #{ alias_name }"
|
||||
path = "CONCAT('$.', JSON_QUOTE(#{ alias_name }.language_code))"
|
||||
[source, "JSON_EXTRACT(#{ document }, #{ path })", "#{ alias_name }.language_code"]
|
||||
end
|
||||
|
||||
def point
|
||||
@point ||= @population.history ? @population.at :
|
||||
@population.timestamp(@population.params['at']) || @population.at
|
||||
end
|
||||
|
||||
def name_expression(row)
|
||||
language_path = @connection.quote("$.#{ @language.to_json }.name")
|
||||
name = "JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, #{ language_path }))"
|
||||
"COALESCE(#{ name }, #{ row }.name)"
|
||||
end
|
||||
|
||||
def parent_source
|
||||
column = @definition.resource == 'posts' ? 'parent_post_ids' : 'parent_tag_ids'
|
||||
ids = "TRIM(REGEXP_REPLACE(COALESCE(v.#{ column }, ''), '[[:space:]]+', ' '))"
|
||||
document = "CONCAT('[', REPLACE(#{ ids }, ' ', ','), ']')"
|
||||
source = json_table(document, '$[*]', 'parent_entry')
|
||||
id = 'CAST(parent_entry.item AS UNSIGNED)'
|
||||
return [source, id] if @definition.resource == 'posts'
|
||||
|
||||
bound = @connection.quote(point)
|
||||
source += ' LEFT JOIN tag_versions parent_version ON parent_version.tag_id = ' + id +
|
||||
" AND parent_version.created_at <= #{ bound } AND " +
|
||||
HistoryAccess.sql(TagVersion, 'parent_version') +
|
||||
' AND NOT EXISTS (SELECT 1 FROM tag_versions newer_parent ' +
|
||||
'WHERE newer_parent.tag_id = parent_version.tag_id ' +
|
||||
"AND newer_parent.created_at <= #{ bound } " +
|
||||
'AND newer_parent.version_no > parent_version.version_no)'
|
||||
[source, id]
|
||||
end
|
||||
|
||||
def post_tag_source
|
||||
source = json_table('v.tags_json', '$[*]', 'tag_snapshot')
|
||||
source += ' LEFT JOIN tag_versions referenced_tag ON referenced_tag.tag_id = ' +
|
||||
scalar('tag_snapshot.item', 'tag_id', :integer) +
|
||||
' AND referenced_tag.version_no = ' + scalar('tag_snapshot.item', 'version_no', :integer) +
|
||||
" AND referenced_tag.created_at <= #{ @connection.quote(point) } AND " +
|
||||
HistoryAccess.sql(TagVersion, 'referenced_tag')
|
||||
source
|
||||
end
|
||||
|
||||
def language_field(source, document, parts, inherited_language = nil)
|
||||
return [source, scalar(document, 'name')] if parts.empty?
|
||||
return [source, inherited_language] if parts == ['languageCode'] && inherited_language
|
||||
|
||||
if parts.first == 'aliases'
|
||||
aliases = "JSON_EXTRACT(#{ document }, '$.aliases')"
|
||||
source += ' JOIN ' + json_table(aliases, '$[*]', 'alias_entry')
|
||||
child = parts[1] || 'name'
|
||||
return [source, inherited_language] if child == 'languageCode' && inherited_language
|
||||
columns = { 'scriptCode' => 'script_code', 'name' => 'name' }
|
||||
key = columns.fetch(child) { raise ArgumentError }
|
||||
return [source, scalar('alias_entry.item', key)]
|
||||
end
|
||||
key = { 'name' => 'name', 'scriptCode' => 'script_code',
|
||||
'autoGenerated' => 'auto_generated' }.fetch(parts.first) { raise ArgumentError }
|
||||
[source, scalar(document, key, CHILD_TYPES.fetch(parts.first, :string))]
|
||||
end
|
||||
|
||||
def source_for(key)
|
||||
root, *parts = key.split('.')
|
||||
return [nil, "(#{ @population.latest_sql })"] if root == 'is_latest'
|
||||
if root == 'user' && parts.any?
|
||||
expression = parts.first == 'id' ? 'v.created_by_user_id' :
|
||||
'(SELECT actor.name FROM users actor WHERE actor.id = v.created_by_user_id)'
|
||||
return [nil, expression]
|
||||
end
|
||||
return [nil, 'v.thumbnail_base'] if root == 'thumbnail'
|
||||
return [nil, 'v.deprecated_at'] if root == 'deprecated'
|
||||
if root == 'parents'
|
||||
source, id = parent_source
|
||||
return [source, parts.first == 'name' ? name_expression('parent_version') : id]
|
||||
end
|
||||
if root == 'tags' || root == 'tags_json'
|
||||
return post_field(parts)
|
||||
end
|
||||
if root == 'names' || root == 'aliases'
|
||||
source, document, language = names_source('v.names', 'name_language')
|
||||
parts = ['aliases', *parts] if root == 'aliases'
|
||||
return language_field(source, document, parts, language)
|
||||
end
|
||||
if root == 'members' || root == 'placements'
|
||||
path = '$[*]' + parts.take_while { _1 == 'children' }.map { '.children[*]' }.join
|
||||
source = json_table("v.#{ root }", path, 'structure_entry')
|
||||
if root == 'members' && parts.empty?
|
||||
value = "COALESCE(#{ scalar('structure_entry.item', 'tag_id', :integer) }, " +
|
||||
"#{ scalar('structure_entry.item', 'tag_group_id', :integer) })"
|
||||
return [source, value]
|
||||
end
|
||||
leaf = parts.last || 'sectionName'
|
||||
leaf = 'sectionName' if leaf == 'children'
|
||||
column = leaf.gsub(/[A-Z]/) { "_#{ _1.downcase }" }
|
||||
return [source, scalar('structure_entry.item', column, CHILD_TYPES.fetch(leaf, :string))]
|
||||
end
|
||||
if root == 'export_paths_json'
|
||||
source, value, profile = names_source('v.export_paths_json', 'export_profile')
|
||||
return [source, parts.first == 'profile' ? profile : "JSON_UNQUOTE(#{ value })"]
|
||||
end
|
||||
return [nil, name_expression('v')] if root == 'name' && @definition.resource == 'tags'
|
||||
|
||||
[nil, @definition.expression(root)]
|
||||
end
|
||||
|
||||
def post_field(parts)
|
||||
source = post_tag_source
|
||||
document = 'tag_snapshot.item'
|
||||
case parts.first
|
||||
when 'names'
|
||||
names, entry, language = names_source('referenced_tag.names', 'tag_language')
|
||||
language_field("#{ source } JOIN #{ names }", entry, parts.drop(1), language)
|
||||
when 'sections'
|
||||
sections = "JSON_EXTRACT(#{ document }, '$.sections')"
|
||||
source += ' JOIN ' + json_table(sections, '$[*]', 'section_entry')
|
||||
[source, 'section_entry.item']
|
||||
when 'id'
|
||||
[source, "COALESCE(#{ scalar(document, 'tag_id', :integer) }, " +
|
||||
"#{ scalar(document, 'external_tag_id', :integer) })"]
|
||||
when 'versionNo' then [source, scalar(document, 'version_no', :integer)]
|
||||
when 'kind'
|
||||
[source, "CASE WHEN #{ scalar(document, 'tag_id') } IS NULL " +
|
||||
"THEN 'external' ELSE 'internal' END"]
|
||||
when 'category'
|
||||
[source, "COALESCE(#{ scalar(document, 'category') }, referenced_tag.category)"]
|
||||
else
|
||||
[source, "COALESCE(#{ scalar(document, 'name') }, referenced_tag.name)"]
|
||||
end
|
||||
end
|
||||
|
||||
def interval(scope, source, document, bounds)
|
||||
raise ArgumentError if bounds.compact.any?(&:negative?)
|
||||
raise ArgumentError if bounds.all? && bounds.first > bounds.last
|
||||
|
||||
first = scalar(document, 'begin_ms', :integer)
|
||||
last = scalar(document, 'end_ms', :integer)
|
||||
clauses = []
|
||||
values = []
|
||||
if bounds.first
|
||||
clauses << "(#{ last } IS NULL OR #{ last } > ?)"
|
||||
values << bounds.first
|
||||
end
|
||||
if bounds.last
|
||||
clauses << "#{ first } < ?"
|
||||
values << bounds.last
|
||||
end
|
||||
scope.where("EXISTS (SELECT 1 FROM #{ source } WHERE #{ clauses.join(' AND ') })", *values)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
# Only confirmed combinations belong here; all others retain the existing behaviour.
|
||||
class CountsDefaults
|
||||
POST_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'title', 'url',
|
||||
'thumbnail_base', 'tags', 'parents', 'original_created_from',
|
||||
'original_created_before', 'event_type', 'user', 'created_at'].freeze
|
||||
|
||||
def self.entries(resource)
|
||||
result = [{ definition: 'history', mode: 'summary', params: {
|
||||
'rows' => 'created_at:month',
|
||||
'cols' => 'event_type',
|
||||
'sort' => 'a0:desc', 'conditions' => '' } }]
|
||||
result << { definition: 'latest', mode: 'summary', params: {
|
||||
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
|
||||
details = detail_params(resource)
|
||||
if details
|
||||
['latest', 'history'].each do |definition|
|
||||
result << { definition:, mode: 'details', params: details }
|
||||
end
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def self.detail_params(resource)
|
||||
if resource == 'posts'
|
||||
return { 'columns' => POST_COLUMNS.join(' '), 'order' => POST_COLUMNS.join(' '),
|
||||
'children' => 'tags.name parents.id', 'nested_json' => '0',
|
||||
'conditions' => '', 'tags' => '' }
|
||||
end
|
||||
unless resource == 'tags'
|
||||
return { 'columns' => CountsExportProjection.default_columns(resource).join(' '),
|
||||
'children' => CountsExportProjection.default_children(resource).join(' ') }
|
||||
end
|
||||
|
||||
columns = ['resource_id', 'version_no', 'is_latest', 'name', 'category',
|
||||
'aliases', 'parents', 'deprecated', 'event_type', 'user', 'created_at']
|
||||
{ 'columns' => columns.join(' '), 'order' => columns.join(' '),
|
||||
'children' => 'aliases.name parents.name', 'nested_json' => '0' }
|
||||
end
|
||||
|
||||
def self.for(params)
|
||||
settings = entries(params.fetch('resource', 'posts')).find do |entry|
|
||||
entry[:definition] == params.fetch('definition', 'latest') &&
|
||||
entry[:mode] == params.fetch('mode', 'summary')
|
||||
end&.fetch(:params) || {}
|
||||
if settings.key?('sort') && params.key?('rows')
|
||||
actual = QueryState.axes(params['rows'])
|
||||
expected = QueryState.axes(settings.fetch('rows'))
|
||||
compatible = actual.is_a?(Array) && actual.first.is_a?(Hash) &&
|
||||
actual.first['field'] == expected.first['field'] &&
|
||||
actual.first.fetch('grain', 'month') == expected.first['grain']
|
||||
unless compatible
|
||||
settings = settings.except('sort')
|
||||
end
|
||||
end
|
||||
settings
|
||||
end
|
||||
|
||||
def self.metadata(resource)
|
||||
entries(resource).map do |entry|
|
||||
entry.merge(params: entry[:params].map { |key, value| { key:, value: } })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
require 'tempfile'
|
||||
require 'fileutils'
|
||||
|
||||
# Finish generation before reporting readiness; downloads never hold the full file in RAM.
|
||||
class CountsExportDownload
|
||||
EXPIRY = 10.minutes
|
||||
MAX_CONCURRENT_EXPORTS = 2
|
||||
MAX_FILE_BYTES = 128.megabytes
|
||||
MAX_STORED_BYTES = 512.megabytes
|
||||
MAX_EXPORT_SECONDS = 120
|
||||
class Busy < StandardError; end
|
||||
class TooLarge < StandardError; end
|
||||
class TimedOut < StandardError; end
|
||||
class StorageFull < StandardError; end
|
||||
|
||||
def self.directory
|
||||
Rails.root.join(ENV.fetch('COUNTS_EXPORT_DIRECTORY', 'tmp/counts_exports'))
|
||||
end
|
||||
def self.verifier = Rails.application.message_verifier('counts_export_download')
|
||||
|
||||
def self.prepare(exporter, owner, progress: nil, check: nil, identifier: nil)
|
||||
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||
authority = HistoryAccess.authority
|
||||
slot = acquire_slot
|
||||
|
||||
begin
|
||||
generate(exporter, owner, progress:, check:, identifier:, authority:)
|
||||
ensure
|
||||
slot.close
|
||||
end
|
||||
end
|
||||
|
||||
def self.acquire_slot
|
||||
MAX_CONCURRENT_EXPORTS.times do |index|
|
||||
path = directory.join(".slot-#{ index }.lock")
|
||||
handle = File.open(path, File::RDWR | File::CREAT, 0o600)
|
||||
return handle if handle.flock(File::LOCK_EX | File::LOCK_NB)
|
||||
|
||||
handle.close
|
||||
end
|
||||
raise Busy
|
||||
end
|
||||
|
||||
def self.generate(exporter, owner, progress: nil, check: nil, identifier: nil, authority: HistoryAccess.authority)
|
||||
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||
Dir.glob(directory.join('counts-*.export')).each do |path|
|
||||
File.unlink(path) if File.mtime(path) < Time.current - EXPIRY
|
||||
rescue Errno::ENOENT
|
||||
# Another request may have removed an expired download.
|
||||
end
|
||||
stored = Dir.glob(directory.join('counts-*')).sum do |path|
|
||||
File.size(path)
|
||||
rescue Errno::ENOENT
|
||||
0
|
||||
end
|
||||
reserved = MAX_CONCURRENT_EXPORTS * MAX_FILE_BYTES
|
||||
raise StorageFull if stored + reserved > MAX_STORED_BYTES
|
||||
|
||||
prefix = identifier ? "counts-#{ identifier }-" : 'counts-'
|
||||
file = Tempfile.create([prefix, '.part'], directory)
|
||||
complete = false
|
||||
bytes = 0
|
||||
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MAX_EXPORT_SECONDS
|
||||
path = file.path
|
||||
begin
|
||||
file.binmode
|
||||
exporter.body(progress:).each do |chunk|
|
||||
check&.call
|
||||
raise TimedOut if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
||||
|
||||
bytes += chunk.bytesize
|
||||
raise TooLarge if bytes > MAX_FILE_BYTES
|
||||
|
||||
file.write(chunk)
|
||||
end
|
||||
file.flush
|
||||
file.fsync
|
||||
file.close
|
||||
finished_path = path.sub(/\.part\z/, '.export')
|
||||
File.rename(path, finished_path)
|
||||
path = finished_path
|
||||
token = verifier.generate({ 'file' => File.basename(path),
|
||||
'resource' => exporter.resource, 'format' => exporter.format,
|
||||
'owner' => owner, **authority }, expires_in: EXPIRY, purpose: 'counts_export')
|
||||
complete = true
|
||||
token
|
||||
ensure
|
||||
file.close unless file.closed?
|
||||
File.unlink(path) if !complete && File.exist?(path)
|
||||
end
|
||||
end
|
||||
|
||||
def self.find(token, owner)
|
||||
metadata = verifier.verified(token, purpose: 'counts_export')
|
||||
return unless metadata.is_a?(Hash) && metadata['owner'] == owner
|
||||
return unless HistoryAccess.authorised_artifact?(metadata)
|
||||
return unless metadata['file']&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
|
||||
return unless CountsResourceDefinition::DEFINITIONS.key?(metadata['resource'])
|
||||
return unless ['json', 'csv'].include?(metadata['format'])
|
||||
|
||||
path = directory.join(metadata['file'])
|
||||
return unless path.file?
|
||||
|
||||
metadata.merge('path' => path.to_s)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,255 @@
|
||||
require 'json'
|
||||
require 'fileutils'
|
||||
|
||||
# A durable, bounded export spool. Web and supervised workers share the same POSIX volume.
|
||||
class CountsExportJobs
|
||||
class Cancelled < StandardError; end
|
||||
ACTIVE = ['queued', 'running', 'cancelling'].freeze
|
||||
|
||||
def self.directory = CountsExportDownload.directory.join('jobs')
|
||||
def self.verifier = Rails.application.message_verifier('counts_export_job')
|
||||
|
||||
def self.synchronise
|
||||
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||
File.open(directory.join('.registry.lock'), File::RDWR | File::CREAT, 0o600) do |lock|
|
||||
lock.flock(File::LOCK_EX)
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def self.write(state)
|
||||
path = directory.join("#{ state.fetch('id') }.json")
|
||||
temporary = "#{ path }.#{ SecureRandom.hex(8) }.tmp"
|
||||
File.open(temporary, 'w', 0o600) do |file|
|
||||
file.write(JSON.generate(state))
|
||||
file.flush
|
||||
file.fsync
|
||||
end
|
||||
File.rename(temporary, path)
|
||||
ensure
|
||||
File.unlink(temporary) if temporary && File.exist?(temporary)
|
||||
end
|
||||
|
||||
def self.read(id)
|
||||
JSON.parse(File.read(directory.join("#{ id }.json")))
|
||||
rescue Errno::ENOENT
|
||||
nil
|
||||
end
|
||||
|
||||
def self.remove_download(state)
|
||||
name = state['download_file']
|
||||
return unless name&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
|
||||
|
||||
File.unlink(CountsExportDownload.directory.join(name))
|
||||
rescue Errno::ENOENT
|
||||
nil
|
||||
end
|
||||
|
||||
def self.remove_partial(id)
|
||||
Dir.glob(CountsExportDownload.directory.join("counts-#{ id }-*")).each do |path|
|
||||
File.unlink(path)
|
||||
rescue Errno::ENOENT
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def self.clean
|
||||
Dir.glob(directory.join('*.tmp')).each do |path|
|
||||
File.unlink(path) if File.mtime(path) < Time.current - CountsExportDownload::EXPIRY
|
||||
end
|
||||
Dir.glob(directory.join('*.run')).each do |path|
|
||||
id = File.basename(path, '.run')
|
||||
next if directory.join("#{ id }.json").exist?
|
||||
|
||||
File.open(path, File::RDWR) do |lock|
|
||||
File.unlink(path) if lock.flock(File::LOCK_EX | File::LOCK_NB)
|
||||
end
|
||||
end
|
||||
Dir.glob(directory.join('*.json')).each do |path|
|
||||
state = JSON.parse(File.read(path))
|
||||
next if state.fetch('expires_at') > Time.current.to_f
|
||||
|
||||
run = directory.join("#{ state.fetch('id') }.run")
|
||||
File.open(run, File::RDWR | File::CREAT, 0o600) do |lock|
|
||||
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
||||
state['status'] = 'cancelling'
|
||||
write(state)
|
||||
next
|
||||
end
|
||||
remove_download(state)
|
||||
remove_partial(state.fetch('id'))
|
||||
File.unlink(path)
|
||||
File.unlink(run)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.start(payload, owner, language)
|
||||
payload = payload.dup
|
||||
time_key = payload.key?('definition') ? 'execution_at' : 'at'
|
||||
if payload[time_key].nil? || payload[time_key] == ''
|
||||
payload[time_key] = Time.current.utc.iso8601(6)
|
||||
end
|
||||
exporter = CountsExporter.new(payload, language_code: language)
|
||||
synchronise do
|
||||
clean
|
||||
active = Dir.glob(directory.join('*.json')).count do |path|
|
||||
ACTIVE.include?(JSON.parse(File.read(path))['status'])
|
||||
end
|
||||
raise CountsExportDownload::Busy if active >= CountsExportDownload::MAX_CONCURRENT_EXPORTS
|
||||
|
||||
id = SecureRandom.hex(24)
|
||||
state = { 'id' => id, 'owner' => owner, 'payload' => payload,
|
||||
'language' => language, 'resource' => exporter.resource,
|
||||
'format' => exporter.format, 'status' => 'queued',
|
||||
'processed' => 0, 'total' => nil,
|
||||
**HistoryAccess.authority,
|
||||
'expires_at' => (Time.current + CountsExportDownload::EXPIRY).to_f }
|
||||
write(state)
|
||||
verifier.generate({ 'id' => id, 'owner' => owner },
|
||||
expires_in: CountsExportDownload::EXPIRY, purpose: 'counts_export_job')
|
||||
end
|
||||
end
|
||||
|
||||
def self.authorised_id(token, owner)
|
||||
value = verifier.verified(token, purpose: 'counts_export_job')
|
||||
return unless value.is_a?(Hash) && value['owner'] == owner
|
||||
return unless value['id']&.match?(/\A[a-f0-9]{48}\z/)
|
||||
|
||||
value['id']
|
||||
end
|
||||
|
||||
def self.status(token, owner, cancel: false)
|
||||
id = authorised_id(token, owner)
|
||||
return unless id
|
||||
|
||||
synchronise do
|
||||
state = read(id)
|
||||
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
|
||||
|
||||
return unless HistoryAccess.authorised_artifact?(state)
|
||||
|
||||
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
|
||||
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
|
||||
remove_download(state)
|
||||
state.delete('token')
|
||||
write(state)
|
||||
end
|
||||
state.slice('status', 'processed', 'total', 'format', 'token', 'error')
|
||||
end
|
||||
end
|
||||
|
||||
def self.checkpoint(id, processed = nil, total = nil)
|
||||
synchronise do
|
||||
state = read(id)
|
||||
unless state && state['status'] == 'running' && state['expires_at'] > Time.current.to_f
|
||||
raise Cancelled
|
||||
end
|
||||
if processed
|
||||
state.merge!('processed' => processed, 'total' => total)
|
||||
write(state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.work_one
|
||||
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||
Dir.glob(directory.join('*.json')).sort.each do |path|
|
||||
id = File.basename(path, '.json')
|
||||
File.open(directory.join("#{ id }.run"), File::RDWR | File::CREAT, 0o600) do |lock|
|
||||
next unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
||||
|
||||
state = synchronise do
|
||||
candidate = read(id)
|
||||
next unless candidate && ACTIVE.include?(candidate['status'])
|
||||
|
||||
if candidate['status'] != 'queued'
|
||||
candidate['status'] = candidate['status'] == 'cancelling' ? 'cancelled' : 'failed'
|
||||
remove_partial(id)
|
||||
candidate['error'] = 'interrupted'
|
||||
write(candidate)
|
||||
next
|
||||
end
|
||||
candidate['status'] = 'running'
|
||||
write(candidate)
|
||||
candidate
|
||||
end
|
||||
next unless state
|
||||
|
||||
perform(state)
|
||||
return true
|
||||
end
|
||||
end
|
||||
synchronise { clean }
|
||||
false
|
||||
end
|
||||
|
||||
def self.perform(state)
|
||||
actor = state['actor_id'] && User.find_by(id: state['actor_id'])
|
||||
actor = nil unless state['admin'] && actor&.admin? && !actor.banned?
|
||||
HistoryAccess.set(user: actor) do
|
||||
unless HistoryAccess.authorised_artifact?(state)
|
||||
synchronise do
|
||||
current = read(state.fetch('id'))
|
||||
current['status'] = 'failed'
|
||||
write(current)
|
||||
end
|
||||
return
|
||||
end
|
||||
perform_authorised(state)
|
||||
end
|
||||
end
|
||||
|
||||
def self.perform_authorised(state)
|
||||
id = state.fetch('id')
|
||||
token = nil
|
||||
begin
|
||||
checkpoint(id)
|
||||
exporter = CountsExporter.new(state.fetch('payload'), language_code: state.fetch('language'))
|
||||
last = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
progress = lambda do |processed, total|
|
||||
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
if processed.zero? || now - last >= 0.25 || processed == total
|
||||
checkpoint(id, processed, total)
|
||||
last = now
|
||||
end
|
||||
end
|
||||
check = lambda do
|
||||
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
if now - last >= 0.25
|
||||
checkpoint(id)
|
||||
last = now
|
||||
end
|
||||
end
|
||||
token = CountsExportDownload.prepare(exporter, state.fetch('owner'),
|
||||
progress:, check:, identifier: id)
|
||||
synchronise do
|
||||
current = read(id)
|
||||
if (!current || current['status'] != 'running' ||
|
||||
current['expires_at'] <= Time.current.to_f)
|
||||
file = CountsExportDownload.find(token, state.fetch('owner'))
|
||||
File.unlink(file.fetch('path')) if file
|
||||
raise Cancelled
|
||||
end
|
||||
file = CountsExportDownload.find(token, state.fetch('owner'))
|
||||
current.merge!('status' => 'ready', 'token' => token,
|
||||
'download_file' => File.basename(file.fetch('path')))
|
||||
current['total'] = current['processed']
|
||||
write(current)
|
||||
end
|
||||
rescue StandardError => error
|
||||
remove_partial(id)
|
||||
synchronise do
|
||||
current = read(id)
|
||||
if current
|
||||
current['status'] = error.is_a?(Cancelled) ? 'cancelled' : 'failed'
|
||||
current['error'] = error.class.name
|
||||
write(current)
|
||||
end
|
||||
end
|
||||
unless error.is_a?(Cancelled)
|
||||
Rails.logger.error("counts_export_job #{ id }: #{ error.class }")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,265 @@
|
||||
require 'set'
|
||||
|
||||
# Export identifiers are stable; UI translations are supplied separately as column labels.
|
||||
class CountsExportProjection
|
||||
ROOT_KEYS = { 'event_type' => 'event_type', 'resource_id' => 'resource_id', 'version_no' => 'version_no',
|
||||
'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url',
|
||||
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
|
||||
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
|
||||
'original_created_from' => 'original_created_from',
|
||||
'original_created_before' => 'original_created_before',
|
||||
'user' => 'created_by_user', 'created_at' => 'created_at',
|
||||
'name' => 'name', 'names' => 'names', 'category' => 'category',
|
||||
'aliases' => 'aliases', 'deprecated' => 'deprecated' }.freeze
|
||||
CHILD_KEYS = { 'id' => 'id', 'versionNo' => 'version_no', 'kind' => 'kind',
|
||||
'name' => 'name', 'category' => 'category', 'sections' => 'sections',
|
||||
'names' => 'names', 'aliases' => 'aliases',
|
||||
'languageCode' => 'language', 'scriptCode' => 'script',
|
||||
'autoGenerated' => 'auto_generated' }.freeze
|
||||
NAME_FIELDS = ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated'].freeze
|
||||
CHILDREN = {
|
||||
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
|
||||
'names' => NAME_FIELDS,
|
||||
'aliases' => ['languageCode', 'name', 'scriptCode'],
|
||||
'parents' => ['id', 'name'] }.freeze
|
||||
|
||||
def self.default_columns(resource)
|
||||
fields = { 'posts' => ['title', 'url', 'tags', 'parents', 'video_ms'],
|
||||
'tags' => ['name', 'category', 'aliases', 'parents'],
|
||||
'nico_tags' => ['name', 'linked_tags'],
|
||||
'tag_descriptions' => ['language_code', 'body', 'source_language_code'],
|
||||
'tag_groups' => ['name', 'description', 'members'],
|
||||
'materials' => ['url', 'tag_id', 'source_kind'] }.fetch(resource)
|
||||
['resource_id', 'version_no'] + fields + ['event_type', 'user', 'created_at']
|
||||
end
|
||||
|
||||
def self.default_children(resource)
|
||||
{ 'posts' => ['tags.name', 'parents.id'],
|
||||
'tags' => ['aliases.name', 'parents.id'],
|
||||
'tag_groups' => ['members.tagId'] }.fetch(resource, [])
|
||||
end
|
||||
|
||||
attr_reader :columns, :headers
|
||||
|
||||
def initialize(params, resource:, summary:, tags:)
|
||||
if params.key?('definition')
|
||||
params = CountsDefaults.for(params.merge('resource' => resource)).merge(params)
|
||||
end
|
||||
@params = params
|
||||
@labels = JSON.parse(params.fetch('labels', '{}'))
|
||||
raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100
|
||||
raise ArgumentError unless @labels.all? { |key, value| valid_label?(key, value) }
|
||||
@summary = summary
|
||||
allowed = if summary
|
||||
tags ? ['kind', 'tag_id', 'name', 'deprecated', 'count'] : ['group', 'count']
|
||||
else
|
||||
CountsQuery::COLUMNS.fetch(resource)
|
||||
end
|
||||
defaults = summary ? allowed : self.class.default_columns(resource)
|
||||
@columns = QueryState.list(params.fetch('columns', defaults.join(' ')))
|
||||
raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns
|
||||
|
||||
@headers = JSON.parse(params.fetch('headers', JSON.generate(@columns)))
|
||||
unless (@headers.is_a?(Array) && @headers.length == @columns.length &&
|
||||
@headers.all? { _1.is_a?(String) && _1.length <= 200 })
|
||||
raise ArgumentError
|
||||
end
|
||||
@order = QueryState.list(params.fetch('order', ''))
|
||||
@sorts = QueryState.list(params.fetch('child_sort', '')).map do |entry|
|
||||
parts = entry.split(':', -1)
|
||||
raise ArgumentError unless parts.length == 2
|
||||
|
||||
path, direction = parts
|
||||
raise ArgumentError unless ['asc', 'desc'].include?(direction)
|
||||
|
||||
[path, direction]
|
||||
end
|
||||
@selected = params.key?('children') ? QueryState.list(params['children']).to_set :
|
||||
self.class.default_children(resource).to_set
|
||||
valid = CHILDREN.flat_map do |key, fields|
|
||||
paths(fields, key)
|
||||
end
|
||||
raise ArgumentError if @selected && (@selected.to_a - valid).any?
|
||||
prune = lambda do |fields, prefix|
|
||||
fields.each do |key|
|
||||
path = "#{ prefix }.#{ key }"
|
||||
children = fields_for(key)
|
||||
next if children.empty?
|
||||
|
||||
prune.call(children, path)
|
||||
@selected.delete(path) unless children.any? { @selected.include?("#{ path }.#{ _1 }") }
|
||||
end
|
||||
end
|
||||
CHILDREN.each { |key, fields| prune.call(fields, key) }
|
||||
pairs = @columns.zip(@headers).select do |key, _header|
|
||||
!CHILDREN.key?(key) || CHILDREN[key].any? { @selected.include?("#{ key }.#{ _1 }") }
|
||||
end
|
||||
@columns = pairs.map(&:first)
|
||||
@headers = pairs.map(&:last)
|
||||
allowed_sorts = valid.map do |path|
|
||||
parts = path.split('.')
|
||||
field = parts.pop
|
||||
"#{ parts.join('.') }/#{ field }"
|
||||
end
|
||||
if ((@sorts.map(&:first) - allowed_sorts).any? ||
|
||||
@sorts.map(&:first).uniq.length != @sorts.length)
|
||||
raise ArgumentError
|
||||
end
|
||||
end
|
||||
|
||||
def metadata
|
||||
@columns.each_with_index.map do |key, index|
|
||||
{ key: @summary ? key : ROOT_KEYS.fetch(key), label: @headers[index] }
|
||||
end
|
||||
end
|
||||
|
||||
def project(row)
|
||||
@columns.to_h do |key|
|
||||
if @summary
|
||||
value = key == 'group' ? { key: row[:key], name: row[:name] } : row[key.to_sym]
|
||||
[key, value]
|
||||
else
|
||||
value = case key
|
||||
when 'thumbnail' then row[:thumbnail_base]
|
||||
else row[ROOT_KEYS.fetch(key).to_sym]
|
||||
end
|
||||
value = nested(value || [], CHILDREN.fetch(key), key) if CHILDREN.key?(key)
|
||||
[ROOT_KEYS.fetch(key), value]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def csv_record(record, row)
|
||||
record.map do |key, value|
|
||||
key == 'group' ? row[:name] || row[:key] : csv_display(value, key)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_label?(key, value)
|
||||
key.is_a?(String) && value.is_a?(String) && key.length <= 100 && value.length <= 300
|
||||
end
|
||||
|
||||
def csv_display(value, key)
|
||||
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
|
||||
return @labels.fetch("event_type:#{ value }", value) if key == 'event_type'
|
||||
return value ? @labels.fetch('latest', 'true') : '' if key == 'is_latest'
|
||||
return @labels.fetch("kind:#{ value }", value) if key == 'kind'
|
||||
return @labels.fetch("category:#{ value }", value) if key == 'category'
|
||||
if ['language', 'script'].include?(key) && value.is_a?(Hash)
|
||||
return @labels.fetch("#{ key }:#{ value[:code] }",
|
||||
value[:name] || @labels.fetch('notRegistered', ''))
|
||||
end
|
||||
return @labels.fetch("boolean:#{ value }", value) if value == true || value == false
|
||||
return value.map { csv_display(_1, key) } if value.is_a?(Array)
|
||||
if value.is_a?(Hash)
|
||||
return value.to_h do |field, item|
|
||||
[@labels.fetch(field.to_s, field), csv_display(item, field.to_s)]
|
||||
end
|
||||
end
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
def fields_for(key)
|
||||
return NAME_FIELDS if key == 'names'
|
||||
return ['name', 'scriptCode'] if key == 'aliases'
|
||||
|
||||
[]
|
||||
end
|
||||
|
||||
def paths(fields, prefix)
|
||||
fields.flat_map do |key|
|
||||
path = "#{ prefix }.#{ key }"
|
||||
[path, *paths(fields_for(key), path)]
|
||||
end
|
||||
end
|
||||
|
||||
def fetch(row, key) = row.key?(key) ? row[key] : row[key.to_sym]
|
||||
|
||||
def named_code(row, kind)
|
||||
code = fetch(row, "#{ kind }_code")
|
||||
name = fetch(row, "#{ kind }_name")
|
||||
{ code:, name: name.present? && name.to_s.downcase != code.to_s.downcase ? name : nil }
|
||||
end
|
||||
|
||||
def stable(value)
|
||||
case value
|
||||
when Hash
|
||||
value.stringify_keys.sort.to_h.transform_values { stable(_1) }
|
||||
when Array
|
||||
value.map { stable(_1) }
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def sort_value(row, key)
|
||||
if key == 'languageCode'
|
||||
language = named_code(row, 'language')
|
||||
return @labels.fetch("language:#{ language[:code] }", language[:name])
|
||||
end
|
||||
return named_code(row, 'script')[:name] if key == 'scriptCode'
|
||||
|
||||
fetch(row, CHILD_KEYS.fetch(key))
|
||||
end
|
||||
|
||||
def comparison(left, right)
|
||||
if left.is_a?(Array) && right.is_a?(Array) && left.length != right.length
|
||||
return left.length <=> right.length
|
||||
end
|
||||
return left <=> right if left.is_a?(Numeric) && right.is_a?(Numeric)
|
||||
if [true, false].include?(left) && [true, false].include?(right)
|
||||
return (left ? 1 : 0) <=> (right ? 1 : 0)
|
||||
end
|
||||
|
||||
a = left.is_a?(Hash) || left.is_a?(Array) ? JSON.generate(stable(left)) : left.to_s
|
||||
b = right.is_a?(Hash) || right.is_a?(Array) ? JSON.generate(stable(right)) : right.to_s
|
||||
a <=> b
|
||||
end
|
||||
|
||||
def sorted(rows, prefix, fields)
|
||||
sorts = @sorts.filter_map do |path, direction|
|
||||
parent, key = path.split('/', 2)
|
||||
next unless parent == prefix
|
||||
raise ArgumentError unless fields.include?(key)
|
||||
|
||||
[key, direction]
|
||||
end
|
||||
rows.each_with_index.sort do |(left, a), (right, b)|
|
||||
result = 0
|
||||
sorts.each do |key, direction|
|
||||
first = sort_value(left, key)
|
||||
last = sort_value(right, key)
|
||||
result = if first.nil? || last.nil?
|
||||
first.nil? == last.nil? ? 0 : (first.nil? ? 1 : -1)
|
||||
else
|
||||
comparison(first, last) * (direction == 'asc' ? 1 : -1)
|
||||
end
|
||||
break unless result.zero?
|
||||
end
|
||||
result.zero? ? a <=> b : result
|
||||
end.map(&:first)
|
||||
end
|
||||
|
||||
def nested(rows, fields, prefix)
|
||||
keys = fields.select { !@selected || @selected.include?("#{ prefix }.#{ _1 }") }
|
||||
ordered = (@order + keys.map { "#{ prefix }.#{ _1 }" }).uniq
|
||||
.filter_map { |path| keys.find { "#{ prefix }.#{ _1 }" == path } }
|
||||
sorted(rows, prefix, fields).map do |row|
|
||||
ordered.to_h do |key|
|
||||
stable = CHILD_KEYS.fetch(key)
|
||||
value = case key
|
||||
when 'languageCode' then named_code(row, 'language')
|
||||
when 'scriptCode' then named_code(row, 'script')
|
||||
else fetch(row, stable)
|
||||
end
|
||||
if fields_for(key).any?
|
||||
value = nested(value || [], fields_for(key), "#{ prefix }.#{ key }")
|
||||
end
|
||||
[stable, value]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,152 @@
|
||||
require 'csv'
|
||||
|
||||
# One read view and a fixed point for the entire download; only one page is held in memory.
|
||||
class CountsExporter
|
||||
EXTRA_KEYS = ['basis', 'format', 'resource', 'headers', 'children', 'order',
|
||||
'summary_columns', 'nested_json', 'child_sort', 'labels'].freeze
|
||||
attr_reader :format, :resource
|
||||
|
||||
def initialize(params, language_code:)
|
||||
if params.key?('definition')
|
||||
@table_export = CountsTableExport.new(params)
|
||||
@format = @table_export.format
|
||||
@resource = @table_export.resource
|
||||
return
|
||||
end
|
||||
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
|
||||
QueryState.validate!(params, allowed)
|
||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||
|
||||
@params = params
|
||||
@language = language_code
|
||||
@format = params.fetch('format', 'json')
|
||||
@resource = params.fetch('resource', 'posts')
|
||||
@mode = params.fetch('mode', 'summary')
|
||||
@history = params.fetch('basis', 'state') == 'history'
|
||||
raise ArgumentError unless ['json', 'csv'].include?(format)
|
||||
raise ArgumentError unless ['posts', 'tags'].include?(resource)
|
||||
raise ArgumentError unless ['summary', 'details'].include?(@mode)
|
||||
raise ArgumentError unless ['state', 'history'].include?(params.fetch('basis', 'state'))
|
||||
|
||||
@query_params = params.except(*EXTRA_KEYS, 'columns', 'page', 'limit')
|
||||
.merge('mode' => @mode, 'page' => '1', 'limit' => '100')
|
||||
point = CountsStateQuery.new(@query_params.slice('at').merge('resource' => resource),
|
||||
language_code: @language).at
|
||||
@query_params['at'] = point.iso8601(6)
|
||||
@query_params['axis'] ||= !@history && resource == 'posts' ? 'tags' : 'month'
|
||||
@projection = CountsExportProjection.new(params, resource:,
|
||||
summary: @mode == 'summary',
|
||||
tags: !@history && @query_params['axis'] == 'tags')
|
||||
if @history
|
||||
@query_params.delete('kind')
|
||||
history_query
|
||||
else
|
||||
@query_params.delete('latest')
|
||||
@state_query = CountsStateQuery.new(@query_params.merge('resource' => resource),
|
||||
language_code: @language).validate_query!
|
||||
end
|
||||
end
|
||||
|
||||
def body(progress: nil)
|
||||
return @table_export.body(progress:) if @table_export
|
||||
|
||||
Enumerator.new do |output|
|
||||
ApplicationRecord.transaction(isolation: :repeatable_read) do
|
||||
processed = 0
|
||||
first_row = true
|
||||
started = false
|
||||
pages do |page|
|
||||
total = @mode == 'details' ? page[:count] : nil
|
||||
progress&.call(processed, total)
|
||||
unless started
|
||||
meta = { resource:, mode: @mode, basis: @history ? 'history' : 'state',
|
||||
at: @query_params['at'], boundary: 'inclusive',
|
||||
filters: @query_params.except('sort', 'page', 'limit', 'mode'),
|
||||
sort: export_sort,
|
||||
child_sort: sort_metadata(@params.fetch('child_sort', '')),
|
||||
columns: @projection.metadata,
|
||||
children: @params['children']&.split(' '),
|
||||
field_order: @QueryState.list(params.fetch('order', '')), timezone: 'UTC',
|
||||
count: @mode == 'summary' ? page[:group_count] : page[:count],
|
||||
totals: page[:totals], coverage: page[:coverage] }
|
||||
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
|
||||
if format == 'csv'
|
||||
output << CSV.generate_line(
|
||||
@projection.headers.map { csv_value(_1) }, row_sep: "\r\n")
|
||||
end
|
||||
started = true
|
||||
end
|
||||
(page[:versions] || page[:state_rows] || page[:groups] || []).each do |row|
|
||||
record = @projection.project(row)
|
||||
if format == 'json'
|
||||
output << ',' unless first_row
|
||||
output << record.to_json
|
||||
else
|
||||
values = @projection.csv_record(record, row).map { csv_value(_1) }
|
||||
output << CSV.generate_line(values, row_sep: "\r\n")
|
||||
end
|
||||
first_row = false
|
||||
processed += 1
|
||||
progress&.call(processed, total)
|
||||
end
|
||||
end
|
||||
progress&.call(processed, processed)
|
||||
output << ']}' if format == 'json'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def export_sort
|
||||
sort_metadata(@query_params.fetch('sort', default_sort))
|
||||
end
|
||||
|
||||
def sort_metadata(value)
|
||||
value.split(' ').map do |entry|
|
||||
key, direction = entry.split(':', 2)
|
||||
{ key:, direction: }
|
||||
end
|
||||
end
|
||||
|
||||
def default_sort
|
||||
return 'created_at:desc' if @mode == 'details'
|
||||
|
||||
!@history && @query_params['axis'] == 'tags' ? 'count:desc' : 'group:asc'
|
||||
end
|
||||
|
||||
def history_query
|
||||
scope = resource == 'posts' ? PostVersion.with_previous_version : TagVersion.all
|
||||
CountsQuery.new(scope, resource, @query_params)
|
||||
end
|
||||
|
||||
def pages
|
||||
unless @history
|
||||
@state_query.each_page { yield _1 }
|
||||
return
|
||||
end
|
||||
query = history_query
|
||||
total = @mode == 'summary' ? nil : query.count
|
||||
loop do
|
||||
result = if @mode == 'summary'
|
||||
query.summary(include_count: @query_params['page'] == '1')
|
||||
else
|
||||
{ count: total,
|
||||
versions: CountsVersionRepr.many(query.details, resource:,
|
||||
language_code: @language,
|
||||
at: Time.iso8601(@query_params['at'])) }
|
||||
end
|
||||
total ||= result[:group_count]
|
||||
yield result
|
||||
break if @query_params['page'].to_i * 100 >= total
|
||||
|
||||
@query_params['page'] = (@query_params['page'].to_i + 1).to_s
|
||||
query = history_query
|
||||
end
|
||||
end
|
||||
|
||||
def csv_value(value)
|
||||
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
|
||||
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# Coordinates carry their aggregation depth; SQL NULL remains an ordinary axis value.
|
||||
class CountsPivotLayout
|
||||
def self.depths(keys, sorts, measures: true)
|
||||
return [] if keys.length < 2 || sorts.first&.first == 'count' ||
|
||||
sorts.first&.first&.match?(/\Am[0-9]+\z/)
|
||||
|
||||
relevant = sorts.map(&:first).select { keys.include?(_1) || (measures && (_1 == 'count' ||
|
||||
_1.match?(/\Am[0-9]+\z/))) }
|
||||
order = (relevant + keys).uniq
|
||||
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
|
||||
end
|
||||
|
||||
def self.headers(values, depths, next_values: nil, grand: false)
|
||||
result = []
|
||||
values.each_with_index do |tuple, index|
|
||||
result << { values: tuple, kind: 'data' }
|
||||
following = values[index + 1] || next_values
|
||||
depths.reverse_each do |depth|
|
||||
prefix = tuple.first(depth)
|
||||
next if following && following.first(depth) == prefix
|
||||
|
||||
result << { values: prefix, kind: 'subtotal' }
|
||||
end
|
||||
end
|
||||
result << { values: [], kind: 'grand_total' } if grand
|
||||
result
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
# Re-aggregate the population, never add up already grouped cells (multi-value axes overlap).
|
||||
class CountsPivotTotals
|
||||
def initialize(population, axes, row_headers, column_headers)
|
||||
@population = population
|
||||
@axes = axes
|
||||
@rows = row_headers.group_by { _1[:values].length }
|
||||
@columns = column_headers.group_by { _1[:values].length }
|
||||
end
|
||||
|
||||
def call
|
||||
queries = @rows.flat_map do |row_depth, headers|
|
||||
@columns.filter_map do |column_depth, columns|
|
||||
next if row_depth == @axes.rows.length && column_depth == @axes.columns.length
|
||||
|
||||
indexes = (0...row_depth).to_a +
|
||||
(0...column_depth).map { @axes.rows.length + _1 }
|
||||
expressions = indexes.map { @axes.expressions[_1] }
|
||||
scope = @population.scope.except(:select, :order)
|
||||
joins = @axes.joins(indexes.map { @axes.all[_1].fetch('field') })
|
||||
scope = scope.joins(joins) unless joins.empty?
|
||||
if row_depth.positive?
|
||||
tuples = headers.map { _1[:values] }.uniq
|
||||
predicate = tuples.map { Array.new(row_depth) { |i|
|
||||
"(#{ expressions[i] }) <=> ?"
|
||||
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
|
||||
scope = scope.where(predicate, *tuples.flatten)
|
||||
end
|
||||
if column_depth.positive?
|
||||
tuples = columns.map { _1[:values] }.uniq
|
||||
predicate = tuples.map { Array.new(column_depth) { |i|
|
||||
"(#{ expressions[row_depth + i] }) <=> ?"
|
||||
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
|
||||
scope = scope.where(predicate, *tuples.flatten)
|
||||
end
|
||||
grouped = expressions.each_with_index.map { |expression, index|
|
||||
"#{ expression } AS v#{ index }"
|
||||
}
|
||||
# Fixed-width result coordinates, independent of NULL and of translated labels.
|
||||
padding = (expressions.length...@axes.all.length).map { "NULL AS v#{ _1 }" }
|
||||
fields = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
||||
grouped + padding + ['COUNT(DISTINCT v.id) AS count']
|
||||
scope = scope.group(*expressions.map { Arel.sql(_1) }) if expressions.any?
|
||||
scope.optimizer_hints('MAX_EXECUTION_TIME(10000)')
|
||||
.select(Arel.sql(fields.join(', '))).to_sql
|
||||
end
|
||||
end
|
||||
return [] if queries.empty?
|
||||
|
||||
sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ')
|
||||
@population.definition.model.connection.select_all(
|
||||
"SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sql }) totals").map do |record|
|
||||
row_depth = record['row_depth'].to_i
|
||||
column_depth = record['column_depth'].to_i
|
||||
{ row: (0...row_depth).map { record["v#{ _1 }"] },
|
||||
column: (0...column_depth).map { record["v#{ row_depth + _1 }"] },
|
||||
count: record['count'].to_i }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,168 @@
|
||||
# Population first, then filters. Previous versions are joined before period filtering.
|
||||
class CountsPopulation
|
||||
attr_reader :definition, :params, :at, :history, :scope, :timezone
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
@definition = CountsResourceDefinition.new(params.fetch('resource', 'posts'))
|
||||
@history = params.fetch('definition', 'latest') == 'history'
|
||||
raise ArgumentError unless ['latest', 'history'].include?(params.fetch('definition', 'latest'))
|
||||
|
||||
@timezone = TZInfo::Timezone.get(params.fetch('timezone', 'UTC'))
|
||||
@at = timestamp(params['execution_at']) || Time.current.utc
|
||||
raise ArgumentError if at > Time.current + 1.second
|
||||
|
||||
@scope = definition.model.all
|
||||
make_population
|
||||
@scope = HistoryAccess.visible(@scope, 'v')
|
||||
apply_filters
|
||||
rescue TZInfo::InvalidTimezoneIdentifier
|
||||
raise ArgumentError
|
||||
end
|
||||
|
||||
def timestamp(value, end_date: false)
|
||||
return nil if value.nil? || value == ''
|
||||
raise ArgumentError unless value.is_a?(String)
|
||||
|
||||
if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
||||
date = Date.iso8601(value)
|
||||
date += 1 if end_date
|
||||
timezone.local_to_utc(Time.utc(date.year, date.month, date.day))
|
||||
else
|
||||
raise ArgumentError unless value.match?(/(?:Z|[+-]\d{2}:\d{2})\z/)
|
||||
|
||||
time = Time.iso8601(value).utc
|
||||
raise ArgumentError unless time.year.between?(1900, 2100)
|
||||
|
||||
time
|
||||
end
|
||||
rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound, Date::Error
|
||||
raise ArgumentError
|
||||
end
|
||||
|
||||
def latest_sql(row = 'v', cutoff = nil)
|
||||
cutoff ||= history ? at : timestamp(params['at']) || at
|
||||
keys = definition.keys.map { "newer.#{ _1 } = #{ row }.#{ _1 }" }.join(' AND ')
|
||||
bound = definition.model.connection.quote(cutoff)
|
||||
<<~SQL.squish
|
||||
NOT EXISTS (SELECT 1 FROM #{ definition.table } newer
|
||||
WHERE #{ keys } AND newer.version_no > #{ row }.version_no
|
||||
AND newer.#{ definition.time_column } <= #{ bound })
|
||||
SQL
|
||||
end
|
||||
|
||||
def changed_sql(key)
|
||||
column = definition.column(key)
|
||||
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def make_population
|
||||
table = definition.table
|
||||
@scope = @scope.from("#{ table } v").select('v.*')
|
||||
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
|
||||
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
|
||||
'AND previous.version_no = v.version_no - 1 AND ' +
|
||||
HistoryAccess.sql(definition.model, 'previous'))
|
||||
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
|
||||
unless history
|
||||
point = timestamp(params['at']) || at
|
||||
raise ArgumentError if point > at
|
||||
|
||||
@scope = @scope.where("v.#{ definition.time_column } <= ?", point)
|
||||
.where(Arel.sql(latest_sql('v', point))).where.not('v.event_type' => 'discard')
|
||||
return
|
||||
end
|
||||
first = timestamp(params['from'])
|
||||
last = timestamp(params['through'], end_date: true)
|
||||
raise ArgumentError if first && last && first > last
|
||||
|
||||
@scope = @scope.where('v.created_at >= ?', first) if first
|
||||
if last
|
||||
operator = params['through'].length == 10 ? '<' : '<='
|
||||
@scope = @scope.where("v.created_at #{ operator } ?", last)
|
||||
end
|
||||
end
|
||||
|
||||
def apply_filters
|
||||
['resource_id', 'user'].each do |key|
|
||||
value = params[key == 'user' ? 'user_id' : key]
|
||||
next if value.nil? || value == ''
|
||||
raise ArgumentError unless value.match?(/\A[0-9]+\z/)
|
||||
|
||||
@scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i)
|
||||
end
|
||||
apply_tags if params['tags'].present? || params['tag'].present?
|
||||
conditions = QueryState.records(params, 'conditions')
|
||||
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
|
||||
|
||||
conditions.each { apply_condition(_1) }
|
||||
drills = QueryState.records(params, 'drill')
|
||||
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
|
||||
|
||||
drills.each do |entry|
|
||||
key = entry.fetch('field')
|
||||
if key == 'tags'
|
||||
@scope = @scope.where(snapshot_membership(entry.fetch('value')))
|
||||
elsif key == 'changed_field'
|
||||
raise ArgumentError unless history && definition.changes.include?(entry['value'])
|
||||
|
||||
@scope = @scope.where(Arel.sql(changed_sql(entry['value'])))
|
||||
elsif definition.fields[key] == :datetime && entry['grain']
|
||||
@scope = @scope.where(definition.expression(key) => nil) if entry['value'].nil?
|
||||
if entry['value']
|
||||
expression = CountsAxes.time_expression(definition.expression(key),
|
||||
entry['grain'], timezone)
|
||||
@scope = @scope.where("#{ expression } = ?", entry['value'])
|
||||
end
|
||||
else
|
||||
raise ArgumentError unless definition.axes(history).include?(key)
|
||||
|
||||
@scope = @scope.where(definition.expression(key) => entry['value'])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def apply_condition(entry)
|
||||
@condition ||= CountsCondition.new(self)
|
||||
@scope = @condition.apply(@scope, entry)
|
||||
end
|
||||
|
||||
def snapshot_membership(value)
|
||||
kind, identifier = value.to_s.split(':', 2)
|
||||
raise ArgumentError unless ['internal', 'external'].include?(kind)
|
||||
raise ArgumentError unless identifier&.match?(/\A[1-9]\d*\z/)
|
||||
|
||||
key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
|
||||
definition.model.sanitize_sql_array([
|
||||
"JSON_CONTAINS(v.tags_json, JSON_OBJECT('#{ key }', CAST(? AS UNSIGNED)))",
|
||||
identifier.to_i])
|
||||
end
|
||||
|
||||
def apply_tags
|
||||
raise ArgumentError unless definition.resource == 'posts'
|
||||
|
||||
if params['tag'].present?
|
||||
@scope = @scope.where(snapshot_membership(
|
||||
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
||||
end
|
||||
return unless params['tags'].present?
|
||||
|
||||
locale = Locale.find_by!(code: params.fetch('locale', 'ja'))
|
||||
refs = PostTagSearch.parse_references(params, params['tags'].split)
|
||||
raw = params['tags']
|
||||
literals = PostTagSearch.literals(locale, raw, references: refs)
|
||||
predicates = literals.map do |literal|
|
||||
internal, external = PostTagSearch.references(locale, literal)
|
||||
parts = Array(internal).map { snapshot_membership("internal:#{ _1 }") } +
|
||||
Array(external).map { snapshot_membership("external:#{ _1 }") }
|
||||
expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
|
||||
literal[:negative] ? "NOT #{ expression }" : expression
|
||||
end
|
||||
return if predicates.empty?
|
||||
|
||||
any = params['match'] == 'any' || raw.split.any? { _1.downcase == 'type:or' }
|
||||
@scope = @scope.where(Arel.sql("(#{ predicates.join(any ? ' OR ' : ' AND ') })"))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,207 @@
|
||||
# Read-only history queries. SQL identifiers and expressions are server-owned.
|
||||
class CountsQuery
|
||||
AXES = ['month', 'user'].freeze
|
||||
COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'event_type', 'user', 'created_at'].freeze
|
||||
COLUMNS = {
|
||||
'posts' => COMMON_COLUMNS + [
|
||||
'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms',
|
||||
'original_created_from', 'original_created_before'],
|
||||
'tags' => COMMON_COLUMNS + [
|
||||
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
|
||||
KEYS = [
|
||||
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
|
||||
'user_id', 'resource_id', 'tag', 'columns', 'sort', 'at'].freeze
|
||||
|
||||
attr_reader :page, :limit, :mode, :axis
|
||||
|
||||
def initialize(scope, resource, params, latest_expression: nil)
|
||||
@table = scope.klass.table_name
|
||||
@resource = resource
|
||||
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
||||
@params = params
|
||||
@latest_expression = latest_expression
|
||||
scope = HistoryAccess.visible(scope)
|
||||
@scope = scope.except(:select, :order).joins(
|
||||
"LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id")
|
||||
raise ArgumentError unless (params.keys - KEYS).empty?
|
||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||
|
||||
@mode = params.fetch('mode', 'summary')
|
||||
@axis = params.fetch('axis', 'month')
|
||||
raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis)
|
||||
|
||||
@page = integer('page', 1)
|
||||
@limit = integer('limit', 20)
|
||||
raise ArgumentError if limit > 100 || page > 1_000_000
|
||||
|
||||
columns = QueryState.list(params.fetch('columns', COLUMNS.fetch(resource).join(' ')))
|
||||
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
|
||||
raise ArgumentError
|
||||
end
|
||||
@sort = parse_sort
|
||||
apply_filters
|
||||
end
|
||||
|
||||
def relation = @scope
|
||||
|
||||
def count = @scope.count
|
||||
|
||||
def latest_expression
|
||||
return @latest_expression if @latest_expression
|
||||
|
||||
at = timestamp('at')
|
||||
cutoff = at ? "AND newer.created_at <= #{ @scope.klass.connection.quote(at) }" : ''
|
||||
<<~SQL.squish
|
||||
NOT EXISTS (SELECT 1 FROM #{ @table } newer
|
||||
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
|
||||
AND newer.version_no > #{ @table }.version_no #{ cutoff })
|
||||
SQL
|
||||
end
|
||||
|
||||
def details
|
||||
@scope.select("#{ @table }.*", Arel.sql("(#{ latest_expression }) AS counts_latest"))
|
||||
.order(Arel.sql(order_sql(detail_expressions, ["#{ @table }.id DESC"])))
|
||||
.limit(limit).offset((page - 1) * limit)
|
||||
end
|
||||
|
||||
def summary(include_count: true)
|
||||
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
|
||||
"#{ @table }.created_by_user_id"
|
||||
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
|
||||
sql = @scope.group(Arel.sql(expression)).select(Arel.sql(
|
||||
"#{ expression } AS group_key, COUNT(*) AS record_count, " +
|
||||
"#{ group_order } AS group_name")).to_sql
|
||||
connection = @scope.klass.connection
|
||||
source = "FROM (#{ sql }) counts_groups"
|
||||
groups = include_count ? connection.select_value("SELECT COUNT(*) #{ source }") : nil
|
||||
expressions = { 'group' => ['group_name'], 'count' => ['record_count'] }
|
||||
order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC'])
|
||||
rows = connection.select_all(
|
||||
"SELECT group_key, group_name, record_count #{ source } " +
|
||||
"ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }")
|
||||
{ group_count: groups&.to_i,
|
||||
groups: rows.map do |row|
|
||||
{ key: row['group_key'],
|
||||
name: axis == 'user' ? row['group_name'] : nil,
|
||||
count: row['record_count'].to_i }
|
||||
end }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_sort
|
||||
value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc')
|
||||
return [] if value.empty?
|
||||
|
||||
values = QueryState.list(value).map { _1.split(':', -1) }
|
||||
allowed = mode == 'details' ? COLUMNS.fetch(@resource) - ['thumbnail'] : ['group', 'count']
|
||||
invalid = values.any? do |key, direction, *extra|
|
||||
extra.any? || !allowed.include?(key) || !['asc', 'desc'].include?(direction)
|
||||
end
|
||||
if (values.empty? || values.length > allowed.length || invalid ||
|
||||
values.map(&:first).uniq.length != values.length)
|
||||
raise ArgumentError
|
||||
end
|
||||
values
|
||||
end
|
||||
|
||||
# NULL always sorts last, including descending keys. The final key is unique.
|
||||
def order_sql(expressions, final)
|
||||
(@sort.flat_map do |key, direction|
|
||||
expressions.fetch(key).flat_map do |expression|
|
||||
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
|
||||
end
|
||||
end + final).join(', ')
|
||||
end
|
||||
|
||||
def detail_expressions
|
||||
common = { 'resource_id' => ["#{ @table }.#{ @id_column }"],
|
||||
'version_no' => ["#{ @table }.version_no"],
|
||||
'is_latest' => ["(#{ latest_expression })"],
|
||||
'user' => ['counts_actor.name', "#{ @table }.created_by_user_id"],
|
||||
'created_at' => ["#{ @table }.created_at"] }
|
||||
if @resource == 'posts'
|
||||
fields = [
|
||||
'title', 'url', 'thumbnail_base', 'video_ms',
|
||||
'original_created_from', 'original_created_before']
|
||||
common.merge(fields.to_h { [_1, ["#{ @table }.#{ _1 }"]] }).merge(
|
||||
'tags' => json_order('post_versions.tags_json'),
|
||||
'parents' => list_order('post_versions.parent_post_ids'))
|
||||
else
|
||||
name = "JSON_UNQUOTE(JSON_EXTRACT(tag_versions.names, #{ locale_name_path }))"
|
||||
common.merge(
|
||||
'name' => ["COALESCE(#{ name }, tag_versions.name)"],
|
||||
'names' => json_order('tag_versions.names'),
|
||||
'category' => ['tag_versions.category'],
|
||||
'aliases' => json_order(
|
||||
"JSON_EXTRACT(tag_versions.names, '$.*.aliases[*]')"),
|
||||
'parents' => list_order('tag_versions.parent_tag_ids'),
|
||||
'deprecated' => ['tag_versions.deprecated_at'])
|
||||
end
|
||||
end
|
||||
|
||||
def locale_name_path
|
||||
language = Locale.find_by(code: @params.fetch('locale', 'ja'))&.language_code || 'ja'
|
||||
path = "$.#{ JSON.generate(language) }.name"
|
||||
@scope.klass.connection.quote(path)
|
||||
end
|
||||
|
||||
# Multi-valued parent cells sort by cardinality, then binary snapshot text.
|
||||
def json_order(column)
|
||||
[
|
||||
"COALESCE(JSON_LENGTH(#{ column }), 0)",
|
||||
"COALESCE(CAST(#{ column } AS CHAR), '[]') COLLATE utf8mb4_bin"]
|
||||
end
|
||||
|
||||
def list_order(column)
|
||||
[
|
||||
"CASE WHEN #{ column } = '' THEN 0 ELSE " +
|
||||
"1 + LENGTH(#{ column }) - LENGTH(REPLACE(#{ column }, ' ', '')) END",
|
||||
"#{ column } COLLATE utf8mb4_bin"]
|
||||
end
|
||||
|
||||
def integer(key, default = nil)
|
||||
value = @params[key].presence
|
||||
return default if value.nil? && !default.nil?
|
||||
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
|
||||
|
||||
value.to_i
|
||||
end
|
||||
|
||||
def timestamp(key)
|
||||
value = @params[key].presence
|
||||
return nil unless value
|
||||
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
|
||||
raise ArgumentError unless value.match?(pattern)
|
||||
|
||||
Date.iso8601(value.first(10))
|
||||
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value)
|
||||
end
|
||||
|
||||
def apply_filters
|
||||
at = timestamp('at')
|
||||
first = timestamp('from')
|
||||
last = timestamp('before')
|
||||
raise ArgumentError if first && last && first >= last
|
||||
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1'))
|
||||
|
||||
@scope = @scope.where("#{ @table }.created_at <= ?", at) if at
|
||||
@scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0'
|
||||
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
|
||||
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
|
||||
if @params['resource_id'].present?
|
||||
@scope = @scope.where(@table => { @id_column => integer('resource_id') })
|
||||
end
|
||||
if @params['user_id'].present?
|
||||
user = @params['user_id'] == 'none' ? nil : integer('user_id')
|
||||
@scope = @scope.where(@table => { created_by_user_id: user })
|
||||
end
|
||||
return unless @params['tag'].present?
|
||||
raise ArgumentError unless @resource == 'posts'
|
||||
|
||||
id = integer('tag')
|
||||
@scope = @scope.where(
|
||||
"JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED))) OR " +
|
||||
"JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED)))", id, id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,146 @@
|
||||
# Server-owned version identities, attributes and field types. No client SQL is accepted.
|
||||
class CountsResourceDefinition
|
||||
COMMON = { 'resource_id' => :integer, 'version_no' => :integer,
|
||||
'event_type' => :enum, 'user' => :integer, 'created_at' => :datetime }.freeze
|
||||
DEFINITIONS = {
|
||||
'posts' => [PostVersion, ['post_id'], {
|
||||
'title' => :string, 'url' => :string, 'thumbnail_base' => :string,
|
||||
'tags_json' => :json, 'parent_post_ids' => :string, 'video_ms' => :integer,
|
||||
'original_created_from' => :datetime, 'original_created_before' => :datetime }],
|
||||
'tags' => [TagVersion, ['tag_id'], {
|
||||
'name' => :string, 'names' => :json, 'category' => :enum, 'aliases' => :string,
|
||||
'parent_tag_ids' => :string, 'deprecated_at' => :datetime }],
|
||||
'nico_tags' => [NicoTagVersion, ['tag_id'], {
|
||||
'name' => :string, 'linked_tags' => :string }],
|
||||
'tag_descriptions' => [TagDescriptionVersion, ['tag_id', 'language_code'], {
|
||||
'language_code' => :enum, 'body' => :string,
|
||||
'source_language_code' => :enum, 'source_version_no' => :integer }],
|
||||
'tag_groups' => [TagGroupVersion, ['tag_group_id'], {
|
||||
'name' => :string, 'description' => :string,
|
||||
'members' => :json, 'placements' => :json }],
|
||||
'materials' => [MaterialVersion, ['material_id'], {
|
||||
'url' => :string, 'parent_id' => :integer, 'tag_id' => :integer,
|
||||
'tag_name' => :string, 'tag_category' => :enum,
|
||||
'discarded_at' => :datetime, 'updated_at' => :datetime,
|
||||
'updated_by_user_id' => :integer, 'export_paths_json' => :json,
|
||||
'file_blob_id' => :integer, 'file_filename' => :string,
|
||||
'file_content_type' => :string, 'file_byte_size' => :integer,
|
||||
'file_checksum' => :string, 'file_sha256' => :string,
|
||||
'source_kind' => :enum, 'source_uri' => :string, 'source_path' => :string,
|
||||
'source_file_id' => :string, 'normalized_source_key' => :string }] }.freeze
|
||||
OPERATORS = {
|
||||
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], boolean: ['eq', 'ne'],
|
||||
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
|
||||
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
|
||||
interval: ['range'] }.freeze
|
||||
LABELS = {
|
||||
'resource_id' => 'counts:columns.resourceId', 'version_no' => 'counts:columns.version',
|
||||
'is_latest' => 'counts:columns.latest', 'event_type' => 'counts:eventType',
|
||||
'user' => 'history:actor', 'created_at' => 'counts:columns.changedAt',
|
||||
'thumbnail' => 'counts:columns.thumbnailDisplay',
|
||||
'thumbnail_base' => 'counts:columns.thumbnailURL',
|
||||
'title' => 'counts:columns.postTitle', 'url' => 'counts:columns.postURL',
|
||||
'tags' => 'counts:columns.tags', 'tags_json' => 'counts:columns.tags',
|
||||
'video_ms' => 'shared:labels.duration', 'name' => 'counts:columns.name',
|
||||
'names' => 'counts:columns.names', 'category' => 'common:categoryLabel',
|
||||
'aliases' => 'tags:aliases', 'deprecated' => 'counts:columns.deprecated',
|
||||
'original_created_from' => 'counts:columns.originalFrom',
|
||||
'original_created_before' => 'counts:columns.originalBefore',
|
||||
'language_code' => 'counts:columns.language' }.freeze
|
||||
CHILD_LABELS = {
|
||||
'id' => 'counts:columns.resourceId', 'versionNo' => 'history:version',
|
||||
'kind' => 'counts:columns.kind', 'name' => 'history:name',
|
||||
'category' => 'common:categoryLabel', 'sections' => 'counts:columns.sections',
|
||||
'names' => 'counts:columns.names', 'aliases' => 'tags:aliases',
|
||||
'languageCode' => 'counts:columns.language',
|
||||
'scriptCode' => 'history:nameChanges.script',
|
||||
'autoGenerated' => 'history:nameChanges.automatic',
|
||||
'tagId' => 'counts:attributes.tag_id', 'tagGroupId' => 'counts:resources.tag_groups',
|
||||
'sectionName' => 'counts:attributes.section_name',
|
||||
'description' => 'counts:attributes.description',
|
||||
'children' => 'counts:attributes.children', 'profile' => 'counts:attributes.profile',
|
||||
'path' => 'counts:attributes.path' }.freeze
|
||||
|
||||
attr_reader :model, :keys, :fields, :resource
|
||||
|
||||
def initialize(resource)
|
||||
@resource = resource
|
||||
@model, @keys, attributes = DEFINITIONS.fetch(resource) { raise ArgumentError }
|
||||
@fields = COMMON.merge(attributes)
|
||||
end
|
||||
|
||||
def table = model.table_name
|
||||
def time_column = 'created_at'
|
||||
def column(key)
|
||||
raise ArgumentError unless fields.key?(key)
|
||||
|
||||
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
|
||||
end
|
||||
|
||||
def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
|
||||
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
|
||||
def axes(history)
|
||||
fields.reject { |_key, type| type == :json }.keys +
|
||||
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
|
||||
end
|
||||
|
||||
def metadata
|
||||
fields.map do |key, type|
|
||||
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
|
||||
operators: OPERATORS.fetch(type, []) }
|
||||
end
|
||||
end
|
||||
|
||||
def field_label(key) = LABELS.fetch(key, "counts:attributes.#{ key }")
|
||||
|
||||
def output_fields
|
||||
content = case resource
|
||||
when 'posts'
|
||||
['thumbnail', 'title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
|
||||
'original_created_from', 'original_created_before']
|
||||
when 'tags' then ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
|
||||
else fields.keys - COMMON.keys
|
||||
end
|
||||
keys = ['resource_id', 'version_no', 'is_latest'] + (content - ['thumbnail']) +
|
||||
['event_type', 'user', 'created_at']
|
||||
keys.unshift('thumbnail') if content.include?('thumbnail')
|
||||
keys.map do |key|
|
||||
label = if key == 'parents'
|
||||
resource == 'tags' ? 'tags:parents' : 'shared:labels.parentPosts'
|
||||
else field_label(key)
|
||||
end
|
||||
field = { key:, label: }
|
||||
children = child_fields(key)
|
||||
field[:children] = children if children.any?
|
||||
field
|
||||
end
|
||||
end
|
||||
|
||||
def child_fields(key, depth = 4)
|
||||
keys = case key
|
||||
when 'tags' then ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names']
|
||||
when 'names' then ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated']
|
||||
when 'aliases'
|
||||
resource == 'tags' ? ['languageCode', 'name', 'scriptCode'] : ['name', 'scriptCode']
|
||||
when 'parents' then resource == 'tags' ? ['id', 'name'] : ['id']
|
||||
when 'members' then ['tagId', 'tagGroupId', 'versionNo']
|
||||
when 'export_paths_json' then ['profile', 'path']
|
||||
when 'placements', 'children'
|
||||
scalar = ['sectionName', 'description', 'tagId', 'tagGroupId']
|
||||
scalar + (depth > 0 ? ['children'] : [])
|
||||
else []
|
||||
end
|
||||
keys.map do |child|
|
||||
label = key == 'members' && child == 'tagId' ?
|
||||
'counts:columns.resourceId' : CHILD_LABELS.fetch(child)
|
||||
field = { key: child, label: }
|
||||
children = child_fields(child, child == 'children' ? depth - 1 : depth)
|
||||
# Language is attached to top-level aliases, not to aliases inside a language entry.
|
||||
children = children.reject { _1[:key] == 'languageCode' } if child == 'aliases'
|
||||
field[:children] = children if children.any?
|
||||
field
|
||||
end
|
||||
end
|
||||
|
||||
def filter_metadata = @filter_metadata ||= CountsCondition.fields(self)
|
||||
end
|
||||
@@ -0,0 +1,370 @@
|
||||
# Restores one recorded version per resource before applying record filters.
|
||||
class CountsStateQuery
|
||||
KEYS = ['locale', 'resource', 'mode', 'at', 'from', 'before', 'grain',
|
||||
'kind', 'tag', 'page', 'limit', 'sort', 'axis', 'user_id', 'resource_id'].freeze
|
||||
MAX_PERIODS = 366
|
||||
|
||||
attr_reader :at
|
||||
|
||||
def initialize(params, language_code:)
|
||||
raise ArgumentError unless (params.keys - KEYS).empty?
|
||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||
|
||||
@params = params
|
||||
@connection = ApplicationRecord.connection
|
||||
@language = language_code
|
||||
@resource = params.fetch('resource', 'posts')
|
||||
@mode = params.fetch('mode', 'summary')
|
||||
@inclusive = ['summary', 'details'].include?(@mode)
|
||||
@axis = params.fetch('axis', @resource == 'posts' ? 'tags' : 'month')
|
||||
@kind = params.fetch('kind', 'internal')
|
||||
@grain = params.fetch('grain', 'month')
|
||||
raise ArgumentError unless ['posts', 'tags'].include?(@resource)
|
||||
raise ArgumentError unless ['summary', 'details', 'state', 'trend', 'snapshot'].include?(@mode)
|
||||
raise ArgumentError unless ['internal', 'external', 'all'].include?(@kind)
|
||||
raise ArgumentError unless ['day', 'month', 'year'].include?(@grain)
|
||||
|
||||
raise ArgumentError unless ['tags', 'month', 'user'].include?(@axis)
|
||||
if @mode == 'summary' && @axis == 'tags' && @resource != 'posts'
|
||||
raise ArgumentError
|
||||
end
|
||||
|
||||
@page = integer('page', 1)
|
||||
@limit = integer('limit', 20)
|
||||
@tag = integer('tag') if params['tag'].present?
|
||||
raise ArgumentError if @limit > 100 || @page > 1_000_000
|
||||
raise ArgumentError if @tag && @kind == 'all'
|
||||
raise ArgumentError if @mode == 'trend' && !@tag
|
||||
|
||||
@now = Time.current.utc
|
||||
@at = timestamp('at') || @now
|
||||
raise ArgumentError if @at > @now
|
||||
end
|
||||
|
||||
def call(metadata: true)
|
||||
@metadata = metadata
|
||||
# Keep totals, rows, and detail pages on one repeatable-read database snapshot.
|
||||
ApplicationRecord.transaction do
|
||||
unless @history_loaded
|
||||
@post_start = HistoryAccess.visible(PostVersion.all).minimum(:created_at)
|
||||
@tag_start = HistoryAccess.visible(TagVersion.all).minimum(:created_at)
|
||||
@history_loaded = true
|
||||
end
|
||||
result = { population_count: HistoryAccess.population_count(
|
||||
@resource == 'posts' ? PostVersion : TagVersion), page: @page, limit: @limit, at: @at.iso8601(6),
|
||||
coverage: { basis: 'recorded_snapshots', exact: false,
|
||||
post_history_from: @post_start&.iso8601(6),
|
||||
tag_history_from: @tag_start&.iso8601(6) } }
|
||||
case @mode
|
||||
when 'trend'
|
||||
result.merge(trend)
|
||||
when 'summary'
|
||||
result.merge(metadata ? { totals: totals } : {}).merge(summary)
|
||||
when 'details', 'snapshot'
|
||||
result.merge(metadata ? { totals: totals } : {}).merge(details)
|
||||
else
|
||||
result.merge(metadata ? { totals: totals } : {}).merge(listing)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def each_page
|
||||
return enum_for(:each_page) unless block_given?
|
||||
|
||||
@page = 1
|
||||
first = call
|
||||
yield first
|
||||
total = ['summary', 'state', 'trend'].include?(@mode) ? first[:group_count] : first[:count]
|
||||
while @page * @limit < total
|
||||
@page += 1
|
||||
yield call(metadata: false)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_query!
|
||||
filtered_scope(@resource)
|
||||
case @mode
|
||||
when 'summary'
|
||||
if @axis == 'tags'
|
||||
order_sql({ 'name' => 'name', 'count' => 'post_count', 'tag_id' => 'tag_id',
|
||||
'kind' => 'kind', 'deprecated' => 'deprecated_at' }, 'count:desc', [])
|
||||
else
|
||||
record_query('summary')
|
||||
end
|
||||
when 'details', 'snapshot'
|
||||
record_query('details')
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def quote(value) = @connection.quote(value)
|
||||
|
||||
def integer(key, default = nil)
|
||||
value = @params[key]
|
||||
return default if value.blank? && default
|
||||
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
|
||||
|
||||
value.to_i
|
||||
end
|
||||
|
||||
def timestamp(key)
|
||||
value = @params[key].presence
|
||||
return nil unless value
|
||||
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
|
||||
raise ArgumentError unless value.match?(pattern)
|
||||
|
||||
Date.iso8601(value.first(10))
|
||||
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value).utc
|
||||
end
|
||||
|
||||
# Version numbers order equal-timestamp writes. Do not filter tags before this selection.
|
||||
def latest_condition(table, row, boundary)
|
||||
key = table == 'post_versions' ? 'post_id' : 'tag_id'
|
||||
<<~SQL.squish
|
||||
#{ row }.created_at #{ @inclusive ? '<=' : '<' } #{ boundary } AND NOT EXISTS (
|
||||
SELECT 1 FROM #{ table } newer
|
||||
WHERE newer.#{ key } = #{ row }.#{ key }
|
||||
AND newer.created_at #{ @inclusive ? '<=' : '<' } #{ boundary }
|
||||
AND newer.version_no > #{ row }.version_no)
|
||||
SQL
|
||||
end
|
||||
|
||||
def state_sql(table, boundary, live: false)
|
||||
model = { 'post_versions' => PostVersion, 'tag_versions' => TagVersion,
|
||||
'nico_tag_versions' => NicoTagVersion }.fetch(table)
|
||||
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) } " +
|
||||
"AND (#{ HistoryAccess.sql(model, 'v') })"
|
||||
live ? "#{ sql } AND v.event_type <> 'discard'" : sql
|
||||
end
|
||||
|
||||
def membership(row)
|
||||
key = @kind == 'external' ? 'external_tag_id' : 'tag_id'
|
||||
"JSON_CONTAINS(#{ row }.tags_json, JSON_OBJECT('#{ key }', #{ @tag }))"
|
||||
end
|
||||
|
||||
# Deprecation keeps a tag in existence. Only an explicit discard removes it.
|
||||
def tag_visible(boundary)
|
||||
return '1 = 1' if @kind == 'external'
|
||||
|
||||
<<~SQL.squish
|
||||
NOT EXISTS (SELECT 1 FROM tag_versions t
|
||||
WHERE t.tag_id = #{ @tag } AND t.event_type = 'discard'
|
||||
AND #{ latest_condition('tag_versions', 't', boundary) })
|
||||
SQL
|
||||
end
|
||||
|
||||
def ctes
|
||||
boundary = quote(@at)
|
||||
<<~SQL
|
||||
WITH post_at AS (#{ filtered_scope('posts').to_sql }),
|
||||
tag_at AS (#{ state_sql('tag_versions', boundary) }),
|
||||
nico_at AS (#{ state_sql('nico_tag_versions', boundary) }),
|
||||
pairs AS (
|
||||
SELECT DISTINCT p.post_id,
|
||||
CASE WHEN entry.tag_id IS NULL THEN 'external' ELSE 'internal' END AS kind,
|
||||
COALESCE(entry.tag_id, entry.external_tag_id) AS tag_id
|
||||
FROM post_at p
|
||||
JOIN JSON_TABLE(CAST(p.tags_json AS JSON), '$[*]' COLUMNS (
|
||||
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY ERROR ON ERROR,
|
||||
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY ERROR ON ERROR
|
||||
)) entry
|
||||
LEFT JOIN tag_at t ON t.tag_id = entry.tag_id
|
||||
WHERE COALESCE(entry.tag_id, entry.external_tag_id) IS NOT NULL
|
||||
AND (t.event_type IS NULL OR t.event_type <> 'discard')
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
def totals
|
||||
row = @connection.select_one(<<~SQL)
|
||||
#{ ctes }
|
||||
SELECT (SELECT COUNT(*) FROM post_at) AS posts,
|
||||
(SELECT COUNT(*) FROM (#{ filtered_scope('tags').to_sql }) counted_tags) AS tags,
|
||||
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard'
|
||||
AND deprecated_at IS NOT NULL) AS deprecated_tags,
|
||||
(SELECT COUNT(*) FROM pairs) AS assignments,
|
||||
(SELECT COUNT(*) FROM pairs WHERE kind = 'external') AS external_assignments
|
||||
SQL
|
||||
row.transform_values!(&:to_i)
|
||||
row.merge('posts' => available?(@post_start, @at) ? row['posts'] : nil,
|
||||
'assignments' => available?(@post_start, @at) ? row['assignments'] : nil,
|
||||
'external_assignments' => available?(@post_start, @at) ?
|
||||
row['external_assignments'] : nil,
|
||||
'tags' => available?(@tag_start, @at) ? row['tags'] : nil,
|
||||
'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil)
|
||||
end
|
||||
|
||||
def available?(first, boundary)
|
||||
first && (@inclusive ? first <= boundary : first < boundary)
|
||||
end
|
||||
|
||||
def listing
|
||||
path = quote("$.#{ JSON.generate(@language) }.name")
|
||||
catalog = <<~SQL
|
||||
, tag_counts AS (
|
||||
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
|
||||
FROM pairs membership GROUP BY membership.kind, membership.tag_id
|
||||
), catalog AS (
|
||||
SELECT 'internal' AS kind, tag_id,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(names, #{ path })), name) AS name,
|
||||
deprecated_at FROM tag_at WHERE event_type <> 'discard'
|
||||
UNION ALL
|
||||
SELECT tc.kind, tc.tag_id, external_version.name, NULL AS deprecated_at
|
||||
FROM tag_counts tc
|
||||
LEFT JOIN tag_at internal_version
|
||||
ON tc.kind = 'internal' AND internal_version.tag_id = tc.tag_id
|
||||
LEFT JOIN nico_at external_version
|
||||
ON tc.kind = 'external' AND external_version.tag_id = tc.tag_id
|
||||
WHERE tc.kind = 'external' OR internal_version.tag_id IS NULL
|
||||
)
|
||||
SQL
|
||||
filters = []
|
||||
filters << "c.kind = #{ quote(@kind) }" unless @kind == 'all'
|
||||
filters << "c.tag_id = #{ @tag }" if @tag
|
||||
where = filters.any? ? "WHERE #{ filters.join(' AND ') }" : ''
|
||||
source = 'FROM catalog c LEFT JOIN tag_counts n ON n.kind = c.kind AND n.tag_id = c.tag_id'
|
||||
prefix = "#{ ctes } #{ catalog }"
|
||||
total = if @metadata
|
||||
@connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i
|
||||
end
|
||||
order = order_sql({ 'name' => 'c.name', 'count' => 'COALESCE(n.post_count, 0)',
|
||||
'tag_id' => 'c.tag_id', 'kind' => 'c.kind',
|
||||
'deprecated' => 'c.deprecated_at' }, 'count:desc',
|
||||
['c.kind ASC', 'c.tag_id ASC'])
|
||||
rows = @connection.select_all(<<~SQL)
|
||||
#{ prefix }
|
||||
SELECT c.kind, c.tag_id, c.name, c.deprecated_at,
|
||||
COALESCE(n.post_count, 0) AS post_count #{ source } #{ where }
|
||||
ORDER BY #{ order } LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
|
||||
SQL
|
||||
{ count: total, group_count: total, state_rows: rows.map do |row|
|
||||
{ kind: row['kind'], tag_id: row['tag_id'], name: row['name'],
|
||||
deprecated: row['deprecated_at']&.iso8601,
|
||||
count: available?(@post_start, @at) ? row['post_count'].to_i : nil,
|
||||
at: @at.iso8601(6) }
|
||||
end }
|
||||
end
|
||||
|
||||
# All record filters apply after selecting the latest recorded version at the point.
|
||||
def restored_scope(resource)
|
||||
model = resource == 'posts' ? PostVersion : TagVersion
|
||||
table = model.table_name
|
||||
scope = model.where(Arel.sql(latest_condition(table, table, quote(@at))))
|
||||
.where.not(event_type: 'discard')
|
||||
if @tag
|
||||
scope = if resource == 'posts'
|
||||
scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }")
|
||||
elsif @kind == 'internal'
|
||||
scope.where(tag_id: @tag)
|
||||
else
|
||||
scope.none
|
||||
end
|
||||
end
|
||||
scope
|
||||
end
|
||||
|
||||
def filtered_scope(resource)
|
||||
params = @params.slice('from', 'before', 'user_id', 'resource_id')
|
||||
.merge('mode' => 'details', 'latest' => '0', 'sort' => '')
|
||||
CountsQuery.new(restored_scope(resource), resource, params,
|
||||
latest_expression: '1').relation
|
||||
end
|
||||
|
||||
def record_query(mode)
|
||||
params = @params.slice('locale', 'sort', 'page', 'limit')
|
||||
.merge('mode' => mode, 'latest' => '0', 'axis' => @axis == 'tags' ? 'month' : @axis,
|
||||
'page' => @page.to_s, 'limit' => @limit.to_s)
|
||||
CountsQuery.new(filtered_scope(@resource), @resource, params, latest_expression: '1')
|
||||
end
|
||||
|
||||
def summary
|
||||
return listing if @axis == 'tags'
|
||||
|
||||
query = record_query('summary')
|
||||
{ count: @metadata ? query.count : nil }.merge(query.summary(include_count: @metadata))
|
||||
end
|
||||
|
||||
def details
|
||||
query = record_query('details')
|
||||
{ count: @metadata ? query.count : nil,
|
||||
versions: CountsVersionRepr.many(query.details, resource: @resource,
|
||||
language_code: @language, at: @at,
|
||||
inclusive: @inclusive) }
|
||||
end
|
||||
|
||||
def next_boundary(time)
|
||||
case @grain
|
||||
when 'day' then time.beginning_of_day + 1.day
|
||||
when 'month' then time.beginning_of_month.next_month
|
||||
else time.beginning_of_year.next_year
|
||||
end
|
||||
end
|
||||
|
||||
def periods
|
||||
@before = timestamp('before') || @now.beginning_of_month
|
||||
@from = timestamp('from') || @before.prev_year
|
||||
raise ArgumentError if @from >= @before || @before > @now
|
||||
|
||||
rows = []
|
||||
boundary = next_boundary(@from)
|
||||
while boundary <= @before
|
||||
raise ArgumentError if rows.length >= MAX_PERIODS
|
||||
|
||||
format = { 'day' => '%Y-%m-%d', 'month' => '%Y-%m', 'year' => '%Y' }.fetch(@grain)
|
||||
rows << [boundary, (boundary - 1.second).strftime(format)]
|
||||
boundary = next_boundary(boundary)
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
def trend
|
||||
points = periods
|
||||
metadata = { count: points.length, group_count: points.length, state_rows: [],
|
||||
from: @from.iso8601, before: @before.iso8601, grain: @grain }
|
||||
return metadata if points.empty?
|
||||
|
||||
values = points.map do |boundary, period|
|
||||
"SELECT CAST(#{ quote(boundary) } AS DATETIME(6)) AS boundary, " +
|
||||
"#{ quote(period) } AS period"
|
||||
end.join(' UNION ALL ')
|
||||
order = order_sql({ 'period' => 'boundary', 'count' => 'post_count' },
|
||||
'period:asc', ['boundary ASC'])
|
||||
known = @post_start ? "periods.boundary > #{ quote(@post_start) }" : 'FALSE'
|
||||
rows = @connection.select_all(<<~SQL)
|
||||
WITH periods AS (#{ values }), series AS (
|
||||
SELECT periods.boundary, periods.period,
|
||||
CASE WHEN #{ known } THEN COUNT(DISTINCT p.post_id) ELSE NULL END AS post_count
|
||||
FROM periods
|
||||
LEFT JOIN post_versions p ON p.event_type <> 'discard'
|
||||
AND #{ membership('p') }
|
||||
AND #{ latest_condition('post_versions', 'p', 'periods.boundary') }
|
||||
AND #{ tag_visible('periods.boundary') }
|
||||
GROUP BY periods.boundary, periods.period
|
||||
)
|
||||
SELECT * FROM series ORDER BY #{ order }
|
||||
LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
|
||||
SQL
|
||||
metadata.merge(state_rows: rows.map do |row|
|
||||
{ kind: @kind, tag_id: @tag, name: nil, period: row['period'],
|
||||
at: row['boundary'].utc.iso8601(6), count: row['post_count']&.to_i }
|
||||
end)
|
||||
end
|
||||
|
||||
def order_sql(expressions, default, final)
|
||||
raw = @params.fetch('sort', default)
|
||||
sorts = raw.empty? ? [] : QueryState.list(raw).map { _1.split(':', -1) }
|
||||
raise ArgumentError if sorts.map(&:first).uniq.length != sorts.length
|
||||
raise ArgumentError if sorts.length > expressions.length
|
||||
|
||||
order = sorts.flat_map do |key, direction, *extra|
|
||||
if extra.any? || !expressions.key?(key) || !['asc', 'desc'].include?(direction)
|
||||
raise ArgumentError
|
||||
end
|
||||
expression = expressions.fetch(key)
|
||||
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
|
||||
end
|
||||
(order + final).join(', ')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,329 @@
|
||||
require 'csv'
|
||||
|
||||
# Export uses the exact same population and grouped cells as the interactive table.
|
||||
class CountsTableExport
|
||||
EXTRA = ['format', 'columns', 'headers', 'labels', 'children', 'child_sort',
|
||||
'order', 'nested_json', 'summary_columns', 'basis', 'axis', 'latest'].freeze
|
||||
attr_reader :format, :resource
|
||||
|
||||
def initialize(params)
|
||||
QueryState.validate!(params, CountsTableQuery::KEYS + EXTRA + ['measures'])
|
||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||
|
||||
params = CountsDefaults.for(params).merge(params)
|
||||
unless params.key?('children')
|
||||
params = params.merge('children' => CountsExportProjection.default_children(
|
||||
params.fetch('resource', 'posts')).join(' '))
|
||||
end
|
||||
@params = params
|
||||
@labels = JSON.parse(params.fetch('labels', '{}'))
|
||||
raise ArgumentError unless @labels.is_a?(Hash)
|
||||
@selected = params.key?('children') ? QueryState.list(params['children']) : nil
|
||||
@order = QueryState.list(params.fetch('order', ''))
|
||||
@child_sort = QueryState.list(params.fetch('child_sort', '')).map { _1.split(':', -1) }
|
||||
@child_sort.each do |path, direction, *extra|
|
||||
raise ArgumentError if extra.any? || !['asc', 'desc'].include?(direction)
|
||||
raise ArgumentError unless path&.match?(/\A[a-zA-Z_]+(?:\.[a-zA-Z]+)*\/[a-zA-Z]+\z/)
|
||||
end
|
||||
@format = params.fetch('format', 'json')
|
||||
@resource = params.fetch('resource', 'posts')
|
||||
raise ArgumentError unless ['json', 'csv'].include?(format)
|
||||
|
||||
@query = CountsTableQuery.new(params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
|
||||
key == 'measures' || QueryState.valid_key?(key) }
|
||||
.merge('page' => '1', 'limit' => '100'))
|
||||
@summary = @query.mode == 'summary'
|
||||
default_columns = @summary ? @query.axes.all.map { _1.fetch('field') } + ['count'] :
|
||||
CountsExportProjection.default_columns(resource)
|
||||
@columns = QueryState.list(params.fetch('columns', default_columns.join(' ')))
|
||||
default_headers = @columns.map { @labels.fetch(_1, _1) }
|
||||
@headers = JSON.parse(params.fetch('headers', default_headers.to_json))
|
||||
raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) }
|
||||
if @summary && @headers.any? && @headers.length != @query.axes.all.length + 1
|
||||
raise ArgumentError
|
||||
end
|
||||
|
||||
unless @summary
|
||||
if ['posts', 'tags'].include?(resource)
|
||||
@projection = CountsExportProjection.new(params, resource:, summary: false, tags: false)
|
||||
else
|
||||
allowed = @query.population.definition.fields.keys + ['is_latest']
|
||||
raise ArgumentError if (@columns - allowed).any?
|
||||
raise ArgumentError unless @columns.length == @headers.length && @columns.uniq == @columns
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def body(progress: nil)
|
||||
Enumerator.new do |output|
|
||||
ApplicationRecord.transaction(isolation: :repeatable_read) do
|
||||
@language_names = Language.pluck(:code, :name).to_h
|
||||
processed = 0
|
||||
started = false
|
||||
first = true
|
||||
@query.each_page do |page|
|
||||
total = @summary ? nil : page[:count]
|
||||
progress&.call(processed, total)
|
||||
unless started
|
||||
metadata = page.except(:versions, :cells, :axis_labels, :column_values,
|
||||
:row_headers, :column_headers, :pivot_totals).merge(
|
||||
resource:, record_unit: @summary && format == 'json' ? 'pivot_cell' : 'row',
|
||||
body_row_totals: 'once_per_logical_group', page_grand_total_repeated: false,
|
||||
filters: @params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
|
||||
key == 'measures' || QueryState.valid_key?(key) },
|
||||
fields: @query.population.definition.metadata,
|
||||
columns: @columns, column_labels: @headers,
|
||||
order: @params['order'], children: @params['children'],
|
||||
sort: @params.fetch('sort', ''), child_sort: @params['child_sort'])
|
||||
output << (format == 'json' ? "{\"metadata\":#{ metadata.to_json },\"data\":[" : "\uFEFF")
|
||||
if format == 'csv'
|
||||
headings = @summary ? pivot_headers(page) : [@headers]
|
||||
headings.each do |headers|
|
||||
output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n")
|
||||
end
|
||||
end
|
||||
started = true
|
||||
end
|
||||
if @summary
|
||||
pivot_rows(page).each do |header|
|
||||
records = page.fetch(:column_headers).map { pivot_cell(header, _1, page) }
|
||||
if format == 'csv'
|
||||
labels = pivot_row_labels(header, page)
|
||||
values = labels + records.map { _1[:count] }
|
||||
output << CSV.generate_line(values.map { safe(_1) }, row_sep: "\r\n")
|
||||
processed += records.length
|
||||
progress&.call(processed, nil)
|
||||
else
|
||||
records.each do |record|
|
||||
output << ',' unless first
|
||||
output << record.to_json
|
||||
first = false
|
||||
processed += 1
|
||||
progress&.call(processed, nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
next
|
||||
end
|
||||
page[:versions].each do |row|
|
||||
record = project(row)
|
||||
if format == 'json'
|
||||
output << ',' unless first
|
||||
output << record.to_json
|
||||
else
|
||||
values = if @summary
|
||||
row[:values].each_with_index.map do |value, index|
|
||||
summary_value(value, index, page)
|
||||
end + [row[:count]]
|
||||
elsif @projection
|
||||
@projection.csv_record(record, row)
|
||||
else
|
||||
record.map { |key, value| csv_logical(key, value) }
|
||||
end
|
||||
output << CSV.generate_line(values.map { safe(_1) }, row_sep: "\r\n")
|
||||
end
|
||||
first = false
|
||||
processed += 1
|
||||
progress&.call(processed, total)
|
||||
end
|
||||
end
|
||||
progress&.call(processed, processed)
|
||||
output << ']}' if format == 'json'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pivot_rows(page)
|
||||
page.fetch(:row_headers).reject do |header|
|
||||
header[:kind] == 'grand_total' && @query.page * @query.limit < page[:group_count]
|
||||
end
|
||||
end
|
||||
|
||||
def total_label(header)
|
||||
key = header[:kind] == 'grand_total' ?
|
||||
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal'
|
||||
@labels.fetch(key, '')
|
||||
end
|
||||
|
||||
def pivot_row_labels(header, page)
|
||||
values = header[:values].each_with_index.map { |value, index|
|
||||
summary_value(value, index, page)
|
||||
}
|
||||
if header[:kind] == 'data' && @previous_pivot_row&.fetch(:kind) == 'data'
|
||||
values.each_index do |index|
|
||||
if header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1)
|
||||
values[index] = ''
|
||||
end
|
||||
end
|
||||
end
|
||||
@previous_pivot_row = header
|
||||
values << total_label(header) unless header[:kind] == 'data'
|
||||
leading = [@query.axes.rows.length, @query.axes.columns.any? ? 1 : 0].max
|
||||
Array.new(leading) { values[_1] || '' }
|
||||
end
|
||||
|
||||
def pivot_headers(page)
|
||||
row_count = @query.axes.rows.length
|
||||
cols = @query.axes.columns
|
||||
leading = [row_count, cols.any? ? 1 : 0].max
|
||||
columns = page.fetch(:column_headers)
|
||||
headings = Array.new([cols.length, 1].max) do |level|
|
||||
prefix = Array.new(leading) { level.zero? ? @headers[_1].to_s : '' }
|
||||
prefix = [''] if row_count.zero? && cols.any?
|
||||
prefix + columns.each_with_index.map do |header, index|
|
||||
values = header[:values]
|
||||
if cols.empty?
|
||||
@labels.fetch('count', '')
|
||||
elsif level == values.length && header[:kind] != 'data'
|
||||
total_label(header)
|
||||
elsif level < values.length
|
||||
previous = index.positive? ? columns[index - 1][:values] : nil
|
||||
if level < cols.length - 1 && previous && previous.first(level + 1) == values.first(level + 1)
|
||||
''
|
||||
else
|
||||
summary_value(values[level], row_count + level, page)
|
||||
end
|
||||
else
|
||||
''
|
||||
end
|
||||
end
|
||||
end
|
||||
return headings if cols.empty?
|
||||
|
||||
cols.each_index.flat_map do |level|
|
||||
prefix = Array.new(leading, '')
|
||||
prefix = headings.first.first(leading) if level.zero?
|
||||
names = columns.each_with_index.map do |header, index|
|
||||
next '' if header[:values].length < level
|
||||
|
||||
previous = index.positive? ? columns[index - 1][:values] : nil
|
||||
if (previous && previous.length >= level &&
|
||||
previous.first(level) == header[:values].first(level))
|
||||
''
|
||||
else
|
||||
@headers[row_count + level].to_s
|
||||
end
|
||||
end
|
||||
headings[level] = Array.new(leading, '') + headings[level].drop(leading) if level.zero?
|
||||
[prefix + names, headings[level]]
|
||||
end
|
||||
end
|
||||
|
||||
def pivot_cell(row, column, page)
|
||||
@pivot_lookup ||= {}
|
||||
# Discard the previous page's lookup; memory remains bounded by one page and its rollups.
|
||||
unless @pivot_page.equal?(page)
|
||||
@pivot_page = page
|
||||
@pivot_lookup = page.fetch(:cells).to_h do |cell|
|
||||
values = cell[:values]
|
||||
[[values.first(@query.axes.rows.length), values.drop(@query.axes.rows.length)], cell[:count]]
|
||||
end
|
||||
page.fetch(:pivot_totals).each { @pivot_lookup[[_1[:row], _1[:column]]] = _1[:count] }
|
||||
end
|
||||
kinds = [row[:kind], column[:kind]]
|
||||
kind = if kinds.include?('grand_total')
|
||||
'grand_total'
|
||||
elsif kinds.include?('subtotal')
|
||||
'subtotal'
|
||||
else
|
||||
'data'
|
||||
end
|
||||
{ row: row[:values], column: column[:values], kind:,
|
||||
row_kind: row[:kind], column_kind: column[:kind],
|
||||
row_depth: row[:values].length, column_depth: column[:values].length,
|
||||
count: @pivot_lookup.fetch([row[:values], column[:values]], 0) }
|
||||
end
|
||||
|
||||
def summary_value(value, index, page)
|
||||
field = @query.axes.all.fetch(index).fetch('field')
|
||||
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
||||
|
||||
names = page[:axis_labels].fetch("a#{ index }", {})
|
||||
name = field == 'user' ? names[value.to_s] : names.fetch(value.to_s, value)
|
||||
if field == 'user'
|
||||
name = @labels.fetch('notRegistered', '') if name.nil? || name == ''
|
||||
pattern = @labels.fetch('actorFormat', '{{name}} (#{{id}})')
|
||||
return pattern.gsub(/\{\{(name|id)\}\}/) do
|
||||
Regexp.last_match(1) == 'name' ? name.to_s : value.to_s
|
||||
end
|
||||
end
|
||||
@labels.fetch("#{ field }:#{ value }", name)
|
||||
end
|
||||
|
||||
def project(row)
|
||||
return { row: row[:values].first(@query.axes.rows.length),
|
||||
column: row[:values].drop(@query.axes.rows.length), count: row[:count] } if @summary
|
||||
return @projection.project(row) if @projection
|
||||
|
||||
@columns.to_h do |column|
|
||||
key = column == 'user' ? 'created_by_user' : column
|
||||
value = row[key]
|
||||
value = project_nested(value, key) if ['members', 'placements', 'export_paths_json'].include?(key)
|
||||
[key, value]
|
||||
end
|
||||
end
|
||||
|
||||
def camel(key)
|
||||
key.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
|
||||
end
|
||||
|
||||
def project_nested(value, path)
|
||||
rows = path == 'export_paths_json' ? (value || {}).map { |profile, target|
|
||||
{ 'profile' => profile, 'path' => target }
|
||||
} : Array(value)
|
||||
sorts = @child_sort.filter_map do |key, direction|
|
||||
parent, field = key.split('/', 2)
|
||||
[field, direction] if parent == path
|
||||
end
|
||||
rows = rows.each_with_index.sort do |(first, a), (last, b)|
|
||||
compared = 0
|
||||
sorts.each do |field, direction|
|
||||
left = first.find { |key, _| camel(key) == field }&.last
|
||||
right = last.find { |key, _| camel(key) == field }&.last
|
||||
compared = if left.nil? || right.nil?
|
||||
left.nil? == right.nil? ? 0 : (left.nil? ? 1 : -1)
|
||||
else
|
||||
(left.to_s <=> right.to_s) * (direction == 'asc' ? 1 : -1)
|
||||
end
|
||||
break unless compared.zero?
|
||||
end
|
||||
compared.zero? ? a <=> b : compared
|
||||
end.map(&:first)
|
||||
rows.map do |row|
|
||||
fields = row.keys.select { !@selected || @selected.include?("#{ path }.#{ camel(_1) }") }
|
||||
ordered = (@order + fields.map { "#{ path }.#{ camel(_1) }" }).uniq
|
||||
.filter_map { |item| fields.find { "#{ path }.#{ camel(_1) }" == item } }
|
||||
ordered.to_h do |key|
|
||||
item = row[key]
|
||||
item = project_nested(item, "#{ path }.#{ camel(key) }") if item.is_a?(Array)
|
||||
[key, item]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def csv_logical(key, value)
|
||||
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
|
||||
return value ? @labels.fetch('latest', '') : '' if key == 'is_latest'
|
||||
if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
|
||||
return @query.population.timezone.utc_to_local(value.utc).iso8601
|
||||
end
|
||||
if key == 'language_code' || key == 'source_language_code'
|
||||
return @language_names[value]
|
||||
end
|
||||
if value.is_a?(Hash)
|
||||
return value.to_h { |field, item| [@labels.fetch(camel(field.to_s), ''), csv_logical(field, item)] }
|
||||
end
|
||||
return value.map { csv_logical(key, _1) } if value.is_a?(Array)
|
||||
|
||||
@labels.fetch("#{ key }:#{ value }", value)
|
||||
end
|
||||
|
||||
def safe(value)
|
||||
return value if value.is_a?(Numeric)
|
||||
|
||||
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
|
||||
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,345 @@
|
||||
# Common entrypoint for six version resources; legacy endpoints keep their contracts.
|
||||
class CountsTableQuery
|
||||
MAX_COLUMNS = 100
|
||||
class TooManyColumns < ArgumentError; end
|
||||
|
||||
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
|
||||
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
|
||||
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale', 'measures', 'column_order'].freeze
|
||||
attr_reader :population, :axes, :page, :limit, :mode
|
||||
|
||||
def initialize(params)
|
||||
QueryState.validate!(params, KEYS + ['measures'])
|
||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||
|
||||
params = CountsDefaults.for(params).slice(*KEYS).merge(params)
|
||||
@params = params
|
||||
@population = CountsPopulation.new(params)
|
||||
@definition = population.definition
|
||||
@connection = @definition.model.connection
|
||||
@axes = CountsAxes.new(population)
|
||||
@page = Integer(params.fetch('page', '1'), 10)
|
||||
@limit = Integer(params.fetch('limit', '20'), 10)
|
||||
@mode = params.fetch('mode', 'summary')
|
||||
raise ArgumentError unless page.between?(1, 1_000_000) && limit.between?(1, 100)
|
||||
raise ArgumentError unless ['summary', 'details'].include?(mode)
|
||||
|
||||
@sort = QueryState.list(params.fetch('sort', '')).map { _1.split(':', -1) }
|
||||
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
|
||||
@definition.fields.keys + ['is_latest'] +
|
||||
(@definition.resource == 'posts' ? ['tags', 'parents'] : []) +
|
||||
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
|
||||
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
|
||||
@sort.each do |key, direction, *extra|
|
||||
measure = mode == 'summary' && key&.match?(/\Am[0-9]{1,2}\z/)
|
||||
measure_values(key) if measure
|
||||
if extra.any? || (!allowed.include?(key) && !measure) || !['asc', 'desc'].include?(direction)
|
||||
raise ArgumentError
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def call
|
||||
ApplicationRecord.transaction do
|
||||
result = metadata.merge(mode == 'summary' ? summary : details)
|
||||
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
|
||||
# A scalar result has one row regardless of private attributes or predicates.
|
||||
public_count = result[:excluded_count].zero? || (mode == 'summary' && axes.rows.empty?)
|
||||
result.merge(visible_output_count: visible,
|
||||
output_count: public_count ? visible : nil,
|
||||
output_count_scope: public_count ?
|
||||
'before_authorisation' : 'unavailable_private_history',
|
||||
output_count_unit: 'table_body_rows',
|
||||
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
|
||||
result[:group_count] > limit)
|
||||
end
|
||||
end
|
||||
|
||||
def metadata
|
||||
@population_counts ||= HistoryAccess.population_counts(@definition.model)
|
||||
@population_counts.merge(
|
||||
population_count_scope: 'all_recorded_versions',
|
||||
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
||||
definition: population.history ? 'history' : 'latest',
|
||||
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
|
||||
fields: @definition.metadata,
|
||||
count: population.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count)
|
||||
end
|
||||
|
||||
def summary
|
||||
expressions = axes.expressions
|
||||
selected = expressions.each_with_index.map do |expression, index|
|
||||
"#{ expression } AS a#{ index }"
|
||||
end
|
||||
source = population.scope
|
||||
source = source.joins(axes.joins) unless axes.joins.empty?
|
||||
sql = if expressions.empty?
|
||||
source.except(:select).select('COUNT(DISTINCT v.id) AS count').to_sql
|
||||
else
|
||||
source.except(:select).group(*expressions.map { Arel.sql(_1) })
|
||||
.select(Arel.sql((selected + ['COUNT(DISTINCT v.id) AS count']).join(', ')))
|
||||
.to_sql
|
||||
end
|
||||
prefix = "WITH cells AS (#{ sql })"
|
||||
column_values = limited_columns(prefix)
|
||||
row_keys = axes.rows.each_index.map { "a#{ _1 }" }
|
||||
# Page whole row tuples so a pivot row is never split across display pages.
|
||||
row_selection = row_keys.empty? ? '1 AS singleton' : row_keys.join(', ')
|
||||
row_group = row_keys.empty? ? 'GROUP BY singleton' : "GROUP BY #{ row_keys.join(', ') }"
|
||||
measure_joins = []
|
||||
extras = []
|
||||
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
|
||||
if key == 'count' || key.match?(/\Am[0-9]+\z/)
|
||||
values = key == 'count' ? [] : measure_values(key)
|
||||
relation = measure_relation(values)
|
||||
matches = row_keys.map { "grouped.#{ _1 } <=> sort#{ index }.#{ _1 }" }
|
||||
condition = matches.empty? ? 'TRUE' : matches.join(' AND ')
|
||||
measure_joins << "LEFT JOIN (#{ relation }) sort#{ index } ON #{ condition }"
|
||||
extras << "sort#{ index }.count AS sort#{ index }"
|
||||
"sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }"
|
||||
elsif row_keys.include?(key)
|
||||
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
|
||||
end
|
||||
end
|
||||
order_rows = row_sort + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" }
|
||||
order_rows = ['singleton ASC'] if order_rows.empty?
|
||||
grouped = "SELECT #{ row_selection } FROM cells #{ row_group }"
|
||||
selection = (['grouped.*'] + extras).join(', ')
|
||||
group_sql = "SELECT #{ selection } FROM (#{ grouped }) grouped #{ measure_joins.join(' ') }"
|
||||
total = @connection.select_value(<<~SQL).to_i
|
||||
#{ prefix }
|
||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (#{ group_sql }) row_groups
|
||||
SQL
|
||||
join = row_keys.map { "cells.#{ _1 } <=> page_rows.#{ _1 }" }.join(' AND ')
|
||||
join = 'TRUE' if row_keys.empty?
|
||||
order_cols = @sort.filter_map do |key, direction|
|
||||
next unless key.match?(/\Aa[0-9]+\z/) && !row_keys.include?(key)
|
||||
|
||||
"cells.#{ key } #{ direction.upcase }"
|
||||
end
|
||||
order = ['page_rows.position'] + order_cols +
|
||||
axes.all.each_index.map { "cells.a#{ _1 }" }
|
||||
records = @connection.select_all(<<~SQL)
|
||||
#{ prefix }, row_groups AS (#{ group_sql }),
|
||||
numbered AS (SELECT row_groups.*,
|
||||
ROW_NUMBER() OVER (ORDER BY #{ order_rows.join(', ') }) AS position FROM row_groups),
|
||||
page_rows AS (SELECT * FROM numbered
|
||||
WHERE position > #{ (page - 1) * limit } AND position <= #{ page * limit })
|
||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ cells.* FROM cells JOIN page_rows ON #{ join }
|
||||
ORDER BY #{ order.join(', ') }
|
||||
SQL
|
||||
next_row = @connection.select_one(<<~SQL)
|
||||
#{ prefix }, row_groups AS (#{ group_sql }),
|
||||
numbered AS (SELECT row_groups.*,
|
||||
ROW_NUMBER() OVER (ORDER BY #{ order_rows.join(', ') }) AS position FROM row_groups)
|
||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM numbered
|
||||
WHERE position = #{ page * limit + 1 }
|
||||
SQL
|
||||
row_values = records.map { |record| row_keys.map { record[_1] } }.uniq
|
||||
row_depths = CountsPivotLayout.depths(row_keys, @sort)
|
||||
column_keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
|
||||
column_sort = @sort
|
||||
if @params.key?('column_order') && column_keys.any? &&
|
||||
!(@sort.first&.first == 'count' || @sort.first&.first&.match?(/\Am[0-9]+\z/))
|
||||
column_sort = [[column_keys.first, 'asc']] + @sort
|
||||
end
|
||||
column_depths = CountsPivotLayout.depths(column_keys, column_sort, measures: false)
|
||||
row_headers = CountsPivotLayout.headers(row_values, row_depths,
|
||||
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?)
|
||||
if axes.rows.empty? && axes.columns.any?
|
||||
row_headers = [{ values: [], kind: 'grand_total' }]
|
||||
end
|
||||
column_headers = CountsPivotLayout.headers(column_values, column_depths,
|
||||
grand: axes.columns.any?)
|
||||
if axes.columns.empty? && axes.rows.any?
|
||||
column_headers = [{ values: [], kind: 'grand_total' }]
|
||||
end
|
||||
totals = CountsPivotTotals.new(population, axes, row_headers, column_headers).call
|
||||
{ group_count: total,
|
||||
visible_output_count: output_row_count(prefix, row_keys, row_depths, total),
|
||||
column_values:, row_headers:, column_headers:, pivot_totals: totals,
|
||||
cells: records.map do |row|
|
||||
{ values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i }
|
||||
end, axis_labels: axis_labels(records, column_values) }
|
||||
end
|
||||
|
||||
def details
|
||||
query = population.scope.select(Arel.sql(
|
||||
"(#{ population.latest_sql }) AS counts_latest"))
|
||||
order = sort_sql { detail_expression(_1) }
|
||||
query = query.order(Arel.sql((order + ['v.id DESC']).join(', ')))
|
||||
.limit(limit).offset((page - 1) * limit)
|
||||
rows = query.to_a
|
||||
if ['posts', 'tags'].include?(@definition.resource)
|
||||
point = population.history ? population.at :
|
||||
population.timestamp(@params['at']) || population.at
|
||||
values = CountsVersionRepr.many(rows, resource: @definition.resource,
|
||||
language_code: locale.language_code,
|
||||
at: point)
|
||||
values.each_with_index { |value, index| value[:event_type] = rows[index].event_type }
|
||||
else
|
||||
users = User.where(id: rows.map(&:created_by_user_id)).pluck(:id, :name).to_h
|
||||
values = rows.map do |row|
|
||||
attributes = @definition.fields.keys.to_h do |key|
|
||||
[key, row.public_send(@definition.column(key))]
|
||||
end
|
||||
attributes.merge('id' => row.id, 'resource_id' => row[@definition.keys.first],
|
||||
'is_latest' => row.attributes['counts_latest'].to_i == 1,
|
||||
'created_by_user' => row.created_by_user_id &&
|
||||
{ id: row.created_by_user_id, name: users[row.created_by_user_id] })
|
||||
end
|
||||
end
|
||||
{ versions: values }
|
||||
end
|
||||
|
||||
def each_page
|
||||
total = nil
|
||||
loop do
|
||||
result = call
|
||||
total ||= mode == 'summary' ? result[:group_count] : result[:count]
|
||||
yield result
|
||||
break if page * limit >= total
|
||||
|
||||
@page += 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def output_row_count(prefix, row_keys, depths, total)
|
||||
# With no row axes the table has exactly one body row, including the zero-count case.
|
||||
return 1 if row_keys.empty?
|
||||
|
||||
subtotals = depths.map do |depth|
|
||||
keys = row_keys.first(depth).join(', ')
|
||||
"(SELECT COUNT(*) FROM (SELECT #{ keys } FROM cells GROUP BY #{ keys }) subtotal_rows)"
|
||||
end
|
||||
expressions = [total.to_s, '1'] + subtotals
|
||||
@output_row_count ||= @connection.select_value(<<~SQL).to_i
|
||||
#{ prefix }
|
||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ #{ expressions.join(' + ') } AS output_rows
|
||||
SQL
|
||||
end
|
||||
|
||||
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
|
||||
|
||||
def limited_columns(prefix)
|
||||
keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
|
||||
return [[]] if keys.empty?
|
||||
|
||||
configured = @sort.select { |key, _direction| keys.include?(key) }
|
||||
directions = (configured + keys.map { [_1, 'asc'] }).uniq { _1.first }
|
||||
order = directions.map { |key, direction|
|
||||
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
|
||||
}.join(', ')
|
||||
custom = QueryState.records(@params, 'column_order')
|
||||
if custom.any?
|
||||
raise ArgumentError unless custom.map { _1['value'] }.uniq.length == custom.length
|
||||
|
||||
cases = custom.each_with_index.map do |record, index|
|
||||
"WHEN #{ keys.first } <=> #{ @connection.quote(record['value']) } THEN #{ index }"
|
||||
end.join(' ')
|
||||
order = "CASE #{ cases } ELSE #{ custom.length } END, #{ order }"
|
||||
end
|
||||
values = @connection.select_rows(<<~SQL)
|
||||
#{ prefix }
|
||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
|
||||
ORDER BY #{ order } LIMIT #{ MAX_COLUMNS + 1 }
|
||||
SQL
|
||||
raise TooManyColumns if values.length > MAX_COLUMNS
|
||||
|
||||
values
|
||||
end
|
||||
|
||||
def measure_relation(values)
|
||||
row_expressions = axes.expressions.first(axes.rows.length)
|
||||
source = population.scope.except(:select, :order)
|
||||
fields = axes.rows + axes.columns.first(values.length)
|
||||
joins = axes.joins(fields.map { _1.fetch('field') })
|
||||
source = source.joins(joins) unless joins.empty?
|
||||
values.each_with_index do |value, index|
|
||||
expression = axes.expressions[axes.rows.length + index]
|
||||
source = source.where("(#{ expression }) <=> ?", value)
|
||||
end
|
||||
selected = row_expressions.each_with_index.map { |expression, index|
|
||||
"#{ expression } AS a#{ index }"
|
||||
} + ['COUNT(DISTINCT v.id) AS count']
|
||||
source = source.group(*row_expressions.map { Arel.sql(_1) }) if row_expressions.any?
|
||||
source.select(Arel.sql(selected.join(', '))).to_sql
|
||||
end
|
||||
|
||||
def measure_values(key)
|
||||
records = QueryState.records(@params, 'measures').select { _1['key'] == key }
|
||||
raise ArgumentError if records.empty?
|
||||
raise ArgumentError unless records.map { _1['axis'] }.sort == (0...records.length).to_a
|
||||
|
||||
value = records.sort_by { _1['axis'] }.map { _1['value'] }
|
||||
unless (value.is_a?(Array) && value.length <= axes.columns.length &&
|
||||
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
|
||||
raise ArgumentError
|
||||
end
|
||||
value
|
||||
end
|
||||
|
||||
def detail_expression(key)
|
||||
return "(#{ population.latest_sql })" if key == 'is_latest'
|
||||
aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ?
|
||||
'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at' }
|
||||
field = aliases.fetch(key, key)
|
||||
expression = @definition.expression(field)
|
||||
return expression unless @definition.fields[field] == :json
|
||||
|
||||
"CAST(#{ expression } AS CHAR) COLLATE utf8mb4_bin"
|
||||
end
|
||||
|
||||
def sort_sql
|
||||
@sort.flat_map do |key, direction|
|
||||
expression = yield key
|
||||
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
|
||||
end
|
||||
end
|
||||
|
||||
def axis_labels(records, column_values)
|
||||
result = {}
|
||||
axes.all.each_with_index do |axis, index|
|
||||
values = records.map { _1["a#{ index }"] }
|
||||
if index >= axes.rows.length
|
||||
values += column_values.map { _1[index - axes.rows.length] }
|
||||
end
|
||||
values = values.compact.uniq
|
||||
labels = case axis['field']
|
||||
when 'user'
|
||||
User.where(id: values).pluck(:id, :name).to_h.transform_keys(&:to_s)
|
||||
when 'language_code', 'source_language_code'
|
||||
Language.where(code: values).pluck(:code, :name).to_h
|
||||
when 'tags'
|
||||
tag_labels(values)
|
||||
else {}
|
||||
end
|
||||
result["a#{ index }"] = labels
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def tag_labels(values)
|
||||
internal = values.grep(/\Ainternal:/).map { _1.split(':').last.to_i }
|
||||
external = values.grep(/\Aexternal:/).map { _1.split(':').last.to_i }
|
||||
point = population.timestamp(@params['at']) || population.at
|
||||
labels = {}
|
||||
sources = [[TagVersion, internal, 'internal'], [NicoTagVersion, external, 'external']]
|
||||
sources.each do |model, ids, kind|
|
||||
next if ids.empty?
|
||||
|
||||
table = model.table_name
|
||||
model.where(tag_id: ids).where('created_at <= ?', point)
|
||||
.where("NOT EXISTS (SELECT 1 FROM #{ table } newer " +
|
||||
"WHERE newer.tag_id = #{ table }.tag_id AND newer.created_at <= ? " +
|
||||
"AND newer.version_no > #{ table }.version_no)", point).each do |row|
|
||||
names = row.respond_to?(:names) ? row.names : nil
|
||||
name = names&.dig(locale.language_code, 'name') || row.name
|
||||
labels["#{ kind }:#{ row.tag_id }"] = name
|
||||
end
|
||||
end
|
||||
labels
|
||||
end
|
||||
end
|
||||
@@ -95,14 +95,10 @@ class PostCreatePlan
|
||||
def build_default_tag_specs locale, direct_tag_specs
|
||||
default_tag_specs = []
|
||||
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
|
||||
default_tag_specs << {
|
||||
name: TAGME_TAG_NAME,
|
||||
category: :meta }
|
||||
default_tag_specs << { name: TAGME_TAG_NAME, category: :meta }
|
||||
end
|
||||
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) }
|
||||
default_tag_specs << {
|
||||
name: NO_DEERJIKIST_TAG_NAME,
|
||||
category: :meta }
|
||||
default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta }
|
||||
end
|
||||
|
||||
default_tag_specs
|
||||
@@ -117,9 +113,9 @@ class PostCreatePlan
|
||||
end
|
||||
|
||||
def validate_new_tag_spec! locale, spec
|
||||
tag_name = TagName.new(name: spec[:name], language_code: locale.language_code,
|
||||
script_code: locale.script_code, primary_flg: true)
|
||||
tag = Tag.new(category: spec[:category])
|
||||
tag_name = TagName.new(tag:, name: spec[:name], language_code: locale.language_code,
|
||||
script_code: locale.script_code, primary_flg: true)
|
||||
return if tag_name.valid? && tag.valid?
|
||||
|
||||
post = Post.new
|
||||
@@ -140,10 +136,10 @@ class PostCreatePlan
|
||||
end
|
||||
expanded_parent_specs =
|
||||
Tag.expand_parent_tags(existing_snapshot_tags)
|
||||
.reject(&:deprecated?)
|
||||
.map { |tag|
|
||||
{ name: tag.name(locale.language_code),
|
||||
category: tag.category.to_sym }
|
||||
.reject(&:deprecated?)
|
||||
.map { |tag|
|
||||
{ name: tag.name(locale.language_code),
|
||||
category: tag.category.to_sym }
|
||||
}
|
||||
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
|
||||
end
|
||||
@@ -164,9 +160,10 @@ class PostCreatePlan
|
||||
def existing_tags_by_name locale
|
||||
@existing_tags_by_name ||= begin
|
||||
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
|
||||
TagName.where(language_code: locale.language_code, primary_flg: true, name: names)
|
||||
.includes(:tag)
|
||||
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
|
||||
TagName
|
||||
.where(language_code: locale.language_code, primary_flg: true, name: names)
|
||||
.includes(:tag)
|
||||
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
|
||||
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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Shared literal parsing and reference resolution for current and snapshot post search.
|
||||
class PostTagSearch
|
||||
def self.parse_references(raw, literals)
|
||||
return nil unless raw.key?('tag_refs')
|
||||
|
||||
references = QueryState.records(raw, 'tag_refs')
|
||||
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 ArgumentError unless valid
|
||||
|
||||
references.map { _1['name'] } == literals ? references : nil
|
||||
end
|
||||
|
||||
def self.literals(locale, raw, references: nil)
|
||||
raw.split.each_with_index.filter_map do |token, index|
|
||||
next if token.downcase == 'type:or'
|
||||
|
||||
reference = references&.[](index)
|
||||
name = token.sub(/\Anot:/i, '')
|
||||
name = TagName.canonicalise(locale, name).first unless reference
|
||||
negative = reference&.key?('negative') ? reference['negative'] :
|
||||
token.downcase.start_with?('not:')
|
||||
{ name:, negative:, reference: }
|
||||
end
|
||||
end
|
||||
|
||||
def self.references(locale, literal)
|
||||
reference = literal[:reference]
|
||||
return [reference['tag_id'], reference['external_tag_id']] if reference
|
||||
|
||||
[TagName.where(language_code: locale.language_code, name: literal[:name])
|
||||
.distinct.pluck(:tag_id),
|
||||
ExternalTag.where("CONCAT(platform, ':', name) = ?", literal[:name]).pluck(:id)]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
# Public query grammar shared with frontend/src/lib/queryState.ts.
|
||||
class QueryState
|
||||
SCHEMAS = {
|
||||
'conditions' => ['field', 'operator', 'value', 'end', 'blank'],
|
||||
'drill' => ['field', 'grain', 'value', 'value_type'],
|
||||
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
||||
'column_order' => ['value', 'value_type'],
|
||||
'measures' => ['key', 'axis', 'value', 'value_type'] }.freeze
|
||||
|
||||
def self.list(value)
|
||||
return [] if value.nil? || value == ''
|
||||
raise ArgumentError unless value.is_a?(String) && value.bytesize <= 32_768
|
||||
|
||||
values = value.split(/ /, -1)
|
||||
raise ArgumentError if values.length > 200 || values.any?(&:empty?) || values.uniq != values
|
||||
|
||||
values
|
||||
end
|
||||
|
||||
def self.valid_key?(key)
|
||||
root, index, field, extra = key.split('.', -1)
|
||||
!extra && SCHEMAS.key?(root) && index&.match?(/\A(?:0|[1-9][0-9]?)\z/) &&
|
||||
SCHEMAS[root].include?(field)
|
||||
end
|
||||
|
||||
def self.validate!(params, allowed)
|
||||
raise ArgumentError if params.length > 1000
|
||||
params.each do |key, value|
|
||||
raise ArgumentError unless allowed.include?(key) || valid_key?(key)
|
||||
raise ArgumentError unless value.is_a?(String) && value.bytesize <= 131_072
|
||||
end
|
||||
SCHEMAS.each_key { records(params, _1) }
|
||||
end
|
||||
|
||||
def self.records(params, key)
|
||||
records = {}
|
||||
params.each do |name, value|
|
||||
next unless name.start_with?("#{ key }.")
|
||||
raise ArgumentError unless params.key?(key) && valid_key?(name)
|
||||
|
||||
_root, index, field = name.split('.')
|
||||
record = records[index.to_i] ||= {}
|
||||
if ['blank', 'negative', 'missing'].include?(field)
|
||||
raise ArgumentError unless ['0', '1'].include?(value)
|
||||
|
||||
value = value == '1'
|
||||
elsif ['tag_id', 'external_tag_id', 'axis'].include?(field)
|
||||
raise ArgumentError unless value.match?(/\A[0-9]{1,18}\z/)
|
||||
|
||||
value = value.to_i
|
||||
end
|
||||
record[field] = value
|
||||
end
|
||||
raise ArgumentError if params.key?(key) && params[key] != ''
|
||||
raise ArgumentError unless records.keys.sort == (0...records.length).to_a
|
||||
|
||||
records.sort.map do |_index, record|
|
||||
type = record.delete('value_type')
|
||||
if ['drill', 'measures', 'column_order'].include?(key)
|
||||
raise ArgumentError unless ['null', 'string', 'number', 'boolean'].include?(type)
|
||||
|
||||
raise ArgumentError unless type == 'null' || record.key?('value')
|
||||
|
||||
value = record['value']
|
||||
record['value'] = case type
|
||||
when 'null' then nil
|
||||
when 'number'
|
||||
raise ArgumentError unless value&.match?(/\A-?[0-9]{1,18}\z/)
|
||||
Integer(value, 10)
|
||||
when 'boolean'
|
||||
raise ArgumentError unless ['true', 'false'].include?(value)
|
||||
value == 'true'
|
||||
else value
|
||||
end
|
||||
end
|
||||
record
|
||||
end
|
||||
end
|
||||
|
||||
def self.axes(value)
|
||||
list(value).map do |token|
|
||||
field, grain, extra = token.split(':', -1)
|
||||
raise ArgumentError if extra || !field.match?(/\A[a-z_]+\z/) || grain == ''
|
||||
|
||||
grain ? { 'field' => field, 'grain' => grain } : { 'field' => field }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,137 @@
|
||||
require 'digest'
|
||||
|
||||
|
||||
class SharedResourceIdAllocator
|
||||
SPACES = {
|
||||
'tags' => ['tags', 'external_tags'],
|
||||
'wiki' => ['tag_descriptions', 'tag_groups'],
|
||||
'wiki_history' => ['tag_description_versions', 'tag_group_versions'] }.freeze
|
||||
HISTORY_IDS = {
|
||||
'tags' => [
|
||||
['tag_versions', 'tag_id'],
|
||||
['nico_tag_versions', 'tag_id'],
|
||||
['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
|
||||
lock_space!(record.class, space)
|
||||
|
||||
# 明示-tic な Id. 指定は許可する.
|
||||
return if record.id.present?
|
||||
|
||||
next_id = record.class.uncached do
|
||||
historical_max = HISTORY_IDS.fetch(space, []).map { |table, column|
|
||||
quoted_table = connection.quote_table_name(table)
|
||||
quoted_column = connection.quote_column_name(column)
|
||||
connection.select_value("SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i
|
||||
}.max.to_i
|
||||
|
||||
[next_ids(connection, tables).max, historical_max + 1].max
|
||||
end
|
||||
|
||||
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
|
||||
connection.execute('SET SESSION information_schema_stats_expiry = 0')
|
||||
quoted_tables = tables.map { connection.quote(_1) }.join(', ')
|
||||
rows = connection.select_all(<<~SQL).to_a
|
||||
SELECT TABLE_NAME, ENGINE, AUTO_INCREMENT
|
||||
FROM information_schema.tables
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (#{ quoted_tables })
|
||||
SQL
|
||||
persistent = rows.all? do |row|
|
||||
row['ENGINE'] == 'InnoDB' && row['AUTO_INCREMENT'].to_i.positive?
|
||||
end
|
||||
unless rows.size == tables.size && persistent
|
||||
raise ActiveRecord::ActiveRecordError, 'Persistent InnoDB counters are required'
|
||||
end
|
||||
rows.map { _1.fetch('AUTO_INCREMENT').to_i }
|
||||
ensure
|
||||
connection.execute("SET SESSION information_schema_stats_expiry = #{ expiry }")
|
||||
end
|
||||
end
|
||||
private_class_method :next_ids
|
||||
end
|
||||
@@ -1,54 +1,69 @@
|
||||
class TagDescriptionOperation
|
||||
LANGUAGE_CODE = 'ja'
|
||||
class Conflict < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.create!(tag:, body:, created_by_user:, message: nil)
|
||||
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: LANGUAGE_CODE)
|
||||
tag_id: tag.id, language_code: locale.language_code)
|
||||
|
||||
page = Wiki::CompatibilityWriter.create!(
|
||||
tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:)
|
||||
revision = page.wiki_revisions.order(id: :desc).first
|
||||
record_version!(tag:, revision:, body:, user: created_by_user,
|
||||
description: nil)
|
||||
page
|
||||
description = TagDescription.new(tag:, language_code: locale.language_code)
|
||||
body = normalise_body!(description, body)
|
||||
record_version!(description:, body:, user: created_by_user)
|
||||
|
||||
description
|
||||
end
|
||||
end
|
||||
|
||||
def self.update!(tag:, body:, updated_by_user:, message: nil,
|
||||
base_revision_id: nil)
|
||||
def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
description = TagDescription.lock.find_by!(
|
||||
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
||||
page = WikiPage.lock.find(description.id)
|
||||
revision = Wiki::CompatibilityWriter.update!(
|
||||
page:, body:, updated_by_user:, message:,
|
||||
base_revision_id:)
|
||||
next unless revision
|
||||
tag_id: tag.id, language_code: locale.language_code)
|
||||
body = normalise_body!(description, body)
|
||||
latest = TagDescriptionVersion.find_by(
|
||||
tag_id: tag.id, language_code: locale.language_code,
|
||||
version_no: description.version_no)
|
||||
if base_revision_id.present? && latest&.id != base_revision_id.to_i
|
||||
raise Conflict, '競合が発生してゐます.'
|
||||
end
|
||||
next if latest && normalise_body(description.body) == body
|
||||
|
||||
record_version!(tag:, revision:, body: revision.body,
|
||||
user: updated_by_user, description:)
|
||||
revision
|
||||
record_version!(description:, body:, user: updated_by_user)
|
||||
end
|
||||
end
|
||||
|
||||
def self.record_version! tag:, revision:, body:, user:, description:
|
||||
version_no = description ? description.version_no + 1 : 1
|
||||
def self.record_version! description:, body:, user:
|
||||
creating = description.new_record?
|
||||
version_no = creating ? 1 : description.version_no + 1
|
||||
description.created_by_user_id = user.id if creating
|
||||
description.update!(body:, version_no:, updated_by_user_id: user.id)
|
||||
TagDescriptionVersion.create!(
|
||||
id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE,
|
||||
version_no:, event_type: description ? 'update' : 'create', body:,
|
||||
created_by_user_id: user.id, created_at: revision.created_at)
|
||||
if description
|
||||
description.update!(body:, version_no:, updated_by_user_id: user.id)
|
||||
else
|
||||
TagDescription.create!(
|
||||
id: revision.wiki_page_id, tag_id: tag.id,
|
||||
language_code: LANGUAGE_CODE, version_no:, body:,
|
||||
created_by_user_id: user.id, updated_by_user_id: user.id)
|
||||
end
|
||||
tag_id: description.tag_id, language_code: description.language_code,
|
||||
version_no:, event_type: creating ? 'create' : 'update', body:,
|
||||
source_language_code: description.source_language_code,
|
||||
source_version_no: description.source_version_no,
|
||||
created_by_user_id: user.id, created_at: Time.current)
|
||||
end
|
||||
|
||||
private_class_method :record_version!
|
||||
|
||||
def self.normalise_body body
|
||||
body.to_s.gsub(/\r\n?/, "\n")
|
||||
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
|
||||
.sub(/\n+\z/, '')
|
||||
end
|
||||
private_class_method :normalise_body
|
||||
|
||||
def self.normalise_body! description, body
|
||||
normalised = normalise_body(body)
|
||||
return normalised if normalised.present?
|
||||
|
||||
description.errors.add(:body, :blank)
|
||||
raise ActiveRecord::RecordInvalid, description
|
||||
end
|
||||
private_class_method :normalise_body!
|
||||
end
|
||||
|
||||
@@ -7,8 +7,8 @@ class TagGroupMarkdownParser
|
||||
;
|
||||
end
|
||||
|
||||
def self.parse(body:, group: nil)
|
||||
new(body:, group:).parse
|
||||
def self.parse(locale, body:, group: nil)
|
||||
new(body:, group:).parse(locale)
|
||||
end
|
||||
|
||||
def initialize(body:, group:)
|
||||
@@ -16,7 +16,7 @@ class TagGroupMarkdownParser
|
||||
@group = group
|
||||
end
|
||||
|
||||
def parse
|
||||
def parse locale
|
||||
parse_body = @body.gsub(/([^\n])\n([\x23]{1,6}[ \t]+\S)/, "\\1\n\n\\2")
|
||||
root = Kramdown::Document.new(parse_body, input: 'GFM', auto_ids: false).root
|
||||
placements = []
|
||||
@@ -51,7 +51,7 @@ class TagGroupMarkdownParser
|
||||
when :ul
|
||||
invalid!('未対応のリスト項目があります.') if node.attr.any?
|
||||
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
|
||||
target.concat(node.children.map { parse_list_item(_1) })
|
||||
target.concat(node.children.map { parse_list_item(locale, _1) })
|
||||
else
|
||||
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
@@ -66,7 +66,7 @@ class TagGroupMarkdownParser
|
||||
|
||||
private
|
||||
|
||||
def parse_list_item(item)
|
||||
def parse_list_item locale, item
|
||||
invalid!('リスト項目ではありません.') unless item.type == :li
|
||||
invalid!('未対応のリスト項目があります.') if item.attr.any?
|
||||
|
||||
@@ -81,29 +81,30 @@ class TagGroupMarkdownParser
|
||||
label = own.map { inline_text(_1) }.join(' ').strip
|
||||
invalid!('空のリスト項目は使用できません.') if label.blank?
|
||||
|
||||
node = if label.start_with?(PREFIX)
|
||||
group_name = label.delete_prefix(PREFIX).strip
|
||||
child_group = TagGroup.find_by(name: group_name)
|
||||
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
|
||||
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
|
||||
{ 'tag_group_id' => child_group.id }
|
||||
elsif label.end_with?(':', ':')
|
||||
section_name = label.sub(/[::]\z/, '').strip
|
||||
invalid!('Section 名は必須です.') if section_name.blank?
|
||||
{ 'section_name' => section_name, 'description' => nil }
|
||||
else
|
||||
{ 'tag_id' => resolve_tag(label) }
|
||||
end
|
||||
node =
|
||||
if (group_name = TagGroupRepr.name_from_title(label))
|
||||
group_name = group_name.strip
|
||||
child_group = TagGroup.find_by(name: group_name)
|
||||
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
|
||||
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
|
||||
{ 'tag_group_id' => child_group.id }
|
||||
elsif label.end_with?(':', ':')
|
||||
section_name = label.sub(/[::]\z/, '').strip
|
||||
invalid!('Section 名は必須です.') if section_name.blank?
|
||||
{ 'section_name' => section_name, 'description' => nil }
|
||||
else
|
||||
{ 'tag_id' => resolve_tag(locale, label) }
|
||||
end
|
||||
|
||||
node['children'] = nested.flat_map do |list|
|
||||
invalid!('未対応の入れ子リストです.') unless list.type == :ul
|
||||
list.children.map { parse_list_item(_1) }
|
||||
list.children.map { parse_list_item(locale, _1) }
|
||||
end
|
||||
node
|
||||
end
|
||||
|
||||
def resolve_tag(name)
|
||||
names = TagName.where(language_code: 'ja', name:).to_a
|
||||
def resolve_tag locale, name
|
||||
names = TagName.where(language_code: locale.language_code, name:).to_a
|
||||
tag_ids = names.filter_map(&:tag_id).uniq
|
||||
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
|
||||
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
|
||||
|
||||
@@ -19,15 +19,20 @@ class TagGroupMarkdownRenderer
|
||||
blocks << clean_text(@group['description'])
|
||||
end
|
||||
|
||||
sections = placements.select { _1.key?('section_name') }
|
||||
if sections.any? && sections.size != placements.size
|
||||
raise UnsupportedStructure,
|
||||
'最上位の見出し Section と Placement の混在は Markdown にできません.'
|
||||
end
|
||||
first_section = placements.index { _1.key?('section_name') }
|
||||
|
||||
if placements.all? { _1.key?('section_name') }
|
||||
blocks.concat(placements.map { render_heading(_1, 2) })
|
||||
else
|
||||
if first_section
|
||||
if placements.drop(first_section).any? { !_1.key?('section_name') }
|
||||
raise UnsupportedStructure,
|
||||
'見出し Section の後に最上位 Placement は配置できません.'
|
||||
end
|
||||
|
||||
if first_section.positive?
|
||||
blocks << render_list(placements.take(first_section), 0)
|
||||
end
|
||||
|
||||
blocks.concat(placements.drop(first_section).map { render_heading(_1, 2) })
|
||||
elsif placements.any?
|
||||
blocks << render_list(placements, 0)
|
||||
end
|
||||
|
||||
@@ -74,7 +79,7 @@ class TagGroupMarkdownRenderer
|
||||
first_section = section_indexes.first
|
||||
if children[first_section..].any? { !_1.key?('section_name') }
|
||||
raise UnsupportedStructure,
|
||||
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
|
||||
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
|
||||
end
|
||||
list_nodes = children[0...first_section]
|
||||
blocks << render_list(list_nodes, 0) if list_nodes.any?
|
||||
|
||||
@@ -1,49 +1,51 @@
|
||||
class TagGroupOperation
|
||||
MAX_PLACEMENT_DEPTH = 5
|
||||
PREFIX = TagGroupRepr::PREFIX
|
||||
|
||||
def self.create!(name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false, compatibility_body: nil)
|
||||
group, sync_issue = ApplicationRecord.transaction do
|
||||
def self.create!(locale, name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false)
|
||||
ApplicationRecord.transaction do
|
||||
group = TagGroup.new(version_no: 1, created_by_user: actor,
|
||||
updated_by_user: actor)
|
||||
attributes = prepare!(group:, name:, description:, members:, placements:)
|
||||
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
|
||||
group.assign_attributes(name: attributes.fetch(:name),
|
||||
description: attributes.fetch(:description))
|
||||
group.save!
|
||||
persist_structure!(group:, attributes:, actor:)
|
||||
validate_compatibility!(locale, group) if require_compatibility
|
||||
persist_version!(group:, attributes:, actor:, event_type: 'create')
|
||||
issue = sync_compatibility!(group:, actor:, old_name: nil,
|
||||
require_compatibility:, compatibility_body:)
|
||||
[group, issue]
|
||||
group
|
||||
end
|
||||
report_sync_issue(group, actor, sync_issue)
|
||||
group
|
||||
end
|
||||
|
||||
def self.update!(group:, name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false, compatibility_body: nil)
|
||||
group, sync_issue = ApplicationRecord.transaction do
|
||||
def self.update!(locale, group:, name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false)
|
||||
ApplicationRecord.transaction do
|
||||
group.lock!
|
||||
old_name = group.name
|
||||
attributes = prepare!(group:, name:, description:, members:, placements:)
|
||||
next [group, nil] if unchanged?(group, attributes)
|
||||
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
|
||||
if unchanged?(group, attributes)
|
||||
validate_compatibility!(locale, group) if require_compatibility
|
||||
next group
|
||||
end
|
||||
|
||||
group.update!(name: attributes.fetch(:name),
|
||||
description: attributes.fetch(:description),
|
||||
version_no: group.version_no + 1,
|
||||
updated_by_user: actor)
|
||||
persist_structure!(group:, attributes:, actor:)
|
||||
validate_compatibility!(locale, group) if require_compatibility
|
||||
persist_version!(group:, attributes:, actor:, event_type: 'update')
|
||||
issue = sync_compatibility!(group:, actor:, old_name:,
|
||||
require_compatibility:, compatibility_body:)
|
||||
[group, issue]
|
||||
group
|
||||
end
|
||||
report_sync_issue(group, actor, sync_issue)
|
||||
group
|
||||
end
|
||||
|
||||
def self.prepare!(group:, name:, description:, members:, placements:)
|
||||
def self.validate_compatibility! locale, group
|
||||
TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group))
|
||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||
invalid!(group, :base, error.message)
|
||||
end
|
||||
private_class_method :validate_compatibility!
|
||||
|
||||
def self.prepare!(locale:, group:, name:, description:, members:, placements:)
|
||||
record = group
|
||||
normalized_name = name.to_s.strip
|
||||
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
|
||||
@@ -61,7 +63,7 @@ class TagGroupOperation
|
||||
|
||||
member_rows = normalize_members(record, members)
|
||||
placement_tree = normalize_placements(record, placements)
|
||||
validate_references!(record, member_rows, placement_tree)
|
||||
validate_references!(locale, record, member_rows, placement_tree)
|
||||
validate_containment!(record, member_rows)
|
||||
{ name: normalized_name, description:, members: member_rows,
|
||||
placements: placement_tree }
|
||||
@@ -185,7 +187,7 @@ class TagGroupOperation
|
||||
end
|
||||
private_class_method :normalize_placement_nodes
|
||||
|
||||
def self.validate_references!(record, members, placements)
|
||||
def self.validate_references!(locale, record, members, placements)
|
||||
membership = members.to_set
|
||||
placed = placement_references(placements).uniq
|
||||
missing = placed - membership.to_a
|
||||
@@ -204,13 +206,14 @@ class TagGroupOperation
|
||||
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
|
||||
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
|
||||
.pluck(:tag_group_id, :version_no).to_set
|
||||
tag_names = TagName.where(tag_id: tag_ids, language_code: 'ja', primary_flg: true)
|
||||
tag_names = TagName.where(
|
||||
tag_id: tag_ids, language_code: locale.language_code, primary_flg: true)
|
||||
.pluck(:tag_id).uniq
|
||||
|
||||
members.each do |member|
|
||||
if member.key?('tag_id')
|
||||
id = member.fetch('tag_id')
|
||||
invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") unless
|
||||
invalid!(record, :members, :invalid) unless
|
||||
tag_names.include?(id)
|
||||
version_no = tags.fetch(id).version_no
|
||||
unless tag_versions.include?([id, version_no])
|
||||
@@ -313,57 +316,6 @@ class TagGroupOperation
|
||||
end
|
||||
private_class_method :persist_version!
|
||||
|
||||
def self.sync_compatibility!(group:, actor:, old_name:,
|
||||
require_compatibility:, compatibility_body:)
|
||||
representation = TagGroupRepr.one(group)
|
||||
body = begin
|
||||
TagGroupMarkdownRenderer.render(representation)
|
||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||
if compatibility_body.present?
|
||||
compatibility_body
|
||||
elsif require_compatibility
|
||||
invalid!(group, :base, error.message)
|
||||
else
|
||||
return error.message
|
||||
end
|
||||
end
|
||||
invalid!(group, :base, '本文は必須です.') if body.blank?
|
||||
|
||||
title = "#{ PREFIX }#{ group.name }"
|
||||
old_title = old_name && "#{ PREFIX }#{ old_name }"
|
||||
page = if old_title
|
||||
WikiPage.joins(:tag_name).find_by(tag_names: { name: old_title })
|
||||
else
|
||||
nil
|
||||
end
|
||||
page ||= WikiPage.joins(:tag_name).find_by(tag_names: { name: title })
|
||||
|
||||
if page
|
||||
tag_name = page.tag_name
|
||||
tag_name.update!(name: title) if tag_name.name != title
|
||||
Wiki::Commit.legacy_content!(page:, body:, created_user: actor, message: nil,
|
||||
base_revision_id: nil)
|
||||
else
|
||||
tag_name = TagName.find_or_initialize_by(language_code: 'ja', name: title)
|
||||
tag_name.script_code = 'Jpan'
|
||||
tag_name.primary_flg = true
|
||||
tag_name.auto_generated = false
|
||||
tag_name.save!
|
||||
Wiki::Commit.create_legacy_content!(tag_name:, body:, created_by_user: actor)
|
||||
end
|
||||
nil
|
||||
end
|
||||
private_class_method :sync_compatibility!
|
||||
|
||||
def self.report_sync_issue(group, actor, reason)
|
||||
return unless reason
|
||||
|
||||
payload = { status: 'unsupported_structure', tag_group_id: group.id,
|
||||
version_no: group.version_no, user_id: actor&.id, reason: }
|
||||
Rails.logger.warn("tag_group_wiki_sync_skipped #{ payload.to_json }")
|
||||
end
|
||||
private_class_method :report_sync_issue
|
||||
|
||||
def self.placement_references(nodes)
|
||||
nodes.flat_map do |node|
|
||||
own = if node.key?('tag_id')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
require 'digest'
|
||||
|
||||
|
||||
module Wiki
|
||||
class Commit
|
||||
class Conflict < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.create_content! tag_name:, body:, created_by_user:, message: nil
|
||||
if tag_name.tag && tag_name.language_code == 'ja'
|
||||
return TagDescriptionOperation.create!(
|
||||
tag: tag_name.tag, body:, created_by_user:, message:)
|
||||
end
|
||||
|
||||
create_legacy_content!(tag_name:, body:, created_by_user:, message:)
|
||||
end
|
||||
|
||||
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
|
||||
normalised = normalise_body(body)
|
||||
|
||||
page = WikiPage.new(tag_name:,
|
||||
body: normalised,
|
||||
created_user: created_by_user,
|
||||
updated_user: created_by_user)
|
||||
|
||||
if normalised.blank?
|
||||
page.errors.add(:body, :blank)
|
||||
raise ActiveRecord::RecordInvalid, page
|
||||
end
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
page.save!
|
||||
|
||||
new(page:, created_user: created_by_user).content!(
|
||||
body: normalised,
|
||||
message:,
|
||||
base_revision_id: nil)
|
||||
|
||||
page
|
||||
end
|
||||
end
|
||||
|
||||
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
|
||||
description = TagDescription.find_by(id: page.id)
|
||||
if description
|
||||
return TagDescriptionOperation.update!(
|
||||
tag: description.tag, body:, updated_by_user: created_user,
|
||||
message:, base_revision_id:)
|
||||
end
|
||||
|
||||
legacy_content!(page:, body:, created_user:, message:, base_revision_id:)
|
||||
end
|
||||
|
||||
def self.legacy_content! page:, body:, created_user:, message: nil,
|
||||
base_revision_id: nil
|
||||
new(page:, created_user:).content!(body:, message:, base_revision_id:)
|
||||
end
|
||||
|
||||
def self.redirect! page:, redirect_page:, created_user:, message: nil, base_revision_id: nil
|
||||
new(page:, created_user:).redirect!(redirect_page:, message:, base_revision_id:)
|
||||
end
|
||||
|
||||
def initialize page:, created_user:
|
||||
@page = page
|
||||
@created_user = created_user
|
||||
end
|
||||
|
||||
def content! body:, message:, base_revision_id:
|
||||
normalised = self.class.normalise_body(body)
|
||||
if normalised.blank?
|
||||
@page.errors.add(:body, :blank)
|
||||
raise ActiveRecord::RecordInvalid, @page
|
||||
end
|
||||
|
||||
lines = split_lines(normalised)
|
||||
|
||||
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
|
||||
tree_sha = Digest::SHA256.hexdigest(line_shas.join(','))
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@page.lock!
|
||||
|
||||
if base_revision_id.present?
|
||||
current_id = @page.wiki_revisions.maximum(:id)
|
||||
if current_id && current_id != base_revision_id.to_i
|
||||
raise Conflict,
|
||||
"競合が発生してゐます" +
|
||||
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
|
||||
end
|
||||
end
|
||||
|
||||
line_id_by_sha = upsert_lines!(lines, line_shas)
|
||||
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
|
||||
@page.update!(body: normalised)
|
||||
|
||||
WikiVersionRecorder.record!(
|
||||
page: @page,
|
||||
event_type: @page.wiki_versions.exists? ? :update : :create,
|
||||
reason: message,
|
||||
created_by_user: @created_user)
|
||||
|
||||
rev = WikiRevision.create!(
|
||||
wiki_page: @page,
|
||||
base_revision_id:,
|
||||
created_user: @created_user,
|
||||
kind: :content,
|
||||
redirect_page_id: nil,
|
||||
message:,
|
||||
lines_count: lines.length,
|
||||
tree_sha256: tree_sha)
|
||||
|
||||
rows = line_ids.each_with_index.map do |line_id, pos|
|
||||
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
|
||||
end
|
||||
WikiRevisionLine.insert_all!(rows) if rows.any?
|
||||
rev
|
||||
end
|
||||
end
|
||||
|
||||
def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.'
|
||||
|
||||
def self.normalise_body body
|
||||
s = body.to_s
|
||||
s.gsub!(/\r\n?/, "\n")
|
||||
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
|
||||
s.gsub(/\n+$/, '')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def split_lines(body) = body.split("\n")
|
||||
|
||||
def upsert_lines! lines, line_shas
|
||||
now = Time.current
|
||||
|
||||
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
|
||||
|
||||
missing_by_sha = { }
|
||||
|
||||
line_shas.each_with_index do |sha, i|
|
||||
next if id_by_sha.key?(sha)
|
||||
next if missing_by_sha.key?(sha)
|
||||
|
||||
missing_by_sha[sha] = {
|
||||
sha256: sha,
|
||||
body: lines[i],
|
||||
created_at: now,
|
||||
updated_at: now }
|
||||
end
|
||||
|
||||
if missing_by_sha.any?
|
||||
WikiLine.upsert_all(missing_by_sha.values)
|
||||
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
|
||||
end
|
||||
|
||||
id_by_sha
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,33 +0,0 @@
|
||||
module Wiki
|
||||
class CompatibilityWriter
|
||||
def self.create! tag_name:, body:, created_by_user:, message: nil
|
||||
Commit.create_legacy_content!(
|
||||
tag_name:, body:, created_by_user:, message:)
|
||||
end
|
||||
|
||||
def self.update! page:, body:, updated_by_user:, message: nil,
|
||||
base_revision_id: nil
|
||||
normalised = Commit.normalise_body(body)
|
||||
if normalised.blank?
|
||||
return Commit.legacy_content!(
|
||||
page:, body: normalised, created_user: updated_by_user, message:,
|
||||
base_revision_id:)
|
||||
end
|
||||
|
||||
if base_revision_id.present?
|
||||
current_id = page.wiki_revisions.maximum(:id)
|
||||
if current_id && current_id != base_revision_id.to_i
|
||||
raise Commit::Conflict,
|
||||
"競合が発生してゐます" +
|
||||
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
|
||||
end
|
||||
end
|
||||
|
||||
return if Commit.normalise_body(page.body) == normalised
|
||||
|
||||
Commit.legacy_content!(
|
||||
page:, body: normalised, created_user: updated_by_user, message:,
|
||||
base_revision_id:)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,21 +0,0 @@
|
||||
class WikiVersionRecorder < VersionRecorder
|
||||
def self.record! page:, event_type:, reason: nil, created_by_user:
|
||||
new(page:, event_type:, reason:, created_by_user:).record!
|
||||
end
|
||||
|
||||
def initialize page:, event_type:, reason: nil, created_by_user:
|
||||
@reason = reason
|
||||
super(record: page, event_type:, created_by_user:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def version_class = WikiVersion
|
||||
def version_association = :wiki_versions
|
||||
def record_key = :wiki_page
|
||||
|
||||
def snapshot_attributes = {
|
||||
title: @record.title,
|
||||
body: @record.body,
|
||||
reason: @reason }
|
||||
end
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Rails.application.config.to_prepare do
|
||||
[ActiveStorage::Blobs::ProxyController, ActiveStorage::Blobs::RedirectController,
|
||||
ActiveStorage::Representations::ProxyController,
|
||||
ActiveStorage::Representations::RedirectController, ActiveStorage::DiskController].each do |controller|
|
||||
controller.include(HistoryStorageAccess) unless controller < HistoryStorageAccess
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,20 @@
|
||||
Rails.application.routes.draw do
|
||||
get 'counts/definitions/:resource', to: 'counts_tables#definition'
|
||||
get 'counts/query', to: 'counts_tables#index'
|
||||
post 'counts/export/jobs', to: 'counts_exports#create_job'
|
||||
get 'counts/export/jobs/:token', to: 'counts_exports#job_status'
|
||||
delete 'counts/export/jobs/:token', to: 'counts_exports#cancel_job'
|
||||
post 'counts/export/prepare', to: 'counts_exports#prepare'
|
||||
get 'counts/export/files/:token', to: 'counts_exports#download'
|
||||
get 'counts/export', to: 'counts_exports#index'
|
||||
post 'counts/export', to: 'counts_exports#index'
|
||||
get 'counts/state', to: 'counts_states#index'
|
||||
get 'counts/posts', to: 'counts_posts#index'
|
||||
get 'counts/tags', to: 'counts_tags#index'
|
||||
|
||||
resources :locales, only: [:index]
|
||||
resources :sitemap_entries, only: [:index, :show], param: :resource
|
||||
|
||||
resources :tag_groups, only: [:index, :show, :create, :update]
|
||||
|
||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||
@@ -43,6 +59,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 +70,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 +116,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
|
||||
|
||||
ファイル差分が大きすぎるため省略します
差分を読み込み
@@ -0,0 +1,9 @@
|
||||
class DropWikiPages < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
drop_table :wiki_versions
|
||||
drop_table :wiki_revision_lines
|
||||
drop_table :wiki_lines
|
||||
drop_table :wiki_revisions
|
||||
drop_table :wiki_pages
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class RemoveTagNameIdFromTags < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
remove_foreign_key :tags, column: :tag_name_id
|
||||
remove_foreign_key :tag_names, column: :canonical_id
|
||||
|
||||
remove_column :tags, :tag_name_id
|
||||
remove_column :tag_names, :canonical_id
|
||||
end
|
||||
|
||||
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class DeleteNameForWikiFromTagNames < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
execute <<~SQL
|
||||
DELETE
|
||||
FROM
|
||||
tag_names
|
||||
WHERE
|
||||
tag_id IS NULL
|
||||
SQL
|
||||
|
||||
change_column_null :tag_names, :tag_id, false
|
||||
end
|
||||
|
||||
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||
end
|
||||
生成ファイル
+2
-89
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -578,17 +578,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
||||
end
|
||||
|
||||
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_id"
|
||||
t.bigint "tag_id", null: false
|
||||
t.string "language_code", limit: 16, null: false
|
||||
t.string "name", null: false
|
||||
t.string "script_code", limit: 4, null: false
|
||||
t.boolean "primary_flg", null: false
|
||||
t.boolean "auto_generated", default: false, null: false
|
||||
t.bigint "canonical_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)"
|
||||
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
|
||||
t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true
|
||||
t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true
|
||||
t.index ["script_code"], name: "fk_rails_dd783b3d1c"
|
||||
@@ -625,7 +623,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
||||
end
|
||||
|
||||
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_name_id", null: false
|
||||
t.string "category", default: "general", null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.datetime "created_at", null: false
|
||||
@@ -633,7 +630,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
||||
t.integer "post_count", default: 0, null: false
|
||||
t.integer "version_no", null: false
|
||||
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
||||
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
|
||||
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
||||
end
|
||||
@@ -773,76 +769,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
||||
t.index ["banned_at"], name: "index_users_on_banned_at"
|
||||
end
|
||||
|
||||
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "sha256", limit: 64, null: false
|
||||
t.text "body", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["sha256"], name: "index_wiki_lines_on_sha256", unique: true
|
||||
end
|
||||
|
||||
create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_name_id", null: false
|
||||
t.text "body", null: false
|
||||
t.bigint "created_user_id", null: false
|
||||
t.bigint "updated_user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "discarded_at"
|
||||
t.integer "version_no", null: false
|
||||
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
|
||||
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
|
||||
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
|
||||
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "wiki_revision_id", null: false
|
||||
t.integer "position", null: false
|
||||
t.bigint "wiki_line_id", null: false
|
||||
t.index ["wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_line_id"
|
||||
t.index ["wiki_revision_id", "position"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_position", unique: true
|
||||
t.index ["wiki_revision_id", "wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_wiki_line_id"
|
||||
t.index ["wiki_revision_id"], name: "index_wiki_revision_lines_on_wiki_revision_id"
|
||||
end
|
||||
|
||||
create_table "wiki_revisions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "wiki_page_id", null: false
|
||||
t.bigint "base_revision_id"
|
||||
t.bigint "created_user_id", null: false
|
||||
t.integer "kind", default: 0, null: false
|
||||
t.bigint "redirect_page_id"
|
||||
t.string "message"
|
||||
t.integer "lines_count", default: 0, null: false
|
||||
t.string "tree_sha256", limit: 64
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["base_revision_id"], name: "index_wiki_revisions_on_base_revision_id"
|
||||
t.index ["created_user_id"], name: "index_wiki_revisions_on_created_user_id"
|
||||
t.index ["kind"], name: "index_wiki_revisions_on_kind"
|
||||
t.index ["redirect_page_id"], name: "index_wiki_revisions_on_redirect_page_id"
|
||||
t.index ["tree_sha256"], name: "index_wiki_revisions_on_tree_sha256"
|
||||
t.index ["wiki_page_id", "id"], name: "index_wiki_revisions_on_wiki_page_id_and_id"
|
||||
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
|
||||
end
|
||||
|
||||
create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "wiki_page_id", null: false
|
||||
t.integer "version_no", null: false
|
||||
t.string "event_type", null: false
|
||||
t.string "title", null: false
|
||||
t.text "body", null: false
|
||||
t.text "reason"
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
|
||||
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
|
||||
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
|
||||
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
|
||||
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
|
||||
@@ -915,12 +841,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
||||
add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
|
||||
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
|
||||
add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code"
|
||||
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
|
||||
add_foreign_key "tag_names", "tags"
|
||||
add_foreign_key "tag_similarities", "tags"
|
||||
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
|
||||
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tags", "tag_names"
|
||||
add_foreign_key "theatre_comments", "theatres"
|
||||
add_foreign_key "theatre_comments", "users"
|
||||
add_foreign_key "theatre_programmes", "posts"
|
||||
@@ -945,15 +869,4 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
||||
add_foreign_key "user_post_views", "posts"
|
||||
add_foreign_key "user_post_views", "users"
|
||||
add_foreign_key "user_theme_slots", "users"
|
||||
add_foreign_key "wiki_pages", "tag_names"
|
||||
add_foreign_key "wiki_pages", "users", column: "created_user_id"
|
||||
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
|
||||
add_foreign_key "wiki_revision_lines", "wiki_lines"
|
||||
add_foreign_key "wiki_revision_lines", "wiki_revisions"
|
||||
add_foreign_key "wiki_revisions", "users", column: "created_user_id"
|
||||
add_foreign_key "wiki_revisions", "wiki_pages"
|
||||
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
|
||||
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
|
||||
add_foreign_key "wiki_versions", "users", column: "created_by_user_id"
|
||||
add_foreign_key "wiki_versions", "wiki_pages"
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace :counts do
|
||||
desc 'Process the persistent Counts export queue (run under a process supervisor)'
|
||||
task export_worker: :environment do
|
||||
stopping = false
|
||||
['TERM', 'INT'].each { |signal| Signal.trap(signal) { stopping = true } }
|
||||
until stopping
|
||||
worked = Rails.application.executor.wrap { CountsExportJobs.work_one }
|
||||
sleep(1) unless worked
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace :help do
|
||||
desc 'ヘルプ移行'
|
||||
task migrate: :environment do
|
||||
help_path = ENV.fetch('BTRC_HUB_HELP_PATH')
|
||||
abort 'ヘルプ置場指定してね.' if help_path.blank?
|
||||
|
||||
# ファイルがすでに存在する場合は通さなぃ.
|
||||
abort 'ファイル消してね.' if Dir.glob("#{ help_path }/*").present?
|
||||
|
||||
# 現状物理削除はなぃはず
|
||||
pages =
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.where(tag_names: { tag_id: nil, language_code: 'ja' })
|
||||
.where.not("tag_names.name LIKE 'タググループ:%'")
|
||||
|
||||
puts "移行対象: #{pages.count} 件"
|
||||
abort '移行対象がありません.' if pages.empty?
|
||||
|
||||
pages.find_each do |page|
|
||||
puts page.body
|
||||
puts page.title
|
||||
|
||||
puts 'この identifier は何にする?'
|
||||
identifier = $stdin.gets.chomp
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }"
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }/editions"
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }/editions/ja"
|
||||
|
||||
last_file_name = ''
|
||||
WikiVersion.where(wiki_page_id: page.id).order(:version_no).each do |ver|
|
||||
last_file_name, last_file_name_no = last_file_name.split('.')
|
||||
|
||||
file_name = ver.created_at.strftime('%Y-%m-%d')
|
||||
if file_name == last_file_name
|
||||
file_name += ".#{ (last_file_name_no || 1).to_i + 1 }"
|
||||
end
|
||||
|
||||
body = "# #{ ver.title.sub(/^ヘルプ:/, '') }\n\n#{ ver.body }"
|
||||
File.write "#{ help_path }/#{ identifier }/editions/ja/#{ file_name }.mdx", body
|
||||
|
||||
last_file_name = file_name
|
||||
end
|
||||
|
||||
article_meta = { 'identifier' => identifier,
|
||||
'canonical_language' => 'ja',
|
||||
'languages' => { 'ja' => { 'current_edition' => last_file_name } } }
|
||||
File.write "#{ help_path }/#{ identifier }/article.yml", article_meta.to_yaml
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,34 +0,0 @@
|
||||
require 'rails_helper'
|
||||
require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages'
|
||||
|
||||
RSpec.describe 'Tag description migration identity' do
|
||||
it 'preserves every revision ID and repeated body in an independent sequence' do
|
||||
user = create_member_user!
|
||||
tag_name = create(:tag_name, name: 'description_migration')
|
||||
page = Wiki::Commit.create_content!(
|
||||
tag_name:, body: 'A', created_by_user: user)
|
||||
revisions = [page.current_revision]
|
||||
['A', 'B', 'B', 'A'].each do |body|
|
||||
revisions << Wiki::Commit.content!(
|
||||
page:, body:, created_user: user, base_revision_id: revisions.last.id)
|
||||
end
|
||||
tag = create(:tag, primary_tag_name: tag_name)
|
||||
legacy_state = [page.reload.attributes, revisions.map(&:attributes)]
|
||||
expect(page.wiki_versions.count).to eq(3)
|
||||
|
||||
migration = MigrateTagDescriptionsFromWikiPages.new
|
||||
migration.suppress_messages { migration.up }
|
||||
|
||||
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja')
|
||||
.order(:version_no)
|
||||
expect(versions.pluck(:id)).to eq(revisions.map(&:id))
|
||||
expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5])
|
||||
expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A'])
|
||||
expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5)
|
||||
expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at))
|
||||
expect(TagDescription.find(page.id)).to have_attributes(
|
||||
tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A')
|
||||
expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }])
|
||||
.to eq(legacy_state)
|
||||
end
|
||||
end
|
||||
@@ -1,119 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group migration identity and structure' do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
it 'preserves page and revision IDs, timestamps and repeated historical structures' do
|
||||
tag = group_tag('洗澡鹿')
|
||||
body = "説明文\n\n- 洗澡鹿\n- 洗澡鹿"
|
||||
page = legacy_group('伊地知ニジカ亜種', body)
|
||||
revisions = [page.current_revision]
|
||||
[body, '# 空の区分', body].each do |text|
|
||||
revisions << Wiki::Commit.legacy_content!(page:, body: text, created_user: user)
|
||||
end
|
||||
before_state = group_state.except(*TagGroupRecords::GROUP_TABLES)
|
||||
|
||||
migrate_groups
|
||||
|
||||
versions = group_rows('tag_group_versions', tag_group_id: page.id)
|
||||
.sort_by { _1.fetch('version_no') }
|
||||
expect(versions.map { _1.fetch('id') }).to eq(revisions.map(&:id))
|
||||
expect(versions.map { _1.fetch('version_no') }).to eq([1, 2, 3, 4])
|
||||
expect(versions.map { _1.fetch('created_at') }).to eq(revisions.map(&:created_at))
|
||||
expect(versions.map { _1.fetch('created_by_user_id') }).to eq([user.id] * 4)
|
||||
expect(versions.map { _1.fetch('event_type') }).to eq(['create', 'update', 'update', 'update'])
|
||||
expect(group_version(page.id, 1).fetch('placements')).to eq(
|
||||
group_version(page.id, 2).fetch('placements'))
|
||||
expect(group_version(page.id, 3).fetch('placements')).to eq([
|
||||
{ 'section_name' => '空の区分', 'description' => nil, 'children' => [] }])
|
||||
expect_group_snapshot(
|
||||
page.id, number: 4, description: '説明文',
|
||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }],
|
||||
placements: [{ 'tag_id' => tag.id, 'children' => [] }] * 2)
|
||||
expect(group_rows('tag_groups', id: page.id).sole).to include(
|
||||
'created_at' => page.created_at, 'updated_at' => page.reload.updated_at)
|
||||
expect(group_state.except(*TagGroupRecords::GROUP_TABLES)).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'migrates nested list Sections and child groups as different placement kinds' do
|
||||
series = group_tag('ぼざろクリーチャーシリーズ')
|
||||
deer = group_tag('洗澡鹿')
|
||||
nijika = group_tag('伊地知ニジカ')
|
||||
child = legacy_group('伊地知ニジカ亜種', <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ亜種:
|
||||
- 洗澡鹿
|
||||
MARKDOWN
|
||||
parent = legacy_group('ぼざろクリーチャーシリーズのキャラクター', <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ
|
||||
- タググループ:伊地知ニジカ亜種
|
||||
MARKDOWN
|
||||
|
||||
migrate_groups
|
||||
|
||||
expect_group_snapshot(
|
||||
child.id, number: 1,
|
||||
members: [series, deer].map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
|
||||
placements: [{ 'tag_id' => series.id, 'children' => [
|
||||
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
|
||||
'children' => [{ 'tag_id' => deer.id, 'children' => [] }] }] }])
|
||||
expect_group_snapshot(
|
||||
parent.id, number: 1,
|
||||
members: [
|
||||
{ 'tag_id' => series.id, 'version_no' => series.version_no },
|
||||
{ 'tag_id' => nijika.id, 'version_no' => nijika.version_no },
|
||||
{ 'tag_group_id' => child.id, 'version_no' => 1 }],
|
||||
placements: [{ 'tag_id' => series.id, 'children' => [
|
||||
{ 'tag_id' => nijika.id, 'children' => [
|
||||
{ 'tag_group_id' => child.id, 'children' => [] }] }] }])
|
||||
end
|
||||
|
||||
it 'preserves heading order, group paragraphs and Section descriptions' do
|
||||
names = ['タグ希望', 'キャラクター名希望', '楽曲情報希望', 'bot操作', 'ニジラー情報不詳']
|
||||
tags = names.map { group_tag(_1) }
|
||||
markdown = <<~MARKDOWN
|
||||
全体の説明
|
||||
|
||||
# 要求
|
||||
要求の説明
|
||||
|
||||
- タグ希望
|
||||
- キャラクター名希望
|
||||
- 楽曲情報希望
|
||||
|
||||
# 情報
|
||||
- bot操作
|
||||
- ニジラー情報不詳
|
||||
MARKDOWN
|
||||
page = legacy_group('メタタグ', markdown)
|
||||
expect(page.current_revision.body).to eq(markdown.chomp)
|
||||
|
||||
migrate_groups
|
||||
|
||||
leaves = tags.map { { 'tag_id' => _1.id, 'children' => [] } }
|
||||
leaves.first['children'] = leaves[1..2]
|
||||
expect_group_snapshot(
|
||||
page.id, number: 1, description: '全体の説明',
|
||||
members: tags.map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
|
||||
placements: [
|
||||
{ 'section_name' => '要求', 'description' => '要求の説明',
|
||||
'children' => [leaves.first] },
|
||||
{ 'section_name' => '情報', 'description' => nil,
|
||||
'children' => leaves[3..4] }])
|
||||
end
|
||||
|
||||
it 'leaves Help in the legacy domain' do
|
||||
page = Wiki::Commit.create_legacy_content!(
|
||||
tag_name: create(:tag_name, name: 'Help:移行対象外'),
|
||||
body: 'Help 本文', created_by_user: user)
|
||||
before_state = group_state
|
||||
migrate_groups
|
||||
expect(group_state).to eq(before_state)
|
||||
expect(page.reload.body).to eq('Help 本文')
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,17 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'TagName database invariants' do
|
||||
def insert_name!(**attributes)
|
||||
TagName.insert_all!([{ name: 'constraint_name', language_code: 'ja',
|
||||
script_code: 'Jpan', primary_flg: true,
|
||||
created_at: Time.current, updated_at: Time.current,
|
||||
**attributes }])
|
||||
def insert_name! **attributes
|
||||
defaults = {
|
||||
tag_id: create(:tag, primary_tag_name: nil).id,
|
||||
name: 'constraint_name',
|
||||
language_code: 'ja',
|
||||
script_code: 'Jpan',
|
||||
primary_flg: true,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current }
|
||||
|
||||
TagName.insert_all!([defaults.merge(attributes)])
|
||||
end
|
||||
|
||||
it 'defaults persisted names to human-confirmed provenance' do
|
||||
@@ -33,4 +39,9 @@ RSpec.describe 'TagName database invariants' do
|
||||
.to raise_error(ActiveRecord::RecordNotUnique)
|
||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'rejects a name without a tag' do
|
||||
expect { insert_name!(tag_id: nil) }
|
||||
.to raise_error(ActiveRecord::NotNullViolation)
|
||||
end
|
||||
end
|
||||
|
||||
変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示
新しいイシューから参照
ユーザーをブロックする