コミットを比較
28
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
2563b3fef3 | ||
|
|
d5bb9c8508 | ||
|
|
f44a436e20 | ||
|
|
c61d9d76e3 | ||
|
|
88711d2bd8 | ||
|
|
b4139f8370 | ||
|
|
f91f9d0eb5 | ||
|
|
3e8f209542 | ||
|
|
8642225b94 | ||
|
|
96a5bb8601 | ||
|
|
e71663bd22 | ||
|
|
52a8d62be5 | ||
|
|
3e05757820 | ||
|
|
d411b3b40f | ||
|
|
304090f717 | ||
|
|
084a090cab | ||
|
|
75d7dac999 | ||
|
|
e44887cf15 | ||
|
|
534a66f90d | ||
|
|
592932b5a5 | ||
|
|
4a6ace3322 | ||
|
|
e6ab941774 | ||
|
|
f336b2f13b | ||
|
|
785b5ef8bc | ||
|
|
7f0efc3a46 | ||
|
|
a947032247 | ||
|
|
3115e31fb1 | ||
|
|
f1181e8510 |
@@ -124,7 +124,7 @@ npm run preview
|
|||||||
- For arrays, never put whitespace or a line break immediately before `]`.
|
- For arrays, never put whitespace or a line break immediately before `]`.
|
||||||
- Keep the first element on the same line as `[` by default.
|
- Keep the first element on the same line as `[` by default.
|
||||||
- If an array would exceed the line limit, break after `[` and indent
|
- If an array would exceed the line limit, break after `[` and indent
|
||||||
elements by 4 spaces.
|
elements 4 spaces deeper than the statement's base indentation.
|
||||||
|
|
||||||
### Ruby delimiter and wrapping rules
|
### Ruby delimiter and wrapping rules
|
||||||
|
|
||||||
@@ -140,6 +140,32 @@ npm run preview
|
|||||||
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
|
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
|
||||||
- Ruby の method chain や call argument を折り返す際、call-site の `)` を
|
- Ruby の method chain や call argument を折り返す際、call-site の `)` を
|
||||||
block close のやうに独立させない。
|
block close のやうに独立させない。
|
||||||
|
- Ruby では、行末の `\` を用途を問はず一切使用しない。
|
||||||
|
- Ruby では、文字列連結、logger message、method call、条件式、SQL 断片、
|
||||||
|
正規表現その他すべての式で、行末バックスラッシュによる継続を禁止する。
|
||||||
|
- Ruby の block body は、その基準位置から 2 空白深くする。
|
||||||
|
- Ruby の wrapped expression、method argument、array element、hash pair などの
|
||||||
|
continuation indentation は、その statement の基準位置から 4 空白深くする。
|
||||||
|
- Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。
|
||||||
|
- Ruby では、暗黙的に継続可能な構文を優先し、method call、array、Hash 及び
|
||||||
|
括弧内ではバックスラッシュなしで改行する。
|
||||||
|
- Ruby では、行長制限を守るために行末バックスラッシュを導入してはならない。
|
||||||
|
- Ruby で行末バックスラッシュが必要に見える場合は、括弧内で自然に改行する、
|
||||||
|
一つの文字列補間へまとめる、中間変数へ分ける、`format` を使ふ、heredoc を
|
||||||
|
使ふ、array 又は Hash を組み立ててから処理する、条件式全体を括弧で囲む、
|
||||||
|
method へ抽出する、のいづれかへ書き換へる。
|
||||||
|
- Ruby では、一つの文字列を、改行をまたいだ隣接文字列 literal として記述しない。
|
||||||
|
- Ruby では、method argument 内でも、複数の文字列 literal を区切りなしで縦に
|
||||||
|
並べない。
|
||||||
|
- RSpec の `describe`、`context`、`it` 等の description が長い場合は、意味を
|
||||||
|
保ったまま一行へ収まる文言へ短縮する。
|
||||||
|
- 文字列を短縮できない場合は、用途に応じて `format`、heredoc 又は中間変数を
|
||||||
|
検討する。
|
||||||
|
- ただし RSpec description では、原則として簡潔な一行の文字列を使ふ。
|
||||||
|
- formatter 又は自動修正にも、Ruby の行末バックスラッシュを生成させない。
|
||||||
|
- 新規 code だけでなく、今回触れる Ruby code にも行末バックスラッシュを残さない。
|
||||||
|
- 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白
|
||||||
|
深くするため、結果として行頭から 6 空白になる。
|
||||||
|
|
||||||
Bad:
|
Bad:
|
||||||
|
|
||||||
@@ -191,6 +217,63 @@ end
|
|||||||
|
|
||||||
Bad:
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
Rails.logger.info(
|
||||||
|
"post_import_metadata_fetch_failure "\
|
||||||
|
"#{ payload.to_json }")
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
payload = {
|
||||||
|
error: e.class.name,
|
||||||
|
message: e.message }
|
||||||
|
|
||||||
|
Rails.logger.info(
|
||||||
|
"post_import_metadata_fetch_failure #{ payload.to_json }")
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
message = "first "\
|
||||||
|
"second"
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
message = format(
|
||||||
|
'%<first>s %<second>s',
|
||||||
|
first: 'first',
|
||||||
|
second: 'second')
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
result = first_value + \
|
||||||
|
second_value
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
it(
|
||||||
|
'returns 409 when stale changes '
|
||||||
|
'do not conflict'
|
||||||
|
) do
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
it 'returns mergeable 409 for stale non-conflicting changes' do
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
```rb
|
```rb
|
||||||
records.each {
|
records.each {
|
||||||
do_work(_1) }
|
do_work(_1) }
|
||||||
@@ -225,20 +308,37 @@ records.each {
|
|||||||
formatting as the local reference shape: component and callback bodies use
|
formatting as the local reference shape: component and callback bodies use
|
||||||
2-space block indentation, single-line bodies do not gain unnecessary
|
2-space block indentation, single-line bodies do not gain unnecessary
|
||||||
braces, wrapped expressions use 4-space continuation indentation, multi-
|
braces, wrapped expressions use 4-space continuation indentation, multi-
|
||||||
stage ternaries use explicit parentheses, and only complete leading runs
|
stage ternaries use explicit parentheses, and TypeScript / TSX indentation
|
||||||
of 8 spaces are compressed to tabs.
|
and alignment whitespace compress every complete run of 8 spaces to tabs.
|
||||||
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
|
|
||||||
- A tab does not represent one indentation level.
|
- A tab does not represent one indentation level.
|
||||||
- Do not replace 2-space or 4-space indentation with tabs.
|
- In TypeScript and TSX, first determine visible indentation using 2-space
|
||||||
- First determine visible indentation using 2-space block indentation and
|
block indentation and 4-space continuation indentation, then compress every
|
||||||
4-space continuation indentation, then compress only complete leading runs
|
complete run of 8 spaces used for indentation or column alignment to tabs.
|
||||||
of 8 spaces into tabs.
|
- In TypeScript and TSX, 8-space compression is mandatory, not optional.
|
||||||
- Tabs are only for leading indentation, never for spaces after non-space
|
- In TypeScript and TSX, this applies both to leading indentation and to
|
||||||
text.
|
alignment whitespace after non-space text, such as aligned inline type
|
||||||
- Keep residual leading 2, 4, or 6 spaces after any tab compression.
|
columns.
|
||||||
|
- In TypeScript and TSX, keep residual 2, 4, or 6 spaces after each tab
|
||||||
|
compression.
|
||||||
|
- In TypeScript and TSX, do not treat a tab as one logical indentation level.
|
||||||
|
- In TypeScript and TSX, do not alter string literals, template-literal
|
||||||
|
contents, regular expressions, or user-facing text merely to apply tab
|
||||||
|
compression.
|
||||||
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
|
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
|
||||||
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
|
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
|
||||||
tab + 4 spaces, 16 columns = 2 tabs.
|
tab + 4 spaces, 14 columns = 1 tab + 6 spaces, 16 columns = 2 tabs.
|
||||||
|
- When TypeScript or TSX code already uses column alignment, apply the same
|
||||||
|
8-space compression rule to that alignment whitespace.
|
||||||
|
- Example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Props = {
|
||||||
|
row: PostImportRow
|
||||||
|
displayNumber?: number
|
||||||
|
onEdit?: () => void
|
||||||
|
onRetry?: () => void
|
||||||
|
onToggleSkip?: (checked: boolean) => void }
|
||||||
|
```
|
||||||
- TypeScript and TSX imports may stay on one line if they remain within the
|
- TypeScript and TSX imports may stay on one line if they remain within the
|
||||||
line limit; do not expand short type-only imports mechanically.
|
line limit; do not expand short type-only imports mechanically.
|
||||||
- Keep runtime value imports and type imports in separate declarations.
|
- Keep runtime value imports and type imports in separate declarations.
|
||||||
@@ -473,9 +573,33 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
- When new user-facing wording appears necessary, ask the user for the exact
|
- When new user-facing wording appears necessary, ask the user for the exact
|
||||||
wording and placement before implementing it.
|
wording and placement before implementing it.
|
||||||
- Do not invent replacement copy when removing unrequested wording.
|
- 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 and rerun them until
|
fixture、mock、snapshot、テスト補助コードも対象とする。
|
||||||
they pass or the remaining failure is clearly blocked.
|
既存のテスト差分を勝手に修正・差戻ししない。
|
||||||
|
この規約はディレクトリ固有の自動的なテスト実行推奨より優先する。
|
||||||
|
- 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
|
||||||
|
contracts, validation, routes, authentication, permissions, UI, copy,
|
||||||
|
dependencies, limits, thresholds, defaults, migrations, schema, or
|
||||||
|
environment settings to satisfy tests.
|
||||||
|
- Do not make production code match failing tests, mock assumptions, fixtures,
|
||||||
|
snapshots, old expectations, or stale setup. This includes changing
|
||||||
|
production constants merely because a test expects a different value.
|
||||||
|
- If test work reveals a production bug, spec mismatch, or missing behaviour,
|
||||||
|
stop without modifying production code and report: the failing test or
|
||||||
|
discovered issue, the related production file, the actual behaviour, the
|
||||||
|
expected behaviour, and why a production change appears necessary.
|
||||||
|
- Modify production code for test failures only when the user explicitly asks
|
||||||
|
for that production change. Do not expand a test task into a production task
|
||||||
|
on your own authority.
|
||||||
|
- If the user explicitly asks for both production implementation and test
|
||||||
|
updates, implement production code to the confirmed specification first,
|
||||||
|
then add or update tests to verify that specification. Never roll production
|
||||||
|
behaviour back to satisfy old tests.
|
||||||
|
- If it is unclear whether the test or the production implementation is stale,
|
||||||
|
or a test cannot be corrected without changing production code, ask the user
|
||||||
|
instead of guessing.
|
||||||
|
|
||||||
## Backend rules
|
## Backend rules
|
||||||
|
|
||||||
@@ -507,6 +631,19 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
|
|
||||||
## Frontend rules
|
## 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.
|
- 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`;
|
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
|
||||||
avoid ad hoc query key arrays.
|
avoid ad hoc query key arrays.
|
||||||
@@ -598,10 +735,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
beginning of a line.
|
beginning of a line.
|
||||||
- The TSX-specific self-review must confirm JSX closing markers and closing
|
- The TSX-specific self-review must confirm JSX closing markers and closing
|
||||||
parentheses keep the surrounding compact style.
|
parentheses keep the surrounding compact style.
|
||||||
- The TypeScript/TSX self-review must confirm leading block indentation uses
|
- The TypeScript/TSX self-review must confirm block indentation uses 2 spaces
|
||||||
2 spaces per level, wrapped continuations use the repository's 4-space
|
per level, wrapped continuations use the repository's 4-space continuation
|
||||||
continuation alignment, and complete leading runs of 8 spaces may be
|
alignment, and every complete run of 8 spaces used for indentation or
|
||||||
compressed to tabs.
|
alignment has been compressed to tabs.
|
||||||
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
|
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
|
||||||
- Put two blank lines before and after top-level `const` function
|
- Put two blank lines before and after top-level `const` function
|
||||||
declarations, unless imports, exports, or file boundaries make that awkward.
|
declarations, unless imports, exports, or file boundaries make that awkward.
|
||||||
@@ -614,9 +751,18 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
- Indent the block body 2 spaces deeper than the keyword and opening brace.
|
- Indent the block body 2 spaces deeper than the keyword and opening brace.
|
||||||
- Put the closing `}` on its own line at the same indentation as the keyword.
|
- Put the closing `}` on its own line at the same indentation as the keyword.
|
||||||
- Do not write `try {`, `catch {`, or `finally {`.
|
- Do not write `try {`, `catch {`, or `finally {`.
|
||||||
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a
|
- In TypeScript and TSX, convert every complete run of 8 spaces used for
|
||||||
tab character.
|
indentation or alignment to a tab character.
|
||||||
- A leading tab is exactly equivalent to 8 leading spaces.
|
- In TypeScript and TSX, a tab is exactly equivalent to 8 columns, whether it
|
||||||
|
appears at the beginning of a line or in alignment whitespace after
|
||||||
|
non-space text.
|
||||||
|
- In TSX, JSX nesting uses 2-space block indentation and wrapped JSX
|
||||||
|
attributes use 4-space continuation indentation; after visible columns are
|
||||||
|
determined, compress every complete run of 8 spaces in the resulting
|
||||||
|
indentation or alignment to tabs.
|
||||||
|
- In TSX, do not leave JSX subtree indentation at 8, 10, 12, 14, or 16
|
||||||
|
columns as spaces alone; convert each complete run of 8 spaces to tabs and
|
||||||
|
keep only the residual 2, 4, or 6 spaces.
|
||||||
- In TypeScript and TSX function declarations, including `const` arrow
|
- In TypeScript and TSX function declarations, including `const` arrow
|
||||||
function declarations, classify the parameter list before placing the closing
|
function declarations, classify the parameter list before placing the closing
|
||||||
`)`.
|
`)`.
|
||||||
@@ -1259,9 +1405,9 @@ to `.ts` and `.tsx`:
|
|||||||
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
|
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
|
||||||
otherwise.
|
otherwise.
|
||||||
8. JSX closing parentheses keep the compact local style.
|
8. JSX closing parentheses keep the compact local style.
|
||||||
9. Leading block indentation uses 2 spaces per level, wrapped continuations
|
9. Block indentation uses 2 spaces per level, wrapped continuations use the
|
||||||
use the repository's 4-space continuation alignment, and complete leading
|
repository's 4-space continuation alignment, and every complete run of 8
|
||||||
runs of 8 spaces may be compressed to tabs.
|
spaces used for indentation or alignment has been compressed to tabs.
|
||||||
10. No line has trailing whitespace.
|
10. No line has trailing whitespace.
|
||||||
|
|
||||||
Preferred:
|
Preferred:
|
||||||
|
|||||||
@@ -1,22 +1,52 @@
|
|||||||
class ApplicationController < ActionController::API
|
class ApplicationController < ActionController::API
|
||||||
|
include ActionController::Cookies
|
||||||
|
include ActionController::RequestForgeryProtection
|
||||||
|
|
||||||
|
class UnsupportedLocale < StandardError
|
||||||
|
;
|
||||||
|
end
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||||
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
|
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
|
||||||
|
rescue_from(UnsupportedLocale) { head :not_found }
|
||||||
|
|
||||||
before_action :reject_banned_ip_address!
|
before_action :reject_banned_ip_address!
|
||||||
before_action :authenticate_user
|
before_action :authenticate_user
|
||||||
before_action :reject_banned_user!
|
before_action :reject_banned_user!
|
||||||
|
before_action :validate_explicit_locale!
|
||||||
|
protect_from_forgery with: :exception
|
||||||
|
self.forgery_protection_origin_check = true
|
||||||
|
rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf
|
||||||
|
after_action :prevent_authenticated_caching
|
||||||
|
|
||||||
def current_user = @current_user
|
def current_user = @current_user
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def reject_invalid_csrf
|
||||||
|
response.headers['X-CSRF-Invalid'] = '1'
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
|
head :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
def authenticate_user
|
def authenticate_user
|
||||||
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
|
code = session[:inheritance_code]
|
||||||
return if code.blank?
|
return if code.blank?
|
||||||
|
|
||||||
@current_user = User.find_by(inheritance_code: code)
|
@current_user = User.find_by(inheritance_code: code)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def prevent_authenticated_caching
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store' if current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def establish_user_session user
|
||||||
|
reset_session
|
||||||
|
session[:inheritance_code] = user.inheritance_code
|
||||||
|
@current_user = user
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
|
end
|
||||||
|
|
||||||
def bool? key, default: false
|
def bool? key, default: false
|
||||||
return default if params[key].nil?
|
return default if params[key].nil?
|
||||||
|
|
||||||
@@ -85,4 +115,29 @@ class ApplicationController < ActionController::API
|
|||||||
base_errors: },
|
base_errors: },
|
||||||
status:
|
status:
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalise_json value
|
||||||
|
return nil if value.nil?
|
||||||
|
return JSON.parse(value) if value.is_a?(String)
|
||||||
|
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_locale!
|
||||||
|
return @resolved_locale if defined?(@resolved_locale)
|
||||||
|
|
||||||
|
code = params.fetch(:locale, 'ja')
|
||||||
|
unless code.is_a?(String) && Locale.codes.value?(code)
|
||||||
|
raise UnsupportedLocale
|
||||||
|
end
|
||||||
|
|
||||||
|
locale = Locale.find_by(code:)
|
||||||
|
raise UnsupportedLocale unless locale && locale.deprecated_at.nil?
|
||||||
|
|
||||||
|
@resolved_locale = locale
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_explicit_locale!
|
||||||
|
resolve_locale! if params.key?(:locale)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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 = { 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,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)
|
||||||
|
response.headers['Content-Type'] = exporter.format == 'json' ?
|
||||||
|
'application/json; charset=utf-8' : 'text/csv; charset=utf-8'
|
||||||
|
response.headers['Content-Disposition'] =
|
||||||
|
"attachment; filename=\"counts-#{ exporter.resource }.#{ exporter.format }\""
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
# Rack::ETag otherwise materialises the response to calculate a digest.
|
||||||
|
response.headers['Last-Modified'] = Time.current.httpdate
|
||||||
|
self.response_body = exporter.body
|
||||||
|
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,21 @@
|
|||||||
|
class CountsTablesController < ApplicationController
|
||||||
|
def definition
|
||||||
|
resolve_locale!
|
||||||
|
definition = CountsResourceDefinition.new(params[:resource])
|
||||||
|
render json: { fields: definition.metadata,
|
||||||
|
axes: { latest: definition.axes(false), history: definition.axes(true) },
|
||||||
|
grains: CountsAxes::GRAINS }
|
||||||
|
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
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
class DeerjikistsController < ApplicationController
|
class DeerjikistsController < ApplicationController
|
||||||
def show
|
def show
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
platform = params[:platform].to_s.strip
|
platform = params[:platform].to_s.strip
|
||||||
code = params[:code].to_s.strip
|
code = params[:code].to_s.strip
|
||||||
return render_bad_request('platform は必須です.') if platform.blank?
|
return render_bad_request('platform は必須です.') if platform.blank?
|
||||||
@@ -7,10 +9,10 @@ class DeerjikistsController < ApplicationController
|
|||||||
|
|
||||||
deerjikist = Deerjikist
|
deerjikist = Deerjikist
|
||||||
.joins(:tag)
|
.joins(:tag)
|
||||||
.includes(tag: :tag_name)
|
.includes(tag: :tag_names)
|
||||||
.find_by(platform:, code:)
|
.find_by(platform:, code:)
|
||||||
if deerjikist
|
if deerjikist
|
||||||
render json: DeerjikistRepr.base(deerjikist)
|
render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code)
|
||||||
else
|
else
|
||||||
head :not_found
|
head :not_found
|
||||||
end
|
end
|
||||||
@@ -20,6 +22,7 @@ class DeerjikistsController < ApplicationController
|
|||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
platform = params[:platform].to_s.strip
|
platform = params[:platform].to_s.strip
|
||||||
code = params[:code].to_s.strip
|
code = params[:code].to_s.strip
|
||||||
tag_id = params[:tag_id].to_i
|
tag_id = params[:tag_id].to_i
|
||||||
@@ -32,7 +35,7 @@ class DeerjikistsController < ApplicationController
|
|||||||
d.save!
|
d.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: DeerjikistRepr.base(deerjikist)
|
render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class GekanatorPostsController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
posts =
|
posts =
|
||||||
Post
|
Post
|
||||||
.preload(:post_similarities, tags: :tag_name)
|
.preload(:post_similarities, tags: :tag_names)
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
.order(Arel.sql(
|
.order(Arel.sql(
|
||||||
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
|
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
|
||||||
@@ -56,7 +56,7 @@ class GekanatorPostsController < ApplicationController
|
|||||||
def tag_json tag
|
def tag_json tag
|
||||||
{
|
{
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
name: tag.name,
|
name: tag.name('ja'),
|
||||||
category: tag.category
|
category: tag.category
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_name)
|
.joins(:tag_names)
|
||||||
.where(category: categories.uniq)
|
.where(category: categories.uniq)
|
||||||
.where(tag_names: { name: names.uniq })
|
.where(tag_names: { language_code: 'ja', primary_flg: true, name: names.uniq })
|
||||||
.where.not(deprecated_at: nil)
|
.where.not(deprecated_at: nil)
|
||||||
.pluck('tags.category', 'tag_names.name')
|
.pluck('tags.category', 'tag_names.name')
|
||||||
.each_with_object({ }) do |(category, name), h|
|
.each_with_object({ }) do |(category, name), h|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,6 +7,8 @@ class MaterialsController < ApplicationController
|
|||||||
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
|
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
page = (params[:page].presence || 1).to_i
|
page = (params[:page].presence || 1).to_i
|
||||||
limit = (params[:limit].presence || 20).to_i
|
limit = (params[:limit].presence || 20).to_i
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ class MaterialsController < ApplicationController
|
|||||||
q = Material.includes(:material_export_items,
|
q = Material.includes(:material_export_items,
|
||||||
thumbnail_attachment: :blob,
|
thumbnail_attachment: :blob,
|
||||||
file_attachment: :blob,
|
file_attachment: :blob,
|
||||||
tag: :tag_name)
|
tag: :tag_names)
|
||||||
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
|
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
|
||||||
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
|
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
|
||||||
q = apply_material_tag_filter(q, filters, tag_graph)
|
q = apply_material_tag_filter(q, filters, tag_graph)
|
||||||
@@ -42,7 +44,10 @@ class MaterialsController < ApplicationController
|
|||||||
.offset(offset)
|
.offset(offset)
|
||||||
.to_a
|
.to_a
|
||||||
|
|
||||||
response = { materials: MaterialRepr.list_many(materials, host: request.base_url),
|
response = { materials: MaterialRepr.list_many(
|
||||||
|
materials,
|
||||||
|
host: request.base_url,
|
||||||
|
language_code: locale.language_code),
|
||||||
count: }
|
count: }
|
||||||
if filters[:tag_id]
|
if filters[:tag_id]
|
||||||
response[:tag_scope] = material_index_tag_scope(filters, tag_graph)
|
response[:tag_scope] = material_index_tag_scope(filters, tag_graph)
|
||||||
@@ -55,6 +60,8 @@ class MaterialsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
material =
|
material =
|
||||||
Material
|
Material
|
||||||
.includes(:tag, :material_export_items)
|
.includes(:tag, :material_export_items)
|
||||||
@@ -63,15 +70,20 @@ class MaterialsController < ApplicationController
|
|||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
return head :not_found unless material
|
return head :not_found unless material
|
||||||
|
|
||||||
wiki_page_body = material.tag&.tag_name&.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).merge(wiki_page_body:)
|
render json: MaterialRepr.base(material, host: request.base_url,
|
||||||
|
language_code: locale.language_code).merge(wiki_page_body:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
tag_name_raw = params[:tag].to_s.strip
|
tag_name_raw = params[:tag].to_s.strip
|
||||||
file = params[:file]
|
file = params[:file]
|
||||||
file_sha256 = MaterialFileSha256.from_upload(file)
|
file_sha256 = MaterialFileSha256.from_upload(file)
|
||||||
@@ -89,13 +101,14 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
Material.transaction do
|
Material.transaction do
|
||||||
tag = resolve_material_tag!(tag_name_raw)
|
tag = resolve_material_tag!(locale, tag_name_raw)
|
||||||
material = Material.new(tag:, url:,
|
material = Material.new(tag:, url:,
|
||||||
created_by_user: current_user,
|
created_by_user: current_user,
|
||||||
updated_by_user: current_user)
|
updated_by_user: current_user)
|
||||||
material.file.attach(uploaded_blob) if uploaded_blob
|
material.file.attach(uploaded_blob) if uploaded_blob
|
||||||
material.save!
|
material.save!
|
||||||
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
|
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user,
|
||||||
|
language_code: locale.language_code)
|
||||||
upsert_export_paths!(material)
|
upsert_export_paths!(material)
|
||||||
MaterialVersionRecorder.record!(material:, event_type: :create,
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
created_by_user: current_user)
|
created_by_user: current_user)
|
||||||
@@ -107,7 +120,8 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
if material
|
if material
|
||||||
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
|
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
|
||||||
render json: MaterialRepr.base(material, host: request.base_url), status: :created
|
render json: MaterialRepr.base(material, host: request.base_url,
|
||||||
|
language_code: locale.language_code), status: :created
|
||||||
else
|
else
|
||||||
render_validation_error material
|
render_validation_error material
|
||||||
end
|
end
|
||||||
@@ -132,12 +146,14 @@ class MaterialsController < ApplicationController
|
|||||||
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
|
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
|
||||||
return render_material_import_block(block) if block
|
return render_material_import_block(block) if block
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
|
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
Material.transaction do
|
Material.transaction do
|
||||||
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
|
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
|
||||||
tag = resolve_material_tag!(tag_name_raw)
|
tag = resolve_material_tag!(locale, tag_name_raw)
|
||||||
material.assign_attributes(tag:, url:, updated_by_user: current_user)
|
material.assign_attributes(tag:, url:, updated_by_user: current_user)
|
||||||
if uploaded_blob
|
if uploaded_blob
|
||||||
material.file.attach(uploaded_blob)
|
material.file.attach(uploaded_blob)
|
||||||
@@ -145,7 +161,8 @@ class MaterialsController < ApplicationController
|
|||||||
material.file.detach
|
material.file.detach
|
||||||
end
|
end
|
||||||
material.save!
|
material.save!
|
||||||
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
|
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user,
|
||||||
|
language_code: locale.language_code)
|
||||||
upsert_export_paths!(material)
|
upsert_export_paths!(material)
|
||||||
MaterialVersionRecorder.record!(material:, event_type: :update,
|
MaterialVersionRecorder.record!(material:, event_type: :update,
|
||||||
created_by_user: current_user)
|
created_by_user: current_user)
|
||||||
@@ -157,7 +174,8 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
|
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
|
||||||
|
|
||||||
render json: MaterialRepr.base(material, host: request.base_url)
|
render json: MaterialRepr.base(material, host: request.base_url,
|
||||||
|
language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@@ -236,10 +254,9 @@ class MaterialsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_material_tag! tag_name_raw
|
def resolve_material_tag! locale, tag_name_raw
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material,
|
||||||
tag = tag_name.tag
|
created_by_user: current_user)
|
||||||
tag || Tag.create!(tag_name:, category: :material)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def material_index_needs_tag_name? filters
|
def material_index_needs_tag_name? filters
|
||||||
@@ -253,7 +270,12 @@ class MaterialsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def material_index_join_tag_name q
|
def material_index_join_tag_name q
|
||||||
q.left_joins(tag: :tag_name)
|
q.left_joins(:tag).joins(<<~SQL.squish)
|
||||||
|
LEFT JOIN tag_names
|
||||||
|
ON tag_names.tag_id = tags.id
|
||||||
|
AND tag_names.language_code = 'ja'
|
||||||
|
AND tag_names.primary_flg = TRUE
|
||||||
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
def material_index_join_file_blob q
|
def material_index_join_file_blob q
|
||||||
@@ -269,9 +291,11 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
def apply_material_query q, term
|
def apply_material_query q, term
|
||||||
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
|
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
|
||||||
q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \
|
q.where(<<~SQL.squish, q: like)
|
||||||
'material_file_blobs.filename LIKE :q',
|
tag_names.name LIKE :q
|
||||||
q: like)
|
OR materials.url LIKE :q
|
||||||
|
OR material_file_blobs.filename LIKE :q
|
||||||
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_material_media_kind q, media_kind
|
def apply_material_media_kind q, media_kind
|
||||||
@@ -323,8 +347,9 @@ class MaterialsController < ApplicationController
|
|||||||
end
|
end
|
||||||
tags_by_id =
|
tags_by_id =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_name)
|
.joins(:tag_names)
|
||||||
.where(id: scope_tag_ids)
|
.where(id: scope_tag_ids,
|
||||||
|
tag_names: { language_code: 'ja', primary_flg: true })
|
||||||
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
|
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
|
||||||
.each_with_object({ }) do |(id, name, category, deprecated_at), hash|
|
.each_with_object({ }) do |(id, name, category, deprecated_at), hash|
|
||||||
hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? }
|
hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? }
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
class NicoTagsController < ApplicationController
|
class NicoTagsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
name = params[:name].presence
|
name = params[:name].presence
|
||||||
linked_tag = params[:linked_tag].presence
|
linked_tag = params[:linked_tag].presence
|
||||||
link_status = params[:link_status].presence
|
link_status = params[:link_status].presence
|
||||||
@@ -15,30 +17,36 @@ class NicoTagsController < ApplicationController
|
|||||||
limit = 1 if limit < 1
|
limit = 1 if limit < 1
|
||||||
|
|
||||||
post_tag_max_sql =
|
post_tag_max_sql =
|
||||||
PostTag
|
PostExternalTag
|
||||||
.select('tag_id, MAX(created_at) AS max_created_at')
|
.select('external_tag_id, MAX(created_at) AS max_created_at')
|
||||||
.group('tag_id')
|
.group('external_tag_id')
|
||||||
.to_sql
|
.to_sql
|
||||||
|
|
||||||
q = Tag.nico_tags
|
q =
|
||||||
.joins(:tag_name)
|
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.tag_id = tags.id')
|
'ON post_tag_max.external_tag_id = external_tags.id')
|
||||||
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
|
.includes(linked_tags: [:tag_names, :tag_descriptions])
|
||||||
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
|
if name
|
||||||
|
q = q.where(('external_tags.name LIKE ? ' +
|
||||||
|
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
|
||||||
|
"%#{ name }%", "%#{ name }")
|
||||||
|
end
|
||||||
|
|
||||||
if linked_tag
|
if linked_tag
|
||||||
linked_tag_ids =
|
linked_tag_ids =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_name)
|
.joins(:tag_names)
|
||||||
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
|
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||||
.pluck(:id)
|
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
|
||||||
|
.pluck(:id)
|
||||||
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
|
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
|
||||||
q = q.where(id: linked_nico_tag_ids)
|
q = q.where(id: linked_nico_tag_ids)
|
||||||
end
|
end
|
||||||
if link_status.in?(['linked', 'unlinked'])
|
if link_status.in?(['linked', 'unlinked'])
|
||||||
exists_sql =
|
exists_sql =
|
||||||
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
|
'EXISTS (SELECT 1 FROM nico_tag_relations ' +
|
||||||
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
|
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
|
||||||
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
|
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,54 +54,69 @@ class NicoTagsController < ApplicationController
|
|||||||
sort_sql =
|
sort_sql =
|
||||||
case order[0]
|
case order[0]
|
||||||
when 'name'
|
when 'name'
|
||||||
'tag_names.name'
|
'external_tags.name'
|
||||||
when 'updated_at'
|
when 'updated_at'
|
||||||
'post_tag_max.max_created_at'
|
'post_tag_max.max_created_at'
|
||||||
else
|
else
|
||||||
"tags.#{ order[0] }"
|
"external_tags.#{ order[0] }"
|
||||||
end
|
end
|
||||||
tags = q.reselect('tags.*',
|
tags = q.reselect('external_tags.*',
|
||||||
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
|
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
|
||||||
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
|
.order(Arel.sql("#{ sort_sql } #{ order[1] }, external_tags.id #{ order[1] }"))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset((page - 1) * limit)
|
.offset((page - 1) * limit)
|
||||||
.to_a
|
.to_a
|
||||||
|
|
||||||
render json: { tags: tags.map { |tag|
|
render json: { tags: tags.map { |tag|
|
||||||
TagRepr.base(tag).merge(
|
external_tag_json(tag).merge(
|
||||||
recent_post_tag_created_at: tag.recent_post_tag_created_at,
|
recent_post_tag_created_at: tag.recent_post_tag_created_at,
|
||||||
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
|
linked_tags: tag.linked_tags.map { |lt|
|
||||||
|
TagRepr.base(lt, language_code: locale.language_code)
|
||||||
|
})
|
||||||
}, count: }
|
}, count: }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
tag = ExternalTag.find(params[:id])
|
||||||
|
|
||||||
|
render json: external_tag_json(tag)
|
||||||
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
id = params[:id].to_i
|
id = params[:id].to_i
|
||||||
|
|
||||||
tag = Tag.find(id)
|
tag = ExternalTag.find(id)
|
||||||
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
|
|
||||||
|
|
||||||
linked_tag_names = params[:tags].to_s.split
|
linked_tag_names = params[:tags].to_s.split
|
||||||
linked_tags = nil
|
linked_tags = nil
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
|
linked_tags = Tag.normalise_tags!(locale, linked_tag_names,
|
||||||
with_no_deerjikist: false)
|
with_tagme: false,
|
||||||
if linked_tags.any? { |t| t.nico? }
|
with_no_deerjikist: false,
|
||||||
raise Tag::NicoTagNormalisationError
|
created_by_user: current_user)
|
||||||
end
|
|
||||||
|
|
||||||
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
|
TagVersioning.record_tag_snapshots!(
|
||||||
|
linked_tags,
|
||||||
|
created_by_user: current_user,
|
||||||
|
language_code: locale.language_code)
|
||||||
|
|
||||||
tag.linked_tags = linked_tags
|
tag.linked_tags = linked_tags
|
||||||
tag.save!
|
tag.save!
|
||||||
|
|
||||||
NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
NicoTagVersionRecorder.record!(external_tag: tag,
|
||||||
|
event_type: :update,
|
||||||
|
created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
|
render json: tag.linked_tags.map { |t|
|
||||||
|
TagRepr.base(t, language_code: locale.language_code)
|
||||||
|
}, status: :ok
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
|
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
@@ -102,11 +125,39 @@ class NicoTagsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def external_tag_json tag
|
||||||
|
{ id: tag.id,
|
||||||
|
name: "#{ tag.platform }:#{ tag.name }",
|
||||||
|
category: 'nico',
|
||||||
|
post_count: tag.post_count,
|
||||||
|
created_at: tag.created_at,
|
||||||
|
updated_at: tag.created_at,
|
||||||
|
deprecated_at: nil,
|
||||||
|
aliases: [],
|
||||||
|
parents: [],
|
||||||
|
has_wiki: false,
|
||||||
|
material_id: nil,
|
||||||
|
has_deerjikists: false }
|
||||||
|
end
|
||||||
|
|
||||||
def render_nico_tag_form_record_invalid record
|
def render_nico_tag_form_record_invalid record
|
||||||
if record.is_a?(TagName) || record.is_a?(Tag)
|
names =
|
||||||
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
|
case record
|
||||||
"タグ名 “#{ record.name }”: #{ message }"
|
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
|
else
|
||||||
render_validation_error record
|
render_validation_error record
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
class PostVersionsController < ApplicationController
|
class PostVersionsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
post_id = params[:post].presence
|
post_id = params[:post].presence
|
||||||
tag_id = params[:tag].presence
|
tag_id = params[:tag].presence&.to_i
|
||||||
|
external_tag_id = params[:external_tag].presence&.to_i
|
||||||
|
return head :bad_request if tag_id && external_tag_id
|
||||||
|
|
||||||
page = (params[:page].presence || 1).to_i
|
page = (params[:page].presence || 1).to_i
|
||||||
limit = (params[:limit].presence || 20).to_i
|
limit = (params[:limit].presence || 20).to_i
|
||||||
|
|
||||||
@@ -10,30 +14,16 @@ class PostVersionsController < ApplicationController
|
|||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
tag_name =
|
q = version_scope
|
||||||
if tag_id
|
|
||||||
TagName.joins(:tag).find_by(tag: { id: tag_id })
|
|
||||||
end
|
|
||||||
return render json: { versions: [], count: 0 } if tag_id && tag_name.blank?
|
|
||||||
|
|
||||||
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 AS prev_tags',
|
|
||||||
'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 = q.where('post_versions.post_id = ?', post_id) if post_id
|
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
||||||
if tag_name
|
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
|
||||||
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name)
|
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
|
||||||
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " +
|
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
|
||||||
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
|
'OR JSON_CONTAINS(prev.tags_json,' +
|
||||||
kw: "% #{ escaped } %")
|
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id }))")
|
||||||
|
elsif tag_id
|
||||||
|
q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', #{ tag_id })) " +
|
||||||
|
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', #{ tag_id }))")
|
||||||
end
|
end
|
||||||
|
|
||||||
count = q.except(:select, :order, :limit, :offset).count
|
count = q.except(:select, :order, :limit, :offset).count
|
||||||
@@ -42,83 +32,129 @@ class PostVersionsController < ApplicationController
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
|
|
||||||
render json: { versions: serialise_versions(versions), count: }
|
render json: { versions: serialise_versions(versions, locale.language_code), count: }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def serialise_versions rows
|
def 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
|
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||||
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
||||||
|
snapshots = rows.flat_map { |row|
|
||||||
|
[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|
|
rows.map do |row|
|
||||||
cur_tags = split_tags(row.tags)
|
cur_tags = snapshot_tag_literals(
|
||||||
prev_tags = split_tags(row.attributes['prev_tags'])
|
normalise_json(row.tags_json), tag_names, external_tag_names)
|
||||||
|
prev_tags = snapshot_tag_literals(
|
||||||
|
normalise_json(row.attributes['prev_tags_json']) || [], tag_names, external_tag_names)
|
||||||
|
|
||||||
{
|
{ post_id: row.post_id,
|
||||||
post_id: row.post_id,
|
|
||||||
version_no: row.version_no,
|
version_no: row.version_no,
|
||||||
event_type: row.event_type,
|
event_type: row.event_type,
|
||||||
title: {
|
title: { current: row.title, prev: row.attributes['prev_title'] },
|
||||||
current: row.title,
|
url: { current: row.url, prev: row.attributes['prev_url'] },
|
||||||
prev: row.attributes['prev_title']
|
thumbnail: { current: nil, prev: nil },
|
||||||
},
|
thumbnail_base: { current: row.thumbnail_base,
|
||||||
url: {
|
prev: row.attributes['prev_thumbnail_base'] },
|
||||||
current: row.url,
|
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
|
||||||
prev: row.attributes['prev_url']
|
|
||||||
},
|
|
||||||
thumbnail: {
|
|
||||||
current: nil,
|
|
||||||
prev: nil
|
|
||||||
},
|
|
||||||
thumbnail_base: {
|
|
||||||
current: row.thumbnail_base,
|
|
||||||
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),
|
tags: build_version_tags(cur_tags, prev_tags),
|
||||||
|
parent_post_ids: row.parent_post_ids,
|
||||||
original_created_from: {
|
original_created_from: {
|
||||||
current: row.original_created_from&.iso8601,
|
current: row.original_created_from&.iso8601,
|
||||||
prev: row.attributes['prev_original_created_from']&.iso8601
|
prev: row.attributes['prev_original_created_from']&.iso8601 },
|
||||||
},
|
|
||||||
original_created_before: {
|
original_created_before: {
|
||||||
current: row.original_created_before&.iso8601,
|
current: row.original_created_before&.iso8601,
|
||||||
prev: row.attributes['prev_original_created_before']&.iso8601
|
prev: row.attributes['prev_original_created_before']&.iso8601 },
|
||||||
},
|
|
||||||
created_at: row.created_at.iso8601,
|
created_at: row.created_at.iso8601,
|
||||||
created_by_user:
|
created_by_user:
|
||||||
if row.created_by_user_id
|
if row.created_by_user_id
|
||||||
{
|
{ id: row.created_by_user_id,
|
||||||
id: row.created_by_user_id,
|
name: users_by_id[row.created_by_user_id] }
|
||||||
name: users_by_id[row.created_by_user_id]
|
end }
|
||||||
}
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def external_tag_names_for snapshots
|
||||||
|
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
|
||||||
|
names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
|
||||||
|
[id, "#{ platform }:#{ name }"]
|
||||||
|
}
|
||||||
|
missing_ids = ids - names.keys
|
||||||
|
|
||||||
|
NicoTagVersion
|
||||||
|
.where(tag_id: missing_ids)
|
||||||
|
.order(:tag_id, version_no: :desc)
|
||||||
|
.pluck(:tag_id, :name)
|
||||||
|
.each { |id, name| names[id] ||= name }
|
||||||
|
|
||||||
|
names
|
||||||
|
end
|
||||||
|
|
||||||
|
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']),
|
||||||
|
{ 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, { identity: [:external_tag, snapshot['external_tag_id']],
|
||||||
|
name: external_tag_names[snapshot['external_tag_id']],
|
||||||
|
restore_name: nil, external: true }]
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
end
|
.sort_by { |order, tag| [order, tag[:name].downcase] }
|
||||||
|
.map(&:second)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_version_tags(cur_tags, prev_tags)
|
def tag_category_order category
|
||||||
(cur_tags | prev_tags).map do |name|
|
['deerjikist', 'meme', 'character', 'general', 'material', 'meta'].index(category) || 6
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_version_tags cur_tags, prev_tags
|
||||||
|
current = cur_tags.index_by { _1[:identity] }
|
||||||
|
previous = prev_tags.index_by { _1[:identity] }
|
||||||
|
|
||||||
|
(current.keys | previous.keys).map do |identity|
|
||||||
type =
|
type =
|
||||||
if cur_tags.include?(name) && prev_tags.include?(name)
|
if current.key?(identity) && previous.key?(identity)
|
||||||
'context'
|
'context'
|
||||||
elsif cur_tags.include?(name)
|
elsif current.key?(identity)
|
||||||
'added'
|
'added'
|
||||||
else
|
else
|
||||||
'removed'
|
'removed'
|
||||||
end
|
end
|
||||||
|
|
||||||
{
|
entry = current[identity] || previous[identity]
|
||||||
name:,
|
{ name: entry[:name], restore_name: entry[:restore_name],
|
||||||
type:
|
external: entry[:external], type: }
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def split_tags(tags)
|
|
||||||
tags.to_s.split(/\s+/).reject(&:blank?)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
url = params[:url].presence
|
url = params[:url].presence
|
||||||
title = params[:title].presence
|
title = params[:title].presence
|
||||||
original_created_from = params[:original_created_from].presence
|
original_created_from = params[:original_created_from].presence
|
||||||
@@ -36,8 +38,8 @@ class PostsController < ApplicationController
|
|||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
pt_max_sql =
|
pt_max_sql =
|
||||||
PostTag
|
PostVersion
|
||||||
.select('post_id, MAX(updated_at) AS max_updated_at')
|
.select('post_id, MAX(created_at) AS max_updated_at')
|
||||||
.group('post_id')
|
.group('post_id')
|
||||||
.to_sql
|
.to_sql
|
||||||
|
|
||||||
@@ -46,13 +48,12 @@ class PostsController < ApplicationController
|
|||||||
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
|
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
|
||||||
|
|
||||||
q =
|
q =
|
||||||
filtered_posts
|
filtered_posts(locale)
|
||||||
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
|
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
|
||||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
||||||
.preload(:uploaded_user, :parents, :children,
|
.preload(:external_tags, :uploaded_user, :parents, :children,
|
||||||
active_post_tags: [:sections,
|
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||||
{ tag: [:deerjikists, :materials,
|
:tag_names, :tag_descriptions] }])
|
||||||
{ tag_name: :wiki_page }] }])
|
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
|
|
||||||
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
||||||
@@ -91,7 +92,8 @@ class PostsController < ApplicationController
|
|||||||
q = q.except(:select, :order)
|
q = q.except(:select, :order)
|
||||||
|
|
||||||
render json: { posts: posts.map { |post|
|
render json: { posts: posts.map { |post|
|
||||||
PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json|
|
PostRepr.base(post, language_code: locale.language_code)
|
||||||
|
.merge(updated_at: post.updated_at_all).tap do |json|
|
||||||
json['thumbnail'] =
|
json['thumbnail'] =
|
||||||
if post.thumbnail.attached?
|
if post.thumbnail.attached?
|
||||||
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||||
@@ -103,16 +105,19 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def random
|
def random
|
||||||
post = filtered_posts.preload(:uploaded_user, :parents, :children,
|
locale = resolve_locale!
|
||||||
active_post_tags: [:sections,
|
|
||||||
{ tag: [:deerjikists, :materials,
|
post =
|
||||||
{ tag_name: :wiki_page }] }])
|
filtered_posts(locale)
|
||||||
.with_attached_thumbnail
|
.preload(:uploaded_user, :parents, :children,
|
||||||
.order('RAND()')
|
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||||
.first
|
:tag_names, :tag_descriptions] }])
|
||||||
|
.with_attached_thumbnail
|
||||||
|
.order('RAND()')
|
||||||
|
.first
|
||||||
return head :not_found unless post
|
return head :not_found unless post
|
||||||
|
|
||||||
render json: PostRepr.base(post, current_user)
|
render json: PostRepr.base(post, current_user, language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def metadata
|
def metadata
|
||||||
@@ -187,12 +192,13 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
post =
|
post =
|
||||||
Post
|
Post
|
||||||
.includes(:uploaded_user, :parents, :children,
|
.includes(:uploaded_user, :parents, :children,
|
||||||
active_post_tags: [:sections,
|
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||||
{ tag: [:deerjikists, :materials,
|
:tag_names, :tag_descriptions] }])
|
||||||
{ tag_name: :wiki_page }] }])
|
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
return head :not_found unless post
|
return head :not_found unless post
|
||||||
@@ -206,17 +212,21 @@ class PostsController < ApplicationController
|
|||||||
parent_posts:,
|
parent_posts:,
|
||||||
child_posts:,
|
child_posts:,
|
||||||
sibling_posts:,
|
sibling_posts:,
|
||||||
related:)
|
related:,
|
||||||
.merge(tags: build_tag_tree_for(post))
|
language_code: locale.language_code)
|
||||||
|
.merge(tags: build_tag_tree_for(post, locale.language_code))
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
preflight = PostCreatePreflight.new(
|
preflight = PostCreatePreflight.new(
|
||||||
attributes: post_create_attributes,
|
attributes: post_create_attributes,
|
||||||
thumbnail: params[:thumbnail]).run
|
thumbnail: params[:thumbnail],
|
||||||
|
host: request.base_url).run(locale)
|
||||||
return render json: dry_run_json(preflight) if bool?(:dry)
|
return render json: dry_run_json(preflight) if bool?(:dry)
|
||||||
if preflight[:existing_post_id].present?
|
if preflight[:existing_post_id].present?
|
||||||
post = Post.new(url: preflight[:url])
|
post = Post.new(url: preflight[:url])
|
||||||
@@ -242,10 +252,10 @@ class PostsController < ApplicationController
|
|||||||
:post_tag_specs,
|
:post_tag_specs,
|
||||||
:tag_sections,
|
:tag_sections,
|
||||||
:normalised_parent_post_ids).symbolize_keys).merge(
|
:normalised_parent_post_ids).symbolize_keys).merge(
|
||||||
thumbnail: params[:thumbnail])).create!
|
thumbnail: params[:thumbnail])).create!(locale)
|
||||||
|
|
||||||
post.reload
|
post.reload
|
||||||
render json: PostRepr.base(post), status: :created
|
render json: PostRepr.base(post, language_code: locale.language_code), status: :created
|
||||||
rescue PostCreatePreflight::ValidationFailed => e
|
rescue PostCreatePreflight::ValidationFailed => e
|
||||||
render_validation_error fields: e.fields, base: e.base_errors
|
render_validation_error fields: e.fields, base: e.base_errors
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
@@ -271,9 +281,18 @@ class PostsController < ApplicationController
|
|||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form]
|
return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form]
|
||||||
return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
|
return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
posts = parse_bulk_posts_manifest
|
posts = parse_bulk_posts_manifest
|
||||||
thumbnails = parse_bulk_thumbnails(posts.length)
|
thumbnails = parse_bulk_thumbnails(posts.length)
|
||||||
result = PostBulkCreator.new(actor: current_user, posts:, thumbnails:).run
|
|
||||||
|
result = PostBulkCreator.new(
|
||||||
|
actor: current_user,
|
||||||
|
posts:,
|
||||||
|
thumbnails:,
|
||||||
|
host: request.base_url).run(locale)
|
||||||
|
|
||||||
render json: result
|
render json: result
|
||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
render_bad_request 'posts manifest の JSON が不正です.'
|
render_bad_request 'posts manifest の JSON が不正です.'
|
||||||
@@ -306,6 +325,7 @@ class PostsController < ApplicationController
|
|||||||
base_version_no = parse_base_version_no
|
base_version_no = parse_base_version_no
|
||||||
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
|
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
|
||||||
|
|
||||||
|
locale = Locale.find_by(code: params[:locale].presence) || Locale.nipponese
|
||||||
title = params[:title].presence
|
title = params[:title].presence
|
||||||
tag_names = params[:tags].to_s.split
|
tag_names = params[:tags].to_s.split
|
||||||
original_created_from = params[:original_created_from]
|
original_created_from = params[:original_created_from]
|
||||||
@@ -325,9 +345,10 @@ class PostsController < ApplicationController
|
|||||||
base_version = post.post_versions.find_by!(version_no: base_version_no)
|
base_version = post.post_versions.find_by!(version_no: base_version_no)
|
||||||
|
|
||||||
base_snapshot = post_snapshot_from_version(base_version)
|
base_snapshot = post_snapshot_from_version(base_version)
|
||||||
current_snapshot = post_snapshot_from_record(post)
|
current_snapshot = post_snapshot_from_record(post, locale)
|
||||||
end
|
end
|
||||||
incoming_snapshot = post_incoming_snapshot(title:,
|
incoming_snapshot = post_incoming_snapshot(locale:,
|
||||||
|
title:,
|
||||||
original_created_from:,
|
original_created_from:,
|
||||||
original_created_before:,
|
original_created_before:,
|
||||||
tag_names:,
|
tag_names:,
|
||||||
@@ -356,14 +377,14 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
apply_post_snapshot!(post, snapshot_to_apply)
|
apply_post_snapshot!(locale, post, snapshot_to_apply)
|
||||||
end
|
end
|
||||||
|
|
||||||
return render json: conflict_json, status: :conflict if conflict_json
|
return render json: conflict_json, status: :conflict if conflict_json
|
||||||
|
|
||||||
post.reload
|
post.reload
|
||||||
json = PostRepr.base(post, current_user)
|
json = PostRepr.base(post, current_user, language_code: locale.language_code)
|
||||||
json['tags'] = build_tag_tree_for(post)
|
json['tags'] = build_tag_tree_for(post, locale.language_code)
|
||||||
render json:, status: :ok
|
render json:, status: :ok
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
|
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
|
||||||
@@ -379,77 +400,84 @@ class PostsController < ApplicationController
|
|||||||
render_post_form_record_invalid e.record
|
render_post_form_record_invalid e.record
|
||||||
end
|
end
|
||||||
|
|
||||||
def changes
|
def tag_filters
|
||||||
id = params[:id].presence
|
source = resolve_locale!
|
||||||
tag_id = params[:tag].presence
|
code = params[:target_locale]
|
||||||
page = (params[:page].presence || 1).to_i
|
return head :not_found unless code.is_a?(String) && Locale.codes.value?(code)
|
||||||
limit = (params[:limit].presence || 20).to_i
|
|
||||||
|
|
||||||
page = 1 if page < 1
|
target = Locale.find_by(code:, deprecated_at: nil)
|
||||||
limit = 1 if limit < 1
|
return head :not_found unless target
|
||||||
|
|
||||||
offset = (page - 1) * limit
|
literals = params[:tags].to_s.split
|
||||||
|
references = tag_filter_references(literals)
|
||||||
pts = PostTag.with_discarded
|
names = literals.each_with_index.map do |literal, index|
|
||||||
pts = pts.where(post_id: id) if id.present?
|
reference = references&.[](index)
|
||||||
pts = pts.where(tag_id:) if tag_id.present?
|
(reference && reference['negative'] == false) ? literal : literal.sub(/\Anot:/i, '')
|
||||||
pts = pts.includes(:post, :created_user, :deleted_user,
|
|
||||||
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
|
||||||
|
|
||||||
events = []
|
|
||||||
pts.each do |pt|
|
|
||||||
tag = TagRepr.base(pt.tag)
|
|
||||||
post = pt.post
|
|
||||||
|
|
||||||
events << Event.new(
|
|
||||||
post:,
|
|
||||||
tag:,
|
|
||||||
user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
|
|
||||||
change_type: 'add',
|
|
||||||
timestamp: pt.created_at)
|
|
||||||
|
|
||||||
if pt.discarded_at
|
|
||||||
events << Event.new(
|
|
||||||
post:,
|
|
||||||
tag:,
|
|
||||||
user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
|
|
||||||
change_type: 'remove',
|
|
||||||
timestamp: pt.discarded_at)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
events.sort_by!(&:timestamp)
|
internal = references ? {} : TagName.where(
|
||||||
events.reverse!
|
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
|
||||||
|
|
||||||
render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def filtered_posts
|
def tag_filter_references literals
|
||||||
|
PostTagSearch.parse_references(params[:tag_refs], literals)
|
||||||
|
rescue ArgumentError, JSON::ParserError, TypeError
|
||||||
|
raise ActionController::BadRequest
|
||||||
|
end
|
||||||
|
|
||||||
|
def filtered_posts locale
|
||||||
tag_names = params[:tags].to_s.split
|
tag_names = params[:tags].to_s.split
|
||||||
match_type = params[:match]
|
match_type = params[:match]
|
||||||
if tag_names.present?
|
if tag_names.present?
|
||||||
filter_posts_by_tags(tag_names, match_type)
|
filter_posts_by_tags(locale, tag_names, match_type)
|
||||||
else
|
else
|
||||||
Post.all
|
Post.all
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_posts_by_tags tag_names, match_type
|
def filter_posts_by_tags locale, tag_names, match_type
|
||||||
literals = tag_names.map do |raw_name|
|
references = tag_filter_references(tag_names)
|
||||||
{ name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first,
|
literals = PostTagSearch.literals(locale, tag_names.join(' '), references:)
|
||||||
negative: raw_name.downcase.start_with?('not:') }
|
|
||||||
end
|
|
||||||
|
|
||||||
return Post.all if literals.empty?
|
return Post.all if literals.empty?
|
||||||
|
|
||||||
if match_type == 'any'
|
if match_type == 'any'
|
||||||
literals.reduce(Post.none) do |posts, literal|
|
literals.reduce(Post.none) do |posts, literal|
|
||||||
posts.or(tag_literal_relation(literal[:name], negative: literal[:negative]))
|
posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative],
|
||||||
|
reference: literal[:reference]))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
literals.reduce(Post.all) do |posts, literal|
|
literals.reduce(Post.all) do |posts, literal|
|
||||||
ids = tagged_post_ids_for(literal[:name])
|
ids = tagged_post_ids_for(locale, literal[:name], reference: literal[:reference])
|
||||||
if literal[:negative]
|
if literal[:negative]
|
||||||
posts.where.not(id: ids)
|
posts.where.not(id: ids)
|
||||||
else
|
else
|
||||||
@@ -459,8 +487,8 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_literal_relation name, negative:
|
def tag_literal_relation locale, name, negative:, reference: nil
|
||||||
ids = tagged_post_ids_for(name)
|
ids = tagged_post_ids_for(locale, name, reference:)
|
||||||
if negative
|
if negative
|
||||||
Post.where.not(id: ids)
|
Post.where.not(id: ids)
|
||||||
else
|
else
|
||||||
@@ -468,8 +496,12 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def tagged_post_ids_for(name) =
|
def tagged_post_ids_for locale, name, reference: nil
|
||||||
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
|
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
|
def sync_post_tags! post, desired_tags, sections
|
||||||
desired_tags.each do |t|
|
desired_tags.each do |t|
|
||||||
@@ -497,13 +529,13 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
|
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
|
||||||
pt.discard_by!(current_user)
|
pt.destroy!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_tag_tree_for post
|
def build_tag_tree_for post, language_code = 'ja'
|
||||||
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
|
post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? }
|
||||||
tags = post_tags.map(&:tag)
|
tags = post_tags.map(&:tag)
|
||||||
tag_ids = tags.map(&:id)
|
tag_ids = tags.map(&:id)
|
||||||
|
|
||||||
@@ -532,7 +564,7 @@ class PostsController < ApplicationController
|
|||||||
sections = sections_by_tag_id.fetch(tag_id, [])
|
sections = sections_by_tag_id.fetch(tag_id, [])
|
||||||
|
|
||||||
if path.include?(tag_id)
|
if path.include?(tag_id)
|
||||||
return TagRepr.inline(tag).merge(children: [], sections:)
|
return TagRepr.inline(tag, language_code:).merge(children: [], sections:)
|
||||||
end
|
end
|
||||||
|
|
||||||
if memo.key?(tag_id)
|
if memo.key?(tag_id)
|
||||||
@@ -544,10 +576,13 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
|
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
|
||||||
|
|
||||||
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
|
memo[tag_id] = TagRepr.inline(tag, language_code:).merge(children:, sections:)
|
||||||
end
|
end
|
||||||
|
|
||||||
root_ids.filter_map { |id| build_node.call(id, []) }
|
internal_tags = root_ids.filter_map { |id| build_node.call(id, []) }
|
||||||
|
external_tags =
|
||||||
|
post.external_tags.map { ExternalTagRepr.inline(_1).merge(children: [], sections: []) }
|
||||||
|
internal_tags + external_tags
|
||||||
end
|
end
|
||||||
|
|
||||||
def sibling_posts_by_parent parent_post_ids
|
def sibling_posts_by_parent parent_post_ids
|
||||||
@@ -627,7 +662,7 @@ class PostsController < ApplicationController
|
|||||||
return nil if post_id.blank?
|
return nil if post_id.blank?
|
||||||
|
|
||||||
post = Post.with_attached_thumbnail.find_by(id: post_id)
|
post = Post.with_attached_thumbnail.find_by(id: post_id)
|
||||||
PostCompactRepr.base(post)
|
PostCompactRepr.base(post, host: request.base_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
def dry_run_json preflight
|
def dry_run_json preflight
|
||||||
@@ -694,42 +729,50 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def editable_tag_names_from_version version
|
def editable_tag_names_from_version version
|
||||||
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
|
version.tags_json
|
||||||
|
.select { _1.key?('tag_id') }
|
||||||
|
.map { Post.tag_snapshot_literal(_1) }
|
||||||
|
.compact
|
||||||
|
.sort
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_snapshot_from_record post
|
def post_snapshot_from_record post, locale
|
||||||
{ title: post.title,
|
{ title: post.title,
|
||||||
video_ms: post.video_ms,
|
video_ms: post.video_ms,
|
||||||
original_created_from: snapshot_time(post.original_created_from),
|
original_created_from: snapshot_time(post.original_created_from),
|
||||||
original_created_before: snapshot_time(post.original_created_before),
|
original_created_before: snapshot_time(post.original_created_before),
|
||||||
tag_names: editable_tag_names_from_post(post),
|
tag_names: editable_tag_names_from_post(post, locale.language_code),
|
||||||
parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
|
parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def editable_tag_names_from_post post
|
def editable_tag_names_from_post post, language_code
|
||||||
post
|
post
|
||||||
.post_tags
|
.post_tags
|
||||||
.kept
|
.joins(tag: :tag_names)
|
||||||
.joins(tag: :tag_name)
|
|
||||||
.merge(Tag.not_nico)
|
|
||||||
.merge(Tag.where(deprecated_at: nil))
|
.merge(Tag.where(deprecated_at: nil))
|
||||||
.includes(:sections, tag: :tag_name)
|
.where(tag_names: { language_code:, primary_flg: true })
|
||||||
|
.includes(:sections, tag: :tag_names)
|
||||||
.order('tag_names.name')
|
.order('tag_names.name')
|
||||||
.map do |post_tag|
|
.map do |post_tag|
|
||||||
name = post_tag.tag.tag_name.name
|
name = post_tag.tag.name(language_code)
|
||||||
sections = post_tag.sections.sort_by(&:begin_ms)
|
sections = post_tag.sections.sort_by(&:begin_ms)
|
||||||
|
|
||||||
next name if sections.empty?
|
next name if sections.empty?
|
||||||
|
|
||||||
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
|
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
|
||||||
end
|
end.compact.sort
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
|
def post_incoming_snapshot locale:, title:, original_created_from:, original_created_before:,
|
||||||
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
|
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
|
||||||
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
|
validate_original_created_values!(original_created_from, original_created_before)
|
||||||
with_sections: true) =>
|
|
||||||
{ tags:, sections: }
|
Tag.normalise_tags!(locale, tag_names,
|
||||||
|
with_tagme: false,
|
||||||
|
with_no_deerjikist: false,
|
||||||
|
deny_deprecated: true,
|
||||||
|
with_sections: true,
|
||||||
|
created_by_user: current_user) => { tags:, sections: }
|
||||||
|
|
||||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||||
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
|
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
|
||||||
@@ -740,7 +783,7 @@ class PostsController < ApplicationController
|
|||||||
original_created_from: snapshot_time(original_created_from),
|
original_created_from: snapshot_time(original_created_from),
|
||||||
original_created_before: snapshot_time(original_created_before),
|
original_created_before: snapshot_time(original_created_before),
|
||||||
tag_names: tags.uniq(&:id).map { |tag|
|
tag_names: tags.uniq(&:id).map { |tag|
|
||||||
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
|
"#{ tag.name(locale.language_code) }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
|
||||||
}.sort,
|
}.sort,
|
||||||
parent_post_ids: parent_post_ids.sort }
|
parent_post_ids: parent_post_ids.sort }
|
||||||
end
|
end
|
||||||
@@ -764,6 +807,23 @@ class PostsController < ApplicationController
|
|||||||
value.to_s
|
value.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_original_created_values! original_created_from, original_created_before
|
||||||
|
candidate = Post.new(
|
||||||
|
url: 'https://example.invalid/original-created-validation',
|
||||||
|
original_created_from:,
|
||||||
|
original_created_before:)
|
||||||
|
candidate.valid?
|
||||||
|
fields = [:original_created_from, :original_created_before, :original_created_at]
|
||||||
|
relevant_errors = candidate.errors.select { fields.include?(_1.attribute) }
|
||||||
|
return if relevant_errors.empty?
|
||||||
|
|
||||||
|
invalid_post = Post.new
|
||||||
|
relevant_errors.each { |error|
|
||||||
|
invalid_post.errors.add(error.attribute, error.message)
|
||||||
|
}
|
||||||
|
raise ActiveRecord::RecordInvalid, invalid_post
|
||||||
|
end
|
||||||
|
|
||||||
def section_literal section
|
def section_literal section
|
||||||
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
|
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
|
||||||
end
|
end
|
||||||
@@ -847,7 +907,7 @@ class PostsController < ApplicationController
|
|||||||
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
|
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_post_snapshot! post, snapshot
|
def apply_post_snapshot! locale, post, snapshot
|
||||||
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
|
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
|
||||||
|
|
||||||
post.update!(title: snapshot[:title],
|
post.update!(title: snapshot[:title],
|
||||||
@@ -855,15 +915,14 @@ class PostsController < ApplicationController
|
|||||||
original_created_from: snapshot[:original_created_from],
|
original_created_from: snapshot[:original_created_from],
|
||||||
original_created_before: snapshot[:original_created_before])
|
original_created_before: snapshot[:original_created_before])
|
||||||
|
|
||||||
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
|
Tag.normalise_tags!(locale, snapshot[:tag_names],
|
||||||
deny_deprecated: true,
|
with_tagme: false,
|
||||||
with_sections: true) =>
|
deny_deprecated: true,
|
||||||
{ tags: editable_tags, sections: }
|
with_sections: true,
|
||||||
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
|
created_by_user: current_user) => { tags:, sections: }
|
||||||
|
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user,
|
||||||
|
language_code: locale.language_code)
|
||||||
|
|
||||||
readonly_tags = post.tags.nico.to_a
|
|
||||||
|
|
||||||
tags = readonly_tags + editable_tags
|
|
||||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||||
|
|
||||||
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
|
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
class SitemapEntriesController < ApplicationController
|
||||||
|
PAGE_SIZE = 1_000
|
||||||
|
RESOURCES = [
|
||||||
|
'posts', 'tags', 'deerjikists', 'materials',
|
||||||
|
'descriptions', 'tag_groups', 'theatres'].freeze
|
||||||
|
|
||||||
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
resources = RESOURCES.map { |resource| { resource:, count: entries(resource, locale).count } }
|
||||||
|
render json: { resources:, page_size: PAGE_SIZE }
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
locale = resolve_locale!
|
||||||
|
resource = params[:resource]
|
||||||
|
return head :not_found unless RESOURCES.include?(resource)
|
||||||
|
|
||||||
|
page = Integer(params.fetch(:page, '1'), exception: false)
|
||||||
|
return head :bad_request unless page && page.positive?
|
||||||
|
|
||||||
|
scope = entries(resource, locale)
|
||||||
|
return head :not_found if page > [(scope.count.to_f / PAGE_SIZE).ceil, 1].max
|
||||||
|
|
||||||
|
ids = scope.order(:id).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).pluck(:id)
|
||||||
|
render json: { ids: }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def entries resource, locale
|
||||||
|
named_tags = TagName.where(language_code: locale.language_code, primary_flg: true)
|
||||||
|
.select(:tag_id)
|
||||||
|
case resource
|
||||||
|
when 'posts'
|
||||||
|
Post.all
|
||||||
|
when 'tags'
|
||||||
|
Tag.where(id: named_tags)
|
||||||
|
when 'deerjikists'
|
||||||
|
Tag.where(id: named_tags).where(id: Deerjikist.select(:tag_id))
|
||||||
|
when 'materials'
|
||||||
|
Material.all
|
||||||
|
when 'descriptions'
|
||||||
|
TagDescription.where(language_code: locale.language_code, tag_id: named_tags)
|
||||||
|
.where.not(body: [nil, ''])
|
||||||
|
.where(TagDescriptionVersion
|
||||||
|
.where('tag_description_versions.tag_id = tag_descriptions.tag_id')
|
||||||
|
.where('tag_description_versions.language_code = tag_descriptions.language_code')
|
||||||
|
.where('tag_description_versions.version_no = tag_descriptions.version_no')
|
||||||
|
.arel.exists)
|
||||||
|
when 'tag_groups'
|
||||||
|
TagGroup.joins(:versions)
|
||||||
|
.where('tag_group_versions.version_no = tag_groups.version_no')
|
||||||
|
when 'theatres'
|
||||||
|
Theatre.kept
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,7 +10,6 @@ class TagChildrenController < ApplicationController
|
|||||||
|
|
||||||
parent = Tag.find(parent_id)
|
parent = Tag.find(parent_id)
|
||||||
child = Tag.find(child_id)
|
child = Tag.find(child_id)
|
||||||
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
|
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
|
||||||
@@ -33,7 +32,6 @@ class TagChildrenController < ApplicationController
|
|||||||
|
|
||||||
parent = Tag.find(parent_id)
|
parent = Tag.find(parent_id)
|
||||||
child = Tag.find(child_id)
|
child = Tag.find(child_id)
|
||||||
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
|
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
class TagDescriptionsController < ApplicationController
|
||||||
|
def show
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
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: locale.language_code)
|
||||||
|
|
||||||
|
if description
|
||||||
|
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
|
||||||
|
description.reload
|
||||||
|
render json: representation(description)
|
||||||
|
else
|
||||||
|
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
|
||||||
|
render_validation_error e.record
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
render_record_not_unique
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def representation description
|
||||||
|
description.as_json(only: [
|
||||||
|
:tag_id,
|
||||||
|
:language_code,
|
||||||
|
:body,
|
||||||
|
:version_no,
|
||||||
|
:created_by_user_id,
|
||||||
|
:updated_by_user_id,
|
||||||
|
:created_at,
|
||||||
|
:updated_at])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class TagGroupsController < ApplicationController
|
||||||
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
groups = TagGroup.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(locale, group)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
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!(locale, group:, **write_attributes, actor: current_user)
|
||||||
|
render json: TagGroupRepr.one(locale, group.reload)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def write_attributes
|
||||||
|
{ name: params[:name], description: params[:description],
|
||||||
|
members: params[:members], placements: params[:placements] }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
class TagVersionsController < ApplicationController
|
class TagVersionsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
tag_id = params[:id].presence
|
tag_id = params[:id].presence
|
||||||
page = (params[:page].presence || 1).to_i
|
page = (params[:page].presence || 1).to_i
|
||||||
limit = (params[:limit].presence || 20).to_i
|
limit = (params[:limit].presence || 20).to_i
|
||||||
@@ -9,16 +10,7 @@ class TagVersionsController < ApplicationController
|
|||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
q = TagVersion.joins(<<~SQL.squish)
|
q = version_scope
|
||||||
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.parent_tag_ids AS prev_parent_tag_ids')
|
|
||||||
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
||||||
|
|
||||||
count = q.except(:select, :order, :limit, :offset).count
|
count = q.except(:select, :order, :limit, :offset).count
|
||||||
@@ -27,41 +19,67 @@ class TagVersionsController < ApplicationController
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
|
|
||||||
render json: { versions: serialise_versions(versions), count: }
|
render json: { versions: serialise_versions(versions, locale.language_code), count: }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def serialise_versions rows
|
def 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
|
||||||
|
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
|
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||||
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
||||||
|
parent_ids = rows.flat_map do |row|
|
||||||
|
split_parent_tag_ids(row.parent_tag_ids) +
|
||||||
|
split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
|
||||||
|
end.uniq
|
||||||
|
parents_by_id = Tag.includes(
|
||||||
|
:tag_names, :materials, :deerjikists, :tag_descriptions,
|
||||||
|
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
|
||||||
|
.where(id: parent_ids).index_by(&:id)
|
||||||
|
parent_json = parents_by_id.transform_values { TagRepr.base(_1, language_code:) }
|
||||||
|
|
||||||
rows.map do |row|
|
rows.map do |row|
|
||||||
cur_aliases = split_values(row.aliases)
|
current_names = names_for_locale(row.names, language_code)
|
||||||
prev_aliases = split_values(row.attributes['prev_aliases'])
|
previous_names = names_for_locale(row.attributes['prev_names'], language_code)
|
||||||
|
cur_aliases = current_names.fetch('aliases', []).map { _1.fetch('name') }
|
||||||
|
prev_aliases = previous_names.fetch('aliases', []).map { _1.fetch('name') }
|
||||||
|
|
||||||
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
|
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
|
||||||
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
|
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
|
||||||
|
|
||||||
all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids)
|
|
||||||
|
|
||||||
tags_by_id =
|
|
||||||
Tag
|
|
||||||
.includes(:tag_name, :materials, { tag_name: :wiki_page })
|
|
||||||
.where(id: all_parent_tag_ids)
|
|
||||||
.index_by(&:id)
|
|
||||||
|
|
||||||
parent_tags =
|
parent_tags =
|
||||||
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
|
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
|
||||||
.map do |h|
|
.map do |h|
|
||||||
{ tag: TagRepr.base(tags_by_id[h[:tag_id]]),
|
{ tag: parent_json.fetch(h[:tag_id]),
|
||||||
type: h[:type] }
|
type: h[:type] }
|
||||||
end
|
end
|
||||||
|
|
||||||
{ tag_id: row.tag_id,
|
{ tag_id: row.tag_id,
|
||||||
version_no: row.version_no,
|
version_no: row.version_no,
|
||||||
event_type: row.event_type,
|
event_type: row.event_type,
|
||||||
name: { current: row.name, prev: row.attributes['prev_name'] },
|
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'] },
|
category: { current: row.category, prev: row.attributes['prev_category'] },
|
||||||
deprecated_at: { current: row.deprecated_at&.iso8601,
|
deprecated_at: { current: row.deprecated_at&.iso8601,
|
||||||
prev: row.attributes['prev_deprecated_at']&.iso8601 },
|
prev: row.attributes['prev_deprecated_at']&.iso8601 },
|
||||||
@@ -91,5 +109,45 @@ class TagVersionsController < ApplicationController
|
|||||||
|
|
||||||
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
|
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
|
||||||
|
|
||||||
|
def names_for_locale names, language_code
|
||||||
|
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.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)
|
def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ require 'set'
|
|||||||
|
|
||||||
class TagsController < ApplicationController
|
class TagsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
post_id = params[:post]
|
post_id = params[:post]
|
||||||
|
|
||||||
name = params[:name].presence
|
name = params[:name].presence
|
||||||
@@ -34,49 +36,162 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
q =
|
tags =
|
||||||
if post_id.present?
|
if post_id.present?
|
||||||
Tag.joins(:posts, :tag_name)
|
Tag
|
||||||
|
.joins(:posts, :tag_names)
|
||||||
|
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||||
|
posts: { id: post_id })
|
||||||
else
|
else
|
||||||
Tag.joins(:tag_name)
|
Tag
|
||||||
|
.joins(:tag_names)
|
||||||
|
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||||
end
|
end
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
|
||||||
q = q.where(posts: { id: post_id }) if post_id.present?
|
|
||||||
|
|
||||||
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
|
external_tags =
|
||||||
q = q.where(category:) if category
|
if post_id.present?
|
||||||
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
|
ExternalTag.joins(:posts).where(posts: { id: post_id })
|
||||||
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
|
else
|
||||||
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
|
ExternalTag.all
|
||||||
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
|
end
|
||||||
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
|
|
||||||
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
|
if name
|
||||||
if deprecated_given
|
tags = tags.where('tag_names.name LIKE ?', "%#{ name }%")
|
||||||
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
|
external_tags =
|
||||||
|
external_tags.where("CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?",
|
||||||
|
"%#{ name }%")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if category == 'nico'
|
||||||
|
tags = tags.none
|
||||||
|
elsif category
|
||||||
|
tags = tags.where(category:)
|
||||||
|
external_tags = external_tags.none
|
||||||
|
end
|
||||||
|
|
||||||
|
if post_count_between[0]
|
||||||
|
tags = tags.where('tags.post_count >= ?', post_count_between[0])
|
||||||
|
external_tags = external_tags.where('external_tags.post_count >= ?', post_count_between[0])
|
||||||
|
end
|
||||||
|
|
||||||
|
if post_count_between[1]
|
||||||
|
tags = tags.where('tags.post_count <= ?', post_count_between[1])
|
||||||
|
external_tags = external_tags.where('external_tags.post_count <= ?', post_count_between[1])
|
||||||
|
end
|
||||||
|
|
||||||
|
if created_between[0]
|
||||||
|
tags = tags.where('tags.created_at >= ?', created_between[0])
|
||||||
|
external_tags = external_tags.where('external_tags.created_at >= ?', created_between[0])
|
||||||
|
end
|
||||||
|
|
||||||
|
if created_between[1]
|
||||||
|
tags = tags.where('tags.created_at <= ?', created_between[1])
|
||||||
|
external_tags = external_tags.where('external_tags.created_at <= ?', created_between[1])
|
||||||
|
end
|
||||||
|
|
||||||
|
if updated_between[0]
|
||||||
|
tags = tags.where('tags.updated_at >= ?', updated_between[0])
|
||||||
|
external_tags = external_tags.where('external_tags.created_at >= ?', updated_between[0])
|
||||||
|
end
|
||||||
|
|
||||||
|
if updated_between[1]
|
||||||
|
tags = tags.where('tags.updated_at <= ?', updated_between[1])
|
||||||
|
external_tags = external_tags.where('external_tags.created_at <= ?', updated_between[1])
|
||||||
|
end
|
||||||
|
|
||||||
|
if deprecated_given
|
||||||
|
if deprecated
|
||||||
|
tags = tags.where.not(deprecated_at: nil)
|
||||||
|
external_tags = external_tags.none
|
||||||
|
else
|
||||||
|
tags = tags.where(deprecated_at: nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
tag_sql = tags.select("'tag' AS source",
|
||||||
|
'tags.id',
|
||||||
|
'tag_names.name',
|
||||||
|
'tags.category',
|
||||||
|
'tags.post_count',
|
||||||
|
'tags.created_at',
|
||||||
|
'tags.updated_at',
|
||||||
|
'tags.deprecated_at').to_sql
|
||||||
|
|
||||||
|
external_tag_sql = external_tags.select(
|
||||||
|
"'external_tag' AS source",
|
||||||
|
'external_tags.id',
|
||||||
|
"CONCAT(external_tags.platform, ':', external_tags.name) AS name",
|
||||||
|
"'nico' AS category",
|
||||||
|
'external_tags.post_count',
|
||||||
|
'external_tags.created_at',
|
||||||
|
'external_tags.created_at AS updated_at',
|
||||||
|
'NULL AS deprecated_at').to_sql
|
||||||
|
|
||||||
|
union_sql = "#{ tag_sql } UNION ALL #{ external_tag_sql }"
|
||||||
|
|
||||||
sort_sql =
|
sort_sql =
|
||||||
case order[0]
|
if order[0] == 'category'
|
||||||
when 'name'
|
'CASE category ' +
|
||||||
'tag_names.name'
|
|
||||||
when 'category'
|
|
||||||
'CASE tags.category ' +
|
|
||||||
"WHEN 'deerjikist' THEN 0 " +
|
"WHEN 'deerjikist' THEN 0 " +
|
||||||
"WHEN 'meme' THEN 1 " +
|
"WHEN 'meme' THEN 1 " +
|
||||||
"WHEN 'character' THEN 2 " +
|
"WHEN 'character' THEN 2 " +
|
||||||
"WHEN 'general' THEN 3 " +
|
"WHEN 'general' THEN 3 " +
|
||||||
"WHEN 'material' THEN 4 " +
|
"WHEN 'material' THEN 4 " +
|
||||||
"WHEN 'meta' THEN 5 " +
|
"WHEN 'meta' THEN 5 " +
|
||||||
"WHEN 'nico' THEN 6 END"
|
"WHEN 'nico' THEN 6 " +
|
||||||
|
'END'
|
||||||
else
|
else
|
||||||
"tags.#{ order[0] }"
|
order[0]
|
||||||
end
|
end
|
||||||
tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset)
|
|
||||||
.to_a
|
|
||||||
|
|
||||||
render json: { tags: TagRepr.many(tags), count: q.size }
|
connection = ApplicationRecord.connection
|
||||||
|
|
||||||
|
count = connection.select_value(<<~SQL)
|
||||||
|
SELECT
|
||||||
|
COUNT(0)
|
||||||
|
FROM
|
||||||
|
(#{ union_sql }) legacy_tags
|
||||||
|
SQL
|
||||||
|
|
||||||
|
rows = connection.select_all(<<~SQL).to_a
|
||||||
|
SELECT
|
||||||
|
source
|
||||||
|
, id
|
||||||
|
FROM
|
||||||
|
(#{ union_sql }) legacy_tags
|
||||||
|
ORDER BY
|
||||||
|
#{ sort_sql } #{ order[1] }
|
||||||
|
, id #{ order[1] }
|
||||||
|
, source #{ order[1] }
|
||||||
|
LIMIT
|
||||||
|
#{ limit }
|
||||||
|
OFFSET
|
||||||
|
#{ offset }
|
||||||
|
SQL
|
||||||
|
|
||||||
|
tag_ids = rows.filter { _1['source'] == 'tag' }.map { _1['id'] }
|
||||||
|
external_tag_ids = rows.filter { _1['source'] == 'external_tag' }.map { _1['id'] }
|
||||||
|
|
||||||
|
tags_by_id =
|
||||||
|
Tag
|
||||||
|
.joins(:tag_names)
|
||||||
|
.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)
|
||||||
|
external_tags_by_id =
|
||||||
|
ExternalTag
|
||||||
|
.where(id: external_tag_ids)
|
||||||
|
.index_by(&:id)
|
||||||
|
|
||||||
|
render json: { tags: rows.map { |row|
|
||||||
|
if row['source'] == 'tag'
|
||||||
|
TagRepr.base(tags_by_id.fetch(row['id']), language_code: locale.language_code)
|
||||||
|
else
|
||||||
|
external_tag_json(external_tags_by_id.fetch(row['id']))
|
||||||
|
end
|
||||||
|
}, count: }
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_depth
|
def with_depth
|
||||||
@@ -84,7 +199,8 @@ class TagsController < ApplicationController
|
|||||||
parent_tag_id = nil if parent_tag_id <= 0
|
parent_tag_id = nil if parent_tag_id <= 0
|
||||||
material_filter = material_filter_param(default: 'any')
|
material_filter = material_filter_param(default: 'any')
|
||||||
|
|
||||||
graph = build_with_depth_graph(material_filter)
|
locale = resolve_locale!
|
||||||
|
graph = build_with_depth_graph(material_filter, locale.language_code)
|
||||||
|
|
||||||
tag_ids =
|
tag_ids =
|
||||||
if parent_tag_id
|
if parent_tag_id
|
||||||
@@ -99,137 +215,231 @@ class TagsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def autocomplete
|
def autocomplete
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
||||||
|
prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%"
|
||||||
|
|
||||||
with_nico = bool?(:nico, default: true)
|
with_nico = bool?(:nico, default: true)
|
||||||
present_only = bool?(:present, default: true)
|
present_only = bool?(:present, default: true)
|
||||||
|
|
||||||
alias_rows =
|
alias_rows =
|
||||||
TagName
|
TagName
|
||||||
.where('name LIKE ?', "#{ q }%")
|
.where(language_code: locale.language_code, primary_flg: false)
|
||||||
.where.not(canonical_id: nil)
|
.where('name LIKE ?', prefix)
|
||||||
.pluck(:canonical_id, :name)
|
.pluck(:tag_id, :name)
|
||||||
|
|
||||||
matched_alias_by_tag_name_id = { }
|
matched_alias_by_tag_id = { }
|
||||||
canonical_ids = []
|
tag_ids = []
|
||||||
|
alias_rows.each do |tag_id, alias_name|
|
||||||
alias_rows.each do |canonical_id, alias_name|
|
tag_ids << tag_id
|
||||||
canonical_ids << canonical_id
|
matched_alias_by_tag_id[tag_id] ||= alias_name
|
||||||
matched_alias_by_tag_name_id[canonical_id] ||= alias_name
|
|
||||||
end
|
end
|
||||||
|
|
||||||
base = Tag.joins(:tag_name)
|
base =
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
Tag
|
||||||
.where(deprecated_at: nil)
|
.joins(:tag_names)
|
||||||
|
.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
|
base = base.where('tags.post_count > 0') if present_only
|
||||||
|
|
||||||
canonical_hit =
|
canonical_hit = base.where('tag_names.name LIKE ?', prefix)
|
||||||
base
|
|
||||||
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
|
|
||||||
'tag_names.name LIKE ?'),
|
|
||||||
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
|
|
||||||
|
|
||||||
tags =
|
internal_tags = canonical_hit.or(base.where(id: tag_ids.uniq))
|
||||||
if canonical_ids.present?
|
|
||||||
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
|
|
||||||
else
|
|
||||||
canonical_hit
|
|
||||||
end
|
|
||||||
|
|
||||||
tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a
|
internal_rows =
|
||||||
|
internal_tags
|
||||||
|
.order(Arel.sql('tags.post_count DESC, tag_names.name'))
|
||||||
|
.limit(20)
|
||||||
|
.map { |tag|
|
||||||
|
TagRepr.base(tag, language_code: locale.language_code)
|
||||||
|
.merge(matched_alias: matched_alias_by_tag_id[tag.id])
|
||||||
|
}
|
||||||
|
|
||||||
render json: tags.map { |tag|
|
return render json: internal_rows unless with_nico
|
||||||
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
|
|
||||||
}
|
external_base = ExternalTag.all
|
||||||
|
external_base = external_base.where('post_count > 0') if present_only
|
||||||
|
|
||||||
|
external_rows =
|
||||||
|
external_base
|
||||||
|
.where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", prefix, prefix)
|
||||||
|
.order(post_count: :desc, name: :asc)
|
||||||
|
.limit(20)
|
||||||
|
.map { external_tag_json(_1) }
|
||||||
|
|
||||||
|
rows =
|
||||||
|
(internal_rows + external_rows)
|
||||||
|
.sort_by { |row| [-row['post_count'], row['name'] || row[:name]] }
|
||||||
|
.first(20)
|
||||||
|
|
||||||
|
render json: rows
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
tag = Tag.joins(:tag_name)
|
locale = resolve_locale!
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
|
||||||
.find_by(id: params[:id])
|
tag =
|
||||||
if tag
|
Tag
|
||||||
render json: TagRepr.base(tag)
|
.joins(:tag_names)
|
||||||
else
|
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
|
||||||
head :not_found
|
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
|
||||||
end
|
.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
|
||||||
|
|
||||||
|
external_tag = ExternalTag.find_by(id: params[:id])
|
||||||
|
return render json: ExternalTagRepr.base(external_tag) if external_tag
|
||||||
|
|
||||||
|
head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def show_by_name
|
def show_by_name
|
||||||
name = params[:name].to_s.strip
|
name = params[:name].to_s.strip
|
||||||
return render_bad_request('name は必須です.') if name.blank?
|
return render_bad_request('name は必須です.') if name.blank?
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name)
|
locale = resolve_locale!
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
|
||||||
.find_by(tag_names: { name: })
|
tag =
|
||||||
if tag
|
Tag
|
||||||
render json: TagRepr.base(tag)
|
.joins(:tag_names)
|
||||||
else
|
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
|
||||||
head :not_found
|
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
|
||||||
end
|
.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)
|
||||||
|
return head :not_found unless external_name
|
||||||
|
|
||||||
|
external_tag = ExternalTag.find_by(platform:, name: external_name)
|
||||||
|
return head :not_found unless external_tag
|
||||||
|
|
||||||
|
render json: ExternalTagRepr.base(external_tag)
|
||||||
end
|
end
|
||||||
|
|
||||||
def deerjikists
|
def deerjikists
|
||||||
tag = Tag.joins(:tag_name)
|
locale = resolve_locale!
|
||||||
.includes(:tag_name, tag_name: :wiki_page)
|
|
||||||
.find_by(id: params[:id])
|
tag =
|
||||||
|
Tag.joins(:tag_names)
|
||||||
|
.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
|
return head :not_found unless tag
|
||||||
|
|
||||||
render json: { tag: TagRepr.base(tag),
|
render json: { tag: TagRepr.base(tag, language_code: locale.language_code),
|
||||||
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
|
deerjikists: DeerjikistRepr.many(
|
||||||
|
tag.deerjikists,
|
||||||
|
language_code: locale.language_code) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def deerjikists_by_name
|
def deerjikists_by_name
|
||||||
name = params[:name].to_s.strip
|
name = params[:name].to_s.strip
|
||||||
return render_bad_request('name は必須です.') if name.blank?
|
return render_bad_request('name は必須です.') if name.blank?
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name)
|
locale = resolve_locale!
|
||||||
.includes(:tag_name, tag_name: :wiki_page)
|
|
||||||
.find_by(tag_names: { name: })
|
tag =
|
||||||
|
Tag
|
||||||
|
.joins(:tag_names)
|
||||||
|
.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
|
return head :not_found unless tag
|
||||||
|
|
||||||
render json: { tag: TagRepr.base(tag),
|
render json: { tag: TagRepr.base(tag, language_code: locale.language_code),
|
||||||
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
|
deerjikists: DeerjikistRepr.many(
|
||||||
|
tag.deerjikists,
|
||||||
|
language_code: locale.language_code) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_deerjikists
|
def update_deerjikists
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name)
|
locale = resolve_locale!
|
||||||
.includes(:tag_name, tag_name: :wiki_page)
|
|
||||||
.find_by(id: params[:id])
|
tag =
|
||||||
|
Tag
|
||||||
|
.joins(:tag_names)
|
||||||
|
.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
|
return head :not_found unless tag
|
||||||
|
|
||||||
|
rows = normalise_deerjikist_rows(tag)
|
||||||
|
return if performed?
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
tag.deerjikists = []
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
params[:_json].each.with_index do |item, i|
|
tag.lock!
|
||||||
platform = item[:platform]
|
|
||||||
code = normalise_deerjikist_code(platform, item[:code])
|
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
|
||||||
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
|
row_indexes_by_key = rows_by_key(rows)
|
||||||
deerjikist.tag = tag
|
locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
|
||||||
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
|
current_deerjikists = locked_deerjikists.filter { |deerjikist|
|
||||||
|
deerjikist.tag_id == tag.id
|
||||||
|
}
|
||||||
|
requested_deerjikists = locked_deerjikists.filter { |deerjikist|
|
||||||
|
row_indexes_by_key.key?([deerjikist.platform, deerjikist.code])
|
||||||
|
}
|
||||||
|
|
||||||
|
render_deerjikist_conflicts(locale, requested_deerjikists, row_indexes_by_key, tag)
|
||||||
|
raise ActiveRecord::Rollback if performed?
|
||||||
|
|
||||||
|
requested_keys_set = requested_keys.to_set
|
||||||
|
current_deerjikists.each do |deerjikist|
|
||||||
|
key = [deerjikist.platform, deerjikist.code]
|
||||||
|
deerjikist.destroy! unless requested_keys_set.include?(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
existing_keys = requested_deerjikists.to_h { |deerjikist|
|
||||||
|
[[deerjikist.platform, deerjikist.code], true]
|
||||||
|
}
|
||||||
|
requested_keys.each do |platform, code|
|
||||||
|
next if existing_keys[[platform, code]]
|
||||||
|
|
||||||
|
deerjikist = Deerjikist.new(platform:, code:, tag:)
|
||||||
|
row_index = row_indexes_by_key[[platform, code]].first
|
||||||
|
begin
|
||||||
|
render_deerjikist_form_record_invalid(deerjikist, row_index) unless deerjikist.save
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
conflicts = lock_deerjikists_for_tag_update(tag.id, [[platform, code]])
|
||||||
|
render_deerjikist_conflicts(locale, conflicts, row_indexes_by_key, tag)
|
||||||
|
end
|
||||||
raise ActiveRecord::Rollback if performed?
|
raise ActiveRecord::Rollback if performed?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return if performed?
|
return if performed?
|
||||||
|
|
||||||
render json: DeerjikistRepr.many(tag.reload.deerjikists)
|
render json: DeerjikistRepr.many(tag.reload.deerjikists,
|
||||||
|
language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def materials_by_name
|
def materials_by_name
|
||||||
name = params[:name].to_s.strip
|
name = params[:name].to_s.strip
|
||||||
return render_bad_request('name は必須です.') if name.blank?
|
return render_bad_request('name は必須です.') if name.blank?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
material_filter = material_filter_param(default: 'any')
|
material_filter = material_filter_param(default: 'any')
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name)
|
tag =
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
Tag
|
||||||
.find_by(tag_names: { name: })
|
.joins(:tag_names)
|
||||||
|
.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
|
return head :not_found unless tag
|
||||||
|
|
||||||
graph = build_with_depth_graph(material_filter)
|
graph = build_with_depth_graph(material_filter, locale.language_code)
|
||||||
|
|
||||||
render json: build_tag_children(tag, graph:)
|
render json: build_tag_children(tag, graph:, language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_all
|
def update_all
|
||||||
@@ -245,39 +455,37 @@ class TagsController < ApplicationController
|
|||||||
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
||||||
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
|
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
|
||||||
|
|
||||||
return unless validate_tag_rename(tag, name)
|
locale = resolve_locale!
|
||||||
|
|
||||||
|
return unless validate_tag_rename(locale, tag, name)
|
||||||
|
|
||||||
alias_names = params[:aliases].to_s.split.uniq
|
alias_names = params[:aliases].to_s.split.uniq
|
||||||
parent_names = params[:parent_tags].to_s.split.uniq
|
parent_names = params[:parent_tags].to_s.split.uniq
|
||||||
deprecated = bool?(:deprecated)
|
deprecated = bool?(:deprecated)
|
||||||
|
|
||||||
if tag.nico? && deprecated
|
if category == 'nico'
|
||||||
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
|
||||||
end
|
|
||||||
|
|
||||||
if tag.nico? || category == 'nico'
|
|
||||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||||
end
|
end
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user,
|
||||||
|
language_code: locale.language_code)
|
||||||
|
|
||||||
old_name = tag.name
|
old_name = tag.name(locale.language_code)
|
||||||
name_changed = name != old_name
|
name_changed = name != old_name
|
||||||
wiki_page = tag.tag_name.wiki_page if name_changed
|
|
||||||
|
|
||||||
if tag.deprecated? == deprecated
|
if tag.deprecated? == deprecated
|
||||||
tag.update!(category:)
|
tag.update!(category:)
|
||||||
else
|
else
|
||||||
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
|
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
|
||||||
end
|
end
|
||||||
rename_tag_name!(tag, name) if name_changed
|
rename_tag_name!(locale, tag, name) if name_changed
|
||||||
|
|
||||||
alias_names << old_name if name_changed
|
alias_names << old_name if name_changed && old_name.present?
|
||||||
alias_names.delete(name)
|
alias_names.delete(name)
|
||||||
|
|
||||||
update_aliases!(tag, alias_names)
|
update_aliases!(locale, tag, alias_names)
|
||||||
update_parent_tags!(tag, parent_names)
|
update_parent_tags!(locale, tag, parent_names)
|
||||||
|
|
||||||
tag.reload
|
tag.reload
|
||||||
|
|
||||||
@@ -285,17 +493,18 @@ class TagsController < ApplicationController
|
|||||||
tag,
|
tag,
|
||||||
event_type: :update,
|
event_type: :update,
|
||||||
created_by_user: current_user,
|
created_by_user: current_user,
|
||||||
name_changed:,
|
language_code: locale.language_code)
|
||||||
wiki_page:)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: TagRepr.base(tag.reload)
|
render json: TagRepr.base(tag.reload, language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
name = params[:name].presence
|
name = params[:name].presence
|
||||||
category = params[:category].presence
|
category = params[:category].presence
|
||||||
deprecated_given = params.key?(:deprecated)
|
deprecated_given = params.key?(:deprecated)
|
||||||
@@ -303,24 +512,20 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
tag = Tag.find(params[:id])
|
tag = Tag.find(params[:id])
|
||||||
|
|
||||||
if tag.nico? && deprecated_given && deprecated
|
return unless validate_tag_rename(locale, tag, name)
|
||||||
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
|
||||||
end
|
|
||||||
|
|
||||||
return unless validate_tag_rename(tag, name)
|
if category.present? && category == 'nico'
|
||||||
|
|
||||||
if tag.nico? || (category.present? && category == 'nico')
|
|
||||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||||
end
|
end
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user,
|
||||||
|
language_code: locale.language_code)
|
||||||
|
|
||||||
old_name = tag.name
|
old_name = tag.name(locale.language_code)
|
||||||
name_changed = name.present? && name != old_name
|
name_changed = name.present? && name != old_name
|
||||||
wiki_page = tag.tag_name.wiki_page if name_changed
|
|
||||||
|
|
||||||
rename_tag_name!(tag, name) if name_changed
|
rename_tag_name!(locale, tag, name) if name_changed
|
||||||
tag.update!(category:) if category.present?
|
tag.update!(category:) if category.present?
|
||||||
if deprecated_given && tag.deprecated? != deprecated
|
if deprecated_given && tag.deprecated? != deprecated
|
||||||
tag.update!(deprecated_at: deprecated ? Time.current : nil)
|
tag.update!(deprecated_at: deprecated ? Time.current : nil)
|
||||||
@@ -332,11 +537,10 @@ class TagsController < ApplicationController
|
|||||||
tag,
|
tag,
|
||||||
event_type: :update,
|
event_type: :update,
|
||||||
created_by_user: current_user,
|
created_by_user: current_user,
|
||||||
name_changed:,
|
language_code: locale.language_code)
|
||||||
wiki_page:)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: TagRepr.base(tag.reload)
|
render json: TagRepr.base(tag.reload, language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -348,7 +552,7 @@ class TagsController < ApplicationController
|
|||||||
value
|
value
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_with_depth_graph material_filter
|
def build_with_depth_graph material_filter, language_code
|
||||||
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
|
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
|
||||||
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
|
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
|
||||||
|
|
||||||
@@ -363,10 +567,13 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
|
material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
|
||||||
|
|
||||||
tags_by_id = Tag.joins(:tag_name)
|
tags_by_id =
|
||||||
.where(id: tag_ids)
|
Tag
|
||||||
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
|
.joins(:tag_names)
|
||||||
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
|
.where(id: tag_ids,
|
||||||
|
tag_names: { language_code:, primary_flg: true })
|
||||||
|
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
|
||||||
|
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
|
||||||
h[id] = { name:, category:, deprecated: deprecated_at.present?,
|
h[id] = { name:, category:, deprecated: deprecated_at.present?,
|
||||||
has_material: material_tag_ids.include?(id) }
|
has_material: material_tag_ids.include?(id) }
|
||||||
end
|
end
|
||||||
@@ -498,145 +705,147 @@ class TagsController < ApplicationController
|
|||||||
children: [] }
|
children: [] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_tag_children tag, graph: nil
|
def build_tag_children tag, graph: nil, language_code: 'ja'
|
||||||
material = tag.materials.first
|
material = tag.materials.first
|
||||||
tag_graph = graph && graph[:tags_by_id][tag.id]
|
tag_graph = graph && graph[:tags_by_id][tag.id]
|
||||||
material =
|
material =
|
||||||
nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
|
nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
|
||||||
|
|
||||||
children = tag.children.sort_by(&:name)
|
children = tag.children.sort_by { _1.name(language_code) }
|
||||||
if graph
|
if graph
|
||||||
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
|
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
|
||||||
end
|
end
|
||||||
|
|
||||||
TagRepr.base(tag).merge(
|
TagRepr.base(tag, language_code:).merge(
|
||||||
children: children.map { build_tag_children(_1, graph:) },
|
children: children.map { build_tag_children(_1, graph:, language_code:) },
|
||||||
has_material: tag_graph ? tag_graph[:has_material] : material.present?,
|
has_material: tag_graph ? tag_graph[:has_material] : material.present?,
|
||||||
material: material && MaterialRepr.base(material, host: request.base_url))
|
material: material && MaterialRepr.base(
|
||||||
|
material,
|
||||||
|
host: request.base_url,
|
||||||
|
language_code:))
|
||||||
end
|
end
|
||||||
|
|
||||||
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
|
def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja'
|
||||||
if tag.nico?
|
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:)
|
||||||
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
|
||||||
|
|
||||||
return unless name_changed
|
|
||||||
|
|
||||||
wiki_page ||= tag.tag_name.wiki_page
|
|
||||||
return unless wiki_page&.wiki_versions&.exists?
|
|
||||||
|
|
||||||
WikiVersionRecorder.record!(
|
|
||||||
page: wiki_page,
|
|
||||||
event_type: :update,
|
|
||||||
created_by_user:)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_tag_rename tag, name
|
def validate_tag_rename locale, tag, name
|
||||||
return true if name.blank? || name == tag.name
|
return true if name.blank? || name == tag.name(locale.language_code)
|
||||||
|
|
||||||
if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
|
system_tag_names = ['タグ希望', 'bot操作', 'ニジラー情報不詳', '動画', 'ニコニコ']
|
||||||
|
if TagName.exists?(tag_id: tag.id,
|
||||||
|
language_code: 'ja',
|
||||||
|
primary_flg: true,
|
||||||
|
name: system_tag_names)
|
||||||
render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
|
render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
target_tag_name = TagName.with_discarded.find_by(name:)
|
target_tag_name = TagName.find_by(language_code: locale.language_code, name:)
|
||||||
return true if target_tag_name.nil?
|
return true unless target_tag_name&.primary_flg
|
||||||
return true if target_tag_name.canonical_id?
|
|
||||||
|
|
||||||
render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name
|
render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def rename_tag_name! tag, name
|
def rename_tag_name! locale, tag, name
|
||||||
return if name == tag.name
|
return if name == tag.name(locale.language_code)
|
||||||
|
|
||||||
current_tag_name = tag.tag_name
|
current_tag_name = tag.tag_name(locale.language_code)
|
||||||
target_tag_name = TagName.with_discarded.find_by(name:)
|
target_tag_name = TagName.find_by(language_code: locale.language_code, name:)
|
||||||
|
|
||||||
if target_tag_name.nil?
|
unless target_tag_name
|
||||||
current_tag_name.update!(name:)
|
if current_tag_name
|
||||||
|
current_tag_name.update!(name:, auto_generated: false)
|
||||||
|
else
|
||||||
|
current_tag_name = TagName.create!(
|
||||||
|
tag:,
|
||||||
|
language_code: locale.language_code,
|
||||||
|
script_code: locale.script_code,
|
||||||
|
name:,
|
||||||
|
primary_flg: true)
|
||||||
|
tag.association(:tag_names).reset
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
promote_tag_alias!(
|
promote_tag_alias!(tag, current_tag_name:, promoted_tag_name: target_tag_name)
|
||||||
tag,
|
|
||||||
current_tag_name:,
|
|
||||||
promoted_tag_name: target_tag_name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
|
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
|
||||||
old_owner_tag = promoted_tag_name.canonical&.tag
|
old_owner_tag = promoted_tag_name.tag
|
||||||
|
|
||||||
if old_owner_tag && old_owner_tag != tag
|
if old_owner_tag && old_owner_tag != tag
|
||||||
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
|
current_tag_name&.update!(primary_flg: false, auto_generated: false)
|
||||||
promoted_tag_name.update!(canonical: nil)
|
promoted_tag_name.update!(tag:, primary_flg: true, auto_generated: false)
|
||||||
|
tag.association(:tag_names).reset
|
||||||
TagName.with_discarded
|
|
||||||
.where(canonical_id: current_tag_name.id)
|
|
||||||
.where.not(id: promoted_tag_name.id)
|
|
||||||
.find_each do |alias_tag_name|
|
|
||||||
alias_tag_name.update!(canonical: promoted_tag_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
current_tag_name.wiki_page&.update!(tag_name: promoted_tag_name)
|
|
||||||
tag.update!(tag_name: promoted_tag_name)
|
|
||||||
current_tag_name.association(:wiki_page).reset
|
|
||||||
current_tag_name.association(:tag).reset
|
|
||||||
current_tag_name.reload.update!(canonical: promoted_tag_name)
|
|
||||||
|
|
||||||
return unless old_owner_tag && old_owner_tag != tag
|
return unless old_owner_tag && old_owner_tag != tag
|
||||||
|
|
||||||
record_tag_version!(old_owner_tag.reload, event_type: :update, created_by_user: current_user)
|
record_tag_version!(old_owner_tag.reload, event_type: :update,
|
||||||
|
created_by_user: current_user,
|
||||||
|
language_code: promoted_tag_name.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_aliases! tag, alias_names
|
def update_aliases! locale, tag, alias_names
|
||||||
alias_names = alias_names.uniq
|
alias_names = alias_names.uniq
|
||||||
|
|
||||||
affected_tags = [tag]
|
affected_tags = [tag]
|
||||||
|
|
||||||
current_aliases = tag.tag_name.aliases.to_a
|
current_aliases = tag.tag_names.where(language_code: locale.language_code,
|
||||||
|
primary_flg: false).to_a
|
||||||
|
|
||||||
current_aliases.each do |alias_tag_name|
|
requested_aliases = TagName.where(
|
||||||
next if alias_names.include?(alias_tag_name.name)
|
language_code: locale.language_code, name: alias_names)
|
||||||
|
.includes(:tag).index_by(&:name)
|
||||||
affected_tags << alias_tag_name.canonical&.tag
|
|
||||||
end
|
|
||||||
|
|
||||||
alias_names.each do |alias_name|
|
alias_names.each do |alias_name|
|
||||||
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
|
alias_tag_name = requested_aliases[alias_name]
|
||||||
affected_tags << alias_tag_name.canonical&.tag
|
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
|
||||||
|
end
|
||||||
|
affected_tags << alias_tag_name&.tag
|
||||||
end
|
end
|
||||||
|
|
||||||
affected_tags.compact.uniq.each do |affected_tag|
|
affected_tags = affected_tags.compact.uniq(&:id)
|
||||||
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user)
|
affected_tags.each do |affected_tag|
|
||||||
|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user,
|
||||||
|
language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
current_aliases.each do |alias_tag_name|
|
current_aliases.each do |alias_tag_name|
|
||||||
next if alias_names.include?(alias_tag_name.name)
|
next if alias_names.include?(alias_tag_name.name)
|
||||||
|
|
||||||
alias_tag_name.update!(canonical: nil)
|
alias_tag_name.destroy!
|
||||||
end
|
end
|
||||||
|
|
||||||
alias_names.each do |alias_name|
|
alias_names.each do |alias_name|
|
||||||
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
|
alias_tag_name = requested_aliases[alias_name] || TagName.new(
|
||||||
alias_tag_name.update!(canonical: tag.tag_name)
|
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)
|
||||||
end
|
end
|
||||||
|
|
||||||
affected_tags.compact.uniq.each do |affected_tag|
|
affected_tags.each do |affected_tag|
|
||||||
record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
|
# 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)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_parent_tags! tag, parent_names
|
def update_parent_tags! locale, tag, parent_names
|
||||||
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
|
parent_tags = Tag.normalise_tags!(locale, parent_names,
|
||||||
with_no_deerjikist: false,
|
with_tagme: false,
|
||||||
deny_nico: true)
|
with_no_deerjikist: false,
|
||||||
|
created_by_user: current_user)
|
||||||
|
|
||||||
old_parent_tags = tag.parents.to_a
|
old_parent_tags = tag.parents.to_a
|
||||||
|
|
||||||
@@ -653,6 +862,7 @@ class TagsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def normalise_deerjikist_code platform, code
|
def normalise_deerjikist_code platform, code
|
||||||
|
code = code.to_s
|
||||||
return code if platform != 'youtube' || code[0] != '@'
|
return code if platform != 'youtube' || code[0] != '@'
|
||||||
|
|
||||||
url = "https://www.youtube.com/#{ code }"
|
url = "https://www.youtube.com/#{ code }"
|
||||||
@@ -669,6 +879,84 @@ class TagsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalise_deerjikist_rows tag
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
params[:_json].each.with_index do |item, index|
|
||||||
|
platform = item[:platform]
|
||||||
|
unless Deerjikist.platforms.key?(platform)
|
||||||
|
render_deerjikist_platform_invalid(index)
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
code = normalise_deerjikist_code(platform, item[:code])
|
||||||
|
deerjikist = Deerjikist.new(platform:, code:, tag:)
|
||||||
|
unless deerjikist.valid?
|
||||||
|
render_deerjikist_form_record_invalid(deerjikist, index)
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
rows << { index:, platform:, code: }
|
||||||
|
end
|
||||||
|
|
||||||
|
rows
|
||||||
|
end
|
||||||
|
|
||||||
|
def lock_deerjikists_for_tag_update tag_id, keys
|
||||||
|
clauses = ['tag_id = ?']
|
||||||
|
values = [tag_id]
|
||||||
|
|
||||||
|
keys.each do |platform, code|
|
||||||
|
clauses << '(platform = ? AND code = ?)'
|
||||||
|
values << platform << code
|
||||||
|
end
|
||||||
|
|
||||||
|
Deerjikist
|
||||||
|
.where(clauses.join(' OR '), *values)
|
||||||
|
.order(:platform, :code)
|
||||||
|
.lock
|
||||||
|
.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_deerjikist_conflicts locale, deerjikists, row_indexes_by_key, tag
|
||||||
|
conflicts = deerjikists.filter { |deerjikist| deerjikist.tag_id != tag.id }
|
||||||
|
return if conflicts.empty?
|
||||||
|
|
||||||
|
tag_names_by_id =
|
||||||
|
Tag
|
||||||
|
.joins(:tag_names)
|
||||||
|
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||||
|
id: conflicts.map(&:tag_id).uniq)
|
||||||
|
.pluck('tags.id', 'tag_names.name')
|
||||||
|
.to_h
|
||||||
|
fields = { }
|
||||||
|
|
||||||
|
conflicts.each do |deerjikist|
|
||||||
|
message = "この情報は既に「#{ tag_names_by_id[deerjikist.tag_id] }」に紐づいてゐます."
|
||||||
|
row_indexes_by_key[[deerjikist.platform, deerjikist.code]].each do |index|
|
||||||
|
field = :"deerjikists.#{ index }.code"
|
||||||
|
fields[field] ||= []
|
||||||
|
fields[field] << message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render_validation_error fields:
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_deerjikist_platform_invalid index
|
||||||
|
render_validation_error fields: {
|
||||||
|
:"deerjikists.#{ index }.platform" => ['値が不正です.'],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def rows_by_key rows
|
||||||
|
rows.each_with_object({ }) do |row, result|
|
||||||
|
key = [row[:platform], row[:code]]
|
||||||
|
result[key] ||= []
|
||||||
|
result[key] << row[:index]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def render_deerjikist_form_record_invalid deerjikist, index
|
def render_deerjikist_form_record_invalid deerjikist, index
|
||||||
fields = { }
|
fields = { }
|
||||||
|
|
||||||
@@ -687,4 +975,20 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
render_validation_error fields:
|
render_validation_error fields:
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def external_tag_json tag
|
||||||
|
{ 'id' => tag.id,
|
||||||
|
'name' => "#{ tag.platform }:#{ tag.name }",
|
||||||
|
'category' => 'nico',
|
||||||
|
'deprecated_at' => nil,
|
||||||
|
'created_at' => tag.created_at,
|
||||||
|
'updated_at' => tag.created_at,
|
||||||
|
'post_count' => tag.post_count,
|
||||||
|
'matched_alias' => nil,
|
||||||
|
'aliases' => [],
|
||||||
|
'parents' => [],
|
||||||
|
'has_wiki' => false,
|
||||||
|
'material_id' => nil,
|
||||||
|
'has_deerjikists' => false }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class TheatreSkipEventsController < ApplicationController
|
|||||||
events =
|
events =
|
||||||
TheatreSkipEvent
|
TheatreSkipEvent
|
||||||
.where(theatre_id: params[:theatre_id])
|
.where(theatre_id: params[:theatre_id])
|
||||||
.includes(:post, tags: :tag_name)
|
.includes(:post, tags: :tag_names)
|
||||||
.order(created_at: :desc)
|
.order(created_at: :desc)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ class TheatreSkipEventsController < ApplicationController
|
|||||||
{ id: event.id,
|
{ id: event.id,
|
||||||
theatre_id: event.theatre_id,
|
theatre_id: event.theatre_id,
|
||||||
post: { id: event.post.id, title: event.post.title, url: event.post.url },
|
post: { id: event.post.id, title: event.post.title, url: event.post.url },
|
||||||
tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
|
tags: event.tags.map { |tag| { id: tag.id, name: tag.name('ja') } },
|
||||||
programme_position: event.programme_position,
|
programme_position: event.programme_position,
|
||||||
created_at: event.created_at }
|
created_at: event.created_at }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,69 @@
|
|||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
def create
|
def create
|
||||||
|
if current_user
|
||||||
|
return render json: { code: current_user.inheritance_code,
|
||||||
|
user: current_user.slice(:id, :name, :inheritance_code, :role) }
|
||||||
|
end
|
||||||
|
|
||||||
user = nil
|
user = nil
|
||||||
User.transaction do
|
User.transaction do
|
||||||
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
|
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
|
||||||
attach_ip_address!(user)
|
attach_ip_address!(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
establish_user_session(user)
|
||||||
render json: { code: user.inheritance_code,
|
render json: { code: user.inheritance_code,
|
||||||
user: user.slice(:id, :name, :inheritance_code, :role) },
|
user: user.slice(:id, :name, :inheritance_code, :role) },
|
||||||
status: :created
|
status: :created
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify
|
def verify
|
||||||
user = User.find_by(inheritance_code: params[:code])
|
user = if bool?(:restore) && current_user
|
||||||
|
current_user
|
||||||
|
else
|
||||||
|
User.find_by(inheritance_code: params[:code])
|
||||||
|
end
|
||||||
return render json: { valid: false } unless user
|
return render json: { valid: false } unless user
|
||||||
return head :forbidden if user.banned?
|
return head :forbidden if user.banned?
|
||||||
|
|
||||||
attach_ip_address!(user)
|
attach_ip_address!(user)
|
||||||
|
establish_user_session(user)
|
||||||
|
|
||||||
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show_session
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
|
render json: current_user.slice(:id, :name, :role)
|
||||||
|
end
|
||||||
|
|
||||||
|
def csrf
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
|
render json: { token: form_authenticity_token }
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_session
|
||||||
|
reset_session
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
def renew
|
def renew
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
user = current_user
|
user = current_user
|
||||||
user.inheritance_code = SecureRandom.uuid
|
user.inheritance_code = SecureRandom.uuid
|
||||||
user.save!
|
user.save!
|
||||||
|
establish_user_session(user)
|
||||||
render json: { code: user.inheritance_code }
|
render json: { code: user.inheritance_code }
|
||||||
end
|
end
|
||||||
|
|
||||||
def me
|
def me
|
||||||
user = User.find_by(inheritance_code: params[:code])
|
user = current_user
|
||||||
return head :not_found unless user
|
return head :unauthorized unless user
|
||||||
|
|
||||||
|
attach_ip_address!(user)
|
||||||
render json: user.slice(:id, :name, :inheritance_code, :role)
|
render json: user.slice(:id, :name, :inheritance_code, :role)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +1,168 @@
|
|||||||
class WikiPagesController < ApplicationController
|
class WikiPagesController < ApplicationController
|
||||||
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
|
# 互換性のための Facade API
|
||||||
|
|
||||||
|
rescue_from TagDescriptionOperation::Conflict, with: :render_wiki_conflict
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
if title.blank?
|
descriptions =
|
||||||
return render json: WikiPageRepr.base(
|
TagDescription
|
||||||
WikiPage.joins(:tag_name).includes(tag_name: :tag))
|
.where(language_code: locale.language_code)
|
||||||
|
.includes(tag: :tag_names)
|
||||||
|
|
||||||
|
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
||||||
|
tag_groups = TagGroup.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: locale.language_code, primary_flg: true })
|
||||||
|
.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||||
|
tag_groups = tag_groups.where(
|
||||||
|
'CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?',
|
||||||
|
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
|
||||||
end
|
end
|
||||||
|
|
||||||
q = WikiPage.joins(:tag_name).includes(tag_name: :tag)
|
json = descriptions.map { |description| WikiPageRepr.tag_description(locale, description) }
|
||||||
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
|
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||||
render json: WikiPageRepr.base(q.limit(20))
|
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 autolink_candidates
|
||||||
|
locale = resolve_locale!
|
||||||
|
language = params.fetch(:language, locale.language_code)
|
||||||
|
language = 'ja' if language == 'und'
|
||||||
|
return head :not_found unless language.is_a?(String)
|
||||||
|
return head :not_found unless Language.exists?(code: language)
|
||||||
|
|
||||||
|
names = TagDescription.joins(tag: :tag_names)
|
||||||
|
.where(language_code: locale.language_code, tag_names: { language_code: language })
|
||||||
|
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
|
||||||
|
candidates = names.map do |tag_id, description_id, name|
|
||||||
|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
|
||||||
|
end
|
||||||
|
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
|
||||||
|
WikiPageRepr.autolink_group(locale, id:, name:, language:)
|
||||||
|
end)
|
||||||
|
render json: candidates
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
page = WikiPage.joins(:tag_name)
|
locale = resolve_locale!
|
||||||
.includes(tag_name: :tag)
|
reference = TagDescription.find_by(id: params[:id])
|
||||||
.find_by(id: params[:id])
|
tag_description = reference && TagDescription.find_by(
|
||||||
render_wiki_page_or_404 page
|
tag_id: reference.tag_id, language_code: locale.language_code)
|
||||||
|
|
||||||
|
return render_wiki_page_by_tag_description locale, tag_description if tag_description
|
||||||
|
return head :not_found if reference
|
||||||
|
|
||||||
|
tag_group = TagGroup.find_by(id: params[:id])
|
||||||
|
return render_tag_group locale, tag_group if tag_group
|
||||||
|
|
||||||
|
head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def show_by_title
|
def show_by_title
|
||||||
|
locale = resolve_locale!
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
page = WikiPage.joins(:tag_name)
|
|
||||||
.includes(tag_name: :tag)
|
if (name = TagGroupRepr.name_from_title(title))
|
||||||
.find_by(tag_name: { name: title })
|
tag_group = TagGroup.find_by(name:)
|
||||||
render_wiki_page_or_404 page
|
return render_tag_group locale, tag_group if tag_group
|
||||||
|
return head :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
tag_description = tag_description_by_title(locale, title)
|
||||||
|
|
||||||
|
return render_wiki_page_by_tag_description locale, tag_description if tag_description
|
||||||
|
|
||||||
|
head :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_reference
|
||||||
|
locale = resolve_locale!
|
||||||
|
description = TagDescription.find_by(id: params[:id])
|
||||||
|
return head :not_found unless description
|
||||||
|
|
||||||
|
tag = Tag.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
|
||||||
|
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
|
||||||
|
.find(description.tag_id)
|
||||||
|
render json: TagRepr.base(tag, language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def exists
|
def exists
|
||||||
if WikiPage.exists?(id: params[:id])
|
locale = resolve_locale!
|
||||||
head :no_content
|
if TagDescription.exists?(id: params[:id], language_code: locale.language_code)
|
||||||
else
|
return head :no_content
|
||||||
head :not_found
|
|
||||||
end
|
end
|
||||||
|
return head :no_content if TagGroup.exists?(id: params[:id])
|
||||||
|
|
||||||
|
head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def exists_by_title
|
def exists_by_title
|
||||||
|
locale = resolve_locale!
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
if WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
if (name = TagGroupRepr.name_from_title(title))
|
||||||
head :no_content
|
exists = TagGroup.exists?(name:)
|
||||||
else
|
return head exists ? :no_content : :not_found
|
||||||
head :not_found
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
return head :no_content if tag_description_by_title(locale, title)
|
||||||
|
|
||||||
|
head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def diff
|
def diff
|
||||||
id = params[:id]
|
id = params[:id]
|
||||||
return render_bad_request('id は必須です.') if id.blank?
|
return render_bad_request('id は必須です.') if id.blank?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
from = params[:from].presence
|
from = params[:from].presence
|
||||||
to = params[:to].presence
|
to = params[:to].presence
|
||||||
|
|
||||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(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(locale, description)
|
||||||
|
else
|
||||||
|
tag_group = TagGroup.find_by(id:)
|
||||||
|
if tag_group
|
||||||
|
from_rev = from && group_version(tag_group, from)
|
||||||
|
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
|
||||||
|
return head :not_found if (from && !from_rev) || !to_rev
|
||||||
|
|
||||||
from_rev = from && page.wiki_revisions.find(from)
|
from_body = from_rev && render_group_version(locale, from_rev)
|
||||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
to_body = render_group_version(locale, to_rev)
|
||||||
if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?))
|
resource = { 'id' => tag_group.id,
|
||||||
return render_unprocessable_entity('差分を表示できない版です.')
|
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||||
|
'deprecated_at' => nil }
|
||||||
|
else
|
||||||
|
return head :not_found
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
|
from_body ||= from_rev&.body
|
||||||
diff_json = diffs.map { |change|
|
to_body ||= to_rev.body
|
||||||
case change.action
|
diff_json = wiki_diff(from_body, to_body)
|
||||||
when ?=
|
|
||||||
{ type: 'context', content: change.old_element }
|
|
||||||
when ?!
|
|
||||||
[{ type: 'removed', content: change.old_element },
|
|
||||||
{ type: 'added', content: change.new_element }]
|
|
||||||
when ?+
|
|
||||||
{ type: 'added', content: change.new_element }
|
|
||||||
when ?-
|
|
||||||
{ type: 'removed', content: change.old_element }
|
|
||||||
end
|
|
||||||
}.flatten.compact
|
|
||||||
|
|
||||||
render json: { wiki_page_id: page.id,
|
render json: { wiki_page_id: resource.fetch('id'),
|
||||||
title: page.title,
|
title: resource.fetch('title'),
|
||||||
deprecated_at: page.deprecated_at,
|
deprecated_at: resource.fetch('deprecated_at'),
|
||||||
older_revision_id: from_rev&.id,
|
older_revision_id: from_rev&.id,
|
||||||
newer_revision_id: to_rev.id,
|
newer_revision_id: to_rev.id,
|
||||||
diff: diff_json }
|
diff: diff_json }
|
||||||
|
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||||
|
render_unprocessable_entity(error.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -94,20 +176,37 @@ class WikiPagesController < ApplicationController
|
|||||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||||
|
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: title)
|
locale = resolve_locale!
|
||||||
|
|
||||||
page =
|
if (name = TagGroupRepr.name_from_title(title))
|
||||||
Wiki::Commit.create_content!(
|
parsed = TagGroupMarkdownParser.parse(locale, body:, group: nil)
|
||||||
tag_name:,
|
group = TagGroupOperation.create!(
|
||||||
body:,
|
locale, name:, **parsed.except(:compatibility_body), actor: current_user,
|
||||||
created_by_user: current_user,
|
require_compatibility: true)
|
||||||
message:)
|
version = current_group_version(group)
|
||||||
|
return render_group_representation(TagGroupRepr.one(locale, group), version,
|
||||||
|
status: :created)
|
||||||
|
end
|
||||||
|
|
||||||
render json: WikiPageRepr.base(page), status: :created
|
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
|
||||||
|
|
||||||
|
render json: WikiPageRepr.tag_description(locale, description), status: :created
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
render_validation_error e.record
|
render_validation_error e.record
|
||||||
rescue ActiveRecord::RecordNotUnique
|
rescue ActiveRecord::RecordNotUnique
|
||||||
render_record_not_unique
|
render_record_not_unique
|
||||||
|
rescue TagGroupMarkdownParser::InvalidDocument => error
|
||||||
|
render_unprocessable_entity(error.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -120,91 +219,229 @@ class WikiPagesController < ApplicationController
|
|||||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||||
|
|
||||||
page = WikiPage.find(params[:id])
|
locale = resolve_locale!
|
||||||
|
|
||||||
|
group = TagGroup.find_by(id: params[:id])
|
||||||
|
if (name = TagGroupRepr.name_from_title(title))
|
||||||
|
return head :not_found unless group
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
base_revision_id = params[:base_revision_id].presence
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
page.lock!
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
tag.lock!
|
||||||
old_title = page.title
|
old_title = tag_name.name
|
||||||
|
if title != old_title
|
||||||
tag = Tag.find_by(tag_name_id: page.tag_name_id)
|
|
||||||
|
|
||||||
if tag && title != old_title
|
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
page.tag_name.update!(name: title) if title != old_title
|
tag_name.update!(name: title) if title != old_title
|
||||||
|
|
||||||
message = params[:message].presence
|
message = params[:message].presence
|
||||||
Wiki::Commit.content!(page:,
|
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
|
||||||
body:,
|
message:, base_revision_id:)
|
||||||
created_user: current_user,
|
|
||||||
message:,
|
|
||||||
base_revision_id:)
|
|
||||||
|
|
||||||
if tag && title != old_title
|
if title != old_title
|
||||||
tag.reload
|
tag.reload
|
||||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
head :ok
|
head :ok
|
||||||
end
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render_validation_error e.record
|
||||||
def search
|
rescue ActiveRecord::RecordNotUnique
|
||||||
index
|
render_record_not_unique
|
||||||
|
rescue TagGroupMarkdownParser::InvalidDocument => error
|
||||||
|
render_unprocessable_entity(error.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def changes
|
def changes
|
||||||
id = params[:id].presence
|
locale = resolve_locale!
|
||||||
q = WikiRevision.joins(wiki_page: :tag_name)
|
|
||||||
.includes(:created_user, wiki_page: { tag_name: :tag })
|
|
||||||
.order(id: :desc)
|
|
||||||
q = q.where(wiki_page_id: id) if id
|
|
||||||
|
|
||||||
render json: q.limit(200).map { |rev|
|
id = params[:id].presence
|
||||||
{ revision_id: rev.id,
|
description = id && TagDescription.find_by(id:, language_code: locale.language_code)
|
||||||
pred: rev.base_revision_id,
|
if description
|
||||||
succ: nil,
|
return render json: description_changes(locale, TagDescription.where(id: description.id))
|
||||||
wiki_page: { id: rev.wiki_page_id,
|
end
|
||||||
title: rev.wiki_page.title,
|
|
||||||
deprecated_at: rev.wiki_page.deprecated_at },
|
tag_group = id && TagGroup.find_by(id:)
|
||||||
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
if tag_group
|
||||||
kind: rev.kind,
|
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
||||||
message: rev.message,
|
end
|
||||||
timestamp: rev.created_at }
|
|
||||||
}.compact
|
json = []
|
||||||
|
unless id
|
||||||
|
json.concat(tag_group_changes(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)
|
||||||
|
end
|
||||||
|
render json: json.first(200)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def render_wiki_page_or_404 page
|
def render_tag_group locale, group
|
||||||
return head :not_found unless page
|
version = if params[:version].present?
|
||||||
|
group_version(group, params[:version])
|
||||||
rev = find_revision(page)
|
else
|
||||||
return head :not_found unless rev
|
current_group_version(group)
|
||||||
|
|
||||||
if rev.redirect?
|
|
||||||
return (
|
|
||||||
redirect_to wiki_page_by_title_path(title: rev.redirect_page.title),
|
|
||||||
status: :moved_permanently)
|
|
||||||
end
|
end
|
||||||
|
return head :not_found unless version
|
||||||
|
|
||||||
body = rev.body
|
representation =
|
||||||
revision_id = rev.id
|
if params[:version].present?
|
||||||
pred = page.pred_revision_id(revision_id)
|
TagGroupRepr.historical(locale, version)
|
||||||
succ = page.succ_revision_id(revision_id)
|
else
|
||||||
updated_at = rev.created_at
|
TagGroupRepr.one(locale, group)
|
||||||
|
end
|
||||||
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
|
render_group_representation(representation, version)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_revision page
|
def render_group_version locale, version
|
||||||
if params[:version].present?
|
representation = TagGroupRepr.historical(locale, version)
|
||||||
page.wiki_revisions.find_by(id: params[:version])
|
TagGroupMarkdownRenderer.render(representation)
|
||||||
else
|
end
|
||||||
page.current_revision
|
|
||||||
|
def render_group_representation representation, version, body: nil, status: :ok
|
||||||
|
body ||= TagGroupMarkdownRenderer.render(representation)
|
||||||
|
versions = TagGroupVersion.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,
|
||||||
|
'revision_id' => version.id,
|
||||||
|
'pred' => pred,
|
||||||
|
'succ' => succ,
|
||||||
|
'updated_at' => updated_at }, status:
|
||||||
|
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||||
|
render_unprocessable_entity(error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_group_version group
|
||||||
|
TagGroupVersion.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)
|
||||||
|
end
|
||||||
|
|
||||||
|
def wiki_diff from_body, to_body
|
||||||
|
diffs = Diff::LCS.sdiff(from_body.to_s.lines, to_body.to_s.lines)
|
||||||
|
diffs.map { |change|
|
||||||
|
case change.action
|
||||||
|
when ?=
|
||||||
|
{ type: 'context', content: change.old_element }
|
||||||
|
when ?!
|
||||||
|
[{ type: 'removed', content: change.old_element },
|
||||||
|
{ type: 'added', content: change.new_element }]
|
||||||
|
when ?+
|
||||||
|
{ type: 'added', content: change.new_element }
|
||||||
|
when ?-
|
||||||
|
{ type: 'removed', content: change.old_element }
|
||||||
|
end
|
||||||
|
}.flatten.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
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,
|
||||||
|
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] }
|
||||||
|
rows.map do |version|
|
||||||
|
group = groups.fetch(version.tag_group_id)
|
||||||
|
user = version.created_by_user
|
||||||
|
{ revision_id: version.id,
|
||||||
|
pred: predecessors[[group.id, version.version_no - 1]],
|
||||||
|
succ: nil,
|
||||||
|
wiki_page: { id: group.id, title: "#{ TagGroupRepr::PREFIX }#{ version.name }",
|
||||||
|
deprecated_at: nil },
|
||||||
|
user: user && { id: user.id, name: user.name },
|
||||||
|
kind: 'content', message: nil, timestamp: version.created_at }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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]) :
|
||||||
|
versions.find_by(version_no: description.version_no)
|
||||||
|
return head :not_found unless rev
|
||||||
|
|
||||||
|
pred = versions.find_by(version_no: rev.version_no - 1)&.id
|
||||||
|
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(locale, description).merge(
|
||||||
|
body:, revision_id: rev.id, pred:, succ:, updated_at:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def description_versions description
|
||||||
|
TagDescriptionVersion.where(tag_id: description.tag_id,
|
||||||
|
language_code: description.language_code)
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_description_by_title locale, title
|
||||||
|
TagDescription.joins(tag: :tag_names)
|
||||||
|
.find_by(language_code: locale.language_code,
|
||||||
|
tag_names: { language_code: locale.language_code, name: title })
|
||||||
|
end
|
||||||
|
|
||||||
|
def description_changes locale, descriptions
|
||||||
|
versions = TagDescriptionVersion.where(language_code: locale.language_code,
|
||||||
|
tag_id: descriptions.select(:tag_id))
|
||||||
|
.order(id: :desc).limit(200).to_a
|
||||||
|
return [] if versions.empty?
|
||||||
|
|
||||||
|
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: 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] }
|
||||||
|
versions.map do |version|
|
||||||
|
description = by_tag.fetch(version.tag_id)
|
||||||
|
user = users[version.created_by_user_id]
|
||||||
|
{ revision_id: version.id,
|
||||||
|
pred: predecessors[[version.tag_id, version.version_no - 1]],
|
||||||
|
succ: nil,
|
||||||
|
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',
|
||||||
|
message: nil,
|
||||||
|
timestamp: version.created_at }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class ExternalTag < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
|
enum :platform, nico: 'nico'
|
||||||
|
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
||||||
|
|
||||||
|
has_many :post_external_tags, dependent: :delete_all
|
||||||
|
has_many :posts, through: :post_external_tags
|
||||||
|
|
||||||
|
has_many :nico_tag_versions, foreign_key: :tag_id, inverse_of: :external_tag
|
||||||
|
|
||||||
|
has_many :nico_tag_relations,
|
||||||
|
foreign_key: :nico_tag_id,
|
||||||
|
inverse_of: :nico_tag,
|
||||||
|
dependent: :destroy
|
||||||
|
|
||||||
|
has_many :linked_tags, through: :nico_tag_relations, source: :tag
|
||||||
|
|
||||||
|
def snapshot_linked_tag_names language_code = 'ja'
|
||||||
|
linked_tags
|
||||||
|
.joins(:tag_names)
|
||||||
|
.where(tag_names: { language_code:, primary_flg: true })
|
||||||
|
.order('tag_names.name')
|
||||||
|
.pluck('tag_names.name')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class Language < ApplicationRecord
|
||||||
|
has_many :locales, foreign_key: :language_code, primary_key: :code
|
||||||
|
|
||||||
|
enum :code, ja: 'ja', ain: 'ain'
|
||||||
|
end
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
class Locale < ApplicationRecord
|
||||||
|
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 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 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,24 +1,10 @@
|
|||||||
class NicoTagRelation < ApplicationRecord
|
class NicoTagRelation < ApplicationRecord
|
||||||
belongs_to :nico_tag, class_name: 'Tag'
|
belongs_to :nico_tag,
|
||||||
belongs_to :tag, class_name: 'Tag'
|
class_name: 'ExternalTag',
|
||||||
|
foreign_key: :nico_tag_id,
|
||||||
|
inverse_of: :nico_tag_relations
|
||||||
|
belongs_to :tag, class_name: 'Tag', foreign_key: :tag_id
|
||||||
|
|
||||||
validates :nico_tag_id, presence: true
|
validates :nico_tag_id, presence: true
|
||||||
validates :tag_id, presence: true
|
validates :tag_id, presence: true
|
||||||
|
|
||||||
validate :nico_tag_must_be_nico
|
|
||||||
validate :tag_mustnt_be_nico
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def nico_tag_must_be_nico
|
|
||||||
if nico_tag && nico_tag.category != 'nico'
|
|
||||||
errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def tag_mustnt_be_nico
|
|
||||||
if tag && tag.category == 'nico'
|
|
||||||
errors.add :tag_id, '連携先タグのカテゴリはニコニコであってはなりません.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class NicoTagVersion < ApplicationRecord
|
class NicoTagVersion < ApplicationRecord
|
||||||
include VersionRecord
|
include VersionRecord
|
||||||
|
|
||||||
belongs_to :tag
|
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
end
|
end
|
||||||
|
|||||||
+69
-13
@@ -55,25 +55,27 @@ class Post < ApplicationRecord
|
|||||||
belongs_to :uploaded_user, class_name: 'User', optional: true
|
belongs_to :uploaded_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
has_many :post_tags, dependent: :destroy, inverse_of: :post
|
has_many :post_tags, dependent: :destroy, inverse_of: :post
|
||||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
|
has_many :tags, through: :post_tags
|
||||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
|
||||||
has_many :tags, through: :active_post_tags
|
|
||||||
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
|
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
|
||||||
through: :active_post_tags, source: :tag
|
through: :post_tags,
|
||||||
|
source: :tag
|
||||||
|
|
||||||
has_many :user_post_views, dependent: :delete_all
|
has_many :user_post_views, dependent: :delete_all
|
||||||
has_many :post_similarities, dependent: :delete_all
|
has_many :post_similarities, dependent: :delete_all
|
||||||
has_many :post_versions
|
has_many :post_versions
|
||||||
|
|
||||||
has_many :gekanator_guessed_games,
|
has_many :gekanator_guessed_games,
|
||||||
class_name: 'GekanatorGame',
|
class_name: 'GekanatorGame',
|
||||||
foreign_key: :guessed_post_id,
|
foreign_key: :guessed_post_id,
|
||||||
dependent: :delete_all,
|
dependent: :delete_all,
|
||||||
inverse_of: :guessed_post
|
inverse_of: :guessed_post
|
||||||
|
|
||||||
has_many :gekanator_correct_games,
|
has_many :gekanator_correct_games,
|
||||||
class_name: 'GekanatorGame',
|
class_name: 'GekanatorGame',
|
||||||
foreign_key: :correct_post_id,
|
foreign_key: :correct_post_id,
|
||||||
dependent: :delete_all,
|
dependent: :delete_all,
|
||||||
inverse_of: :correct_post
|
inverse_of: :correct_post
|
||||||
|
|
||||||
has_many :gekanator_question_examples, dependent: :delete_all
|
has_many :gekanator_question_examples, dependent: :delete_all
|
||||||
|
|
||||||
has_many :parent_post_implications,
|
has_many :parent_post_implications,
|
||||||
@@ -90,6 +92,9 @@ class Post < ApplicationRecord
|
|||||||
inverse_of: :parent_post
|
inverse_of: :parent_post
|
||||||
has_many :children, through: :child_post_implications, source: :post
|
has_many :children, through: :child_post_implications, source: :post
|
||||||
|
|
||||||
|
has_many :post_external_tags, dependent: :destroy
|
||||||
|
has_many :external_tags, through: :post_external_tags
|
||||||
|
|
||||||
has_one_attached :thumbnail
|
has_one_attached :thumbnail
|
||||||
|
|
||||||
attribute :version_no, :integer, default: 1
|
attribute :version_no, :integer, default: 1
|
||||||
@@ -121,14 +126,14 @@ class Post < ApplicationRecord
|
|||||||
super(options).merge(thumbnail: nil)
|
super(options).merge(thumbnail: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot_tag_names
|
def snapshot_tag_names language_code = 'ja'
|
||||||
post_tags
|
post_tags
|
||||||
.kept
|
.joins(tag: :tag_names)
|
||||||
.joins(tag: :tag_name)
|
.where(tag_names: { language_code:, primary_flg: true })
|
||||||
.includes(:sections, tag: :tag_name)
|
.includes(:sections, tag: :tag_names)
|
||||||
.order('tag_names.name')
|
.order('tag_names.name')
|
||||||
.map do |post_tag|
|
.map do |post_tag|
|
||||||
name = post_tag.tag.tag_name.name
|
name = post_tag.tag.name(language_code)
|
||||||
sections = post_tag.sections.sort_by(&:begin_ms)
|
sections = post_tag.sections.sort_by(&:begin_ms)
|
||||||
|
|
||||||
next name if sections.empty?
|
next name if sections.empty?
|
||||||
@@ -137,8 +142,47 @@ class Post < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.tag_snapshot_literal tag
|
||||||
|
sections = tag.fetch('sections', []).map do |sec|
|
||||||
|
begin_ms = sec.fetch('begin_ms')
|
||||||
|
end_ms = sec['end_ms']
|
||||||
|
|
||||||
|
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
|
||||||
|
end
|
||||||
|
|
||||||
|
"#{ tag.fetch('name') }#{ sections.join }"
|
||||||
|
end
|
||||||
|
|
||||||
|
def snapshot_tags_json language_code = 'ja'
|
||||||
|
tag_snapshots =
|
||||||
|
post_tags
|
||||||
|
.joins(tag: :tag_names)
|
||||||
|
.where(tag_names: { language_code:, primary_flg: true })
|
||||||
|
.includes(:sections, tag: :tag_names)
|
||||||
|
.order('tags.id')
|
||||||
|
.map { |pt|
|
||||||
|
{ 'tag_id' => pt.tag.id,
|
||||||
|
'version_no' => pt.tag.version_no,
|
||||||
|
'name' => pt.tag.name(language_code),
|
||||||
|
'category' => pt.tag.category,
|
||||||
|
'sections' => pt.sections.sort_by(&:begin_ms).map {
|
||||||
|
{ 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms }
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
|
||||||
|
external_tag_snapshots =
|
||||||
|
post_external_tags.order(:external_tag_id).map {
|
||||||
|
{ 'external_tag_id' => _1.external_tag_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
tag_snapshots + external_tag_snapshots
|
||||||
|
end
|
||||||
|
|
||||||
def self.section_literal section
|
def self.section_literal section
|
||||||
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
|
end_ms =
|
||||||
|
section.end_ms ? Post.ms_to_time(section.end_ms) : ''
|
||||||
|
|
||||||
|
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.ms_to_time ms
|
def self.ms_to_time ms
|
||||||
@@ -323,7 +367,9 @@ class Post < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.external_svg_reference?(attribute_name, attribute_value)
|
def self.external_svg_reference?(attribute_name, attribute_value)
|
||||||
return external_svg_url?(attribute_value) if ['href', 'xlink:href', 'src'].include?(attribute_name)
|
if ['href', 'xlink:href', 'src'].include?(attribute_name)
|
||||||
|
return external_svg_url?(attribute_value)
|
||||||
|
end
|
||||||
return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
|
return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
|
||||||
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
|
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
|
||||||
|
|
||||||
@@ -344,7 +390,12 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
def self.svg_url_function_disallowed?(value)
|
def self.svg_url_function_disallowed?(value)
|
||||||
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
|
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
|
||||||
reference = entry.to_s.strip.delete_prefix("'").delete_prefix('"').delete_suffix("'").delete_suffix('"')
|
reference =
|
||||||
|
entry.to_s.strip
|
||||||
|
.delete_prefix("'")
|
||||||
|
.delete_prefix('"')
|
||||||
|
.delete_suffix("'")
|
||||||
|
.delete_suffix('"')
|
||||||
reference.present? && !(reference.start_with?('#'))
|
reference.present? && !(reference.start_with?('#'))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -433,7 +484,12 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
match =
|
match =
|
||||||
value.match(
|
value.match(
|
||||||
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
|
/
|
||||||
|
\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})
|
||||||
|
(?::(\d{2})(?:\.(\d+))?)?
|
||||||
|
(Z|[+-]\d{2}:?\d{2})?
|
||||||
|
\z
|
||||||
|
/x)
|
||||||
return nil if match.nil?
|
return nil if match.nil?
|
||||||
|
|
||||||
year = match[1].to_i
|
year = match[1].to_i
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
class PostExternalTag < ApplicationRecord
|
||||||
|
belongs_to :post
|
||||||
|
belongs_to :external_tag, counter_cache: :post_count
|
||||||
|
end
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
class PostTag < ApplicationRecord
|
class PostTag < ApplicationRecord
|
||||||
include Discard::Model
|
|
||||||
|
|
||||||
before_destroy do
|
|
||||||
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
|
|
||||||
end
|
|
||||||
|
|
||||||
belongs_to :post
|
belongs_to :post
|
||||||
belongs_to :tag, counter_cache: :post_count
|
belongs_to :tag, counter_cache: :post_count
|
||||||
belongs_to :created_user, class_name: 'User', optional: true
|
belongs_to :created_user, class_name: 'User', optional: true
|
||||||
belongs_to :deleted_user, class_name: 'User', optional: true
|
|
||||||
|
|
||||||
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
|
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
|
||||||
foreign_key: [:post_id, :tag_id],
|
foreign_key: [:post_id, :tag_id],
|
||||||
@@ -18,18 +11,5 @@ class PostTag < ApplicationRecord
|
|||||||
|
|
||||||
validates :post_id, presence: true
|
validates :post_id, presence: true
|
||||||
validates :tag_id, presence: true
|
validates :tag_id, presence: true
|
||||||
validates :post_id, uniqueness: {
|
validates :post_id, uniqueness: { scope: :tag_id }
|
||||||
scope: :tag_id,
|
|
||||||
conditions: -> { where(discarded_at: nil) } }
|
|
||||||
|
|
||||||
def discard_by! deleted_user
|
|
||||||
return self if discarded?
|
|
||||||
|
|
||||||
transaction do
|
|
||||||
update!(discarded_at: Time.current, deleted_user:)
|
|
||||||
Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)')
|
|
||||||
end
|
|
||||||
|
|
||||||
self
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ class PostTagSection < ApplicationRecord
|
|||||||
belongs_to :post
|
belongs_to :post
|
||||||
belongs_to :tag
|
belongs_to :tag
|
||||||
|
|
||||||
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
|
belongs_to :post_tag, foreign_key: [:post_id, :tag_id],
|
||||||
primary_key: [:post_id, :tag_id],
|
primary_key: [:post_id, :tag_id],
|
||||||
inverse_of: :sections,
|
inverse_of: :sections,
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
validates :post_id, presence: true
|
validates :post_id, presence: true
|
||||||
validates :tag_id, presence: true
|
validates :tag_id, presence: true
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
class PostVersion < ApplicationRecord
|
class PostVersion < ApplicationRecord
|
||||||
include VersionRecord
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
belongs_to :post
|
belongs_to :post
|
||||||
belongs_to :parent, class_name: 'Post', optional: true
|
belongs_to :parent, class_name: 'Post', optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
class Region < ApplicationRecord
|
||||||
|
;
|
||||||
|
end
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
class Script < ApplicationRecord
|
||||||
|
has_many :locales, foreign_key: :script_code, primary_key: :code
|
||||||
|
|
||||||
|
# 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
|
||||||
+278
-259
@@ -2,7 +2,7 @@ require 'set'
|
|||||||
|
|
||||||
|
|
||||||
class Tag < ApplicationRecord
|
class Tag < ApplicationRecord
|
||||||
include MyDiscard
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
class NicoTagNormalisationError < ArgumentError
|
class NicoTagNormalisationError < ArgumentError
|
||||||
;
|
;
|
||||||
@@ -28,15 +28,12 @@ class Tag < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
has_many :post_tags, inverse_of: :tag
|
has_many :post_tags, inverse_of: :tag
|
||||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
|
has_many :posts, through: :post_tags
|
||||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
|
||||||
has_many :posts, through: :active_post_tags
|
|
||||||
|
|
||||||
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
|
|
||||||
has_many :linked_tags, through: :nico_tag_relations, source: :tag
|
|
||||||
|
|
||||||
has_many :reversed_nico_tag_relations,
|
has_many :reversed_nico_tag_relations,
|
||||||
class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy
|
class_name: 'NicoTagRelation',
|
||||||
|
foreign_key: :tag_id,
|
||||||
|
dependent: :destroy
|
||||||
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
|
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
|
||||||
|
|
||||||
has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
|
has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
|
||||||
@@ -54,32 +51,23 @@ class Tag < ApplicationRecord
|
|||||||
has_many :materials
|
has_many :materials
|
||||||
|
|
||||||
has_many :tag_versions
|
has_many :tag_versions
|
||||||
has_many :nico_tag_versions
|
|
||||||
|
|
||||||
belongs_to :tag_name
|
has_many :tag_names
|
||||||
delegate :wiki_page, to: :tag_name
|
has_many :tag_descriptions
|
||||||
|
|
||||||
attribute :version_no, :integer, default: 1
|
attribute :version_no, :integer, default: 1
|
||||||
|
|
||||||
delegate :name, to: :tag_name, allow_nil: true
|
|
||||||
validates :tag_name, presence: true
|
|
||||||
|
|
||||||
enum :category, deerjikist: 'deerjikist',
|
enum :category, deerjikist: 'deerjikist',
|
||||||
meme: 'meme',
|
meme: 'meme',
|
||||||
character: 'character',
|
character: 'character',
|
||||||
general: 'general',
|
general: 'general',
|
||||||
material: 'material',
|
material: 'material',
|
||||||
nico: 'nico',
|
|
||||||
meta: 'meta'
|
meta: 'meta'
|
||||||
|
|
||||||
validates :category, presence: true, inclusion: { in: Tag.categories.keys }
|
validates :category, presence: true, inclusion: { in: Tag.categories.keys }
|
||||||
|
|
||||||
validate :nico_tag_name_must_start_with_nico
|
|
||||||
validate :tag_name_must_be_canonical
|
|
||||||
validate :category_must_be_deerjikist_with_deerjikists
|
validate :category_must_be_deerjikist_with_deerjikists
|
||||||
validate :nico_tags_cannot_be_deprecated
|
validate :tag_names_must_be_valid
|
||||||
|
|
||||||
scope :nico_tags, -> { nico }
|
|
||||||
|
|
||||||
CATEGORY_PREFIXES = {
|
CATEGORY_PREFIXES = {
|
||||||
'general:' => :general,
|
'general:' => :general,
|
||||||
@@ -93,233 +81,296 @@ class Tag < ApplicationRecord
|
|||||||
'mtr:' => :material,
|
'mtr:' => :material,
|
||||||
'meta:' => :meta }.freeze
|
'meta:' => :meta }.freeze
|
||||||
|
|
||||||
def name= val
|
SYSTEM_TAG_NAMES = ['タグ希望', 'bot操作', 'ニジラー情報不詳', '動画', 'ニコニコ', 'YouTube'].freeze
|
||||||
(self.tag_name ||= build_tag_name).name = val
|
|
||||||
|
def tag_name language_code
|
||||||
|
if tag_names.loaded?
|
||||||
|
tag_names.find { _1.language_code == language_code && _1.primary_flg }
|
||||||
|
else
|
||||||
|
tag_names.find_by(language_code:, primary_flg: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
def name(language_code) = tag_name(language_code)&.name
|
||||||
|
|
||||||
|
def system?
|
||||||
|
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
|
||||||
end
|
end
|
||||||
|
|
||||||
def deprecated? = deprecated_at?
|
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 material_id = materials.first&.id
|
||||||
|
|
||||||
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
|
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
|
||||||
|
|
||||||
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
|
def snapshot_aliases language_code
|
||||||
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
|
if tag_names.loaded?
|
||||||
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
|
return tag_names.select { _1.language_code == language_code && !_1.primary? }
|
||||||
def self.video = find_or_create_by_tag_name!('動画', category: :meta)
|
.sort_by(&:name).map(&:name)
|
||||||
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
|
|
||||||
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)
|
|
||||||
|
|
||||||
def self.normalise_tags! tag_names, with_tagme: true,
|
|
||||||
with_no_deerjikist: true,
|
|
||||||
deny_nico: true,
|
|
||||||
deny_deprecated: false,
|
|
||||||
with_sections: false
|
|
||||||
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
|
||||||
raise NicoTagNormalisationError
|
|
||||||
end
|
end
|
||||||
|
|
||||||
sections = { }
|
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
|
||||||
tags = tag_names.map do |name|
|
|
||||||
raw_name = name
|
|
||||||
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
|
|
||||||
|
|
||||||
name = name.sub(/\A#{ pf }/i, '')
|
|
||||||
|
|
||||||
sections_by_tag = []
|
|
||||||
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
|
|
||||||
name = "#{ match[1] }#{ match[4] }"
|
|
||||||
next if match[2].empty? && match[3].empty?
|
|
||||||
|
|
||||||
sections_by_tag << normalise_section_range!(
|
|
||||||
begin_raw: match[2],
|
|
||||||
end_raw: match[3],
|
|
||||||
tag_name: name)
|
|
||||||
end
|
|
||||||
|
|
||||||
if name.include?('[') || name.include?(']')
|
|
||||||
raise SectionLiteralParseError.new(raw_name, raw_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
name = TagName.canonicalise(name).first
|
|
||||||
|
|
||||||
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
|
|
||||||
if deny_deprecated && tag.deprecated?
|
|
||||||
raise DeprecatedTagNormalisationError, [tag.name]
|
|
||||||
end
|
|
||||||
|
|
||||||
tag.update!(category: cat) if cat && tag.category != cat
|
|
||||||
next if sections_by_tag.empty?
|
|
||||||
|
|
||||||
sections[tag.id] ||= []
|
|
||||||
sections[tag.id].concat(sections_by_tag)
|
|
||||||
sections[tag.id] = merge_section_ranges(sections[tag.id])
|
|
||||||
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
|
|
||||||
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
|
|
||||||
tags.uniq!(&:id)
|
|
||||||
|
|
||||||
if with_sections
|
|
||||||
{ tags:, sections: }
|
|
||||||
else
|
|
||||||
tags
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.expand_parent_tags tags
|
|
||||||
return [] if tags.blank?
|
|
||||||
|
|
||||||
seen = Set.new
|
|
||||||
result = []
|
|
||||||
stack = tags.compact.dup
|
|
||||||
|
|
||||||
until stack.empty?
|
|
||||||
tag = stack.pop
|
|
||||||
next unless tag
|
|
||||||
|
|
||||||
tag.parents.each do |parent|
|
|
||||||
next if seen.include?(parent.id)
|
|
||||||
|
|
||||||
seen << parent.id
|
|
||||||
result << parent
|
|
||||||
stack << parent
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
(result + tags).uniq { |t| t.id }
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.normalise_section_range! begin_raw:, end_raw:, tag_name:
|
|
||||||
begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
|
|
||||||
end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)
|
|
||||||
|
|
||||||
if end_ms
|
|
||||||
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
|
|
||||||
end_ms = begin_ms + 1 if begin_ms == end_ms
|
|
||||||
end
|
|
||||||
|
|
||||||
[begin_ms, end_ms]
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.merge_section_ranges ranges
|
|
||||||
sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
|
|
||||||
merged = []
|
|
||||||
|
|
||||||
sorted_ranges.each do |begin_ms, end_ms|
|
|
||||||
if merged.empty?
|
|
||||||
merged << [begin_ms, end_ms]
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
last_begin_ms, last_end_ms = merged[-1]
|
|
||||||
if last_end_ms.nil? || begin_ms <= last_end_ms
|
|
||||||
merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
|
|
||||||
else
|
|
||||||
merged << [begin_ms, end_ms]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
merged
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.merge_section_end left_end_ms, right_end_ms
|
|
||||||
return nil if left_end_ms.nil? || right_end_ms.nil?
|
|
||||||
|
|
||||||
[left_end_ms, right_end_ms].max
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.find_or_create_by_tag_name! name, category:
|
|
||||||
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
|
|
||||||
tn = tn.canonical if tn.canonical_id?
|
|
||||||
|
|
||||||
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t|
|
|
||||||
t.category = category
|
|
||||||
end
|
|
||||||
rescue ActiveRecord::RecordNotUnique
|
|
||||||
retry
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.merge_tags! target_tag, source_tags, created_by_user: nil
|
|
||||||
target_tag => Tag
|
|
||||||
|
|
||||||
affected_post_ids = Set.new
|
|
||||||
|
|
||||||
Tag.transaction do
|
|
||||||
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)
|
|
||||||
|
|
||||||
Array(source_tags).compact.uniq.each do |source_tag|
|
|
||||||
source_tag => Tag
|
|
||||||
|
|
||||||
next if source_tag == target_tag
|
|
||||||
|
|
||||||
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
|
|
||||||
|
|
||||||
source_tag.post_tags.kept.find_each do |source_pt|
|
|
||||||
post_id = source_pt.post_id
|
|
||||||
affected_post_ids << post_id
|
|
||||||
source_pt.discard_by!(created_by_user)
|
|
||||||
unless PostTag.kept.exists?(post_id:, tag: target_tag)
|
|
||||||
PostTag.create!(post_id:, tag: target_tag)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
source_tag_name = source_tag.tag_name
|
|
||||||
|
|
||||||
if source_tag_name.wiki_page.present?
|
|
||||||
raise ActiveRecord::RecordInvalid.new(source_tag_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
|
|
||||||
source_tag.discard!
|
|
||||||
|
|
||||||
if source_tag.nico?
|
|
||||||
source_tag_name.discard!
|
|
||||||
else
|
|
||||||
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
|
|
||||||
updated_at: Time.current)
|
|
||||||
end
|
|
||||||
|
|
||||||
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
|
||||||
end
|
|
||||||
|
|
||||||
Post.where(id: affected_post_ids.to_a).find_each do |post|
|
|
||||||
PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
|
|
||||||
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
|
|
||||||
end
|
|
||||||
|
|
||||||
# 投稿件数を再集計
|
|
||||||
target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count)
|
|
||||||
end
|
|
||||||
|
|
||||||
target_tag.reload
|
|
||||||
end
|
|
||||||
|
|
||||||
def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)
|
|
||||||
|
|
||||||
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
|
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
|
||||||
|
|
||||||
def snapshot_linked_tag_names
|
def snapshot_linked_tag_names language_code
|
||||||
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
linked_tags
|
||||||
|
.joins(:tag_names)
|
||||||
|
.where(tag_names: { language_code:, primary_flg: true })
|
||||||
|
.order('tag_names.name')
|
||||||
|
.pluck('tag_names.name')
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta)
|
||||||
|
def bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta)
|
||||||
|
def no_deerjikist =
|
||||||
|
find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta)
|
||||||
|
def video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta)
|
||||||
|
def niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta)
|
||||||
|
def youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta)
|
||||||
|
|
||||||
|
def normalise_tags! locale, tag_names,
|
||||||
|
with_tagme: true,
|
||||||
|
with_no_deerjikist: true,
|
||||||
|
deny_deprecated: false,
|
||||||
|
with_sections: false,
|
||||||
|
created_by_user: nil
|
||||||
|
if tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
||||||
|
raise NicoTagNormalisationError
|
||||||
|
end
|
||||||
|
|
||||||
|
sections = { }
|
||||||
|
tags = tag_names.map do |name|
|
||||||
|
raw_name = name
|
||||||
|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
|
||||||
|
|
||||||
|
name = name.sub(/\A#{ pf }/i, '')
|
||||||
|
|
||||||
|
sections_by_tag = []
|
||||||
|
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
|
||||||
|
name = "#{ match[1] }#{ match[4] }"
|
||||||
|
next if match[2].empty? && match[3].empty?
|
||||||
|
|
||||||
|
sections_by_tag << normalise_section_range!(
|
||||||
|
begin_raw: match[2],
|
||||||
|
end_raw: match[3],
|
||||||
|
tag_name: name)
|
||||||
|
end
|
||||||
|
|
||||||
|
if name.include?('[') || name.include?(']')
|
||||||
|
raise SectionLiteralParseError.new(raw_name, raw_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
name = TagName.canonicalise(locale, name).first
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
tag.update!(category: cat) if cat && tag.category != cat
|
||||||
|
next if sections_by_tag.empty?
|
||||||
|
|
||||||
|
sections[tag.id] ||= []
|
||||||
|
sections[tag.id].concat(sections_by_tag)
|
||||||
|
sections[tag.id] = merge_section_ranges(sections[tag.id])
|
||||||
|
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
|
||||||
|
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
|
||||||
|
tags.uniq!(&:id)
|
||||||
|
|
||||||
|
if with_sections
|
||||||
|
{ tags:, sections: }
|
||||||
|
else
|
||||||
|
tags
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def expand_parent_tags tags
|
||||||
|
return [] if tags.blank?
|
||||||
|
|
||||||
|
seen = Set.new
|
||||||
|
result = []
|
||||||
|
stack = tags.compact.dup
|
||||||
|
|
||||||
|
until stack.empty?
|
||||||
|
tag = stack.pop
|
||||||
|
next unless tag
|
||||||
|
|
||||||
|
tag.parents.each do |parent|
|
||||||
|
next if seen.include?(parent.id)
|
||||||
|
|
||||||
|
seen << parent.id
|
||||||
|
result << parent
|
||||||
|
stack << parent
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
(result + tags).uniq { |t| t.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalise_section_range! begin_raw:, end_raw:, tag_name:
|
||||||
|
begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
|
||||||
|
end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)
|
||||||
|
|
||||||
|
if end_ms
|
||||||
|
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
|
||||||
|
end_ms = begin_ms + 1 if begin_ms == end_ms
|
||||||
|
end
|
||||||
|
|
||||||
|
[begin_ms, end_ms]
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge_section_ranges ranges
|
||||||
|
sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
|
||||||
|
merged = []
|
||||||
|
|
||||||
|
sorted_ranges.each do |begin_ms, end_ms|
|
||||||
|
if merged.empty?
|
||||||
|
merged << [begin_ms, end_ms]
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
last_begin_ms, last_end_ms = merged[-1]
|
||||||
|
if last_end_ms.nil? || begin_ms <= last_end_ms
|
||||||
|
merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
|
||||||
|
else
|
||||||
|
merged << [begin_ms, end_ms]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
merged
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge_section_end left_end_ms, right_end_ms
|
||||||
|
return nil if left_end_ms.nil? || right_end_ms.nil?
|
||||||
|
|
||||||
|
[left_end_ms, right_end_ms].max
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
tn = TagName.lock.find_by(language_code:, name:)
|
||||||
|
next tn.tag if 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
|
||||||
|
existing = TagName.find_by(language_code:, name:)
|
||||||
|
raise unless existing
|
||||||
|
|
||||||
|
existing.tag
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge_tags! target_tag, source_tags, created_by_user: nil
|
||||||
|
target_tag => Tag
|
||||||
|
|
||||||
|
affected_post_ids = Set.new
|
||||||
|
|
||||||
|
Tag.transaction do
|
||||||
|
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)
|
||||||
|
|
||||||
|
Array(source_tags).compact.uniq.each do |source_tag|
|
||||||
|
source_tag => Tag
|
||||||
|
|
||||||
|
next if source_tag == target_tag
|
||||||
|
|
||||||
|
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
|
||||||
|
|
||||||
|
source_tag.post_tags.find_each do |source_pt|
|
||||||
|
post_id = source_pt.post_id
|
||||||
|
affected_post_ids << post_id
|
||||||
|
source_pt.destroy!
|
||||||
|
unless PostTag.exists?(post_id:, tag: target_tag)
|
||||||
|
PostTag.create!(post_id:, tag: target_tag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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:)
|
||||||
|
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!
|
||||||
|
|
||||||
|
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
Post.where(id: affected_post_ids.to_a).find_each do |post|
|
||||||
|
PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
|
||||||
|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
# 投稿件数を再集計
|
||||||
|
target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count)
|
||||||
|
end
|
||||||
|
|
||||||
|
target_tag.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_to_ms! str, tag_name:
|
||||||
|
match =
|
||||||
|
case str
|
||||||
|
when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
|
||||||
|
{ hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
|
||||||
|
ms: Regexp.last_match[:ms] }
|
||||||
|
when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
|
||||||
|
{ hours: nil, minutes: Regexp.last_match[:minutes],
|
||||||
|
seconds: Regexp.last_match[:seconds],
|
||||||
|
ms: Regexp.last_match[:ms] }
|
||||||
|
when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
|
||||||
|
{ hours: Regexp.last_match[:hours],
|
||||||
|
minutes: Regexp.last_match[:minutes],
|
||||||
|
seconds: Regexp.last_match[:seconds],
|
||||||
|
ms: Regexp.last_match[:ms] }
|
||||||
|
end
|
||||||
|
|
||||||
|
raise SectionLiteralParseError.new(tag_name, str) unless match
|
||||||
|
|
||||||
|
total_s = match[:seconds].to_i
|
||||||
|
total_s += match[:minutes].to_i * 60 if match[:minutes]
|
||||||
|
total_s += match[:hours].to_i * 3_600 if match[:hours]
|
||||||
|
|
||||||
|
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def nico_tag_name_must_start_with_nico
|
def tag_names_must_be_valid
|
||||||
n = name.to_s
|
tag_names.each do |tag_name|
|
||||||
if ((nico? && !(n.downcase.start_with?('nico:'))) ||
|
next if tag_name.valid?
|
||||||
(!(nico?) && n.downcase.start_with?('nico:')))
|
|
||||||
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def tag_name_must_be_canonical
|
tag_name.errors[:name].each { errors.add :name, _1 }
|
||||||
if tag_name&.canonical_id?
|
|
||||||
errors.add :tag_name, 'tag_names へは実体を示す必要があります.'
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -328,36 +379,4 @@ class Tag < ApplicationRecord
|
|||||||
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.time_to_ms! str, tag_name:
|
|
||||||
match =
|
|
||||||
case str
|
|
||||||
when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
|
|
||||||
{ hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
|
|
||||||
ms: Regexp.last_match[:ms] }
|
|
||||||
when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
|
|
||||||
{ hours: nil, minutes: Regexp.last_match[:minutes],
|
|
||||||
seconds: Regexp.last_match[:seconds],
|
|
||||||
ms: Regexp.last_match[:ms] }
|
|
||||||
when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
|
|
||||||
{ hours: Regexp.last_match[:hours],
|
|
||||||
minutes: Regexp.last_match[:minutes],
|
|
||||||
seconds: Regexp.last_match[:seconds],
|
|
||||||
ms: Regexp.last_match[:ms] }
|
|
||||||
end
|
|
||||||
|
|
||||||
raise SectionLiteralParseError.new(tag_name, str) unless match
|
|
||||||
|
|
||||||
total_s = match[:seconds].to_i
|
|
||||||
total_s += match[:minutes].to_i * 60 if match[:minutes]
|
|
||||||
total_s += match[:hours].to_i * 3_600 if match[:hours]
|
|
||||||
|
|
||||||
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
def nico_tags_cannot_be_deprecated
|
|
||||||
if nico? && deprecated_at.present?
|
|
||||||
errors.add :deprecated_at, 'ニコタグは廃止できません.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class TagDescription < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
|
belongs_to :tag
|
||||||
|
end
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
class TagDescriptionVersion < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
end
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
has_many :tag_group_tags, dependent: :delete_all
|
||||||
|
has_many :tags, through: :tag_group_tags
|
||||||
|
has_many :tag_group_groups, dependent: :delete_all
|
||||||
|
has_many :child_tag_groups, through: :tag_group_groups
|
||||||
|
has_many :placements, -> { order(:position) },
|
||||||
|
class_name: 'TagGroupPlacement', dependent: :delete_all
|
||||||
|
has_many :versions, -> { order(:version_no) },
|
||||||
|
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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class TagGroupGroup < ApplicationRecord
|
||||||
|
self.primary_key = [:tag_group_id, :child_tag_group_id]
|
||||||
|
|
||||||
|
belongs_to :tag_group
|
||||||
|
belongs_to :child_tag_group, class_name: 'TagGroup'
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class TagGroupPlacement < ApplicationRecord
|
||||||
|
self.primary_key = [:tag_group_id, :position]
|
||||||
|
|
||||||
|
belongs_to :tag_group
|
||||||
|
belongs_to :tag, optional: true
|
||||||
|
belongs_to :child_tag_group, class_name: 'TagGroup', optional: true
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class TagGroupTag < ApplicationRecord
|
||||||
|
self.primary_key = [:tag_group_id, :tag_id]
|
||||||
|
|
||||||
|
belongs_to :tag_group
|
||||||
|
belongs_to :tag
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class TagGroupVersion < ApplicationRecord
|
||||||
|
include VersionRecord
|
||||||
|
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
|
belongs_to :tag_group
|
||||||
|
|
||||||
|
scope :ordered, -> { order(:version_no) }
|
||||||
|
end
|
||||||
@@ -1,51 +1,82 @@
|
|||||||
class TagName < ApplicationRecord
|
class TagName < ApplicationRecord
|
||||||
include MyDiscard
|
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
||||||
|
belongs_to :tag
|
||||||
|
|
||||||
has_one :tag
|
validates :name, presence: true,
|
||||||
has_one :wiki_page
|
length: { maximum: 255 },
|
||||||
|
uniqueness: { scope: :language_code }
|
||||||
|
|
||||||
belongs_to :canonical, class_name: 'TagName', optional: true
|
|
||||||
has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id
|
|
||||||
|
|
||||||
validates :name, presence: true, length: { maximum: 255 }, uniqueness: true
|
|
||||||
|
|
||||||
validate :canonical_must_be_canonical
|
|
||||||
validate :alias_name_must_not_have_prefix
|
validate :alias_name_must_not_have_prefix
|
||||||
validate :canonical_must_not_be_present_with_tag_or_wiki_page
|
|
||||||
validate :name_must_be_sanitised
|
validate :name_must_be_sanitised
|
||||||
|
validate :name_mustnt_start_with_nico
|
||||||
|
validate :generated_name_must_be_reserved
|
||||||
|
|
||||||
def self.canonicalise names
|
def primary? = primary_flg
|
||||||
|
|
||||||
|
def self.canonicalise locale, names
|
||||||
names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?)
|
names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?)
|
||||||
return [] if names.blank?
|
return [] if names.blank?
|
||||||
|
|
||||||
tns = TagName.includes(:canonical).where(name: names).index_by(&:name)
|
tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name)
|
||||||
|
|
||||||
names.map { |name| tns[name]&.canonical&.name || name }.uniq
|
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?
|
||||||
|
name
|
||||||
|
else
|
||||||
|
primary_names[tag_name.tag_id] || name
|
||||||
|
end
|
||||||
|
}.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.generate_name locale, tag, base_tag_name
|
||||||
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def canonical_must_be_canonical
|
def generated_name_must_be_reserved
|
||||||
if canonical&.canonical_id?
|
if !auto_generated && name.to_s.start_with?('Tag_#')
|
||||||
errors.add :canonical, 'canonical は実体を示す必要があります.'
|
errors.add :name, 'タグの命名規則に反してゐます.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def alias_name_must_not_have_prefix
|
def alias_name_must_not_have_prefix
|
||||||
if canonical_id? && name.to_s.include?(':')
|
if !(primary?) && name.to_s.include?(':')
|
||||||
errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.'
|
errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def canonical_must_not_be_present_with_tag_or_wiki_page
|
|
||||||
if canonical_id? && (tag || wiki_page)
|
|
||||||
errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def name_must_be_sanitised
|
def name_must_be_sanitised
|
||||||
|
return if auto_generated && name == "Tag_##{ tag_id }"
|
||||||
|
|
||||||
if name? && name != TagNameSanitisationRule.sanitise(name)
|
if name? && name != TagNameSanitisationRule.sanitise(name)
|
||||||
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def name_mustnt_start_with_nico
|
||||||
|
if name.to_s.downcase.start_with?('nico:')
|
||||||
|
errors.add :name, 'タグの命名規則に反してゐます.'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,35 +10,43 @@ class TagNameSanitisationRule < ApplicationRecord
|
|||||||
validate :source_pattern_must_be_regexp
|
validate :source_pattern_must_be_regexp
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def sanitise(name) =
|
def sanitise(name)
|
||||||
rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) }
|
rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) }
|
||||||
|
end
|
||||||
|
|
||||||
def apply!
|
def apply!
|
||||||
TagName.find_each do |tn|
|
Language.find_each do |language|
|
||||||
name = sanitise(tn.name)
|
TagName.where(language:).find_each do |candidate|
|
||||||
next if name == tn.name
|
TagName.transaction do
|
||||||
|
tn = TagName.find_by(id: candidate.id)
|
||||||
|
next unless tn
|
||||||
|
|
||||||
TagName.transaction do
|
name = sanitise(tn.name)
|
||||||
existing_tn = TagName.find_by(name:)
|
next if name == tn.name
|
||||||
if existing_tn
|
|
||||||
existing_tn = existing_tn.canonical || existing_tn
|
|
||||||
next if existing_tn.id == tn.id
|
|
||||||
|
|
||||||
existing_tag = Tag.find_by(tag_name_id: existing_tn.id)
|
existing_tn = TagName.find_by(language:, name:)
|
||||||
source_tag = Tag.find_by(tag_name_id: tn.id)
|
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_tag
|
if existing_tn && target_tag != source_tag
|
||||||
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
|
Tag.merge_tags!(target_tag, source_tag)
|
||||||
elsif source_tag
|
tn.reload.destroy!
|
||||||
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
|
elsif existing_tn
|
||||||
|
if tn.primary?
|
||||||
|
existing_tn.destroy!
|
||||||
|
tn.update_columns(name:, updated_at: Time.current)
|
||||||
|
else
|
||||||
|
tn.destroy!
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# TagName 側の自動サニタイズを回避
|
||||||
|
tn.update_columns(name:, updated_at: Time.current)
|
||||||
end
|
end
|
||||||
tn.discard!
|
|
||||||
|
|
||||||
next
|
TagVersioning.record!(target_tag, event_type: :update, created_by_user: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
# TagName 側の自動サニタイズを回避
|
|
||||||
tn.update_columns(name:, updated_at: Time.current)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ class TagVersion < ApplicationRecord
|
|||||||
meta: 'meta' }, validate: true
|
meta: 'meta' }, validate: true
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
|
validates :names, presence: true
|
||||||
validates :category, presence: true
|
validates :category, presence: true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ class User < ApplicationRecord
|
|||||||
has_many :ip_addresses, through: :user_ips
|
has_many :ip_addresses, through: :user_ips
|
||||||
has_many :user_post_views, dependent: :destroy
|
has_many :user_post_views, dependent: :destroy
|
||||||
has_many :viewed_posts, through: :user_post_views, source: :post
|
has_many :viewed_posts, through: :user_post_views, source: :post
|
||||||
has_many :created_wiki_pages,
|
has_many :created_tag_descriptions, class_name: 'TagDescription',
|
||||||
class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify
|
foreign_key: :created_by_user_id, dependent: :nullify
|
||||||
has_many :updated_wiki_pages,
|
has_many :updated_tag_descriptions, class_name: 'TagDescription',
|
||||||
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
|
foreign_key: :updated_by_user_id, dependent: :nullify
|
||||||
|
|
||||||
def viewed?(post) = user_post_views.exists?(post_id: post.id)
|
def viewed?(post) = user_post_views.exists?(post_id: post.id)
|
||||||
|
|
||||||
|
|||||||
@@ -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,55 +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)
|
|
||||||
def succ_revision_id(revision_id) =
|
|
||||||
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
|
|
||||||
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,103 @@
|
|||||||
|
# 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
|
||||||
|
@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'
|
||||||
|
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 = 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 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 { { id: _1.to_i } },
|
||||||
|
deprecated: row.deprecated_at&.iso8601 }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,11 +6,12 @@ module DeerjikistRepr
|
|||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def base deerjikist
|
def base deerjikist, language_code: 'ja'
|
||||||
deerjikist.as_json(BASE)
|
deerjikist.as_json(BASE).merge(
|
||||||
|
tag: deerjikist.tag && TagRepr.base(deerjikist.tag, language_code:))
|
||||||
end
|
end
|
||||||
|
|
||||||
def many deerjikists
|
def many deerjikists, language_code: 'ja'
|
||||||
deerjikists.map { |d| base(d) }
|
deerjikists.map { |d| base(d, language_code:) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module ExternalTagRepr
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def base tag
|
||||||
|
{ 'id' => tag.id,
|
||||||
|
'name' => "#{ tag.platform }:#{ tag.name }",
|
||||||
|
'category' => 'nico',
|
||||||
|
'post_count' => tag.post_count,
|
||||||
|
'created_at' => tag.created_at,
|
||||||
|
'updated_at' => tag.created_at,
|
||||||
|
'deprecated_at' => nil,
|
||||||
|
'aliases' => [],
|
||||||
|
'parents' => [],
|
||||||
|
'has_wiki' => false,
|
||||||
|
'material_id' => nil,
|
||||||
|
'has_deerjikists' => false }
|
||||||
|
end
|
||||||
|
|
||||||
|
def inline(tag) = base(tag)
|
||||||
|
end
|
||||||
@@ -12,14 +12,15 @@ module MaterialRepr
|
|||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def base material, host:
|
def base material, host:, language_code: 'ja'
|
||||||
material.as_json(BASE).merge(
|
material.as_json(BASE).merge(
|
||||||
|
tag: material.tag && TagRepr.base(material.tag, language_code:),
|
||||||
file: if material.file.attached?
|
file: if material.file.attached?
|
||||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||||
material.file, host:)
|
material.file, host:)
|
||||||
end,
|
end,
|
||||||
thumbnail: thumbnail_url(material, host:),
|
thumbnail: thumbnail_url(material, host:),
|
||||||
thumbnail_fallback_text: thumbnail_fallback_text(material),
|
thumbnail_fallback_text: thumbnail_fallback_text(material, language_code:),
|
||||||
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
|
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
|
||||||
media_kind: media_kind(material),
|
media_kind: media_kind(material),
|
||||||
file_byte_size: material.file_byte_size,
|
file_byte_size: material.file_byte_size,
|
||||||
@@ -27,11 +28,11 @@ module MaterialRepr
|
|||||||
export_items: export_items(material))
|
export_items: export_items(material))
|
||||||
end
|
end
|
||||||
|
|
||||||
def many materials, host:
|
def many materials, host:, language_code: 'ja'
|
||||||
materials.map { |m| base(m, host:) }
|
materials.map { |m| base(m, host:, language_code:) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def list material, host:
|
def list material, host:, language_code: 'ja'
|
||||||
{ id: material.id,
|
{ id: material.id,
|
||||||
version_no: material.version_no,
|
version_no: material.version_no,
|
||||||
url: material.url,
|
url: material.url,
|
||||||
@@ -40,9 +41,9 @@ module MaterialRepr
|
|||||||
source_path: material.source_path,
|
source_path: material.source_path,
|
||||||
source_file_id: material.source_file_id,
|
source_file_id: material.source_file_id,
|
||||||
normalized_source_key: material.normalized_source_key,
|
normalized_source_key: material.normalized_source_key,
|
||||||
tag: compact_tag(material.tag),
|
tag: compact_tag(material.tag, language_code:),
|
||||||
thumbnail: thumbnail_url(material, host:),
|
thumbnail: thumbnail_url(material, host:),
|
||||||
thumbnail_fallback_text: thumbnail_fallback_text(material),
|
thumbnail_fallback_text: thumbnail_fallback_text(material, language_code:),
|
||||||
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
|
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
|
||||||
media_kind: media_kind(material),
|
media_kind: media_kind(material),
|
||||||
content_type: material.content_type,
|
content_type: material.content_type,
|
||||||
@@ -53,8 +54,8 @@ module MaterialRepr
|
|||||||
export_items: export_items(material) }
|
export_items: export_items(material) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_many materials, host:
|
def list_many materials, host:, language_code: 'ja'
|
||||||
materials.map { |m| list(m, host:) }
|
materials.map { |m| list(m, host:, language_code:) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def export_paths material
|
def export_paths material
|
||||||
@@ -79,8 +80,8 @@ module MaterialRepr
|
|||||||
material.thumbnail, host:)
|
material.thumbnail, host:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_fallback_text material
|
def thumbnail_fallback_text material, language_code:
|
||||||
material.tag&.name || material.created_at&.strftime('%Y-%m-%d')
|
material.tag&.name(language_code) || material.created_at&.strftime('%Y-%m-%d')
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_fallback_kind material
|
def thumbnail_fallback_kind material
|
||||||
@@ -98,11 +99,11 @@ module MaterialRepr
|
|||||||
'file_other'
|
'file_other'
|
||||||
end
|
end
|
||||||
|
|
||||||
def compact_tag tag
|
def compact_tag tag, language_code:
|
||||||
return nil unless tag
|
return nil unless tag
|
||||||
|
|
||||||
{ id: tag.id,
|
{ id: tag.id,
|
||||||
name: tag.name,
|
name: tag.name(language_code),
|
||||||
category: tag.category,
|
category: tag.category,
|
||||||
deprecated_at: tag.deprecated_at }
|
deprecated_at: tag.deprecated_at }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
module PostCompactRepr
|
module PostCompactRepr
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def base post
|
def base post, host: nil
|
||||||
return nil if post.nil?
|
return nil if post.nil?
|
||||||
|
|
||||||
PostRepr
|
PostRepr
|
||||||
.common(post)
|
.common(post, host:)
|
||||||
.slice(
|
.slice(
|
||||||
'id',
|
'id',
|
||||||
'title',
|
'title',
|
||||||
|
|||||||
@@ -17,64 +17,112 @@ module PostRepr
|
|||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def base post, current_user = nil
|
def base post, current_user = nil, host: nil, language_code: 'ja'
|
||||||
json = common(post)
|
json =
|
||||||
json['tags'] = tag_json(post)
|
if host.present?
|
||||||
|
common(post, host:)
|
||||||
|
else
|
||||||
|
common(post)
|
||||||
|
end
|
||||||
|
json['tags'] = tag_json(post, language_code:)
|
||||||
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
|
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
|
||||||
json['viewed'] = current_user ? current_user.viewed?(post) : false
|
json['viewed'] = current_user ? current_user.viewed?(post) : false
|
||||||
json
|
json
|
||||||
end
|
end
|
||||||
|
|
||||||
def detail post, current_user = nil, parent_posts: [], child_posts: [],
|
def detail post, current_user = nil, parent_posts: [], child_posts: [],
|
||||||
sibling_posts: { }, related: []
|
sibling_posts: { }, related: [], host: nil, language_code: 'ja'
|
||||||
base(post, current_user).merge(
|
if host.present?
|
||||||
'parent_posts' => cards(parent_posts),
|
base(post, current_user, host:, language_code:).merge(
|
||||||
'child_posts' => cards(child_posts),
|
'parent_posts' => cards(parent_posts, host:),
|
||||||
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
|
'child_posts' => cards(child_posts, host:),
|
||||||
cards(posts)
|
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
|
||||||
},
|
cards(posts, host:)
|
||||||
'related' => cards(related))
|
},
|
||||||
|
'related' => cards(related, host:))
|
||||||
|
else
|
||||||
|
base(post, current_user, language_code:).merge(
|
||||||
|
'parent_posts' => cards(parent_posts),
|
||||||
|
'child_posts' => cards(child_posts),
|
||||||
|
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
|
||||||
|
cards(posts)
|
||||||
|
},
|
||||||
|
'related' => cards(related))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def card post
|
def card post, host: nil
|
||||||
common(post).merge('parent_posts' => [], 'child_posts' => [])
|
if host.present?
|
||||||
|
common(post, host:).merge('parent_posts' => [], 'child_posts' => [])
|
||||||
|
else
|
||||||
|
common(post).merge('parent_posts' => [], 'child_posts' => [])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def cards posts
|
def cards posts, host: nil
|
||||||
posts.map { |post| card(post) }
|
if host.present?
|
||||||
|
posts.map { |post| card(post, host:) }
|
||||||
|
else
|
||||||
|
posts.map { |post| card(post) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def many posts, current_user = nil
|
def many posts, current_user = nil, host: nil
|
||||||
posts.map { |p| base(p, current_user) }
|
if host.present?
|
||||||
|
posts.map { |p| base(p, current_user, host:) }
|
||||||
|
else
|
||||||
|
posts.map { |p| base(p, current_user) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def common post
|
def common post, host: nil
|
||||||
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
|
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
|
||||||
.merge('thumbnail' => thumbnail_url(post))
|
.merge(
|
||||||
|
'thumbnail' =>
|
||||||
|
if host.present?
|
||||||
|
thumbnail_url(post, host:)
|
||||||
|
else
|
||||||
|
thumbnail_url(post)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_json post
|
def tag_json post, language_code:
|
||||||
post
|
internal_tags =
|
||||||
.active_post_tags
|
post
|
||||||
|
.post_tags
|
||||||
.reject { _1.tag.deprecated? }
|
.reject { _1.tag.deprecated? }
|
||||||
.sort_by { _1.tag.name }
|
.sort_by { TagRepr.name_for(_1.tag, language_code) }
|
||||||
.map { |post_tag|
|
.map { |post_tag|
|
||||||
TagRepr.inline(post_tag.tag).merge(
|
TagRepr.inline(post_tag.tag, language_code:).merge(
|
||||||
'children' => [],
|
'children' => [],
|
||||||
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
|
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
external_tags =
|
||||||
|
post
|
||||||
|
.external_tags
|
||||||
|
.sort_by { _1.name }
|
||||||
|
.map { ExternalTagRepr.base(_1).merge('children' => [], 'sections' => []) }
|
||||||
|
|
||||||
|
internal_tags + external_tags
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_url post
|
def thumbnail_url post, host: nil
|
||||||
return nil unless post.thumbnail.attached?
|
return nil unless post.thumbnail.attached?
|
||||||
|
|
||||||
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
|
options = { only_path: false }
|
||||||
|
options[:host] = host if host.present?
|
||||||
|
|
||||||
|
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
|
||||||
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
|
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
|
||||||
Rails.logger.warn(
|
payload = {
|
||||||
"PostRepr.thumbnail_url failed post_id=#{post.id} " \
|
post_id: post.id,
|
||||||
"attachment_id=#{post.thumbnail.attachment&.id} " \
|
attachment_id: post.thumbnail.attachment&.id,
|
||||||
"blob_id=#{post.thumbnail.blob&.id} " \
|
blob_id: post.thumbnail.blob&.id,
|
||||||
"error_class=#{e.class} message=#{e.message}")
|
error_class: e.class,
|
||||||
|
message: e.message }
|
||||||
|
|
||||||
|
Rails.logger.warn("PostRepr.thumbnail_url failed #{ payload.to_json }")
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
module TagGroupRepr
|
||||||
|
PREFIX = 'タググループ:'
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
tag_members = TagGroupTag.where(tag_group_id: ids)
|
||||||
|
.group_by(&:tag_group_id)
|
||||||
|
group_members = TagGroupGroup.where(tag_group_id: ids)
|
||||||
|
.group_by(&:tag_group_id)
|
||||||
|
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
|
||||||
|
.group_by(&: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) +
|
||||||
|
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: 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|
|
||||||
|
group_json(group,
|
||||||
|
tag_members.fetch(group.id, []),
|
||||||
|
group_members.fetch(group.id, []),
|
||||||
|
placements.fetch(group.id, []), tags, names, child_groups)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def one locale, group
|
||||||
|
many(locale, [group]).first
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_group wiki_page
|
||||||
|
{ 'id' => wiki_page.id,
|
||||||
|
'title' => "#{ PREFIX }#{ wiki_page.name }",
|
||||||
|
'deprecated_at' => nil,
|
||||||
|
'created_at' => wiki_page.created_at,
|
||||||
|
'updated_at' => wiki_page.updated_at }
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_version group
|
||||||
|
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||||
|
end
|
||||||
|
|
||||||
|
def historical locale, version
|
||||||
|
members = Array(version.members)
|
||||||
|
placements = Array(version.placements)
|
||||||
|
tag_references = members.filter_map { |member| member['tag_id'] } +
|
||||||
|
collect_ids(placements, 'tag_id')
|
||||||
|
group_references = members.filter_map { |member| member['tag_group_id'] } +
|
||||||
|
collect_ids(placements, 'tag_group_id')
|
||||||
|
tag_versions = historical_tag_versions(tag_references)
|
||||||
|
group_versions = historical_group_versions(group_references)
|
||||||
|
tag_version_numbers = members.filter_map do |member|
|
||||||
|
next unless member['tag_id']
|
||||||
|
|
||||||
|
[member.fetch('tag_id'), member.fetch('version_no')]
|
||||||
|
end.to_h
|
||||||
|
group_version_numbers = members.filter_map do |member|
|
||||||
|
next unless member['tag_group_id']
|
||||||
|
|
||||||
|
[member.fetch('tag_group_id'), member.fetch('version_no')]
|
||||||
|
end.to_h
|
||||||
|
historical_members = members.map do |member|
|
||||||
|
historical_member(locale, member, tag_versions, group_versions)
|
||||||
|
end
|
||||||
|
historical_placements = placements.map do |node|
|
||||||
|
historical_placement(locale, node, tag_versions, group_versions,
|
||||||
|
tag_version_numbers, group_version_numbers)
|
||||||
|
end
|
||||||
|
{ 'id' => version.tag_group_id,
|
||||||
|
'name' => version.name,
|
||||||
|
'description' => version.description,
|
||||||
|
'version_no' => version.version_no,
|
||||||
|
'members' => historical_members,
|
||||||
|
'placements' => historical_placements,
|
||||||
|
'created_at' => version.created_at,
|
||||||
|
'updated_at' => version.created_at,
|
||||||
|
'created_by_user' => user_json(version.created_by_user),
|
||||||
|
'updated_by_user' => user_json(version.created_by_user) }
|
||||||
|
end
|
||||||
|
|
||||||
|
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[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)) }
|
||||||
|
end)
|
||||||
|
{ 'id' => group.id,
|
||||||
|
'name' => group.name,
|
||||||
|
'description' => group.description,
|
||||||
|
'version_no' => group.version_no,
|
||||||
|
'members' => members,
|
||||||
|
'placements' => tree,
|
||||||
|
'created_at' => group.created_at,
|
||||||
|
'updated_at' => group.updated_at,
|
||||||
|
'created_by_user' => user_json(group.created_by_user),
|
||||||
|
'updated_by_user' => user_json(group.updated_by_user) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_json user
|
||||||
|
UserRepr.base(user) if user
|
||||||
|
end
|
||||||
|
|
||||||
|
def placement_tree rows, tags, names, child_groups
|
||||||
|
by_parent = rows.group_by(&:parent_position)
|
||||||
|
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[row.tag_id]) }
|
||||||
|
elsif row.child_tag_group_id
|
||||||
|
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
|
||||||
|
else
|
||||||
|
{ 'section_name' => row.section_name, 'description' => row.description }
|
||||||
|
end
|
||||||
|
node.merge('children' => build.call(row.position))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
build.call(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_json tag, name
|
||||||
|
{ 'id' => tag.id, 'name' => name, 'category' => tag.category }
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_json_reference group
|
||||||
|
{ 'id' => group.id, 'name' => group.name }
|
||||||
|
end
|
||||||
|
|
||||||
|
def collect_ids nodes, key
|
||||||
|
nodes.flat_map do |node|
|
||||||
|
[node[key], *collect_ids(Array(node['children']), key)].compact
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def historical_tag_versions ids
|
||||||
|
TagVersion.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|
|
||||||
|
[version.tag_group_id, version.version_no]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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' => version_name(locale, version),
|
||||||
|
'category' => version.category } }
|
||||||
|
else
|
||||||
|
id = member.fetch('tag_group_id')
|
||||||
|
version = group_versions.fetch([id, member.fetch('version_no')])
|
||||||
|
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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' => version_name(locale, version),
|
||||||
|
'category' => version.category } }
|
||||||
|
elsif node['tag_group_id']
|
||||||
|
id = node.fetch('tag_group_id')
|
||||||
|
version_no = group_version_numbers.fetch(id)
|
||||||
|
version = group_versions.fetch([id, version_no])
|
||||||
|
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
|
||||||
|
else
|
||||||
|
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
|
||||||
|
end
|
||||||
|
result.merge('children' => Array(node['children']).map do |child|
|
||||||
|
historical_placement(locale, child, tag_versions, group_versions,
|
||||||
|
tag_version_numbers, group_version_numbers)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def version_name locale, version
|
||||||
|
version.names[locale.language_code]&.fetch('name')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,18 +3,30 @@
|
|||||||
|
|
||||||
module TagRepr
|
module TagRepr
|
||||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
||||||
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
|
methods: [:material_id, :has_deerjikists] }.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def base tag
|
def base tag, language_code: 'ja'
|
||||||
tag.as_json(BASE).merge(aliases: tag.snapshot_aliases,
|
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||||
parents: tag.parents.map { _1.as_json(BASE) })
|
name: name_for(tag, language_code),
|
||||||
|
aliases: tag.snapshot_aliases(language_code),
|
||||||
|
parents: tag.parents.map { inline(_1, language_code:) })
|
||||||
end
|
end
|
||||||
|
|
||||||
def inline tag
|
def inline tag, language_code: 'ja'
|
||||||
tag.as_json(BASE).merge(aliases: [], parents: [])
|
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||||
|
name: name_for(tag, language_code), aliases: [], parents: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def many(tags) = tags.map { |t| base(t) }
|
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)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,15 +2,48 @@
|
|||||||
|
|
||||||
|
|
||||||
module WikiPageRepr
|
module WikiPageRepr
|
||||||
BASE = { methods: [:title, :deprecated_at] }.freeze
|
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def base wiki_page
|
def tag_description locale, description
|
||||||
wiki_page.as_json(BASE)
|
tag = description.tag
|
||||||
|
|
||||||
|
{ 'id' => description.id,
|
||||||
|
'resource_type' => 'tag_description',
|
||||||
|
'tag_id' => tag.id,
|
||||||
|
'language_code' => description.language_code,
|
||||||
|
'tag_name_id' => tag.tag_name(locale.language_code)&.id,
|
||||||
|
'body' => description.body,
|
||||||
|
'created_user_id' => description.created_by_user_id,
|
||||||
|
'updated_user_id' => description.updated_by_user_id,
|
||||||
|
'created_at' => description.created_at,
|
||||||
|
'updated_at' => description.updated_at,
|
||||||
|
'discarded_at' => nil,
|
||||||
|
'version_no' => description.version_no,
|
||||||
|
'title' => tag.name(locale.language_code),
|
||||||
|
'deprecated_at' => tag.deprecated_at }
|
||||||
end
|
end
|
||||||
|
|
||||||
def many wiki_pages
|
def tag_group tag_group
|
||||||
wiki_pages.map { |p| base(p) }
|
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||||
|
end
|
||||||
|
|
||||||
|
def autolink_tag locale, tag_id:, description_id:, name:, language:
|
||||||
|
{ resource_type: 'tag_description',
|
||||||
|
resource_id: description_id,
|
||||||
|
tag_id:,
|
||||||
|
tag_description_id: description_id,
|
||||||
|
name:,
|
||||||
|
language_code: language,
|
||||||
|
locale_code: locale.code_before_type_cast }
|
||||||
|
end
|
||||||
|
|
||||||
|
def autolink_group locale, id:, name:, language:
|
||||||
|
{ resource_type: 'tag_group',
|
||||||
|
resource_id: id,
|
||||||
|
tag_id: nil,
|
||||||
|
tag_description_id: nil,
|
||||||
|
name: "#{ TagGroupRepr::PREFIX }#{ name }",
|
||||||
|
language_code: language,
|
||||||
|
locale_code: locale.code_before_type_cast }
|
||||||
end
|
end
|
||||||
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,98 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
result = ''
|
||||||
|
if all.any? { _1['field'] == '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 all.any? { _1['field'] == '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 = JSON.parse(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'))
|
||||||
|
elsif axis.key?('grain')
|
||||||
|
raise ArgumentError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
axes
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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)
|
||||||
|
slot = acquire_slot
|
||||||
|
|
||||||
|
begin
|
||||||
|
generate(exporter, owner, progress:, check:, identifier:)
|
||||||
|
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)
|
||||||
|
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 }, 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 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,236 @@
|
|||||||
|
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,
|
||||||
|
'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
|
||||||
|
|
||||||
|
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)
|
||||||
|
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,249 @@
|
|||||||
|
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_at' => 'original_created_at',
|
||||||
|
'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'] }.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:)
|
||||||
|
@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 = params.fetch('columns', defaults.join(',')).split(',')
|
||||||
|
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 = params.fetch('order', '').split(',')
|
||||||
|
@sorts = params.fetch('child_sort', '').split(',').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') ? params['children'].split(',').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?
|
||||||
|
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]
|
||||||
|
when 'original_created_at'
|
||||||
|
{ from: row[:original_created_from], before: row[:original_created_before] }
|
||||||
|
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
|
||||||
|
raise ArgumentError if (params.keys - allowed).any?
|
||||||
|
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: @params.fetch('order', '').split(','), 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,208 @@
|
|||||||
|
# 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
|
||||||
|
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')
|
||||||
|
@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 = JSON.parse(params.fetch('conditions', '[]'))
|
||||||
|
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
|
||||||
|
|
||||||
|
conditions.each { apply_condition(_1) }
|
||||||
|
drills = JSON.parse(params.fetch('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)
|
||||||
|
raise ArgumentError unless entry.is_a?(Hash)
|
||||||
|
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
|
||||||
|
|
||||||
|
key = entry.fetch('field')
|
||||||
|
type = definition.fields.fetch(key) { raise ArgumentError }
|
||||||
|
operator = entry.fetch('operator')
|
||||||
|
raise ArgumentError unless CountsResourceDefinition::OPERATORS.fetch(type, []).include?(operator)
|
||||||
|
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
|
||||||
|
|
||||||
|
expression = definition.expression(key)
|
||||||
|
blank = type == :string || type == :enum ?
|
||||||
|
"(#{ expression } IS NULL OR #{ expression } = '')" : "#{ expression } IS NULL"
|
||||||
|
if entry['blank']
|
||||||
|
@scope = @scope.where(Arel.sql(blank))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
values = [entry['value'], entry['end']]
|
||||||
|
raise ArgumentError unless values.all? { _1.nil? || _1.is_a?(String) }
|
||||||
|
return if values.all? { _1.nil? || _1 == '' }
|
||||||
|
|
||||||
|
parsed = values.map do |value|
|
||||||
|
next nil if value.nil? || value == ''
|
||||||
|
|
||||||
|
case type
|
||||||
|
when :integer
|
||||||
|
raise ArgumentError unless value.match?(/\A-?\d+\z/)
|
||||||
|
|
||||||
|
value.to_i
|
||||||
|
when :datetime then timestamp(value)
|
||||||
|
else value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if operator == 'range'
|
||||||
|
@scope = @scope.where("#{ expression } >= ?", parsed[0]) if parsed[0]
|
||||||
|
@scope = @scope.where("#{ expression } <= ?", parsed[1]) if parsed[1]
|
||||||
|
return
|
||||||
|
end
|
||||||
|
return unless parsed[0]
|
||||||
|
|
||||||
|
sql_operator = { 'eq' => '=', 'ne' => '<>', 'gt' => '>', 'lt' => '<',
|
||||||
|
'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator)
|
||||||
|
sql = "#{ expression } #{ sql_operator } ?"
|
||||||
|
sql = "(#{ sql } OR #{ blank })" if operator == 'ne'
|
||||||
|
@scope = @scope.where(sql, parsed[0])
|
||||||
|
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['tag_refs'], 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,208 @@
|
|||||||
|
# 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_at', '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 = 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 = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',')
|
||||||
|
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 = value.split(',', -1).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(
|
||||||
|
'original_created_at' => [
|
||||||
|
'post_versions.original_created_from', 'post_versions.original_created_before'],
|
||||||
|
'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,65 @@
|
|||||||
|
# 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'] }.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
|
||||||
|
end
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
# 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 = PostVersion.minimum(:created_at)
|
||||||
|
@tag_start = TagVersion.minimum(:created_at)
|
||||||
|
@history_loaded = true
|
||||||
|
end
|
||||||
|
result = { 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)
|
||||||
|
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) }"
|
||||||
|
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? ? [] : raw.split(',', -1).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,207 @@
|
|||||||
|
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)
|
||||||
|
raise ArgumentError if (params.keys - CountsTableQuery::KEYS - EXTRA).any?
|
||||||
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
|
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') ? params['children'].split(',') : nil
|
||||||
|
@order = params.fetch('order', '').split(',')
|
||||||
|
@child_sort = params.fetch('child_sort', '').split(',').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.slice(*CountsTableQuery::KEYS)
|
||||||
|
.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 = params.fetch('columns', default_columns.join(',')).split(',')
|
||||||
|
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).merge(
|
||||||
|
resource:, filters: @params.slice(*CountsTableQuery::KEYS),
|
||||||
|
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'
|
||||||
|
headers = @summary ? summary_headers : @headers
|
||||||
|
output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n")
|
||||||
|
end
|
||||||
|
started = true
|
||||||
|
end
|
||||||
|
(page[:cells] || 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 summary_headers
|
||||||
|
return @headers if @headers.any?
|
||||||
|
|
||||||
|
labels = @labels
|
||||||
|
@query.axes.all.map { labels.fetch(_1['field'], _1['field']) } +
|
||||||
|
[labels.fetch('count', 'count')]
|
||||||
|
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,252 @@
|
|||||||
|
# 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'].freeze
|
||||||
|
attr_reader :population, :axes, :page, :limit, :mode
|
||||||
|
|
||||||
|
def initialize(params)
|
||||||
|
raise ArgumentError if (params.keys - KEYS).any?
|
||||||
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
|
@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 = params.fetch('sort', '').split(',').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', 'original_created_at'] : []) +
|
||||||
|
(@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?(/\Ameasure_[0-9a-f]+\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
|
||||||
|
metadata.merge(mode == 'summary' ? summary : details)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def metadata
|
||||||
|
{ 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(', ') }"
|
||||||
|
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
|
||||||
|
if key == 'count' || key.start_with?('measure_')
|
||||||
|
condition = if key == 'count'
|
||||||
|
'TRUE'
|
||||||
|
else
|
||||||
|
measure_values(key).each_with_index.map do |value, offset|
|
||||||
|
"a#{ axes.rows.length + offset } <=> #{ @connection.quote(value) }"
|
||||||
|
end.join(' AND ').presence || 'TRUE'
|
||||||
|
end
|
||||||
|
["MAX(CASE WHEN #{ condition } THEN count END) AS sort#{ index }",
|
||||||
|
"sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }"]
|
||||||
|
elsif row_keys.include?(key)
|
||||||
|
[nil, "#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
extras = row_sort.filter_map(&:first)
|
||||||
|
order_rows = row_sort.map(&:last) + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" }
|
||||||
|
order_rows = ['singleton ASC'] if order_rows.empty?
|
||||||
|
group_sql = "SELECT #{ ([row_selection] + extras).join(', ') } FROM cells #{ row_group }"
|
||||||
|
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
|
||||||
|
{ group_count: total, column_values:, 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 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?
|
||||||
|
|
||||||
|
order = keys.map { "#{ _1 } IS NULL, #{ _1 }" }.join(', ')
|
||||||
|
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_values(key)
|
||||||
|
value = JSON.parse([key.delete_prefix('measure_')].pack('H*'))
|
||||||
|
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',
|
||||||
|
'original_created_at' => 'original_created_from' }
|
||||||
|
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
|
||||||
@@ -31,7 +31,7 @@ class MaterialVersionRecorder < VersionRecorder
|
|||||||
{ url: @record.url,
|
{ url: @record.url,
|
||||||
parent: @record.parent,
|
parent: @record.parent,
|
||||||
tag: @record.tag,
|
tag: @record.tag,
|
||||||
tag_name: @record.tag&.name,
|
tag_name: @record.tag&.name('ja'),
|
||||||
tag_category: @record.tag&.category,
|
tag_category: @record.tag&.category,
|
||||||
source_kind: @record.source_kind,
|
source_kind: @record.source_kind,
|
||||||
source_uri: @record.source_uri,
|
source_uri: @record.source_uri,
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
class NicoTagVersionRecorder < VersionRecorder
|
class NicoTagVersionRecorder < VersionRecorder
|
||||||
def self.record! tag:, event_type:, created_by_user:
|
def self.record! external_tag:, event_type:, created_by_user:
|
||||||
new(tag:, event_type:, created_by_user:).record!
|
new(external_tag:, event_type:, created_by_user:).record!
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize tag:, event_type:, created_by_user:
|
def initialize external_tag:, event_type:, created_by_user:
|
||||||
super(record: tag, event_type:, created_by_user:)
|
super(record: external_tag, event_type:, created_by_user:)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def version_class = NicoTagVersion
|
def version_class = NicoTagVersion
|
||||||
def version_association = :nico_tag_versions
|
def version_association = :nico_tag_versions
|
||||||
def record_key = :tag
|
def record_key = :external_tag
|
||||||
|
|
||||||
def snapshot_attributes
|
def snapshot_attributes
|
||||||
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
|
{ name: "#{ @record.platform }:#{ @record.name }",
|
||||||
|
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def tracks_version_no_on_record? = false
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
class PostBulkCreator
|
class PostBulkCreator
|
||||||
def initialize actor:, posts:, thumbnails:
|
def initialize actor:, posts:, thumbnails:, host: nil
|
||||||
@actor_id = actor.id
|
@actor_id = actor.id
|
||||||
@posts = posts
|
@posts = posts
|
||||||
@thumbnails = thumbnails
|
@thumbnails = thumbnails
|
||||||
|
@host = host
|
||||||
end
|
end
|
||||||
|
|
||||||
def run
|
def run locale
|
||||||
results = Array.new(@posts.length)
|
results = Array.new(@posts.length)
|
||||||
mutex = Mutex.new
|
mutex = Mutex.new
|
||||||
next_index = 0
|
next_index = 0
|
||||||
@@ -26,7 +27,7 @@ class PostBulkCreator
|
|||||||
break if index >= @posts.length
|
break if index >= @posts.length
|
||||||
|
|
||||||
attributes = @posts[index]
|
attributes = @posts[index]
|
||||||
results[index] = create_row(actor, attributes, index)
|
results[index] = create_row(locale, actor, attributes, index)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error(
|
Rails.logger.error(
|
||||||
"post_bulk_creator_worker_failure #{ { error: e.class.name,
|
"post_bulk_creator_worker_failure #{ { error: e.class.name,
|
||||||
@@ -60,11 +61,12 @@ class PostBulkCreator
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def create_row actor, attributes, index
|
def create_row locale, actor, attributes, index
|
||||||
preflight =
|
preflight =
|
||||||
PostCreatePreflight.new(
|
PostCreatePreflight.new(
|
||||||
attributes: attributes,
|
attributes: attributes,
|
||||||
thumbnail: thumbnail_for(index, attributes)).run
|
thumbnail: thumbnail_for(index, attributes),
|
||||||
|
host: @host).run(locale)
|
||||||
if preflight[:existing_post_id].present?
|
if preflight[:existing_post_id].present?
|
||||||
return {
|
return {
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
@@ -74,7 +76,7 @@ class PostBulkCreator
|
|||||||
|
|
||||||
post = PostCreator.new(
|
post = PostCreator.new(
|
||||||
actor: actor,
|
actor: actor,
|
||||||
attributes: normalised_attributes(attributes, preflight, index)).create!
|
attributes: normalised_attributes(attributes, preflight, index)).create!(locale)
|
||||||
result = {
|
result = {
|
||||||
status: 'created',
|
status: 'created',
|
||||||
post: { id: post.id } }
|
post: { id: post.id } }
|
||||||
@@ -195,6 +197,6 @@ class PostBulkCreator
|
|||||||
end
|
end
|
||||||
|
|
||||||
def compact_existing_post post
|
def compact_existing_post post
|
||||||
PostCompactRepr.base(post)
|
PostCompactRepr.base(post, host: @host)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,50 +8,50 @@ class PostCreatePlan
|
|||||||
@existing_tags_by_name = nil
|
@existing_tags_by_name = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def build!
|
def build! locale
|
||||||
direct_tag_specs, tag_sections = parse_direct_tag_specs
|
direct_tag_specs, tag_sections = parse_direct_tag_specs(locale)
|
||||||
default_tag_specs = build_default_tag_specs(direct_tag_specs)
|
default_tag_specs = build_default_tag_specs(locale, direct_tag_specs)
|
||||||
snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs)
|
snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs)
|
||||||
preload_existing_tags_by_name!(snapshot_tag_specs.map { _1[:name] })
|
preload_existing_tags_by_name!(locale, snapshot_tag_specs.map { _1[:name] })
|
||||||
validate_new_tag_specs!(snapshot_tag_specs)
|
validate_new_tag_specs!(locale, snapshot_tag_specs)
|
||||||
post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs)
|
post_tag_specs = expand_parent_tag_specs(locale, snapshot_tag_specs)
|
||||||
video_ms = normalise_video_ms(snapshot_tag_specs)
|
video_ms = normalise_video_ms(snapshot_tag_specs)
|
||||||
validate_video_sections!(video_ms, tag_sections)
|
validate_video_sections!(video_ms, tag_sections)
|
||||||
parent_post_ids = normalise_parent_post_ids
|
parent_post_ids = normalise_parent_post_ids
|
||||||
validate_parent_post_ids!(parent_post_ids)
|
validate_parent_post_ids!(parent_post_ids)
|
||||||
|
|
||||||
{
|
{ url: @attributes[:url],
|
||||||
url: @attributes[:url],
|
title: @attributes[:title].to_s,
|
||||||
title: @attributes[:title].to_s,
|
thumbnail_base: @attributes[:thumbnail_base].presence,
|
||||||
thumbnail_base: @attributes[:thumbnail_base].presence,
|
original_created_from: @attributes[:original_created_from].presence,
|
||||||
original_created_from: @attributes[:original_created_from].presence,
|
original_created_before: @attributes[:original_created_before].presence,
|
||||||
original_created_before: @attributes[:original_created_before].presence,
|
tags: serialised_tags(direct_tag_specs, tag_sections),
|
||||||
tags: serialised_tags(direct_tag_specs, tag_sections),
|
display_tags: display_tags(direct_tag_specs, tag_sections),
|
||||||
display_tags: display_tags(direct_tag_specs, tag_sections),
|
duration: @attributes[:duration].to_s,
|
||||||
duration: @attributes[:duration].to_s,
|
video_ms: video_ms,
|
||||||
video_ms: video_ms,
|
parent_post_ids: parent_post_ids.join(' '),
|
||||||
parent_post_ids: parent_post_ids.join(' '),
|
direct_tag_specs: direct_tag_specs,
|
||||||
direct_tag_specs: direct_tag_specs,
|
default_tag_specs: default_tag_specs,
|
||||||
default_tag_specs: default_tag_specs,
|
snapshot_tag_specs: snapshot_tag_specs,
|
||||||
snapshot_tag_specs: snapshot_tag_specs,
|
post_tag_specs: post_tag_specs,
|
||||||
post_tag_specs: post_tag_specs,
|
tag_sections: tag_sections,
|
||||||
tag_sections: tag_sections,
|
normalised_parent_post_ids: parent_post_ids }
|
||||||
normalised_parent_post_ids: parent_post_ids }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def tag_names = @attributes[:tags].to_s.split
|
def tag_names = @attributes[:tags].to_s.split
|
||||||
|
|
||||||
def parse_direct_tag_specs
|
def parse_direct_tag_specs locale
|
||||||
tag_sections = { }
|
tag_sections = { }
|
||||||
direct_tag_specs = []
|
direct_tag_specs = []
|
||||||
|
|
||||||
tag_names.each do |raw_name|
|
tag_names.each do |raw_name|
|
||||||
tag_name, category, sections = parse_raw_tag_name(raw_name)
|
tag_name, category, sections = parse_raw_tag_name(locale, raw_name)
|
||||||
existing_tag = existing_tags_by_name[tag_name]
|
existing_tag = existing_tags_by_name(locale)[tag_name]
|
||||||
raise Tag::NicoTagNormalisationError if existing_tag&.nico?
|
if existing_tag&.deprecated?
|
||||||
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated?
|
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name(locale.language_code)]
|
||||||
|
end
|
||||||
|
|
||||||
direct_tag_specs << {
|
direct_tag_specs << {
|
||||||
name: tag_name,
|
name: tag_name,
|
||||||
@@ -67,7 +67,7 @@ class PostCreatePlan
|
|||||||
[merge_tag_specs(direct_tag_specs), tag_sections]
|
[merge_tag_specs(direct_tag_specs), tag_sections]
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_raw_tag_name raw_name
|
def parse_raw_tag_name locale, raw_name
|
||||||
name = raw_name.to_s
|
name = raw_name.to_s
|
||||||
prefix, category =
|
prefix, category =
|
||||||
Tag::CATEGORY_PREFIXES.find {
|
Tag::CATEGORY_PREFIXES.find {
|
||||||
@@ -76,7 +76,7 @@ class PostCreatePlan
|
|||||||
name = name.sub(/\A#{ prefix }/i, '')
|
name = name.sub(/\A#{ prefix }/i, '')
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
|
while match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)
|
||||||
name = "#{ match[1] }#{ match[4] }"
|
name = "#{ match[1] }#{ match[4] }"
|
||||||
next if match[2].empty? && match[3].empty?
|
next if match[2].empty? && match[3].empty?
|
||||||
|
|
||||||
@@ -85,38 +85,37 @@ class PostCreatePlan
|
|||||||
end_raw: match[3],
|
end_raw: match[3],
|
||||||
tag_name: name)
|
tag_name: name)
|
||||||
end
|
end
|
||||||
raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']')
|
if name.include?('[') || name.include?(']')
|
||||||
|
raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
|
||||||
|
end
|
||||||
|
|
||||||
[TagName.canonicalise(name).first, category&.to_sym, sections]
|
[resolved_tag_name(locale, name), category&.to_sym, sections]
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_default_tag_specs direct_tag_specs
|
def build_default_tag_specs locale, direct_tag_specs
|
||||||
default_tag_specs = []
|
default_tag_specs = []
|
||||||
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
|
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
|
||||||
default_tag_specs << {
|
default_tag_specs << { name: TAGME_TAG_NAME, category: :meta }
|
||||||
name: TAGME_TAG_NAME,
|
|
||||||
category: :meta }
|
|
||||||
end
|
end
|
||||||
if direct_tag_specs.none? { deerjikist_tag_spec?(_1) }
|
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) }
|
||||||
default_tag_specs << {
|
default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta }
|
||||||
name: NO_DEERJIKIST_TAG_NAME,
|
|
||||||
category: :meta }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
default_tag_specs
|
default_tag_specs
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_new_tag_specs! specs
|
def validate_new_tag_specs! locale, specs
|
||||||
Array(specs).each do |spec|
|
Array(specs).each do |spec|
|
||||||
next if existing_tags_by_name.key?(spec[:name])
|
next if existing_tags_by_name(locale).key?(spec[:name])
|
||||||
|
|
||||||
validate_new_tag_spec!(spec)
|
validate_new_tag_spec!(locale, spec)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_new_tag_spec! spec
|
def validate_new_tag_spec! locale, spec
|
||||||
tag_name = TagName.new(name: spec[:name])
|
tag = Tag.new(category: spec[:category])
|
||||||
tag = Tag.new(category: spec[:category], tag_name:)
|
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?
|
return if tag_name.valid? && tag.valid?
|
||||||
|
|
||||||
post = Post.new
|
post = Post.new
|
||||||
@@ -131,20 +130,22 @@ class PostCreatePlan
|
|||||||
raise ActiveRecord::RecordInvalid, post
|
raise ActiveRecord::RecordInvalid, post
|
||||||
end
|
end
|
||||||
|
|
||||||
def expand_parent_tag_specs snapshot_tag_specs
|
def expand_parent_tag_specs locale, snapshot_tag_specs
|
||||||
existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] }
|
existing_snapshot_tags = snapshot_tag_specs.filter_map do
|
||||||
|
existing_tags_by_name(locale)[_1[:name]]
|
||||||
|
end
|
||||||
expanded_parent_specs =
|
expanded_parent_specs =
|
||||||
Tag.expand_parent_tags(existing_snapshot_tags)
|
Tag.expand_parent_tags(existing_snapshot_tags)
|
||||||
.reject(&:deprecated?)
|
.reject(&:deprecated?)
|
||||||
.map { |tag|
|
.map { |tag|
|
||||||
{
|
{ name: tag.name(locale.language_code),
|
||||||
name: tag.name,
|
category: tag.category.to_sym }
|
||||||
category: tag.category.to_sym } }
|
}
|
||||||
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
|
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_tag_specs specs
|
def merge_tag_specs specs
|
||||||
specs.each_with_object({ }) do |spec, merged|
|
specs.each_with_object({ }) { |spec, merged|
|
||||||
merged[spec[:name]] =
|
merged[spec[:name]] =
|
||||||
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
|
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
|
||||||
merged[spec[:name]]
|
merged[spec[:name]]
|
||||||
@@ -153,36 +154,41 @@ class PostCreatePlan
|
|||||||
name: spec[:name],
|
name: spec[:name],
|
||||||
category: spec[:category] }
|
category: spec[:category] }
|
||||||
end
|
end
|
||||||
end.values.sort_by { _1[:name] }
|
}.values.sort_by { _1[:name] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def existing_tags_by_name
|
def existing_tags_by_name locale
|
||||||
@existing_tags_by_name ||= begin
|
@existing_tags_by_name ||= begin
|
||||||
names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq
|
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
|
||||||
Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
def preload_existing_tags_by_name! names
|
def preload_existing_tags_by_name! locale, names
|
||||||
wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq
|
wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq
|
||||||
missing_names = wanted_names - existing_tags_by_name.keys
|
missing_names = wanted_names - existing_tags_by_name(locale).keys
|
||||||
return if missing_names.empty?
|
return if missing_names.empty?
|
||||||
|
|
||||||
existing_tags_by_name.merge!(
|
existing_tags_by_name(locale).merge!(
|
||||||
Tag.joins(:tag_name)
|
TagName.where(language_code: locale.language_code,
|
||||||
.where(tag_names: { name: missing_names })
|
primary_flg: true,
|
||||||
.index_by(&:name))
|
name: missing_names)
|
||||||
|
.includes(:tag)
|
||||||
|
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag })
|
||||||
end
|
end
|
||||||
|
|
||||||
def canonical_tag_name_without_sections raw_name
|
def canonical_tag_name_without_sections locale, raw_name
|
||||||
name, = parse_raw_tag_name(raw_name)
|
name, = parse_raw_tag_name(locale, raw_name)
|
||||||
name
|
name
|
||||||
end
|
end
|
||||||
|
|
||||||
def deerjikist_tag_spec? spec
|
def deerjikist_tag_spec? locale, spec
|
||||||
return true if spec[:category] == :deerjikist
|
return true if spec[:category] == :deerjikist
|
||||||
|
|
||||||
existing_tags_by_name[spec[:name]]&.deerjikist?
|
existing_tags_by_name(locale)[spec[:name]]&.deerjikist?
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalise_parent_post_ids
|
def normalise_parent_post_ids
|
||||||
@@ -201,7 +207,7 @@ class PostCreatePlan
|
|||||||
|
|
||||||
def serialised_tags direct_tag_specs, tag_sections
|
def serialised_tags direct_tag_specs, tag_sections
|
||||||
direct_tag_specs.map { |spec|
|
direct_tag_specs.map { |spec|
|
||||||
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) }.join }"
|
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { section_literal(_1) }.join }"
|
||||||
}.sort.join(' ')
|
}.sort.join(' ')
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -210,10 +216,15 @@ class PostCreatePlan
|
|||||||
{
|
{
|
||||||
name: spec[:name],
|
name: spec[:name],
|
||||||
category: spec[:category].to_s,
|
category: spec[:category].to_s,
|
||||||
section_literals: tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) } }
|
section_literals: tag_sections[spec[:name]].to_a.map { section_literal(_1) } }
|
||||||
}.sort_by { _1[:name] }
|
}.sort_by { _1[:name] }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def section_literal range
|
||||||
|
begin_ms, end_ms = range
|
||||||
|
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
|
||||||
|
end
|
||||||
|
|
||||||
def normalise_video_ms snapshot_tag_specs
|
def normalise_video_ms snapshot_tag_specs
|
||||||
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
|
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
|
||||||
|
|
||||||
@@ -254,4 +265,13 @@ class PostCreatePlan
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolved_tag_name locale, name
|
||||||
|
tag_name = TagName.find_by(language_code: locale.language_code, name:)
|
||||||
|
return name if !(tag_name) || tag_name.primary?
|
||||||
|
|
||||||
|
TagName.find_by(language_code: locale.language_code,
|
||||||
|
tag_id: tag_name.tag_id,
|
||||||
|
primary_flg: true)&.name || name
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ class PostCreatePreflight
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize attributes:, thumbnail: nil
|
def initialize attributes:, thumbnail: nil, host: nil
|
||||||
@attributes = attributes.symbolize_keys
|
@attributes = attributes.symbolize_keys
|
||||||
@thumbnail = thumbnail
|
@thumbnail = thumbnail
|
||||||
|
@host = host
|
||||||
end
|
end
|
||||||
|
|
||||||
def run
|
def run locale
|
||||||
preview = PostImportPreviewer.new.preview_rows(
|
preview = PostImportPreviewer.new.preview_rows(
|
||||||
rows: [preview_row],
|
rows: [preview_row],
|
||||||
|
locale:,
|
||||||
fetch_metadata: false).first
|
fetch_metadata: false).first
|
||||||
if preview[:existing_post_id].present?
|
if preview[:existing_post_id].present?
|
||||||
return {
|
return {
|
||||||
@@ -52,7 +54,7 @@ class PostCreatePreflight
|
|||||||
original_created_from: preview[:attributes]['original_created_from'],
|
original_created_from: preview[:attributes]['original_created_from'],
|
||||||
original_created_before: preview[:attributes]['original_created_before'],
|
original_created_before: preview[:attributes]['original_created_before'],
|
||||||
duration: preview[:attributes]['duration'],
|
duration: preview[:attributes]['duration'],
|
||||||
video_ms: preview[:attributes]['video_ms'] }).build!
|
video_ms: preview[:attributes]['video_ms'] }).build!(locale)
|
||||||
|
|
||||||
{
|
{
|
||||||
url: plan[:url],
|
url: plan[:url],
|
||||||
@@ -124,7 +126,7 @@ class PostCreatePreflight
|
|||||||
return nil if post_id.blank?
|
return nil if post_id.blank?
|
||||||
|
|
||||||
post = Post.with_attached_thumbnail.find_by(id: post_id)
|
post = Post.with_attached_thumbnail.find_by(id: post_id)
|
||||||
PostCompactRepr.base(post)
|
PostCompactRepr.base(post, host: @host)
|
||||||
end
|
end
|
||||||
|
|
||||||
def final_field_warnings field_warnings
|
def final_field_warnings field_warnings
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class PostCreator
|
|||||||
@field_warnings = { }
|
@field_warnings = { }
|
||||||
end
|
end
|
||||||
|
|
||||||
def create!
|
def create! locale
|
||||||
thumbnail_attachment = prepare_thumbnail_attachment
|
thumbnail_attachment = prepare_thumbnail_attachment
|
||||||
post = Post.new(title: @attributes[:title].presence,
|
post = Post.new(title: @attributes[:title].presence,
|
||||||
url: @attributes[:url],
|
url: @attributes[:url],
|
||||||
@@ -21,14 +21,17 @@ class PostCreator
|
|||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
post.save!
|
post.save!
|
||||||
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
|
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
|
||||||
snapshot_tags = planned_snapshot_tags
|
snapshot_tags = planned_snapshot_tags(locale)
|
||||||
post_tags = planned_post_tags
|
post_tags = planned_post_tags(locale)
|
||||||
sections = planned_sections
|
sections = planned_sections(locale)
|
||||||
TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
|
TagVersioning.record_tag_snapshots!(
|
||||||
post.video_ms = planned_video_ms
|
snapshot_tags,
|
||||||
|
created_by_user: @actor,
|
||||||
|
language_code: locale.language_code)
|
||||||
|
post.video_ms = planned_video_ms(locale)
|
||||||
post.save!
|
post.save!
|
||||||
sync_post_tags!(post, post_tags, sections)
|
sync_post_tags!(post, post_tags, sections)
|
||||||
sync_parent_posts!(post, planned_parent_post_ids)
|
sync_parent_posts!(post, planned_parent_post_ids(locale))
|
||||||
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
|
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
|
||||||
end
|
end
|
||||||
post
|
post
|
||||||
@@ -45,69 +48,73 @@ class PostCreator
|
|||||||
thumbnail_base: @attributes[:thumbnail_base].presence)
|
thumbnail_base: @attributes[:thumbnail_base].presence)
|
||||||
end
|
end
|
||||||
|
|
||||||
def planned_snapshot_tags = planned_create_attributes[:snapshot_tags]
|
def planned_snapshot_tags(locale) = planned_create_attributes(locale)[:snapshot_tags]
|
||||||
|
|
||||||
def planned_post_tags = planned_create_attributes[:post_tags]
|
def planned_post_tags(locale) = planned_create_attributes(locale)[:post_tags]
|
||||||
|
|
||||||
def planned_sections = planned_create_attributes[:tag_sections]
|
def planned_sections(locale) = planned_create_attributes(locale)[:tag_sections]
|
||||||
|
|
||||||
def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids]
|
def planned_parent_post_ids locale
|
||||||
|
planned_create_attributes(locale)[:normalised_parent_post_ids]
|
||||||
def planned_video_ms
|
|
||||||
planned_create_attributes[:video_ms]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def planned_create_attributes
|
def planned_video_ms locale
|
||||||
|
planned_create_attributes(locale)[:video_ms]
|
||||||
|
end
|
||||||
|
|
||||||
|
def planned_create_attributes locale
|
||||||
@planned_create_attributes ||= begin
|
@planned_create_attributes ||= begin
|
||||||
if @attributes.key?(:snapshot_tag_specs)
|
if @attributes.key?(:snapshot_tag_specs)
|
||||||
snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || [])
|
snapshot_tags = materialise_tags(locale, @attributes[:snapshot_tag_specs] || [])
|
||||||
post_tags = materialise_tags(@attributes[:post_tag_specs] || [])
|
post_tags = materialise_tags(locale, @attributes[:post_tag_specs] || [])
|
||||||
{
|
{
|
||||||
snapshot_tags: snapshot_tags,
|
snapshot_tags: snapshot_tags,
|
||||||
post_tags: post_tags,
|
post_tags: post_tags,
|
||||||
tag_sections: materialise_sections(
|
tag_sections: materialise_sections(
|
||||||
@attributes[:tag_sections] || { },
|
@attributes[:tag_sections] || { },
|
||||||
snapshot_tags,
|
snapshot_tags,
|
||||||
post_tags),
|
post_tags,
|
||||||
|
locale),
|
||||||
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
|
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
|
||||||
video_ms: @attributes[:video_ms] }
|
video_ms: @attributes[:video_ms] }
|
||||||
else
|
else
|
||||||
build_materialised_plan
|
build_materialised_plan(locale)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_materialised_plan
|
def build_materialised_plan locale
|
||||||
plan = PostCreatePlan.new(attributes: @attributes).build!
|
plan = PostCreatePlan.new(attributes: @attributes).build!(locale)
|
||||||
snapshot_tags = materialise_tags(plan[:snapshot_tag_specs] || [])
|
snapshot_tags = materialise_tags(locale, plan[:snapshot_tag_specs] || [])
|
||||||
post_tags = materialise_tags(plan[:post_tag_specs] || [])
|
post_tags = materialise_tags(locale, plan[:post_tag_specs] || [])
|
||||||
{
|
{
|
||||||
snapshot_tags: snapshot_tags,
|
snapshot_tags: snapshot_tags,
|
||||||
post_tags: post_tags,
|
post_tags: post_tags,
|
||||||
tag_sections: materialise_sections(
|
tag_sections: materialise_sections(
|
||||||
plan[:tag_sections] || { },
|
plan[:tag_sections] || { },
|
||||||
snapshot_tags,
|
snapshot_tags,
|
||||||
post_tags),
|
post_tags,
|
||||||
|
locale),
|
||||||
normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [],
|
normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [],
|
||||||
video_ms: plan[:video_ms] }
|
video_ms: plan[:video_ms] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def materialise_tags specs
|
def materialise_tags locale, specs
|
||||||
Array(specs).each_with_object({ }) do |spec, tags|
|
Array(specs).each_with_object({ }) do |spec, tags|
|
||||||
name = spec[:name] || spec['name']
|
name = spec[:name] || spec['name']
|
||||||
category = spec[:category] || spec['category']
|
category = spec[:category] || spec['category']
|
||||||
next if name.blank? || category.blank?
|
next if name.blank? || category.blank?
|
||||||
|
|
||||||
tag = Tag.find_or_create_by_tag_name!(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
|
tag.update!(category:) if tag.category.to_sym != category.to_sym
|
||||||
tags[name] ||= tag
|
tags[name] ||= tag
|
||||||
end.values
|
end.values
|
||||||
end
|
end
|
||||||
|
|
||||||
def materialise_sections sections_by_name, snapshot_tags, post_tags
|
def materialise_sections sections_by_name, snapshot_tags, post_tags, locale = Locale.nipponese
|
||||||
tags_by_name = post_tags.index_by(&:name)
|
tags_by_name = post_tags.index_by { _1.name(locale.language_code) }
|
||||||
snapshot_tags.each do |tag|
|
snapshot_tags.each do |tag|
|
||||||
tags_by_name[tag.name] ||= tag
|
tags_by_name[tag.name(locale.language_code)] ||= tag
|
||||||
end
|
end
|
||||||
|
|
||||||
sections_by_name.each_with_object({ }) do |(tag_name, ranges), sections|
|
sections_by_name.each_with_object({ }) do |(tag_name, ranges), sections|
|
||||||
@@ -121,9 +128,11 @@ class PostCreator
|
|||||||
def sync_post_tags! post, desired_tags, sections
|
def sync_post_tags! post, desired_tags, sections
|
||||||
desired_ids = desired_tags.map(&:id).to_set
|
desired_ids = desired_tags.map(&:id).to_set
|
||||||
current_ids = post.tags.pluck(:id).to_set
|
current_ids = post.tags.pluck(:id).to_set
|
||||||
|
|
||||||
Tag.where(id: desired_ids - current_ids).find_each do |tag|
|
Tag.where(id: desired_ids - current_ids).find_each do |tag|
|
||||||
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
PostTagSection.where(post_id: post.id).destroy_all
|
PostTagSection.where(post_id: post.id).destroy_all
|
||||||
sections.each do |tag_id, ranges|
|
sections.each do |tag_id, ranges|
|
||||||
ranges.each do |begin_ms, end_ms|
|
ranges.each do |begin_ms, end_ms|
|
||||||
@@ -133,10 +142,9 @@ class PostCreator
|
|||||||
end_ms:)
|
end_ms:)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
PostTag.where(post_id: post.id,
|
PostTag.where(post_id: post.id,
|
||||||
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag|
|
tag_id: (current_ids - desired_ids).to_a).destroy_all
|
||||||
post_tag.discard_by!(@actor)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_parent_posts! post, ids
|
def sync_parent_posts! post, ids
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ require 'timeout'
|
|||||||
|
|
||||||
class PostImportPreviewer
|
class PostImportPreviewer
|
||||||
FIELDS = [
|
FIELDS = [
|
||||||
'title',
|
'title',
|
||||||
'thumbnail_base',
|
'thumbnail_base',
|
||||||
'original_created_from',
|
'original_created_from',
|
||||||
'original_created_before',
|
'original_created_before',
|
||||||
'video_ms',
|
'video_ms',
|
||||||
'duration',
|
'duration',
|
||||||
'tags',
|
'tags',
|
||||||
'parent_post_ids'].freeze
|
'parent_post_ids'].freeze
|
||||||
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
|
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
|
||||||
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
|
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
|
||||||
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
|
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
|
||||||
METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze
|
METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze
|
||||||
|
|
||||||
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
|
def preview_rows rows:, locale: Locale.nipponese, fetch_metadata: true, metadata_cache: { }
|
||||||
prepared_rows = rows.map { prepare_row(_1) }
|
prepared_rows = rows.map { prepare_row(_1) }
|
||||||
url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally
|
url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally
|
||||||
existing_posts =
|
existing_posts =
|
||||||
@@ -30,13 +30,14 @@ class PostImportPreviewer
|
|||||||
url_counts)
|
url_counts)
|
||||||
known_tags =
|
known_tags =
|
||||||
preload_known_tags(
|
preload_known_tags(
|
||||||
|
locale,
|
||||||
prepared_rows,
|
prepared_rows,
|
||||||
fetch_metadata,
|
fetch_metadata,
|
||||||
metadata_cache,
|
metadata_cache,
|
||||||
existing_posts,
|
existing_posts,
|
||||||
url_counts)
|
url_counts)
|
||||||
prepared_rows.map { |row|
|
prepared_rows.map { |row|
|
||||||
preview_row(row,
|
preview_row(locale, row,
|
||||||
fetch_metadata:,
|
fetch_metadata:,
|
||||||
metadata_cache:,
|
metadata_cache:,
|
||||||
existing_posts:,
|
existing_posts:,
|
||||||
@@ -59,7 +60,7 @@ class PostImportPreviewer
|
|||||||
source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url))
|
source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url))
|
||||||
end
|
end
|
||||||
|
|
||||||
def preview_row row,
|
def preview_row locale, row,
|
||||||
fetch_metadata:,
|
fetch_metadata:,
|
||||||
metadata_cache:,
|
metadata_cache:,
|
||||||
existing_posts:,
|
existing_posts:,
|
||||||
@@ -124,9 +125,9 @@ class PostImportPreviewer
|
|||||||
|
|
||||||
attributes['url'] = url
|
attributes['url'] = url
|
||||||
validate_basic_data(attributes, validation_errors)
|
validate_basic_data(attributes, validation_errors)
|
||||||
validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
|
validate_preview_tags(locale,
|
||||||
|
merged_tags(tag_sources, provenance['tags']),
|
||||||
validation_errors,
|
validation_errors,
|
||||||
field_warnings,
|
|
||||||
known_tags)
|
known_tags)
|
||||||
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
|
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
|
||||||
attributes.delete('url')
|
attributes.delete('url')
|
||||||
@@ -226,15 +227,15 @@ class PostImportPreviewer
|
|||||||
end
|
end
|
||||||
{ data:, warnings:, validation_errors: { } }
|
{ data:, warnings:, validation_errors: { } }
|
||||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||||
|
payload = { error: e.class.name, message: e.message }
|
||||||
Rails.logger.info(
|
Rails.logger.info(
|
||||||
"post_import_metadata_fetch_unsafe_url "\
|
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
|
||||||
"#{ { error: e.class.name, message: e.message }.to_json }")
|
|
||||||
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
||||||
rescue Preview::HttpFetcher::FetchFailed,
|
rescue Preview::HttpFetcher::FetchFailed,
|
||||||
Preview::HttpFetcher::ResponseTooLarge => e
|
Preview::HttpFetcher::ResponseTooLarge => e
|
||||||
|
payload = { error: e.class.name, message: e.message }
|
||||||
Rails.logger.info(
|
Rails.logger.info(
|
||||||
"post_import_metadata_fetch_failure "\
|
"post_import_metadata_fetch_failure #{ payload.to_json }")
|
||||||
"#{ { error: e.class.name, message: e.message }.to_json }")
|
|
||||||
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -336,18 +337,18 @@ class PostImportPreviewer
|
|||||||
def safe_fetch_metadata url
|
def safe_fetch_metadata url
|
||||||
fetch_metadata(url)
|
fetch_metadata(url)
|
||||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||||
|
payload = { error: e.class.name, message: e.message }
|
||||||
Rails.logger.info(
|
Rails.logger.info(
|
||||||
"post_import_metadata_fetch_unsafe_url "\
|
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
|
||||||
"#{ { error: e.class.name, message: e.message }.to_json }")
|
|
||||||
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
payload = { error: e.class.name, message: e.message }
|
||||||
Rails.logger.error(
|
Rails.logger.error(
|
||||||
"post_import_metadata_fetch_unexpected_failure "\
|
"post_import_metadata_fetch_unexpected_failure #{ payload.to_json }")
|
||||||
"#{ { error: e.class.name, message: e.message }.to_json }")
|
|
||||||
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
||||||
end
|
end
|
||||||
|
|
||||||
def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
|
def preload_known_tags locale, prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
|
||||||
names = prepared_rows.flat_map { |row|
|
names = prepared_rows.flat_map { |row|
|
||||||
attributes = initial_attributes(row)
|
attributes = initial_attributes(row)
|
||||||
provenance = initial_provenance(row)
|
provenance = initial_provenance(row)
|
||||||
@@ -359,15 +360,13 @@ class PostImportPreviewer
|
|||||||
metadata = metadata_for(row[:normal_url], metadata_cache)
|
metadata = metadata_for(row[:normal_url], metadata_cache)
|
||||||
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
|
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
|
||||||
end
|
end
|
||||||
preview_tag_names(merged_tags(tag_sources, provenance['tags']))
|
preview_tag_names(locale, merged_tags(tag_sources, provenance['tags']))
|
||||||
}.compact.uniq
|
}.compact.uniq
|
||||||
return { } if names.empty?
|
return { } if names.empty?
|
||||||
|
|
||||||
Tag.joins(:tag_name)
|
TagName.where(language_code: locale.language_code, primary_flg: true, name: names)
|
||||||
.where(tag_names: { name: names })
|
.includes(:tag)
|
||||||
.includes(:tag_name)
|
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
|
||||||
.to_a
|
|
||||||
.index_by(&:name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def preload_parent_ids prepared_rows
|
def preload_parent_ids prepared_rows
|
||||||
@@ -438,19 +437,21 @@ class PostImportPreviewer
|
|||||||
sources['automatic'].to_s
|
sources['automatic'].to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def preview_tag_names raw
|
def preview_tag_names locale, raw
|
||||||
names = raw.to_s.split
|
names = raw.to_s.split
|
||||||
return [] if names.empty?
|
return [] if names.empty?
|
||||||
if names.any? { _1.downcase.start_with?('nico:') }
|
if names.any? { _1.downcase.start_with?('nico:') }
|
||||||
return []
|
return []
|
||||||
end
|
end
|
||||||
|
|
||||||
names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
|
names.map { |name|
|
||||||
|
TagName.canonicalise(locale, name.sub(/\[.*\]\z/, '')).first
|
||||||
|
}
|
||||||
rescue Tag::SectionLiteralParseError
|
rescue Tag::SectionLiteralParseError
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_preview_tags raw, errors, field_warnings, known_tags
|
def validate_preview_tags locale, raw, errors, known_tags
|
||||||
names = raw.to_s.split
|
names = raw.to_s.split
|
||||||
return if names.empty?
|
return if names.empty?
|
||||||
if names.any? { _1.downcase.start_with?('nico:') }
|
if names.any? { _1.downcase.start_with?('nico:') }
|
||||||
@@ -458,15 +459,12 @@ class PostImportPreviewer
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
parsed = names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
|
parsed = names.map { |name|
|
||||||
|
TagName.canonicalise(locale, name.sub(/\[.*\]\z/, '')).first
|
||||||
|
}
|
||||||
existing = parsed.filter_map { known_tags[_1] }
|
existing = parsed.filter_map { known_tags[_1] }
|
||||||
deprecated = existing.select(&:deprecated?).map(&:name)
|
deprecated = existing.select(&:deprecated?).map { _1.name(locale.language_code) }
|
||||||
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
|
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
|
||||||
known = existing.reject(&:deprecated?).map(&:name)
|
|
||||||
new_tags = parsed.uniq - known
|
|
||||||
if new_tags.present?
|
|
||||||
add_field_warning!(field_warnings, 'tags', "新規タグを作成します: #{ new_tags.join(' ') }")
|
|
||||||
end
|
|
||||||
rescue Tag::SectionLiteralParseError
|
rescue Tag::SectionLiteralParseError
|
||||||
errors[:tags] = ['タグ区間の記法が不正です.']
|
errors[:tags] = ['タグ区間の記法が不正です.']
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ require 'date'
|
|||||||
|
|
||||||
class PostMetadataFetcher
|
class PostMetadataFetcher
|
||||||
TIMESTAMP_PATTERN =
|
TIMESTAMP_PATTERN =
|
||||||
Regexp.new(
|
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2})
|
||||||
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
|
(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?
|
||||||
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
|
(Z|[+-]\d{2}:?\d{2})?\z/x
|
||||||
'(Z|[+-]\d{2}:?\d{2})?\z')
|
|
||||||
|
|
||||||
def self.fetch raw_url
|
def self.fetch raw_url
|
||||||
uri, = Preview::UrlSafety.validate(raw_url)
|
uri, = Preview::UrlSafety.validate(raw_url)
|
||||||
@@ -48,9 +47,9 @@ class PostMetadataFetcher
|
|||||||
|
|
||||||
existing_tags =
|
existing_tags =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_name)
|
.joins(:tag_names)
|
||||||
.where(tag_names: { name: names })
|
.where(tag_names: { language_code: 'ja', primary_flg: true, name: names })
|
||||||
.index_by(&:name)
|
.index_by { _1.name('ja') }
|
||||||
|
|
||||||
names.map { |name|
|
names.map { |name|
|
||||||
tag = existing_tags[name]
|
tag = existing_tags[name]
|
||||||
|
|||||||
@@ -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 if raw.blank?
|
||||||
|
|
||||||
|
references = JSON.parse(raw)
|
||||||
|
valid = references.is_a?(Array) && references.all? do |reference|
|
||||||
|
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
|
||||||
|
|
||||||
|
ids = [reference['tag_id'], reference['external_tag_id']].compact
|
||||||
|
((ids.any? || reference['missing'] == true) &&
|
||||||
|
ids.all? { _1.is_a?(Integer) && _1.positive? } &&
|
||||||
|
(!reference.key?('missing') || (reference['missing'] == true && ids.empty?)) &&
|
||||||
|
(!reference.key?('negative') || [true, false].include?(reference['negative'])))
|
||||||
|
end
|
||||||
|
raise 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
|
||||||
@@ -25,6 +25,7 @@ class PostVersionRecorder < VersionRecorder
|
|||||||
thumbnail_base: @record.thumbnail_base,
|
thumbnail_base: @record.thumbnail_base,
|
||||||
video_ms: @record.video_ms,
|
video_ms: @record.video_ms,
|
||||||
tags: @record.snapshot_tag_names.join(' '),
|
tags: @record.snapshot_tag_names.join(' '),
|
||||||
|
tags_json: @record.snapshot_tags_json,
|
||||||
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
|
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
|
||||||
original_created_from: @record.original_created_from,
|
original_created_from: @record.original_created_from,
|
||||||
original_created_before: @record.original_created_before }
|
original_created_before: @record.original_created_before }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
class TagDescriptionOperation
|
||||||
|
class Conflict < StandardError
|
||||||
|
;
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create! locale, tag:, body:, created_by_user:, message: nil
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
tag.lock!
|
||||||
|
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
|
||||||
|
tag_id: tag.id, language_code: locale.language_code)
|
||||||
|
|
||||||
|
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! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
tag.lock!
|
||||||
|
description = TagDescription.lock.find_by!(
|
||||||
|
tag_id: tag.id, language_code: locale.language_code)
|
||||||
|
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!(description:, body:, user: updated_by_user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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!(
|
||||||
|
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
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
require 'kramdown'
|
||||||
|
|
||||||
|
class TagGroupMarkdownParser
|
||||||
|
PREFIX = TagGroupRepr::PREFIX
|
||||||
|
|
||||||
|
class InvalidDocument < StandardError
|
||||||
|
;
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.parse(locale, body:, group: nil)
|
||||||
|
new(body:, group:).parse(locale)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(body:, group:)
|
||||||
|
@body = body.to_s.gsub(/\r\n?/, "\n")
|
||||||
|
@group = group
|
||||||
|
end
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
description_parts = []
|
||||||
|
headings = []
|
||||||
|
|
||||||
|
root.children.each do |node|
|
||||||
|
case node.type
|
||||||
|
when :blank
|
||||||
|
next
|
||||||
|
when :header
|
||||||
|
level = node.options.fetch(:level)
|
||||||
|
name = inline_text(node).strip
|
||||||
|
invalid!('Section 名は必須です.') if name.blank?
|
||||||
|
|
||||||
|
headings.pop while headings.any? && headings.last.fetch(:level) >= level
|
||||||
|
parent = headings.last&.fetch(:node)
|
||||||
|
section = { 'section_name' => name, 'description' => nil,
|
||||||
|
'children' => [] }
|
||||||
|
(parent ? parent.fetch('children') : placements) << section
|
||||||
|
headings << { level:, node: section }
|
||||||
|
when :p
|
||||||
|
text = inline_markdown(node).strip
|
||||||
|
next if text.empty?
|
||||||
|
|
||||||
|
if headings.any?
|
||||||
|
section = headings.last.fetch(:node)
|
||||||
|
section['description'] = [section['description'], text].compact.join("\n\n")
|
||||||
|
else
|
||||||
|
description_parts << text
|
||||||
|
end
|
||||||
|
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(locale, _1) })
|
||||||
|
else
|
||||||
|
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
description = description_parts.presence&.join("\n\n")
|
||||||
|
{ description:, placements:, members: build_members(placements),
|
||||||
|
compatibility_body: @body.sub(/\n+\z/, '') }
|
||||||
|
rescue Kramdown::Error => error
|
||||||
|
invalid!("Markdown を解析できません:#{ error.message }")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def parse_list_item locale, item
|
||||||
|
invalid!('リスト項目ではありません.') unless item.type == :li
|
||||||
|
invalid!('未対応のリスト項目があります.') if item.attr.any?
|
||||||
|
|
||||||
|
nested, own = item.children.partition { |child| child.type == :ul }
|
||||||
|
invalid!('順序付きリストは使用できません.') if own.any? { _1.type == :ol }
|
||||||
|
own.reject! { _1.type == :blank }
|
||||||
|
invalid!('未対応のリスト項目があります.') unless own.all? do |child|
|
||||||
|
[:p, :text].include?(child.type)
|
||||||
|
end
|
||||||
|
invalid!('リスト項目は一つの段落で指定してください.') if own.size != 1
|
||||||
|
|
||||||
|
label = own.map { inline_text(_1) }.join(' ').strip
|
||||||
|
invalid!('空のリスト項目は使用できません.') if label.blank?
|
||||||
|
|
||||||
|
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(locale, _1) }
|
||||||
|
end
|
||||||
|
node
|
||||||
|
end
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
tag_ids.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_members(nodes)
|
||||||
|
references = []
|
||||||
|
visit = lambda do |items|
|
||||||
|
items.each do |node|
|
||||||
|
references << node.slice('tag_id', 'tag_group_id') if
|
||||||
|
node.key?('tag_id') || node.key?('tag_group_id')
|
||||||
|
visit.call(node.fetch('children'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
visit.call(nodes)
|
||||||
|
references.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def inline_text(node)
|
||||||
|
if node.attr.any?
|
||||||
|
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
|
||||||
|
end
|
||||||
|
|
||||||
|
case node.type
|
||||||
|
when :text
|
||||||
|
node.value.to_s
|
||||||
|
when :p, :header
|
||||||
|
node.children.map { inline_text(_1) }.join
|
||||||
|
else
|
||||||
|
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def inline_markdown(node)
|
||||||
|
case node.type
|
||||||
|
when :text
|
||||||
|
node.value.to_s
|
||||||
|
when :codespan
|
||||||
|
text = node.value.to_s
|
||||||
|
longest = text.scan(/`+/).map(&:length).max || 0
|
||||||
|
delimiter = '`' * (longest + 1)
|
||||||
|
padded = text.start_with?('`', ' ') || text.end_with?('`', ' ')
|
||||||
|
content = padded ? " #{ text } " : text
|
||||||
|
"#{ delimiter }#{ content }#{ delimiter }"
|
||||||
|
when :softbreak
|
||||||
|
"\n"
|
||||||
|
when :br
|
||||||
|
" \n"
|
||||||
|
when :p
|
||||||
|
node.children.map { inline_markdown(_1) }.join
|
||||||
|
when :a
|
||||||
|
href = node.attr.fetch('href')
|
||||||
|
label = node.children.map { inline_markdown(_1) }.join
|
||||||
|
title = node.attr['title']&.gsub('"', '\\"')
|
||||||
|
suffix = title ? " \"#{ title }\"" : ''
|
||||||
|
"[#{ label }](#{ href }#{ suffix })"
|
||||||
|
when :em
|
||||||
|
"*#{ node.children.map { inline_markdown(_1) }.join }*"
|
||||||
|
when :strong
|
||||||
|
"**#{ node.children.map { inline_markdown(_1) }.join }**"
|
||||||
|
else
|
||||||
|
invalid!("未対応の説明文構文 #{ node.type.inspect } です.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalid! message
|
||||||
|
raise InvalidDocument, message
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
class TagGroupMarkdownRenderer
|
||||||
|
class UnsupportedStructure < StandardError
|
||||||
|
;
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.render group
|
||||||
|
new(group).render
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize group
|
||||||
|
@group = group
|
||||||
|
end
|
||||||
|
|
||||||
|
def render
|
||||||
|
placements = @group.fetch('placements')
|
||||||
|
validate_membership!(placements)
|
||||||
|
blocks = []
|
||||||
|
if @group['description'].present?
|
||||||
|
blocks << clean_text(@group['description'])
|
||||||
|
end
|
||||||
|
|
||||||
|
first_section = placements.index { _1.key?('section_name') }
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
blocks.compact.reject(&:empty?).join("\n").sub(/\n+\z/, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_membership! placements
|
||||||
|
placed = placement_members(placements).uniq.sort
|
||||||
|
members = @group.fetch('members').map { |member|
|
||||||
|
if member['tag']
|
||||||
|
['tag', member.fetch('tag').fetch('id')]
|
||||||
|
else
|
||||||
|
['tag_group', member.fetch('tag_group').fetch('id')]
|
||||||
|
end
|
||||||
|
}.uniq.sort
|
||||||
|
missing = members - placed
|
||||||
|
if missing.any?
|
||||||
|
raise UnsupportedStructure, '掲載位置のない membership は Markdown にできません.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def placement_members nodes
|
||||||
|
nodes.flat_map do |node|
|
||||||
|
own = if node['tag']
|
||||||
|
[['tag', node.fetch('tag').fetch('id')]]
|
||||||
|
elsif node['tag_group']
|
||||||
|
[['tag_group', node.fetch('tag_group').fetch('id')]]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
own + placement_members(node.fetch('children'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_heading section, level
|
||||||
|
header = "#{ '#' * level } #{ clean_text(section.fetch('section_name')) }"
|
||||||
|
blocks = [header]
|
||||||
|
blocks << clean_text(section['description']) if section['description'].present?
|
||||||
|
children = section.fetch('children')
|
||||||
|
section_indexes = children.each_index.select { |index| children[index].key?('section_name') }
|
||||||
|
if section_indexes.any?
|
||||||
|
first_section = section_indexes.first
|
||||||
|
if children[first_section..].any? { !_1.key?('section_name') }
|
||||||
|
raise UnsupportedStructure,
|
||||||
|
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
|
||||||
|
end
|
||||||
|
list_nodes = children[0...first_section]
|
||||||
|
blocks << render_list(list_nodes, 0) if list_nodes.any?
|
||||||
|
blocks.concat(children[first_section..].map { render_heading(_1, level + 1) })
|
||||||
|
elsif children.any?
|
||||||
|
blocks << render_list(children, 0)
|
||||||
|
end
|
||||||
|
blocks.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_list nodes, depth
|
||||||
|
nodes.map do |node|
|
||||||
|
indent = ' ' * depth
|
||||||
|
if node.key?('section_name')
|
||||||
|
raise UnsupportedStructure,
|
||||||
|
'リスト内 Section の説明文は Markdown にできません.' if node['description'].present?
|
||||||
|
|
||||||
|
children = node.fetch('children')
|
||||||
|
head = "#{ indent }- #{ clean_text(node.fetch('section_name')) }:"
|
||||||
|
tail = render_list(children, depth + 1) if children.any?
|
||||||
|
[head, tail].compact.join("\n")
|
||||||
|
else
|
||||||
|
label = if node['tag']
|
||||||
|
node.fetch('tag').fetch('name')
|
||||||
|
else
|
||||||
|
"#{ TagGroupRepr::PREFIX }#{ node.fetch('tag_group').fetch('name') }"
|
||||||
|
end
|
||||||
|
raise UnsupportedStructure, '名称のない Placement は Markdown にできません.' if label.blank?
|
||||||
|
|
||||||
|
head = "#{ indent }- #{ clean_text(label) }"
|
||||||
|
children = node.fetch('children')
|
||||||
|
tail = render_list(children, depth + 1) if children.any?
|
||||||
|
[head, tail].compact.join("\n")
|
||||||
|
end
|
||||||
|
end.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_text value
|
||||||
|
value.to_s.gsub(/\r\n?/, "\n").lines.map(&:rstrip).join("\n").strip
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
class TagGroupOperation
|
||||||
|
MAX_PLACEMENT_DEPTH = 5
|
||||||
|
|
||||||
|
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!(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')
|
||||||
|
group
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.update!(locale, group:, name:, description:, members:, placements:, actor:,
|
||||||
|
require_compatibility: false)
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
group.lock!
|
||||||
|
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')
|
||||||
|
group
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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?
|
||||||
|
if normalized_name.length > 255
|
||||||
|
invalid!(record, :name, 'グループ名は 255 文字以内で入力してください.')
|
||||||
|
end
|
||||||
|
invalid!(record, :description, '説明文は文字列で入力してください.') unless
|
||||||
|
description.nil? || description.is_a?(String)
|
||||||
|
|
||||||
|
record.name = normalized_name
|
||||||
|
record.description = description
|
||||||
|
unless record.valid?
|
||||||
|
raise ActiveRecord::RecordInvalid, record
|
||||||
|
end
|
||||||
|
|
||||||
|
member_rows = normalize_members(record, members)
|
||||||
|
placement_tree = normalize_placements(record, placements)
|
||||||
|
validate_references!(locale, record, member_rows, placement_tree)
|
||||||
|
validate_containment!(record, member_rows)
|
||||||
|
{ name: normalized_name, description:, members: member_rows,
|
||||||
|
placements: placement_tree }
|
||||||
|
end
|
||||||
|
private_class_method :prepare!
|
||||||
|
|
||||||
|
def self.unchanged?(group, attributes)
|
||||||
|
unless (group.name_in_database == attributes.fetch(:name) &&
|
||||||
|
group.description_in_database == attributes.fetch(:description))
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
members = TagGroupTag.where(tag_group_id: group.id).pluck(:tag_id)
|
||||||
|
.map { { 'tag_id' => _1 } }
|
||||||
|
members.concat(TagGroupGroup.where(tag_group_id: group.id)
|
||||||
|
.pluck(:child_tag_group_id).map { { 'tag_group_id' => _1 } })
|
||||||
|
requested_members = attributes.fetch(:members).map do |member|
|
||||||
|
member.except('version_no')
|
||||||
|
end
|
||||||
|
return false unless members.to_set == requested_members.to_set
|
||||||
|
|
||||||
|
rows = TagGroupPlacement.where(tag_group_id: group.id).order(:position)
|
||||||
|
.group_by(&:parent_position)
|
||||||
|
build = lambda do |parent_position|
|
||||||
|
rows.fetch(parent_position, []).map do |row|
|
||||||
|
node = if row.tag_id
|
||||||
|
{ 'tag_id' => row.tag_id }
|
||||||
|
elsif row.child_tag_group_id
|
||||||
|
{ 'tag_group_id' => row.child_tag_group_id }
|
||||||
|
else
|
||||||
|
{ 'section_name' => row.section_name, 'description' => row.description }
|
||||||
|
end
|
||||||
|
node.merge('children' => build.call(row.position))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
build.call(nil) == comparable_placements(attributes.fetch(:placements))
|
||||||
|
end
|
||||||
|
private_class_method :unchanged?
|
||||||
|
|
||||||
|
def self.comparable_placements(nodes)
|
||||||
|
nodes.map do |node|
|
||||||
|
result = node.dup
|
||||||
|
result['description'] = node['description'] if node.key?('section_name')
|
||||||
|
result['children'] = comparable_placements(node.fetch('children'))
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :comparable_placements
|
||||||
|
|
||||||
|
def self.normalize_members(record, members)
|
||||||
|
invalid!(record, :members, 'members は配列で指定してください.') unless members.is_a?(Array)
|
||||||
|
|
||||||
|
result = members.map do |member|
|
||||||
|
member = stringify_keys(member)
|
||||||
|
unless member.is_a?(Hash) && member.keys.one? &&
|
||||||
|
(member.key?('tag_id') || member.key?('tag_group_id'))
|
||||||
|
invalid!(record, :members, '各 member は tag_id または tag_group_id を一つ指定してください.')
|
||||||
|
end
|
||||||
|
key = member.keys.first
|
||||||
|
id = integer_id(record, member.fetch(key), :members)
|
||||||
|
{ key => id }
|
||||||
|
end
|
||||||
|
invalid!(record, :members, 'membership に重複があります.') unless result.uniq == result
|
||||||
|
result
|
||||||
|
end
|
||||||
|
private_class_method :normalize_members
|
||||||
|
|
||||||
|
def self.normalize_placements(record, placements)
|
||||||
|
invalid!(record, :placements, 'placements は配列で指定してください.') unless
|
||||||
|
placements.is_a?(Array)
|
||||||
|
|
||||||
|
normalize_placement_nodes(record, placements, 1)
|
||||||
|
end
|
||||||
|
private_class_method :normalize_placements
|
||||||
|
|
||||||
|
def self.normalize_placement_nodes(record, nodes, depth)
|
||||||
|
if depth > MAX_PLACEMENT_DEPTH && nodes.any?
|
||||||
|
invalid!(record, :placements, '配置の深さは 5 階層までです.')
|
||||||
|
end
|
||||||
|
|
||||||
|
nodes.map do |raw_node|
|
||||||
|
node = stringify_keys(raw_node)
|
||||||
|
unless node.is_a?(Hash) && node.key?('children')
|
||||||
|
invalid!(record, :placements, '各 Placement に children 配列が必要です.')
|
||||||
|
end
|
||||||
|
children = node.delete('children')
|
||||||
|
unless children.is_a?(Array)
|
||||||
|
invalid!(record, :placements, 'children は配列で指定してください.')
|
||||||
|
end
|
||||||
|
|
||||||
|
kinds = ['tag_id', 'tag_group_id', 'section_name'].select { node.key?(_1) }
|
||||||
|
invalid!(record, :placements, 'Placement の種類を一つ指定してください.') unless
|
||||||
|
kinds.one?
|
||||||
|
kind = kinds.first
|
||||||
|
allowed = [kind, 'description']
|
||||||
|
invalid!(record, :placements, '不明な Placement 項目があります.') unless
|
||||||
|
(node.keys - allowed).empty?
|
||||||
|
|
||||||
|
case kind
|
||||||
|
when 'tag_id', 'tag_group_id'
|
||||||
|
node[kind] = integer_id(record, node.fetch(kind), :placements)
|
||||||
|
if node['description'].present?
|
||||||
|
invalid!(record, :placements, 'Tag / Tag Group Placement に説明文は指定できません.')
|
||||||
|
end
|
||||||
|
node.delete('description')
|
||||||
|
when 'section_name'
|
||||||
|
section_name = node.fetch(kind)
|
||||||
|
unless section_name.is_a?(String) && section_name.strip.present? &&
|
||||||
|
section_name.length <= 255
|
||||||
|
invalid!(record, :placements, 'Section 名は 1〜255 文字で入力してください.')
|
||||||
|
end
|
||||||
|
node[kind] = section_name.strip
|
||||||
|
unless node['description'].nil? || node['description'].is_a?(String)
|
||||||
|
invalid!(record, :placements, 'Section の説明文は文字列で入力してください.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
node['children'] = normalize_placement_nodes(record, children, depth + 1)
|
||||||
|
node
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :normalize_placement_nodes
|
||||||
|
|
||||||
|
def self.validate_references!(locale, record, members, placements)
|
||||||
|
membership = members.to_set
|
||||||
|
placed = placement_references(placements).uniq
|
||||||
|
missing = placed - membership.to_a
|
||||||
|
invalid!(record, :placements, 'Placement の対象は membership に含めてください.') if
|
||||||
|
missing.any?
|
||||||
|
|
||||||
|
tag_ids = members.filter_map { _1['tag_id'] }
|
||||||
|
group_ids = members.filter_map { _1['tag_group_id'] }
|
||||||
|
tags = Tag.where(id: tag_ids).index_by(&:id)
|
||||||
|
groups = TagGroup.where(id: group_ids).index_by(&:id)
|
||||||
|
invalid!(record, :members, '存在しない Tag が含まれています.') if
|
||||||
|
tags.size != tag_ids.uniq.size
|
||||||
|
invalid!(record, :members, '存在しない Tag Group が含まれています.') if
|
||||||
|
groups.size != group_ids.uniq.size
|
||||||
|
|
||||||
|
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: 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, :invalid) unless
|
||||||
|
tag_names.include?(id)
|
||||||
|
version_no = tags.fetch(id).version_no
|
||||||
|
unless tag_versions.include?([id, version_no])
|
||||||
|
invalid!(record, :members, "Tag ##{ id } の Version がありません.")
|
||||||
|
end
|
||||||
|
member['version_no'] = version_no
|
||||||
|
else
|
||||||
|
id = member.fetch('tag_group_id')
|
||||||
|
version_no = groups.fetch(id).version_no
|
||||||
|
unless group_versions.include?([id, version_no])
|
||||||
|
invalid!(record, :members, "Tag Group ##{ id } の Version がありません.")
|
||||||
|
end
|
||||||
|
member['version_no'] = version_no
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :validate_references!
|
||||||
|
|
||||||
|
def self.validate_containment!(record, members)
|
||||||
|
target_id = record.id
|
||||||
|
return unless target_id
|
||||||
|
|
||||||
|
children = members.filter_map { _1['tag_group_id'] }
|
||||||
|
edges = TagGroupGroup.pluck(:tag_group_id, :child_tag_group_id)
|
||||||
|
.group_by(&:first).transform_values { _1.map(&:last) }
|
||||||
|
edges[target_id] = children
|
||||||
|
visiting = Set.new
|
||||||
|
visited = Set.new
|
||||||
|
visit = lambda do |group_id|
|
||||||
|
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
|
||||||
|
group_id == target_id && visiting.include?(group_id)
|
||||||
|
return if visited.include?(group_id)
|
||||||
|
|
||||||
|
visiting.add(group_id)
|
||||||
|
edges.fetch(group_id, []).each do |child_id|
|
||||||
|
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
|
||||||
|
child_id == target_id
|
||||||
|
visit.call(child_id)
|
||||||
|
end
|
||||||
|
visiting.delete(group_id)
|
||||||
|
visited.add(group_id)
|
||||||
|
end
|
||||||
|
children.each { visit.call(_1) }
|
||||||
|
end
|
||||||
|
private_class_method :validate_containment!
|
||||||
|
|
||||||
|
def self.persist_structure!(group:, attributes:, actor:)
|
||||||
|
tag_members = TagGroupTag.where(tag_group_id: group.id).index_by(&:tag_id)
|
||||||
|
group_members = TagGroupGroup.where(tag_group_id: group.id).index_by(&:child_tag_group_id)
|
||||||
|
attributes.fetch(:members).each do |member|
|
||||||
|
if member.key?('tag_id')
|
||||||
|
next if tag_members.delete(member.fetch('tag_id'))
|
||||||
|
|
||||||
|
TagGroupTag.create!(tag_group_id: group.id, tag_id: member.fetch('tag_id'),
|
||||||
|
created_by_user: actor)
|
||||||
|
else
|
||||||
|
next if group_members.delete(member.fetch('tag_group_id'))
|
||||||
|
|
||||||
|
TagGroupGroup.create!(tag_group_id: group.id,
|
||||||
|
child_tag_group_id: member.fetch('tag_group_id'),
|
||||||
|
created_by_user: actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
existing = TagGroupPlacement.where(tag_group_id: group.id).index_by(&:position)
|
||||||
|
persist_placements!(group:, nodes: attributes.fetch(:placements), actor:, existing:)
|
||||||
|
existing.values.sort_by(&:position).reverse_each(&:delete)
|
||||||
|
tag_members.each_value(&:delete)
|
||||||
|
group_members.each_value(&:delete)
|
||||||
|
end
|
||||||
|
private_class_method :persist_structure!
|
||||||
|
|
||||||
|
def self.persist_placements!(group:, nodes:, actor:, existing:, parent_position: nil,
|
||||||
|
next_position: [0])
|
||||||
|
nodes.each do |node|
|
||||||
|
next_position[0] += 1
|
||||||
|
position = next_position[0]
|
||||||
|
row = existing.delete(position) || TagGroupPlacement.new(
|
||||||
|
tag_group_id: group.id, position:, created_by_user: actor)
|
||||||
|
row.assign_attributes(
|
||||||
|
parent_position:,
|
||||||
|
section_name: node['section_name'], description: node['description'],
|
||||||
|
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'])
|
||||||
|
if row.new_record? || row.changed?
|
||||||
|
row.updated_by_user = actor
|
||||||
|
row.save!
|
||||||
|
end
|
||||||
|
persist_placements!(group:, nodes: node.fetch('children'), actor:, existing:,
|
||||||
|
parent_position: position, next_position:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :persist_placements!
|
||||||
|
|
||||||
|
def self.persist_version!(group:, attributes:, actor:, event_type:)
|
||||||
|
TagGroupVersion.create!(tag_group: group, version_no: group.version_no,
|
||||||
|
event_type:, name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
members: attributes.fetch(:members),
|
||||||
|
placements: attributes.fetch(:placements),
|
||||||
|
created_by_user: actor)
|
||||||
|
end
|
||||||
|
private_class_method :persist_version!
|
||||||
|
|
||||||
|
def self.placement_references(nodes)
|
||||||
|
nodes.flat_map do |node|
|
||||||
|
own = if node.key?('tag_id')
|
||||||
|
[{ 'tag_id' => node.fetch('tag_id') }]
|
||||||
|
elsif node.key?('tag_group_id')
|
||||||
|
[{ 'tag_group_id' => node.fetch('tag_group_id') }]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
own + placement_references(node.fetch('children'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :placement_references
|
||||||
|
|
||||||
|
def self.stringify_keys(value)
|
||||||
|
case value
|
||||||
|
when ActionController::Parameters
|
||||||
|
value.to_unsafe_h.deep_stringify_keys
|
||||||
|
when Hash
|
||||||
|
value.deep_stringify_keys
|
||||||
|
else
|
||||||
|
value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :stringify_keys
|
||||||
|
|
||||||
|
def self.integer_id(record, value, attribute)
|
||||||
|
integer = if value.is_a?(Integer)
|
||||||
|
value
|
||||||
|
elsif value.is_a?(String) && value.match?(/\A[0-9]+\z/)
|
||||||
|
value.to_i
|
||||||
|
end
|
||||||
|
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer
|
||||||
|
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer.positive?
|
||||||
|
integer
|
||||||
|
end
|
||||||
|
private_class_method :integer_id
|
||||||
|
|
||||||
|
def self.invalid!(record, attribute, message)
|
||||||
|
record.errors.add(attribute, message)
|
||||||
|
raise ActiveRecord::RecordInvalid, record
|
||||||
|
end
|
||||||
|
private_class_method :invalid!
|
||||||
|
end
|
||||||
@@ -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
|
class TagVersionRecorder < VersionRecorder
|
||||||
def self.record! tag:, event_type:, created_by_user:
|
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
|
end
|
||||||
|
|
||||||
def initialize tag:, event_type:, created_by_user:
|
def initialize tag:, event_type:, created_by_user:
|
||||||
@@ -14,10 +17,42 @@ class TagVersionRecorder < VersionRecorder
|
|||||||
def record_key = :tag
|
def record_key = :tag
|
||||||
|
|
||||||
def snapshot_attributes
|
def snapshot_attributes
|
||||||
{ name: @record.name,
|
names = snapshot_names
|
||||||
|
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,
|
category: @record.category,
|
||||||
deprecated_at: @record.deprecated_at,
|
deprecated_at: @record.deprecated_at,
|
||||||
aliases: @record.snapshot_aliases.join(' '),
|
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
|
||||||
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
names:,
|
||||||
|
parent_tag_ids: parent_ids.join(' ') }
|
||||||
|
end
|
||||||
|
|
||||||
|
def snapshot_names
|
||||||
|
@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' => Script.codes.fetch(tag_name.script_code, tag_name.script_code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
{ 'name' => primary.name,
|
||||||
|
'script_code' => Script.codes.fetch(primary.script_code, primary.script_code),
|
||||||
|
'auto_generated' => primary.auto_generated,
|
||||||
|
'aliases' => aliases }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,38 +1,31 @@
|
|||||||
class TagVersioning
|
class TagVersioning
|
||||||
def self.record! tag, event_type:, created_by_user:
|
def self.record! tag, event_type:, created_by_user:, language_code: 'ja'
|
||||||
if tag.nico?
|
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:)
|
||||||
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
end
|
||||||
else
|
|
||||||
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
|
||||||
|
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: locked_tag, event_type: :create, created_by_user:, language_code:)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.ensure_snapshot! tag, created_by_user:
|
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
|
||||||
if tag.nico?
|
Tag.transaction do
|
||||||
return if tag.nico_tag_versions.exists?
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
locked_tag = Tag.unscoped.lock.find(tag.id)
|
||||||
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
|
event_type = locked_tag.tag_versions.lock.exists? ? :update : :create
|
||||||
else
|
record!(locked_tag, event_type:, created_by_user:, language_code:)
|
||||||
return if tag.tag_versions.exists?
|
|
||||||
|
|
||||||
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.record_tag_snapshot! tag, created_by_user:
|
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
|
||||||
event_type =
|
|
||||||
if tag.nico?
|
|
||||||
tag.nico_tag_versions.exists? ? :update : :create
|
|
||||||
else
|
|
||||||
tag.tag_versions.exists? ? :update : :create
|
|
||||||
end
|
|
||||||
|
|
||||||
record!(tag, event_type:, created_by_user:)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.record_tag_snapshots! tags, created_by_user:
|
|
||||||
tags.each do |tag|
|
tags.each do |tag|
|
||||||
record_tag_snapshot!(tag, created_by_user:)
|
record_tag_snapshot!(tag, created_by_user:, language_code:)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class TheatrePostSelector
|
|||||||
def weighted_candidates
|
def weighted_candidates
|
||||||
@weighted_candidates ||= begin
|
@weighted_candidates ||= begin
|
||||||
penalties = tag_penalties
|
penalties = tag_penalties
|
||||||
posts = eligible_posts.includes(tags: :tag_name).to_a
|
posts = eligible_posts.includes(tags: :tag_names).to_a
|
||||||
|
|
||||||
posts.map do |post|
|
posts.map do |post|
|
||||||
post_tags = post.tags.to_a
|
post_tags = post.tags.to_a
|
||||||
@@ -82,7 +82,7 @@ class TheatrePostSelector
|
|||||||
def tag_penalty_json
|
def tag_penalty_json
|
||||||
return [] if tag_penalties.empty?
|
return [] if tag_penalties.empty?
|
||||||
|
|
||||||
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
|
tags = Tag.where(id: tag_penalties.keys).includes(:tag_names).index_by(&:id)
|
||||||
|
|
||||||
tag_penalties
|
tag_penalties
|
||||||
.map { |tag_id, penalty|
|
.map { |tag_id, penalty|
|
||||||
@@ -113,7 +113,7 @@ class TheatrePostSelector
|
|||||||
|
|
||||||
def light_tag_json tag
|
def light_tag_json tag
|
||||||
{ id: tag.id,
|
{ id: tag.id,
|
||||||
name: tag.name,
|
name: tag.name('ja'),
|
||||||
category: tag.category }
|
category: tag.category }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class VersionRecorder
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def latest_version = versions.order(version_no: :desc).first
|
def latest_version = versions.order(version_no: :desc).lock.first
|
||||||
|
|
||||||
def versions = @record.public_send(version_association)
|
def versions = @record.public_send(version_association)
|
||||||
|
|
||||||
@@ -47,10 +47,14 @@ class VersionRecorder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_record_version_no! version_no
|
def update_record_version_no! version_no
|
||||||
|
return unless tracks_version_no_on_record?
|
||||||
|
|
||||||
@record.update_columns(version_no:)
|
@record.update_columns(version_no:)
|
||||||
@record.version_no = version_no
|
@record.version_no = version_no
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def tracks_version_no_on_record? = true
|
||||||
|
|
||||||
def validate_version_sequence! latest
|
def validate_version_sequence! latest
|
||||||
if !(latest) && @event_type != 'create'
|
if !(latest) && @event_type != 'create'
|
||||||
raise "#{ version_class.name } first event must be create"
|
raise "#{ version_class.name } first event must be create"
|
||||||
@@ -60,7 +64,7 @@ class VersionRecorder
|
|||||||
raise "#{ version_class.name } create event already exists"
|
raise "#{ version_class.name } create event already exists"
|
||||||
end
|
end
|
||||||
|
|
||||||
return unless latest
|
return if !(latest) || !(tracks_version_no_on_record?)
|
||||||
|
|
||||||
if @record.version_no != latest.version_no
|
if @record.version_no != latest.version_no
|
||||||
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
|
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
|
||||||
|
|||||||
変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示
新しいイシューから参照
ユーザーをブロックする