コミットを比較

..
30 コミット
作成者 SHA1 メッセージ 日付
みてるぞ a508ea6f8b #55 2026-10-03 01:42:54 +09:00
みてるぞ 927ce90adc #55 2026-10-03 01:30:26 +09:00
みてるぞ 860cd82175 #55 2026-10-01 23:21:03 +09:00
みてるぞ 0b9fa03f6d #55 2026-10-01 22:56:15 +09:00
みてるぞ 9d6de29955 #55 2026-10-01 12:45:10 +09:00
みてるぞ 75cc6ac9a2 #55 2026-09-30 01:14:58 +09:00
みてるぞ 1e398bfb97 #55 2026-09-30 00:30:05 +09:00
みてるぞ 3dd13171fc #55 2026-09-28 01:39:26 +09:00
みてるぞ 56b656109d #55 2026-09-28 01:15:00 +09:00
みてるぞ 28e638e360 #55 2026-09-28 00:30:03 +09:00
みてるぞ 749d49327e #55 2026-09-27 23:55:19 +09:00
みてるぞ ab1b0e9def #55 2026-09-27 17:16:08 +09:00
みてるぞ 56205b7052 #55 2026-09-27 17:13:02 +09:00
みてるぞ c912bebbee #55 2026-09-27 15:07:24 +09:00
みてるぞ 701830b643 #55 2026-09-27 03:47:27 +09:00
みてるぞ 5abb6cca98 #55 2026-09-26 20:23:11 +09:00
みてるぞ c9b6f0ba3d Merge remote-tracking branch 'origin/main' into feature/055 2026-09-25 22:05:34 +09:00
みてるぞ f336b2f13b 外部タグを分離 (#419) (#420)
**本番 DB のバックアップをかならずすること**

Reviewed-on: #420
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-09-25 01:15:06 +09:00
みてるぞ 115a14205b #55 2026-09-22 03:04:21 +09:00
みてるぞ 1b1130ccd9 #55 いったん中断;外部タグ分離優先 2026-09-22 02:56:58 +09:00
みてるぞ f6d3dd1c10 Merge remote-tracking branch 'origin/main' into feature/055 2026-09-21 07:48:26 +09:00
みてるぞ 785b5ef8bc tags および tag_names にて論理削除を物理削除に変更 (#416) (#418)
Reviewed-on: #418
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-09-21 07:39:58 +09:00
みてるぞ 7f0efc3a46 post_tags の論理削除と履歴管理を廃止 (#411) (#417)
Reviewed-on: #417
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-09-21 06:02:41 +09:00
みてるぞ b0df11c500 #55 2026-09-21 01:27:10 +09:00
みてるぞ 886128a7a8 #55 2026-09-20 21:01:23 +09:00
みてるぞ 6d1a8f0444 Merge remote-tracking branch 'origin/main' into feature/055 2026-09-20 03:56:03 +09:00
みてるぞ a947032247 ニジラー情報で他ニジラー・タグの持つ情報を奪へてしまふ問題 (#406) (#407)
画面確認がまだ.

Reviewed-on: #407
2026-09-20 03:23:36 +09:00
みてるぞ 3115e31fb1 post_versions.tags_json への移行 (#354) (#415)
画面テストまだ.

Reviewed-on: #415
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-09-20 02:43:31 +09:00
みてるぞ 6f0442d633 #55 マイグレーション・ファイルのみ 2026-09-20 00:11:19 +09:00
みてるぞ f1181e8510 広場投稿追加画面の刷新 (#399) (#413)
Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/413
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-19 00:03:10 +09:00
150個のファイルの変更、7297行の追加、2291行の削除
+157 -24
ファイルの表示
@@ -124,7 +124,7 @@ npm run preview
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- 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
@@ -140,6 +140,32 @@ npm run preview
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
- Ruby の method chain や call argument を折り返す際、call-site の `)` を
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:
@@ -191,6 +217,63 @@ end
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
records.each {
do_work(_1) }
@@ -225,20 +308,37 @@ records.each {
formatting as the local reference shape: component and callback bodies use
2-space block indentation, single-line bodies do not gain unnecessary
braces, wrapped expressions use 4-space continuation indentation, multi-
stage ternaries use explicit parentheses, and only complete leading runs
of 8 spaces are compressed to tabs.
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
stage ternaries use explicit parentheses, and TypeScript / TSX indentation
and alignment whitespace compress every complete run of 8 spaces to tabs.
- A tab does not represent one indentation level.
- Do not replace 2-space or 4-space indentation with tabs.
- First determine visible indentation using 2-space block indentation and
4-space continuation indentation, then compress only complete leading runs
of 8 spaces into tabs.
- Tabs are only for leading indentation, never for spaces after non-space
text.
- Keep residual leading 2, 4, or 6 spaces after any tab compression.
- In TypeScript and TSX, first determine visible indentation using 2-space
block indentation and 4-space continuation indentation, then compress every
complete run of 8 spaces used for indentation or column alignment to tabs.
- In TypeScript and TSX, 8-space compression is mandatory, not optional.
- In TypeScript and TSX, this applies both to leading indentation and to
alignment whitespace after non-space text, such as aligned inline type
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
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
line limit; do not expand short type-only imports mechanically.
- Keep runtime value imports and type imports in separate declarations.
@@ -474,8 +574,32 @@ and layout reuse, follow `frontend/AGENTS.md`.
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until
they pass or the remaining failure is clearly blocked.
test work. When the user asks for tests, keep working within the permitted
test-file scope and rerun them until they pass or the remaining failure is
clearly blocked.
- 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
@@ -598,10 +722,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading block indentation uses
2 spaces per level, wrapped continuations use the repository's 4-space
continuation alignment, and complete leading runs of 8 spaces may be
compressed to tabs.
- The TypeScript/TSX self-review must confirm block indentation uses 2 spaces
per level, wrapped continuations use the repository's 4-space continuation
alignment, and every complete run of 8 spaces used for indentation or
alignment has been compressed to tabs.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
@@ -614,9 +738,18 @@ and layout reuse, follow `frontend/AGENTS.md`.
- 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.
- Do not write `try {`, `catch {`, or `finally {`.
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a
tab character.
- A leading tab is exactly equivalent to 8 leading spaces.
- In TypeScript and TSX, convert every complete run of 8 spaces used for
indentation or alignment to a tab character.
- 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
function declarations, classify the parameter list before placing the closing
`)`.
@@ -1259,9 +1392,9 @@ to `.ts` and `.tsx`:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading block indentation uses 2 spaces per level, wrapped continuations
use the repository's 4-space continuation alignment, and complete leading
runs of 8 spaces may be compressed to tabs.
9. Block indentation uses 2 spaces per level, wrapped continuations use the
repository's 4-space continuation alignment, and every complete run of 8
spaces used for indentation or alignment has been compressed to tabs.
10. No line has trailing whitespace.
Preferred:
+9
ファイルの表示
@@ -85,4 +85,13 @@ class ApplicationController < ActionController::API
base_errors: },
status:
end
def normalise_json value
return nil if value.nil?
return JSON.parse(value) if value.is_a?(String)
value
end
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese
end
+6 -3
ファイルの表示
@@ -1,5 +1,7 @@
class DeerjikistsController < ApplicationController
def show
locale = resolve_locale!
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
return render_bad_request('platform は必須です.') if platform.blank?
@@ -7,10 +9,10 @@ class DeerjikistsController < ApplicationController
deerjikist = Deerjikist
.joins(:tag)
.includes(tag: :tag_name)
.includes(tag: :tag_names)
.find_by(platform:, code:)
if deerjikist
render json: DeerjikistRepr.base(deerjikist)
render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code)
else
head :not_found
end
@@ -20,6 +22,7 @@ class DeerjikistsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i
@@ -32,7 +35,7 @@ class DeerjikistsController < ApplicationController
d.save!
end
render json: DeerjikistRepr.base(deerjikist)
render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code)
end
def destroy
+2 -2
ファイルの表示
@@ -2,7 +2,7 @@ class GekanatorPostsController < ApplicationController
def index
posts =
Post
.preload(:post_similarities, tags: :tag_name)
.preload(:post_similarities, tags: :tag_names)
.with_attached_thumbnail
.order(Arel.sql(
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
@@ -56,7 +56,7 @@ class GekanatorPostsController < ApplicationController
def tag_json tag
{
id: tag.id,
name: tag.name,
name: tag.name('ja'),
category: tag.category
}
end
+2 -2
ファイルの表示
@@ -126,9 +126,9 @@ class GekanatorQuestionsController < ApplicationController
end
Tag
.joins(:tag_name)
.joins(:tag_names)
.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)
.pluck('tags.category', 'tag_names.name')
.each_with_object({ }) do |(category, name), h|
+41 -20
ファイルの表示
@@ -7,6 +7,8 @@ class MaterialsController < ApplicationController
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
def index
locale = resolve_locale!
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -20,7 +22,7 @@ class MaterialsController < ApplicationController
q = Material.includes(:material_export_items,
thumbnail_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.not(tag_id: nil) if filters[:tag_state] == 'tagged'
q = apply_material_tag_filter(q, filters, tag_graph)
@@ -42,7 +44,10 @@ class MaterialsController < ApplicationController
.offset(offset)
.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: }
if filters[:tag_id]
response[:tag_scope] = material_index_tag_scope(filters, tag_graph)
@@ -55,6 +60,8 @@ class MaterialsController < ApplicationController
end
def show
locale = resolve_locale!
material =
Material
.includes(:tag, :material_export_items)
@@ -63,15 +70,17 @@ class MaterialsController < ApplicationController
.find_by(id: params[:id])
return head :not_found unless material
wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body
wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.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
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file)
@@ -89,13 +98,14 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
tag = resolve_material_tag!(tag_name_raw)
tag = resolve_material_tag!(locale, tag_name_raw)
material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob
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)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user)
@@ -107,7 +117,8 @@ class MaterialsController < ApplicationController
if 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
render_validation_error material
end
@@ -132,12 +143,14 @@ class MaterialsController < ApplicationController
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block
locale = resolve_locale!
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
begin
Material.transaction do
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)
if uploaded_blob
material.file.attach(uploaded_blob)
@@ -145,7 +158,8 @@ class MaterialsController < ApplicationController
material.file.detach
end
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)
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user)
@@ -157,7 +171,8 @@ class MaterialsController < ApplicationController
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
def destroy
@@ -236,10 +251,8 @@ class MaterialsController < ApplicationController
nil
end
def resolve_material_tag! tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
def resolve_material_tag! locale, tag_name_raw
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material)
end
def material_index_needs_tag_name? filters
@@ -253,7 +266,12 @@ class MaterialsController < ApplicationController
end
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
def material_index_join_file_blob q
@@ -269,9 +287,11 @@ class MaterialsController < ApplicationController
def apply_material_query q, term
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \
'material_file_blobs.filename LIKE :q',
q: like)
q.where(<<~SQL.squish, q: like)
tag_names.name LIKE :q
OR materials.url LIKE :q
OR material_file_blobs.filename LIKE :q
SQL
end
def apply_material_media_kind q, media_kind
@@ -323,8 +343,9 @@ class MaterialsController < ApplicationController
end
tags_by_id =
Tag
.joins(:tag_name)
.where(id: scope_tag_ids)
.joins(:tag_names)
.where(id: scope_tag_ids,
tag_names: { language_code: 'ja', primary_flg: true })
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), hash|
hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? }
+67 -30
ファイルの表示
@@ -1,5 +1,7 @@
class NicoTagsController < ApplicationController
def index
locale = resolve_locale!
name = params[:name].presence
linked_tag = params[:linked_tag].presence
link_status = params[:link_status].presence
@@ -15,30 +17,36 @@ class NicoTagsController < ApplicationController
limit = 1 if limit < 1
post_tag_max_sql =
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
PostExternalTag
.select('external_tag_id, MAX(created_at) AS max_created_at')
.group('external_tag_id')
.to_sql
q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q =
ExternalTag
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.external_tag_id = external_tags.id')
.includes(linked_tags: { tag_names: :wiki_page })
if name
q = q.where(('external_tags.name LIKE ? ' +
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
"%#{ name }%", "%#{ name }")
end
if linked_tag
linked_tag_ids =
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
Tag
.joins(:tag_names)
.where(tag_names: { language_code: 'ja', primary_flg: true })
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids)
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'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 }")
end
@@ -46,54 +54,68 @@ class NicoTagsController < ApplicationController
sort_sql =
case order[0]
when 'name'
'tag_names.name'
'external_tags.name'
when 'updated_at'
'post_tag_max.max_created_at'
else
"tags.#{ order[0] }"
"external_tags.#{ order[0] }"
end
tags = q.reselect('tags.*',
tags = q.reselect('external_tags.*',
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)
.offset((page - 1) * limit)
.to_a
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,
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: }
end
def show
tag = ExternalTag.find(params[:id])
render json: external_tag_json(tag)
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
id = params[:id].to_i
tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
tag = ExternalTag.find(id)
linked_tag_names = params[:tags].to_s.split
linked_tags = nil
ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
linked_tags = Tag.normalise_tags!(locale, linked_tag_names,
with_tagme: false,
with_no_deerjikist: false)
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.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
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
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
rescue ActiveRecord::RecordInvalid => e
@@ -102,6 +124,21 @@ class NicoTagsController < ApplicationController
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
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
+84 -67
ファイルの表示
@@ -1,7 +1,10 @@
class PostVersionsController < ApplicationController
def index
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
limit = (params[:limit].presence || 20).to_i
@@ -10,12 +13,6 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit
tag_name =
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
@@ -23,17 +20,23 @@ class PostVersionsController < ApplicationController
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',
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
if tag_name
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name)
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " +
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
kw: "% #{ escaped } %")
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
'OR JSON_CONTAINS(prev.tags_json,' +
"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
count = q.except(:select, :order, :limit, :offset).count
@@ -48,77 +51,91 @@ class PostVersionsController < ApplicationController
private
def serialise_versions rows
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
snapshots = rows.flat_map { |row|
[normalise_json(row.tags_json),
normalise_json(row.attributes['prev_tags_json']) || []]
}
external_tag_names = external_tag_names_for(snapshots)
rows.map do |row|
cur_tags = split_tags(row.tags)
prev_tags = split_tags(row.attributes['prev_tags'])
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names)
prev_tags = snapshot_tag_literals(
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names)
{
post_id: row.post_id,
{ post_id: row.post_id,
version_no: row.version_no,
event_type: row.event_type,
title: {
current: row.title,
prev: row.attributes['prev_title']
},
url: {
current: row.url,
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']
},
title: { current: row.title, prev: row.attributes['prev_title'] },
url: { current: row.url, 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),
original_created_from: {
current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601
},
current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601 },
original_created_before: {
current: row.original_created_before&.iso8601,
prev: row.attributes['prev_original_created_before']&.iso8601
},
current: row.original_created_before&.iso8601,
prev: row.attributes['prev_original_created_before']&.iso8601 },
created_at: row.created_at.iso8601,
created_by_user:
if row.created_by_user_id
{
id: row.created_by_user_id,
name: users_by_id[row.created_by_user_id]
}
end
}
if row.created_by_user_id
{ id: row.created_by_user_id,
name: users_by_id[row.created_by_user_id] }
end }
end
end
def build_version_tags(cur_tags, prev_tags)
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, external_tag_names
snapshots
.filter_map { |snapshot|
if snapshot.key?('tag_id')
[tag_category_order(snapshot['category']),
Post.tag_snapshot_literal(snapshot)]
elsif external_tag_names[snapshot['external_tag_id']]
[6, external_tag_names[snapshot['external_tag_id']]]
end
}
.sort_by { |order, name| [order, name.downcase] }
.map(&:second)
end
def tag_category_order category
['deerjikist', 'meme', 'character', 'general', 'material', 'meta'].index(category) || 6
end
def build_version_tags cur_tags, prev_tags
(cur_tags | prev_tags).map do |name|
type =
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
{
name:,
type:
}
{ name:, type: }
end
end
def split_tags(tags)
tags.to_s.split(/\s+/).reject(&:blank?)
end
end
+106 -115
ファイルの表示
@@ -7,6 +7,8 @@ class PostsController < ApplicationController
end
def index
locale = resolve_locale!
url = params[:url].presence
title = params[:title].presence
original_created_from = params[:original_created_from].presence
@@ -36,8 +38,8 @@ class PostsController < ApplicationController
offset = (page - 1) * limit
pt_max_sql =
PostTag
.select('post_id, MAX(updated_at) AS max_updated_at')
PostVersion
.select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id')
.to_sql
@@ -46,13 +48,12 @@ class PostsController < ApplicationController
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
q =
filtered_posts
filtered_posts(locale)
.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"))
.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.preload(:external_tags, :uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_names: :wiki_page }] }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -91,7 +92,8 @@ class PostsController < ApplicationController
q = q.except(:select, :order)
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'] =
if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -103,16 +105,19 @@ class PostsController < ApplicationController
end
def random
post = filtered_posts.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
locale = resolve_locale!
post =
filtered_posts(locale)
.preload(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_names: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
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
def metadata
@@ -187,12 +192,13 @@ class PostsController < ApplicationController
end
def show
locale = resolve_locale!
post =
Post
.includes(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_names: :wiki_page }] }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post
@@ -206,18 +212,21 @@ class PostsController < ApplicationController
parent_posts:,
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post))
related:,
language_code: locale.language_code)
.merge(tags: build_tag_tree_for(post, locale.language_code))
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail],
host: request.base_url).run
host: request.base_url).run(locale)
return render json: dry_run_json(preflight) if bool?(:dry)
if preflight[:existing_post_id].present?
post = Post.new(url: preflight[:url])
@@ -243,10 +252,10 @@ class PostsController < ApplicationController
:post_tag_specs,
:tag_sections,
:normalised_parent_post_ids).symbolize_keys).merge(
thumbnail: params[:thumbnail])).create!
thumbnail: params[:thumbnail])).create!(locale)
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
render_validation_error fields: e.fields, base: e.base_errors
rescue Tag::NicoTagNormalisationError
@@ -272,13 +281,18 @@ class PostsController < ApplicationController
return head :forbidden unless current_user.gte_member?
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
locale = resolve_locale!
posts = parse_bulk_posts_manifest
thumbnails = parse_bulk_thumbnails(posts.length)
result = PostBulkCreator.new(
actor: current_user,
posts:,
thumbnails:,
host: request.base_url).run
host: request.base_url).run(locale)
render json: result
rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.'
@@ -311,6 +325,7 @@ class PostsController < ApplicationController
base_version_no = parse_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
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
@@ -330,9 +345,10 @@ class PostsController < ApplicationController
base_version = post.post_versions.find_by!(version_no: base_version_no)
base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post)
current_snapshot = post_snapshot_from_record(post, locale)
end
incoming_snapshot = post_incoming_snapshot(title:,
incoming_snapshot = post_incoming_snapshot(locale:,
title:,
original_created_from:,
original_created_before:,
tag_names:,
@@ -361,14 +377,14 @@ class PostsController < ApplicationController
end
end
apply_post_snapshot!(post, snapshot_to_apply)
apply_post_snapshot!(locale, post, snapshot_to_apply)
end
return render json: conflict_json, status: :conflict if conflict_json
post.reload
json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post)
json = PostRepr.base(post, current_user, language_code: locale.language_code)
json['tags'] = build_tag_tree_for(post, locale.language_code)
render json:, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
@@ -384,65 +400,21 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record
end
def changes
id = params[:id].presence
tag_id = params[:tag].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
pts = PostTag.with_discarded
pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present?
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
events.sort_by!(&:timestamp)
events.reverse!
render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
end
private
def filtered_posts
def filtered_posts locale
tag_names = params[:tags].to_s.split
match_type = params[:match]
if tag_names.present?
filter_posts_by_tags(tag_names, match_type)
filter_posts_by_tags(locale, tag_names, match_type)
else
Post.all
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|
{ name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first,
{ name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first,
negative: raw_name.downcase.start_with?('not:') }
end
@@ -450,11 +422,11 @@ class PostsController < ApplicationController
if match_type == 'any'
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]))
end
else
literals.reduce(Post.all) do |posts, literal|
ids = tagged_post_ids_for(literal[:name])
ids = tagged_post_ids_for(locale, literal[:name])
if literal[:negative]
posts.where.not(id: ids)
else
@@ -464,8 +436,8 @@ class PostsController < ApplicationController
end
end
def tag_literal_relation name, negative:
ids = tagged_post_ids_for(name)
def tag_literal_relation locale, name, negative:
ids = tagged_post_ids_for(locale, name)
if negative
Post.where.not(id: ids)
else
@@ -473,8 +445,20 @@ class PostsController < ApplicationController
end
end
def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def tagged_post_ids_for locale, name
posts_by_internal_tags =
Post
.joins(tags: :tag_names)
.where(tag_names: { language_code: locale.language_code, name: })
.select(:id)
posts_by_external_tags =
Post
.joins(:external_tags)
.where("CONCAT(external_tags.platform, ':', external_tags.name) = ?", name)
(posts_by_internal_tags + posts_by_external_tags).uniq(&:id)
end
def sync_post_tags! post, desired_tags, sections
desired_tags.each do |t|
@@ -502,13 +486,13 @@ class PostsController < ApplicationController
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.destroy!
end
end
def build_tag_tree_for post
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
def build_tag_tree_for post, language_code = 'ja'
post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id)
@@ -537,7 +521,7 @@ class PostsController < ApplicationController
sections = sections_by_tag_id.fetch(tag_id, [])
if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: [], sections:)
return TagRepr.inline(tag, language_code:).merge(children: [], sections:)
end
if memo.key?(tag_id)
@@ -549,10 +533,13 @@ class PostsController < ApplicationController
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
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
def sibling_posts_by_parent parent_post_ids
@@ -699,43 +686,49 @@ class PostsController < ApplicationController
end
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
def post_snapshot_from_record post
def post_snapshot_from_record post, locale
{ title: post.title,
video_ms: post.video_ms,
original_created_from: snapshot_time(post.original_created_from),
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) }
end
def editable_tag_names_from_post post
def editable_tag_names_from_post post, language_code
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.joins(tag: :tag_names)
.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')
.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)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end.compact.sort
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:
validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
Tag.normalise_tags!(locale, tag_names,
with_tagme: false,
with_no_deerjikist: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
@@ -746,7 +739,7 @@ class PostsController < ApplicationController
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
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,
parent_post_ids: parent_post_ids.sort }
end
@@ -870,7 +863,7 @@ class PostsController < ApplicationController
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end
def apply_post_snapshot! post, snapshot
def apply_post_snapshot! locale, post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title],
@@ -878,15 +871,13 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true,
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
Tag.normalise_tags!(locale, snapshot[:tag_names],
with_tagme: false,
deny_deprecated: true,
with_sections: true) => { 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?)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
-2
ファイルの表示
@@ -10,7 +10,6 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -33,7 +32,6 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+20 -8
ファイルの表示
@@ -1,5 +1,6 @@
class TagVersionsController < ApplicationController
def index
locale = resolve_locale!
tag_id = params[:id].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -18,7 +19,8 @@ class TagVersionsController < ApplicationController
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')
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
count = q.except(:select, :order, :limit, :offset).count
@@ -27,18 +29,20 @@ class TagVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions), count: }
render json: { versions: serialise_versions(versions, locale.language_code), count: }
end
private
def serialise_versions rows
def serialise_versions rows, language_code
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
rows.map do |row|
cur_aliases = split_values(row.aliases)
prev_aliases = split_values(row.attributes['prev_aliases'])
current_names = names_for_locale(row.names, language_code)
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)
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
@@ -47,21 +51,22 @@ class TagVersionsController < ApplicationController
tags_by_id =
Tag
.includes(:tag_name, :materials, { tag_name: :wiki_page })
.includes(:tag_names, :materials, tag_names: :wiki_page)
.where(id: all_parent_tag_ids)
.index_by(&:id)
parent_tags =
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
.map do |h|
{ tag: TagRepr.base(tags_by_id[h[:tag_id]]),
{ tag: TagRepr.base(tags_by_id[h[:tag_id]], language_code:),
type: h[:type] }
end
{ tag_id: row.tag_id,
version_no: row.version_no,
event_type: row.event_type,
name: { current: row.name, prev: row.attributes['prev_name'] },
names: row.names,
name: { current: current_names['name'], prev: previous_names['name'] },
category: { current: row.category, prev: row.attributes['prev_category'] },
deprecated_at: { current: row.deprecated_at&.iso8601,
prev: row.attributes['prev_deprecated_at']&.iso8601 },
@@ -91,5 +96,12 @@ class TagVersionsController < ApplicationController
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
def names_for_locale names, language_code
values = names.is_a?(String) ? JSON.parse(names) : names
values&.fetch(language_code, {}) || {}
rescue JSON::ParserError
{}
end
def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
end
+509 -180
ファイルの表示
@@ -5,6 +5,8 @@ require 'set'
class TagsController < ApplicationController
def index
locale = resolve_locale!
post_id = params[:post]
name = params[:name].presence
@@ -34,49 +36,161 @@ class TagsController < ApplicationController
offset = (page - 1) * limit
q =
tags =
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
Tag.joins(:tag_name)
Tag
.joins(:tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
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
q = q.where(category:) if category
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
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 deprecated_given
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
external_tags =
if post_id.present?
ExternalTag.joins(:posts).where(posts: { id: post_id })
else
ExternalTag.all
end
if name
tags = tags.where('tag_names.name LIKE ?', "%#{ name }%")
external_tags =
external_tags.where("CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?",
"%#{ name }%")
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 =
case order[0]
when 'name'
'tag_names.name'
when 'category'
'CASE tags.category ' +
if order[0] == 'category'
'CASE category ' +
"WHEN 'deerjikist' THEN 0 " +
"WHEN 'meme' THEN 1 " +
"WHEN 'character' THEN 2 " +
"WHEN 'general' THEN 3 " +
"WHEN 'material' THEN 4 " +
"WHEN 'meta' THEN 5 " +
"WHEN 'nico' THEN 6 END"
"WHEN 'nico' THEN 6 " +
'END'
else
"tags.#{ order[0] }"
order[0]
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)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.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
def with_depth
@@ -84,7 +198,8 @@ class TagsController < ApplicationController
parent_tag_id = nil if parent_tag_id <= 0
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 =
if parent_tag_id
@@ -99,137 +214,223 @@ class TagsController < ApplicationController
end
def autocomplete
locale = resolve_locale!
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%"
with_nico = bool?(:nico, default: true)
present_only = bool?(:present, default: true)
alias_rows =
TagName
.where('name LIKE ?', "#{ q }%")
.where.not(canonical_id: nil)
.pluck(:canonical_id, :name)
.where(language_code: locale.language_code, primary_flg: false)
.where('name LIKE ?', prefix)
.pluck(:tag_id, :name)
matched_alias_by_tag_name_id = { }
canonical_ids = []
alias_rows.each do |canonical_id, alias_name|
canonical_ids << canonical_id
matched_alias_by_tag_name_id[canonical_id] ||= alias_name
matched_alias_by_tag_id = { }
tag_ids = []
alias_rows.each do |tag_id, alias_name|
tag_ids << tag_id
matched_alias_by_tag_id[tag_id] ||= alias_name
end
base = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base =
Tag
.joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only
canonical_hit =
base
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
'tag_names.name LIKE ?'),
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
canonical_hit = base.where('tag_names.name LIKE ?', prefix)
tags =
if canonical_ids.present?
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
else
canonical_hit
end
internal_tags = canonical_hit.or(base.where(id: tag_ids.uniq))
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|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
return render json: internal_rows unless with_nico
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
def show
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(id: params[:id])
if tag
render json: TagRepr.base(tag)
else
head :not_found
end
locale = resolve_locale!
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.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
def show_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
if tag
render json: TagRepr.base(tag)
else
head :not_found
end
locale = resolve_locale!
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
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
def deerjikists
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
locale = resolve_locale!
tag =
Tag.joins(:tag_names)
.includes(:tag_names, tag_names: :wiki_page)
.find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
render json: { tag: TagRepr.base(tag, language_code: locale.language_code),
deerjikists: DeerjikistRepr.many(
tag.deerjikists,
language_code: locale.language_code) }
end
def deerjikists_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(tag_names: { name: })
locale = resolve_locale!
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, tag_names: :wiki_page)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
render json: { tag: TagRepr.base(tag, language_code: locale.language_code),
deerjikists: DeerjikistRepr.many(
tag.deerjikists,
language_code: locale.language_code) }
end
def update_deerjikists
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
locale = resolve_locale!
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, tag_names: :wiki_page)
.find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
rows = normalise_deerjikist_rows(tag)
return if performed?
ApplicationRecord.transaction do
tag.deerjikists = []
params[:_json].each.with_index do |item, i|
platform = item[:platform]
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
tag.lock!
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
row_indexes_by_key = rows_by_key(rows)
locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
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?
end
end
return if performed?
render json: DeerjikistRepr.many(tag.reload.deerjikists)
render json: DeerjikistRepr.many(tag.reload.deerjikists,
language_code: locale.language_code)
end
def materials_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
locale = resolve_locale!
material_filter = material_filter_param(default: 'any')
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
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
def update_all
@@ -245,39 +446,38 @@ class TagsController < ApplicationController
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
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
parent_names = params[:parent_tags].to_s.split.uniq
deprecated = bool?(:deprecated)
if tag.nico? && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
if tag.nico? || category == 'nico'
if category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
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
wiki_page = tag.tag_name.wiki_page if name_changed
wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed
if tag.deprecated? == deprecated
tag.update!(category:)
else
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
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)
update_aliases!(tag, alias_names)
update_parent_tags!(tag, parent_names)
update_aliases!(locale, tag, alias_names)
update_parent_tags!(locale, tag, parent_names)
tag.reload
@@ -285,17 +485,20 @@ class TagsController < ApplicationController
tag,
event_type: :update,
created_by_user: current_user,
language_code: locale.language_code,
name_changed:,
wiki_page:)
end
render json: TagRepr.base(tag.reload)
render json: TagRepr.base(tag.reload, language_code: locale.language_code)
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
name = params[:name].presence
category = params[:category].presence
deprecated_given = params.key?(:deprecated)
@@ -303,24 +506,21 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id])
if tag.nico? && deprecated_given && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
return unless validate_tag_rename(locale, tag, name)
return unless validate_tag_rename(tag, name)
if tag.nico? || (category.present? && category == 'nico')
if category.present? && category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
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
wiki_page = tag.tag_name.wiki_page if name_changed
wiki_page = tag.tag_name(locale.language_code)&.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?
if deprecated_given && tag.deprecated? != deprecated
tag.update!(deprecated_at: deprecated ? Time.current : nil)
@@ -332,11 +532,12 @@ class TagsController < ApplicationController
tag,
event_type: :update,
created_by_user: current_user,
language_code: locale.language_code,
name_changed:,
wiki_page:)
end
render json: TagRepr.base(tag.reload)
render json: TagRepr.base(tag.reload, language_code: locale.language_code)
end
private
@@ -348,7 +549,7 @@ class TagsController < ApplicationController
value
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] = [] }
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
@@ -363,10 +564,13 @@ class TagsController < ApplicationController
material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
tags_by_id = Tag.joins(:tag_name)
.where(id: tag_ids)
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
tags_by_id =
Tag
.joins(:tag_names)
.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?,
has_material: material_tag_ids.include?(id) }
end
@@ -498,34 +702,33 @@ class TagsController < ApplicationController
children: [] }
end
def build_tag_children tag, graph: nil
def build_tag_children tag, graph: nil, language_code: 'ja'
material = tag.materials.first
tag_graph = graph && graph[:tags_by_id][tag.id]
material =
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
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
end
TagRepr.base(tag).merge(
children: children.map { build_tag_children(_1, graph:) },
TagRepr.base(tag, language_code:).merge(
children: children.map { build_tag_children(_1, graph:, language_code:) },
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
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return
end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja',
name_changed: false, wiki_page: nil
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:)
return unless name_changed
wiki_page ||= tag.tag_name.wiki_page
wiki_page ||= tag.tag_name('ja')&.wiki_page
return unless wiki_page&.wiki_versions&.exists?
WikiVersionRecorder.record!(
@@ -534,109 +737,140 @@ class TagsController < ApplicationController
created_by_user:)
end
def validate_tag_rename tag, name
return true if name.blank? || name == tag.name
def validate_tag_rename locale, 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
return false
end
target_tag_name = TagName.with_discarded.find_by(name:)
return true if target_tag_name.nil?
return true if target_tag_name.canonical_id?
target_tag_name = TagName.find_by(language_code: locale.language_code, name:)
return true unless target_tag_name&.primary_flg
render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name
false
end
def rename_tag_name! tag, name
return if name == tag.name
def rename_tag_name! locale, tag, name
return if name == tag.name(locale.language_code)
current_tag_name = tag.tag_name
target_tag_name = TagName.with_discarded.find_by(name:)
current_tag_name = tag.tag_name(locale.language_code)
target_tag_name = TagName.find_by(language_code: locale.language_code, name:)
if target_tag_name.nil?
current_tag_name.update!(name:)
unless target_tag_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
end
promote_tag_alias!(
tag,
current_tag_name:,
promoted_tag_name: target_tag_name)
promote_tag_alias!(tag, current_tag_name:, promoted_tag_name: target_tag_name)
end
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
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
promoted_tag_name.update!(canonical: nil)
current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id)
current_tag_name.association(:wiki_page).reset
TagName.with_discarded
.where(canonical_id: current_tag_name.id)
current_tag_name.update!(canonical_id: promoted_tag_name.id,
primary_flg: false, auto_generated: false)
promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true, auto_generated: false)
TagName.where(tag:,
language_code: current_tag_name.language_code,
primary_flg: false)
.where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name|
alias_tag_name.update!(canonical: promoted_tag_name)
alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
end
current_tag_name.wiki_page&.update!(tag_name: promoted_tag_name)
tag.update!(tag_name: promoted_tag_name)
tag.update!(tag_name_id: promoted_tag_name.id)
current_tag_name.association(:wiki_page).reset
current_tag_name.association(:tag).reset
current_tag_name.reload.update!(canonical: promoted_tag_name)
current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
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: current_tag_name.language_code)
end
def update_aliases! tag, alias_names
def update_aliases! locale, tag, alias_names
alias_names = alias_names.uniq
affected_tags = [tag]
current_aliases = tag.tag_name.aliases.to_a
primary_tag_name = TagName.find_by!(tag:,
language_code: locale.language_code,
primary_flg: true)
current_aliases = tag.tag_names.where(language_code: locale.language_code,
primary_flg: false).to_a
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
affected_tags << alias_tag_name.canonical&.tag
affected_tags << alias_tag_name.tag
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag
alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name)
if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id
alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.')
raise ActiveRecord::RecordInvalid, alias_tag_name
end
affected_tags << alias_tag_name&.tag
end
affected_tags.compact.uniq.each do |affected_tag|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user)
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user,
language_code: locale.language_code)
end
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
alias_tag_name.update!(canonical: nil)
alias_tag_name.update!(canonical_id: nil, tag_id: nil)
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name)
alias_tag_name = TagName.find_or_initialize_by(
language_code: locale.language_code,
name: alias_name)
alias_tag_name.script_code = locale.script_code
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false,
canonical_id: primary_tag_name.id)
end
affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
record_tag_version!(affected_tag, event_type: :update,
created_by_user: current_user,
language_code: locale.language_code)
end
end
def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
def update_parent_tags! locale, tag, parent_names
parent_tags = Tag.normalise_tags!(locale, parent_names,
with_tagme: false,
with_no_deerjikist: false)
old_parent_tags = tag.parents.to_a
@@ -653,6 +887,7 @@ class TagsController < ApplicationController
end
def normalise_deerjikist_code platform, code
code = code.to_s
return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }"
@@ -669,6 +904,84 @@ class TagsController < ApplicationController
nil
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
fields = { }
@@ -687,4 +1000,20 @@ class TagsController < ApplicationController
render_validation_error fields:
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
+2 -2
ファイルの表示
@@ -6,7 +6,7 @@ class TheatreSkipEventsController < ApplicationController
events =
TheatreSkipEvent
.where(theatre_id: params[:theatre_id])
.includes(:post, tags: :tag_name)
.includes(:post, tags: :tag_names)
.order(created_at: :desc)
.limit(limit)
@@ -14,7 +14,7 @@ class TheatreSkipEventsController < ApplicationController
{ id: event.id,
theatre_id: event.theatre_id,
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,
created_at: event.created_at }
}
+5 -1
ファイルの表示
@@ -94,7 +94,11 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title)
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
name.script_code = 'Jpan'
name.primary_flg = true
name.auto_generated = false
end
page =
Wiki::Commit.create_content!(
+24
ファイルの表示
@@ -0,0 +1,24 @@
class ExternalTag < ApplicationRecord
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
+5
ファイルの表示
@@ -0,0 +1,5 @@
class Language < ApplicationRecord
has_many :locales, foreign_key: :language_code, primary_key: :code
enum :code, ja: 'ja'
end
+27
ファイルの表示
@@ -0,0 +1,27 @@
class Locale < ApplicationRecord
after_create_commit :generate_tag_names!
belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :script, foreign_key: :script_code, primary_key: :code
enum :code, ja: 'ja', en: 'en', fr: 'fr'
def self.nipponese = Locale.find('ja')
private
def generate_tag_names!
ApplicationRecord.transaction do
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
Tag.where.not(id: tag_ids).find_each do
TagName.create!(tag_id: _1.id,
language_code:,
name: TagName.generate_name(self, _1, _1.tag_name('ja')),
script_code:,
primary_flg: true,
auto_generated: true,
canonical_id: nil)
end
end
end
end
+5 -19
ファイルの表示
@@ -1,24 +1,10 @@
class NicoTagRelation < ApplicationRecord
belongs_to :nico_tag, class_name: 'Tag'
belongs_to :tag, class_name: 'Tag'
belongs_to :nico_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 :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
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class NicoTagVersion < ApplicationRecord
include VersionRecord
belongs_to :tag
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
validates :name, presence: true
end
+69 -13
ファイルの表示
@@ -55,25 +55,27 @@ class Post < ApplicationRecord
belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags
has_many :tags, through: :post_tags
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 :post_similarities, dependent: :delete_all
has_many :post_versions
has_many :gekanator_guessed_games,
class_name: 'GekanatorGame',
foreign_key: :guessed_post_id,
dependent: :delete_all,
inverse_of: :guessed_post
has_many :gekanator_correct_games,
class_name: 'GekanatorGame',
foreign_key: :correct_post_id,
dependent: :delete_all,
inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications,
@@ -90,6 +92,9 @@ class Post < ApplicationRecord
inverse_of: :parent_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
attribute :version_no, :integer, default: 1
@@ -121,14 +126,14 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil)
end
def snapshot_tag_names
def snapshot_tag_names language_code = 'ja'
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.joins(tag: :tag_names)
.where(tag_names: { language_code:, primary_flg: true })
.includes(:sections, tag: :tag_names)
.order('tag_names.name')
.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)
next name if sections.empty?
@@ -137,8 +142,47 @@ class Post < ApplicationRecord
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
"[#{ 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
def self.ms_to_time ms
@@ -323,7 +367,9 @@ class Post < ApplicationRecord
end
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 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)
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?('#'))
end
end
@@ -433,7 +484,12 @@ class Post < ApplicationRecord
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?
year = match[1].to_i
+4
ファイルの表示
@@ -0,0 +1,4 @@
class PostExternalTag < ApplicationRecord
belongs_to :post
belongs_to :external_tag, counter_cache: :post_count
end
+1 -21
ファイルの表示
@@ -1,14 +1,7 @@
class PostTag < ApplicationRecord
include Discard::Model
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
end
belongs_to :post
belongs_to :tag, counter_cache: :post_count
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',
foreign_key: [:post_id, :tag_id],
@@ -18,18 +11,5 @@ class PostTag < ApplicationRecord
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
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
validates :post_id, uniqueness: { scope: :tag_id }
end
+4 -4
ファイルの表示
@@ -4,10 +4,10 @@ class PostTagSection < ApplicationRecord
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
belongs_to :post_tag, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
+3
ファイルの表示
@@ -0,0 +1,3 @@
class Region < ApplicationRecord
;
end
+5
ファイルの表示
@@ -0,0 +1,5 @@
class Script < ApplicationRecord
has_many :locales, foreign_key: :script_code, primary_key: :code
enum :code, Jpan: 'Jpan'
end
+290 -261
ファイルの表示
@@ -2,8 +2,6 @@ require 'set'
class Tag < ApplicationRecord
include MyDiscard
class NicoTagNormalisationError < ArgumentError
;
end
@@ -28,15 +26,12 @@ class Tag < ApplicationRecord
end
has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
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 :posts, through: :post_tags
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 :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
@@ -54,32 +49,22 @@ class Tag < ApplicationRecord
has_many :materials
has_many :tag_versions
has_many :nico_tag_versions
belongs_to :tag_name
delegate :wiki_page, to: :tag_name
has_many :tag_names
attribute :version_no, :integer, default: 1
delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true
enum :category, deerjikist: 'deerjikist',
meme: 'meme',
character: 'character',
general: 'general',
material: 'material',
nico: 'nico',
meta: 'meta'
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 :nico_tags_cannot_be_deprecated
scope :nico_tags, -> { nico }
validate :tag_names_must_be_valid
CATEGORY_PREFIXES = {
'general:' => :general,
@@ -93,8 +78,20 @@ class Tag < ApplicationRecord
'mtr:' => :material,
'meta:' => :meta }.freeze
def name= val
(self.tag_name ||= build_tag_name).name = val
SYSTEM_TAG_NAMES = ['タグ希望', 'bot操作', 'ニジラー情報不詳', '動画', 'ニコニコ', 'YouTube'].freeze
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 wiki_page = tag_name('ja')&.wiki_page
def system?
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
end
def deprecated? = deprecated_at?
@@ -105,221 +102,285 @@ class Tag < ApplicationRecord
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
def self.video = find_or_create_by_tag_name!('動画', category: :meta)
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
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(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
def snapshot_aliases(language_code)
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
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_linked_tag_names
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def snapshot_linked_tag_names language_code
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
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)).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:
language_code = locale.language_code
name = name.to_s.strip
ApplicationRecord.transaction do
tn = TagName.find_or_create_by!(language_code:, name:) do
_1.script_code = locale.script_code
_1.primary_flg = true
end
tag = tn.tag
next tag if tag
tag = Tag.create!(tag_name_id: tn.id, category:)
tn.update!(tag:, primary_flg: true, canonical_id: nil, auto_generated: false)
generate_tag_names!(locale, tag, tn)
tag.association(:tag_names).reset
tag
end
rescue ActiveRecord::RecordNotUnique
retry
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
source_tag_name = source_tag.tag_name('ja')
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.tag_names.update_all(tag_id: target_tag.id, primary_flg: false,
updated_at: Time.current)
source_tag.destroy!
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end
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
private
def generate_tag_names! locale, tag, base_tag_name
ApplicationRecord.transaction do
locales =
Locale
.where(deprecated_at: nil)
.where.not(language_code: locale.language_code)
.order(:language_code, :created_at, :script_code)
.uniq { _1.language_code }
locales.each do |locale|
language_code = locale.language_code
script_code = locale.script_code
TagName.create!(tag_id: tag.id,
language_code:,
name: TagName.generate_name(locale, tag, base_tag_name),
script_code:,
primary_flg: true,
auto_generated: true,
canonical_id: nil)
end
end
end
end
private
def nico_tag_name_must_start_with_nico
n = name.to_s
if ((nico? && !(n.downcase.start_with?('nico:'))) ||
(!(nico?) && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end
end
def tag_names_must_be_valid
tag_names.each do |tag_name|
next if tag_name.valid?
def tag_name_must_be_canonical
if tag_name&.canonical_id?
errors.add :tag_name, 'tag_names へは実体を示す必要があります.'
tag_name.errors[:name].each { errors.add :name, _1 }
end
end
@@ -328,36 +389,4 @@ class Tag < ApplicationRecord
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
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
+41 -19
ファイルの表示
@@ -1,45 +1,61 @@
class TagName < ApplicationRecord
include MyDiscard
has_one :tag
belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :tag, optional: true
has_one :wiki_page
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: { scope: :language_code }
validates :name, presence: true, length: { maximum: 255 }, uniqueness: true
validate :canonical_must_be_canonical
validate :alias_name_must_not_have_prefix
validate :canonical_must_not_be_present_with_tag_or_wiki_page
validate :alias_must_not_have_wiki_page
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?)
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
names.map { |name|
tag_name = tns[name]
if !tag_name || tag_name.primary? || tag_name.tag_id.nil?
name
else
TagName.find_by(language_code: locale.language_code,
tag_id: tag_name.tag_id,
primary_flg: true)&.name || name
end
}.uniq
end
def self.generate_name locale, tag, base_tag_name
# TODO: 言語ごとの自動命名ロジック完成したら書く.
"Tag_##{ tag.id }"
end
private
def canonical_must_be_canonical
if canonical&.canonical_id?
errors.add :canonical, 'canonical は実体を示す必要があります.'
def generated_name_must_be_reserved
if !auto_generated && name.to_s.start_with?('Tag_#')
errors.add :name, 'タグの命名規則に反してゐます.'
end
end
def alias_name_must_not_have_prefix
if canonical_id? && name.to_s.include?(':')
if !(primary?) && name.to_s.include?(':')
errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.'
end
end
def canonical_must_not_be_present_with_tag_or_wiki_page
if canonical_id? && (tag || wiki_page)
errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.'
def alias_must_not_have_wiki_page
if !(primary?) && wiki_page
errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.'
end
end
@@ -48,4 +64,10 @@ class TagName < ApplicationRecord
errors.add :name, '名前に使用できない文字が含まれてゐます.'
end
end
def name_mustnt_start_with_nico
if name.to_s.downcase.start_with?('nico:')
errors.add :name, 'タグの命名規則に反してゐます.'
end
end
end
+30 -21
ファイルの表示
@@ -10,35 +10,44 @@ class TagNameSanitisationRule < ApplicationRecord
validate :source_pattern_must_be_regexp
class << self
def sanitise(name) =
rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) }
def sanitise(name)
rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) }
end
def apply!
TagName.find_each do |tn|
name = sanitise(tn.name)
next if name == tn.name
Language.find_each do |language|
TagName.where(language:).find_each do |tn|
name = sanitise(tn.name)
next if name == tn.name
TagName.transaction do
existing_tn = TagName.find_by(name:)
if existing_tn
existing_tn = existing_tn.canonical || existing_tn
next if existing_tn.id == tn.id
TagName.transaction do
existing_tn = TagName.find_by(language:, name:)
if existing_tn
unless existing_tn.primary_flg
existing_tn = TagName.find_by!(language:,
tag_id: existing_tn.tag_id,
primary_flg: true)
end
next if existing_tn.id == tn.id
existing_tag = Tag.find_by(tag_name_id: existing_tn.id)
source_tag = Tag.find_by(tag_name_id: tn.id)
existing_tag = existing_tn.tag
source_tag = tn.tag
if existing_tag
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
elsif source_tag
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
if existing_tag
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
elsif source_tag
tn.update_columns(tag_id: nil, updated_at: Time.current)
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current)
end
tn.destroy!
next
end
tn.discard!
next
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
end
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
end
end
end
+1
ファイルの表示
@@ -11,5 +11,6 @@ class TagVersion < ApplicationRecord
meta: 'meta' }, validate: true
validates :name, presence: true
validates :names, presence: true
validates :category, presence: true
end
+5 -4
ファイルの表示
@@ -6,11 +6,12 @@ module DeerjikistRepr
module_function
def base deerjikist
deerjikist.as_json(BASE)
def base deerjikist, language_code: 'ja'
deerjikist.as_json(BASE).merge(
tag: deerjikist.tag && TagRepr.base(deerjikist.tag, language_code:))
end
def many deerjikists
deerjikists.map { |d| base(d) }
def many deerjikists, language_code: 'ja'
deerjikists.map { |d| base(d, language_code:) }
end
end
+20
ファイルの表示
@@ -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
+14 -13
ファイルの表示
@@ -12,14 +12,15 @@ module MaterialRepr
module_function
def base material, host:
def base material, host:, language_code: 'ja'
material.as_json(BASE).merge(
tag: material.tag && TagRepr.base(material.tag, language_code:),
file: if material.file.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:)
end,
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),
media_kind: media_kind(material),
file_byte_size: material.file_byte_size,
@@ -27,11 +28,11 @@ module MaterialRepr
export_items: export_items(material))
end
def many materials, host:
materials.map { |m| base(m, host:) }
def many materials, host:, language_code: 'ja'
materials.map { |m| base(m, host:, language_code:) }
end
def list material, host:
def list material, host:, language_code: 'ja'
{ id: material.id,
version_no: material.version_no,
url: material.url,
@@ -40,9 +41,9 @@ module MaterialRepr
source_path: material.source_path,
source_file_id: material.source_file_id,
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_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_text: thumbnail_fallback_text(material, language_code:),
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material),
content_type: material.content_type,
@@ -53,8 +54,8 @@ module MaterialRepr
export_items: export_items(material) }
end
def list_many materials, host:
materials.map { |m| list(m, host:) }
def list_many materials, host:, language_code: 'ja'
materials.map { |m| list(m, host:, language_code:) }
end
def export_paths material
@@ -79,8 +80,8 @@ module MaterialRepr
material.thumbnail, host:)
end
def thumbnail_fallback_text material
material.tag&.name || material.created_at&.strftime('%Y-%m-%d')
def thumbnail_fallback_text material, language_code:
material.tag&.name(language_code) || material.created_at&.strftime('%Y-%m-%d')
end
def thumbnail_fallback_kind material
@@ -98,11 +99,11 @@ module MaterialRepr
'file_other'
end
def compact_tag tag
def compact_tag tag, language_code:
return nil unless tag
{ id: tag.id,
name: tag.name,
name: tag.name(language_code),
category: tag.category,
deprecated_at: tag.deprecated_at }
end
+30 -18
ファイルの表示
@@ -17,23 +17,23 @@ module PostRepr
module_function
def base post, current_user = nil, host: nil
def base post, current_user = nil, host: nil, language_code: 'ja'
json =
if host.present?
common(post, host:)
else
common(post)
end
json['tags'] = tag_json(post)
json['tags'] = tag_json(post, language_code:)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
json
end
def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: [], host: nil
sibling_posts: { }, related: [], host: nil, language_code: 'ja'
if host.present?
base(post, current_user, host:).merge(
base(post, current_user, host:, language_code:).merge(
'parent_posts' => cards(parent_posts, host:),
'child_posts' => cards(child_posts, host:),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
@@ -41,7 +41,7 @@ module PostRepr
},
'related' => cards(related, host:))
else
base(post, current_user).merge(
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|
@@ -86,16 +86,25 @@ module PostRepr
end)
end
def tag_json post
post
.active_post_tags
def tag_json post, language_code:
internal_tags =
post
.post_tags
.reject { _1.tag.deprecated? }
.sort_by { _1.tag.name }
.sort_by { TagRepr.name_for(_1.tag, language_code) }
.map { |post_tag|
TagRepr.inline(post_tag.tag).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
}
TagRepr.inline(post_tag.tag, language_code:).merge(
'children' => [],
'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
def thumbnail_url post, host: nil
@@ -106,11 +115,14 @@ module PostRepr
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
Rails.logger.warn(
"PostRepr.thumbnail_url failed post_id=#{post.id} " \
"attachment_id=#{post.thumbnail.attachment&.id} " \
"blob_id=#{post.thumbnail.blob&.id} " \
"error_class=#{e.class} message=#{e.message}")
payload = {
post_id: post.id,
attachment_id: post.thumbnail.attachment&.id,
blob_id: post.thumbnail.blob&.id,
error_class: e.class,
message: e.message }
Rails.logger.warn("PostRepr.thumbnail_url failed #{ payload.to_json }")
nil
end
end
+12 -7
ファイルの表示
@@ -3,18 +3,23 @@
module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze
module_function
def base tag
tag.as_json(BASE).merge(aliases: tag.snapshot_aliases,
parents: tag.parents.map { _1.as_json(BASE) })
def base tag, language_code: 'ja'
tag.as_json(BASE).merge(name: name_for(tag, language_code),
aliases: tag.snapshot_aliases(language_code),
parents: tag.parents.map { inline(_1, language_code:) })
end
def inline tag
tag.as_json(BASE).merge(aliases: [], parents: [])
def inline tag, language_code: 'ja'
tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: [])
end
def many(tags) = tags.map { |t| base(t) }
def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) }
def name_for tag, language_code
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
end
end
+1 -1
ファイルの表示
@@ -31,7 +31,7 @@ class MaterialVersionRecorder < VersionRecorder
{ url: @record.url,
parent: @record.parent,
tag: @record.tag,
tag_name: @record.tag&.name,
tag_name: @record.tag&.name('ja'),
tag_category: @record.tag&.category,
source_kind: @record.source_kind,
source_uri: @record.source_uri,
+9 -6
ファイルの表示
@@ -1,19 +1,22 @@
class NicoTagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record!
def self.record! external_tag:, event_type:, created_by_user:
new(external_tag:, event_type:, created_by_user:).record!
end
def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
def initialize external_tag:, event_type:, created_by_user:
super(record: external_tag, event_type:, created_by_user:)
end
private
def version_class = NicoTagVersion
def version_association = :nico_tag_versions
def record_key = :tag
def record_key = :external_tag
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
def tracks_version_no_on_record? = false
end
+5 -5
ファイルの表示
@@ -6,7 +6,7 @@ class PostBulkCreator
@host = host
end
def run
def run locale
results = Array.new(@posts.length)
mutex = Mutex.new
next_index = 0
@@ -27,7 +27,7 @@ class PostBulkCreator
break if index >= @posts.length
attributes = @posts[index]
results[index] = create_row(actor, attributes, index)
results[index] = create_row(locale, actor, attributes, index)
rescue StandardError => e
Rails.logger.error(
"post_bulk_creator_worker_failure #{ { error: e.class.name,
@@ -61,12 +61,12 @@ class PostBulkCreator
private
def create_row actor, attributes, index
def create_row locale, actor, attributes, index
preflight =
PostCreatePreflight.new(
attributes: attributes,
thumbnail: thumbnail_for(index, attributes),
host: @host).run
host: @host).run(locale)
if preflight[:existing_post_id].present?
return {
status: 'skipped',
@@ -76,7 +76,7 @@ class PostBulkCreator
post = PostCreator.new(
actor: actor,
attributes: normalised_attributes(attributes, preflight, index)).create!
attributes: normalised_attributes(attributes, preflight, index)).create!(locale)
result = {
status: 'created',
post: { id: post.id } }
+75 -64
ファイルの表示
@@ -8,50 +8,50 @@ class PostCreatePlan
@existing_tags_by_name = nil
end
def build!
direct_tag_specs, tag_sections = parse_direct_tag_specs
default_tag_specs = build_default_tag_specs(direct_tag_specs)
def build! locale
direct_tag_specs, tag_sections = parse_direct_tag_specs(locale)
default_tag_specs = build_default_tag_specs(locale, direct_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] })
validate_new_tag_specs!(snapshot_tag_specs)
post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs)
preload_existing_tags_by_name!(locale, snapshot_tag_specs.map { _1[:name] })
validate_new_tag_specs!(locale, snapshot_tag_specs)
post_tag_specs = expand_parent_tag_specs(locale, snapshot_tag_specs)
video_ms = normalise_video_ms(snapshot_tag_specs)
validate_video_sections!(video_ms, tag_sections)
parent_post_ids = normalise_parent_post_ids
validate_parent_post_ids!(parent_post_ids)
{
url: @attributes[:url],
title: @attributes[:title].to_s,
thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence,
tags: serialised_tags(direct_tag_specs, tag_sections),
display_tags: display_tags(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s,
video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '),
direct_tag_specs: direct_tag_specs,
default_tag_specs: default_tag_specs,
snapshot_tag_specs: snapshot_tag_specs,
post_tag_specs: post_tag_specs,
tag_sections: tag_sections,
normalised_parent_post_ids: parent_post_ids }
{ url: @attributes[:url],
title: @attributes[:title].to_s,
thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence,
tags: serialised_tags(direct_tag_specs, tag_sections),
display_tags: display_tags(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s,
video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '),
direct_tag_specs: direct_tag_specs,
default_tag_specs: default_tag_specs,
snapshot_tag_specs: snapshot_tag_specs,
post_tag_specs: post_tag_specs,
tag_sections: tag_sections,
normalised_parent_post_ids: parent_post_ids }
end
private
def tag_names = @attributes[:tags].to_s.split
def parse_direct_tag_specs
def parse_direct_tag_specs locale
tag_sections = { }
direct_tag_specs = []
tag_names.each do |raw_name|
tag_name, category, sections = parse_raw_tag_name(raw_name)
existing_tag = existing_tags_by_name[tag_name]
raise Tag::NicoTagNormalisationError if existing_tag&.nico?
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated?
tag_name, category, sections = parse_raw_tag_name(locale, raw_name)
existing_tag = existing_tags_by_name(locale)[tag_name]
if existing_tag&.deprecated?
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name(locale.language_code)]
end
direct_tag_specs << {
name: tag_name,
@@ -67,7 +67,7 @@ class PostCreatePlan
[merge_tag_specs(direct_tag_specs), tag_sections]
end
def parse_raw_tag_name raw_name
def parse_raw_tag_name locale, raw_name
name = raw_name.to_s
prefix, category =
Tag::CATEGORY_PREFIXES.find {
@@ -76,7 +76,7 @@ class PostCreatePlan
name = name.sub(/\A#{ prefix }/i, '')
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] }"
next if match[2].empty? && match[3].empty?
@@ -85,19 +85,21 @@ class PostCreatePlan
end_raw: match[3],
tag_name: name)
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
[resolved_tag_name(name), category&.to_sym, sections]
[resolved_tag_name(locale, name), category&.to_sym, sections]
end
def build_default_tag_specs direct_tag_specs
def build_default_tag_specs locale, direct_tag_specs
default_tag_specs = []
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
default_tag_specs << {
name: TAGME_TAG_NAME,
category: :meta }
end
if direct_tag_specs.none? { deerjikist_tag_spec?(_1) }
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) }
default_tag_specs << {
name: NO_DEERJIKIST_TAG_NAME,
category: :meta }
@@ -106,17 +108,18 @@ class PostCreatePlan
default_tag_specs
end
def validate_new_tag_specs! specs
def validate_new_tag_specs! locale, specs
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
def validate_new_tag_spec! spec
tag_name = TagName.new(name: spec[:name])
tag = Tag.new(category: spec[:category], tag_name:)
def validate_new_tag_spec! locale, spec
tag_name = TagName.new(name: spec[:name], language_code: locale.language_code,
script_code: locale.script_code, primary_flg: true)
tag = Tag.new(category: spec[:category])
return if tag_name.valid? && tag.valid?
post = Post.new
@@ -131,20 +134,22 @@ class PostCreatePlan
raise ActiveRecord::RecordInvalid, post
end
def expand_parent_tag_specs snapshot_tag_specs
existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] }
def expand_parent_tag_specs locale, snapshot_tag_specs
existing_snapshot_tags = snapshot_tag_specs.filter_map do
existing_tags_by_name(locale)[_1[:name]]
end
expanded_parent_specs =
Tag.expand_parent_tags(existing_snapshot_tags)
.reject(&:deprecated?)
.map { |tag|
{
name: tag.name,
category: tag.category.to_sym } }
{ name: tag.name(locale.language_code),
category: tag.category.to_sym }
}
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
end
def merge_tag_specs specs
specs.each_with_object({ }) do |spec, merged|
specs.each_with_object({ }) { |spec, merged|
merged[spec[:name]] =
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
merged[spec[:name]]
@@ -153,36 +158,40 @@ class PostCreatePlan
name: spec[:name],
category: spec[:category] }
end
end.values.sort_by { _1[:name] }
}.values.sort_by { _1[:name] }
end
def existing_tags_by_name
def existing_tags_by_name locale
@existing_tags_by_name ||= begin
names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq
Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name)
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
TagName.where(language_code: locale.language_code, primary_flg: true, name: names)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
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
missing_names = wanted_names - existing_tags_by_name.keys
missing_names = wanted_names - existing_tags_by_name(locale).keys
return if missing_names.empty?
existing_tags_by_name.merge!(
Tag.joins(:tag_name)
.where(tag_names: { name: missing_names })
.index_by(&:name))
existing_tags_by_name(locale).merge!(
TagName.where(language_code: locale.language_code,
primary_flg: true,
name: missing_names)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag })
end
def canonical_tag_name_without_sections raw_name
name, = parse_raw_tag_name(raw_name)
def canonical_tag_name_without_sections locale, raw_name
name, = parse_raw_tag_name(locale, raw_name)
name
end
def deerjikist_tag_spec? spec
def deerjikist_tag_spec? locale, spec
return true if spec[:category] == :deerjikist
existing_tags_by_name[spec[:name]]&.deerjikist?
existing_tags_by_name(locale)[spec[:name]]&.deerjikist?
end
def normalise_parent_post_ids
@@ -260,10 +269,12 @@ class PostCreatePlan
end
end
def resolved_tag_name name
tag_name = TagName.includes(:canonical).find_by(name:)
return name if tag_name.nil?
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?
(tag_name.canonical || tag_name).name
TagName.find_by(language_code: locale.language_code,
tag_id: tag_name.tag_id,
primary_flg: true)&.name || name
end
end
+3 -2
ファイルの表示
@@ -15,9 +15,10 @@ class PostCreatePreflight
@host = host
end
def run
def run locale
preview = PostImportPreviewer.new.preview_rows(
rows: [preview_row],
locale:,
fetch_metadata: false).first
if preview[:existing_post_id].present?
return {
@@ -53,7 +54,7 @@ class PostCreatePreflight
original_created_from: preview[:attributes]['original_created_from'],
original_created_before: preview[:attributes]['original_created_before'],
duration: preview[:attributes]['duration'],
video_ms: preview[:attributes]['video_ms'] }).build!
video_ms: preview[:attributes]['video_ms'] }).build!(locale)
{
url: plan[:url],
+40 -32
ファイルの表示
@@ -9,7 +9,7 @@ class PostCreator
@field_warnings = { }
end
def create!
def create! locale
thumbnail_attachment = prepare_thumbnail_attachment
post = Post.new(title: @attributes[:title].presence,
url: @attributes[:url],
@@ -21,14 +21,17 @@ class PostCreator
ApplicationRecord.transaction do
post.save!
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
snapshot_tags = planned_snapshot_tags
post_tags = planned_post_tags
sections = planned_sections
TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
post.video_ms = planned_video_ms
snapshot_tags = planned_snapshot_tags(locale)
post_tags = planned_post_tags(locale)
sections = planned_sections(locale)
TagVersioning.record_tag_snapshots!(
snapshot_tags,
created_by_user: @actor,
language_code: locale.language_code)
post.video_ms = planned_video_ms(locale)
post.save!
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)
end
post
@@ -45,69 +48,73 @@ class PostCreator
thumbnail_base: @attributes[:thumbnail_base].presence)
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_video_ms
planned_create_attributes[:video_ms]
def planned_parent_post_ids locale
planned_create_attributes(locale)[:normalised_parent_post_ids]
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
if @attributes.key?(:snapshot_tag_specs)
snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || [])
post_tags = materialise_tags(@attributes[:post_tag_specs] || [])
snapshot_tags = materialise_tags(locale, @attributes[:snapshot_tag_specs] || [])
post_tags = materialise_tags(locale, @attributes[:post_tag_specs] || [])
{
snapshot_tags: snapshot_tags,
post_tags: post_tags,
tag_sections: materialise_sections(
@attributes[:tag_sections] || { },
snapshot_tags,
post_tags),
post_tags,
locale),
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
video_ms: @attributes[:video_ms] }
else
build_materialised_plan
build_materialised_plan(locale)
end
end
end
def build_materialised_plan
plan = PostCreatePlan.new(attributes: @attributes).build!
snapshot_tags = materialise_tags(plan[:snapshot_tag_specs] || [])
post_tags = materialise_tags(plan[:post_tag_specs] || [])
def build_materialised_plan locale
plan = PostCreatePlan.new(attributes: @attributes).build!(locale)
snapshot_tags = materialise_tags(locale, plan[:snapshot_tag_specs] || [])
post_tags = materialise_tags(locale, plan[:post_tag_specs] || [])
{
snapshot_tags: snapshot_tags,
post_tags: post_tags,
tag_sections: materialise_sections(
plan[:tag_sections] || { },
snapshot_tags,
post_tags),
post_tags,
locale),
normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [],
video_ms: plan[:video_ms] }
end
def materialise_tags specs
def materialise_tags locale, specs
Array(specs).each_with_object({ }) do |spec, tags|
name = spec[:name] || spec['name']
category = spec[:category] || spec['category']
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:)
tag.update!(category:) if tag.category.to_sym != category.to_sym
tags[name] ||= tag
end.values
end
def materialise_sections sections_by_name, snapshot_tags, post_tags
tags_by_name = post_tags.index_by(&:name)
def materialise_sections sections_by_name, snapshot_tags, post_tags, locale = Locale.nipponese
tags_by_name = post_tags.index_by { _1.name(locale.language_code) }
snapshot_tags.each do |tag|
tags_by_name[tag.name] ||= tag
tags_by_name[tag.name(locale.language_code)] ||= tag
end
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
desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set
Tag.where(id: desired_ids - current_ids).find_each do |tag|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
@@ -133,10 +142,9 @@ class PostCreator
end_ms:)
end
end
PostTag.where(post_id: post.id,
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag|
post_tag.discard_by!(@actor)
end
tag_id: (current_ids - desired_ids).to_a).destroy_all
end
def sync_parent_posts! post, ids
+28 -24
ファイルの表示
@@ -16,7 +16,7 @@ class PostImportPreviewer
THUMBNAIL_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) }
url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally
existing_posts =
@@ -30,13 +30,14 @@ class PostImportPreviewer
url_counts)
known_tags =
preload_known_tags(
locale,
prepared_rows,
fetch_metadata,
metadata_cache,
existing_posts,
url_counts)
prepared_rows.map { |row|
preview_row(row,
preview_row(locale, row,
fetch_metadata:,
metadata_cache:,
existing_posts:,
@@ -59,7 +60,7 @@ class PostImportPreviewer
source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url))
end
def preview_row row,
def preview_row locale, row,
fetch_metadata:,
metadata_cache:,
existing_posts:,
@@ -124,7 +125,8 @@ class PostImportPreviewer
attributes['url'] = url
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,
known_tags)
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
@@ -225,15 +227,15 @@ class PostImportPreviewer
end
{ data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_failure #{ payload.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
@@ -335,18 +337,18 @@ class PostImportPreviewer
def safe_fetch_metadata url
fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue StandardError => e
payload = { error: e.class.name, message: e.message }
Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_unexpected_failure #{ payload.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
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|
attributes = initial_attributes(row)
provenance = initial_provenance(row)
@@ -358,15 +360,13 @@ class PostImportPreviewer
metadata = metadata_for(row[:normal_url], metadata_cache)
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
end
preview_tag_names(merged_tags(tag_sources, provenance['tags']))
preview_tag_names(locale, merged_tags(tag_sources, provenance['tags']))
}.compact.uniq
return { } if names.empty?
Tag.joins(:tag_name)
.where(tag_names: { name: names })
.includes(:tag_name)
.to_a
.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
def preload_parent_ids prepared_rows
@@ -437,19 +437,21 @@ class PostImportPreviewer
sources['automatic'].to_s
end
def preview_tag_names raw
def preview_tag_names locale, raw
names = raw.to_s.split
return [] if names.empty?
if names.any? { _1.downcase.start_with?('nico:') }
return []
end
names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
names.map { |name|
TagName.canonicalise(locale, name.sub(/\[.*\]\z/, '')).first
}
rescue Tag::SectionLiteralParseError
[]
end
def validate_preview_tags raw, errors, known_tags
def validate_preview_tags locale, raw, errors, known_tags
names = raw.to_s.split
return if names.empty?
if names.any? { _1.downcase.start_with?('nico:') }
@@ -457,9 +459,11 @@ class PostImportPreviewer
return
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] }
deprecated = existing.select(&:deprecated?).map(&:name)
deprecated = existing.select(&:deprecated?).map { _1.name(locale.language_code) }
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
rescue Tag::SectionLiteralParseError
errors[:tags] = ['タグ区間の記法が不正です.']
+6 -7
ファイルの表示
@@ -3,10 +3,9 @@ require 'date'
class PostMetadataFetcher
TIMESTAMP_PATTERN =
Regexp.new(
'\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
def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url)
@@ -48,9 +47,9 @@ class PostMetadataFetcher
existing_tags =
Tag
.joins(:tag_name)
.where(tag_names: { name: names })
.index_by(&:name)
.joins(:tag_names)
.where(tag_names: { language_code: 'ja', primary_flg: true, name: names })
.index_by { _1.name('ja') }
names.map { |name|
tag = existing_tags[name]
+1
ファイルの表示
@@ -25,6 +25,7 @@ class PostVersionRecorder < VersionRecorder
thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '),
tags_json: @record.snapshot_tags_json,
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before }
+22 -3
ファイルの表示
@@ -1,5 +1,5 @@
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!
end
@@ -14,10 +14,29 @@ class TagVersionRecorder < VersionRecorder
def record_key = :tag
def snapshot_attributes
{ name: @record.name,
names = snapshot_names
compatibility_names = names.fetch('ja')
{ name: compatibility_names.fetch('name'),
category: @record.category,
deprecated_at: @record.deprecated_at,
aliases: @record.snapshot_aliases.join(' '),
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
names:,
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end
def snapshot_names
@record.tag_names.order(:language_code, :name).group_by(&:language_code)
.transform_values do |tag_names|
primary = tag_names.find(&:primary?)
aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name|
{ 'name' => tag_name.name, 'script_code' => tag_name.script_code }
}
{ 'name' => primary.name,
'script_code' => primary.script_code,
'auto_generated' => primary.auto_generated,
'aliases' => aliases }
end
end
end
+10 -26
ファイルの表示
@@ -1,38 +1,22 @@
class TagVersioning
def self.record! tag, event_type:, created_by_user:
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
def self.record! tag, event_type:, created_by_user:, language_code: 'ja'
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:)
end
def self.ensure_snapshot! tag, created_by_user:
if tag.nico?
return if tag.nico_tag_versions.exists?
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
return if tag.tag_versions.exists?
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
else
return if tag.tag_versions.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:, language_code:)
end
def self.record_tag_snapshot! tag, created_by_user:
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:)
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
event_type = tag.tag_versions.exists? ? :update : :create
record!(tag, event_type:, created_by_user:, language_code:)
end
def self.record_tag_snapshots! tags, created_by_user:
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
tags.each do |tag|
record_tag_snapshot!(tag, created_by_user:)
record_tag_snapshot!(tag, created_by_user:, language_code:)
end
end
end
+3 -3
ファイルの表示
@@ -41,7 +41,7 @@ class TheatrePostSelector
def weighted_candidates
@weighted_candidates ||= begin
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|
post_tags = post.tags.to_a
@@ -82,7 +82,7 @@ class TheatrePostSelector
def tag_penalty_json
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
.map { |tag_id, penalty|
@@ -113,7 +113,7 @@ class TheatrePostSelector
def light_tag_json tag
{ id: tag.id,
name: tag.name,
name: tag.name('ja'),
category: tag.category }
end
end
+5 -1
ファイルの表示
@@ -47,10 +47,14 @@ class VersionRecorder
end
def update_record_version_no! version_no
return unless tracks_version_no_on_record?
@record.update_columns(version_no:)
@record.version_no = version_no
end
def tracks_version_no_on_record? = true
def validate_version_sequence! latest
if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
@@ -60,7 +64,7 @@ class VersionRecorder
raise "#{ version_class.name } create event already exists"
end
return unless latest
return if !(latest) || !(tracks_version_no_on_record?)
if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
+3 -3
ファイルの表示
@@ -103,7 +103,7 @@ module Youtube
end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
@@ -117,8 +117,8 @@ module Youtube
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.destroy!
end
end
+3 -3
ファイルの表示
@@ -1,4 +1,4 @@
require "active_support/core_ext/integer/time"
require 'active_support/core_ext/integer/time'
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
@@ -17,8 +17,8 @@ Rails.application.configure do
# Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching.
if Rails.root.join("tmp/caching-dev.txt").exist?
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
if Rails.root.join('tmp/caching-dev.txt').exist?
config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" }
else
config.action_controller.perform_caching = false
end
+1 -2
ファイルの表示
@@ -1,5 +1,5 @@
Rails.application.routes.draw do
resources :nico_tags, path: 'tags/nico', only: [:index, :update]
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
scope 'tags/:parent_id/children', controller: :tag_children do
post ':child_id', action: :create
@@ -55,7 +55,6 @@ Rails.application.routes.draw do
get :metadata
post :bulk
get :random
get :changes
get :versions, to: 'post_versions#index'
end
+299
ファイルの表示
@@ -0,0 +1,299 @@
class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0]
RESOLUTION_GRACE = 1.second
SECTION_LITERAL_PATTERN = /\[[^\[\]\s]*-[^\[\]\s]*\]\z/
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
class MigrationTag < ActiveRecord::Base
self.table_name = 'tags'
end
class MigrationTagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationNicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
def up
add_column :post_versions, :tags_json, :json, after: :tags
MigrationPostVersion.reset_column_information
backfill_missing_initial_tag_versions!
intervals_by_name = build_intervals_by_name
say_with_time 'Backfilling post_versions.tags_json' do
MigrationPostVersion.where(tags_json: nil).find_each(batch_size: 500) do |version|
version.update_columns(tags_json: build_tags_json(version, intervals_by_name))
end
end
change_column_null :post_versions, :tags_json, false
schema = connection.quote(JSON.generate({
type: 'array',
items: { type: 'object',
properties: { id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
category: { type: 'string', enum: ['deerjikist',
'meme',
'character',
'general',
'material',
'meta',
'nico'] },
sections: { type: 'array',
items: { type: 'object',
properties: { begin_ms: { type: 'integer',
minimum: 0 },
end_ms: { type: ['integer',
'null'],
minimum: 0 } },
required: ['begin_ms', 'end_ms'],
additionalProperties: false } } },
required: ['id', 'version_no', 'name', 'category', 'sections'],
additionalProperties: false } }))
add_check_constraint :post_versions,
"JSON_SCHEMA_VALID(#{ schema }, tags_json)",
name: 'chk_post_versions_tags_json_schema'
end
def down
remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'
remove_column :post_versions, :tags_json
end
private
def backfill_missing_initial_tag_versions!
say_with_time 'Backfilling missing initial tag versions' do
tag_rows = missing_initial_version_rows(MigrationTagVersion, nico: false)
nico_rows = missing_initial_version_rows(MigrationNicoTagVersion, nico: true)
MigrationTagVersion.insert_all!(tag_rows) if tag_rows.any?
MigrationNicoTagVersion.insert_all!(nico_rows) if nico_rows.any?
tag_rows.length + nico_rows.length
end
end
def missing_initial_version_rows version_class, nico:
first_versions =
version_class
.order(:tag_id, :version_no)
.to_a
.group_by(&:tag_id)
.transform_values(&:first)
rows = []
MigrationTag.find_each do |tag|
next if (tag.category == 'nico') != nico
first_version = first_versions[tag.id]
next unless first_version
next if valid_initial_version?(first_version)
assert_inferable_initial_version!(tag, first_version)
rows << initial_version_row(tag, first_version, nico:)
end
rows
end
def valid_initial_version? version
version.version_no == 1 && version.event_type == 'create'
end
def assert_inferable_initial_version! tag, version
inferable =
version.version_no == 2 &&
version.event_type == 'discard' &&
tag.created_at < version.created_at
return if inferable
details = [
"tag_id=#{ tag.id }",
"version_no=#{ version.version_no }",
"event_type=#{ version.event_type.inspect }",
"tag_created_at=#{ tag.created_at.iso8601(6) }",
"version_created_at=#{ version.created_at.iso8601(6) }"]
raise "Cannot infer initial tag version: #{ details.join(', ') }"
end
def initial_version_row tag, discard_version, nico:
row = {
tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: discard_version.name,
created_at: tag.created_at,
created_by_user_id: nil }
if nico
return row.merge(linked_tags: discard_version.linked_tags)
end
row.merge(
category: discard_version.category,
aliases: discard_version.aliases,
parent_tag_ids: discard_version.parent_tag_ids,
deprecated_at: discard_version.deprecated_at)
end
def build_intervals_by_name
intervals_by_name = Hash.new { |hash, name| hash[name] = [] }
versions_by_kind = {
tag: versions_by_tag_id(MigrationTagVersion),
nico: versions_by_tag_id(MigrationNicoTagVersion) }
current_names = current_names_by_tag_id
MigrationTag.find_each do |tag|
nico = tag.category == 'nico'
kind = nico ? :nico : :tag
versions = versions_by_kind.fetch(kind).fetch(tag.id, [])
intervals_for(
tag,
versions,
current_name: current_names.fetch(tag.id),
nico:).each do |interval|
name = interval.delete(:name)
intervals_by_name[name] << interval
end
end
intervals_by_name
end
def versions_by_tag_id version_class
version_class
.order(:tag_id, :version_no)
.to_a
.group_by(&:tag_id)
end
def current_names_by_tag_id
MigrationTagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.pluck('tags.id', 'tag_names.name')
.to_h
end
def intervals_for tag, versions, current_name:, nico:
if versions.empty?
return [{
name: current_name,
tag_id: tag.id,
version_no: tag.version_no,
category: nico ? 'nico' : tag.category,
from: tag.created_at,
to: tag.discarded_at }]
end
versions.each_with_index.filter_map do |version, index|
next if version.event_type == 'discard'
{
name: version.name,
tag_id: tag.id,
version_no: version.version_no,
category: nico ? 'nico' : version.category,
from: version.created_at,
to: versions[index + 1]&.created_at || tag.discarded_at }
end
end
def build_tags_json version, intervals_by_name
entries = version.tags.to_s.split.map do |literal|
name = tag_name_from_literal(literal)
interval = resolve_tag!(intervals_by_name.fetch(name, []), name:, version:)
{ 'id' => interval.fetch(:tag_id),
'version_no' => interval.fetch(:version_no),
'name' => name,
'category' => interval.fetch(:category),
'sections' => [] }
end
assert_unique_tag_ids!(version, entries)
entries.sort_by { |entry| entry.fetch('id') }
end
def tag_name_from_literal literal
name = literal.dup
name.sub!(SECTION_LITERAL_PATTERN, '') while name.match?(
SECTION_LITERAL_PATTERN)
if name.empty? || name.include?('[') || name.include?(']')
raise "Invalid legacy tag literal: #{ literal.inspect }"
end
name
end
def resolve_tag! intervals, name:, version:
time = version.created_at
candidates = intervals.select do |interval|
interval.fetch(:from) <= time &&
(interval[:to].nil? || time < interval.fetch(:to))
end
candidates = future_candidates(intervals, time) if candidates.empty?
return candidates.first if candidates.one?
candidate_versions = candidates.map do |candidate|
[candidate.fetch(:tag_id), candidate.fetch(:version_no)]
end
details = [
"post_version_id=#{ version.id }",
"post_id=#{ version.post_id }",
"name=#{ name.inspect }",
"created_at=#{ time.iso8601(6) }",
"candidates=#{ candidate_versions.inspect }"].join(', ')
raise "Could not resolve tag snapshot: #{ details }"
end
def future_candidates intervals, time
candidates = intervals.select do |interval|
interval.fetch(:from) > time &&
interval.fetch(:from) <= time + RESOLUTION_GRACE
end
return [] if candidates.empty?
nearest_from = candidates.map { |interval| interval.fetch(:from) }.min
candidates.select do |interval|
interval.fetch(:from) == nearest_from
end
end
def assert_unique_tag_ids! version, entries
duplicate_tag_ids =
entries
.map { |entry| entry.fetch('id') }
.tally
.select { |_tag_id, count| count > 1 }
.keys
return if duplicate_tag_ids.empty?
details = [
"post_version_id=#{ version.id }",
"duplicate_tag_ids=#{ duplicate_tag_ids.inspect }"].join(', ')
raise "Duplicate tag IDs: #{ details }"
end
end
+53
ファイルの表示
@@ -0,0 +1,53 @@
class DeleteInactiveRecordsFromPostTags < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
post_tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :post_tags, [:tag_id, :discarded_at]
remove_index :post_tags, [:post_id, :discarded_at]
remove_index :post_tags, name: 'idx_post_tags_active_unique'
remove_index :post_tags, :discarded_at
remove_foreign_key :post_tags, column: :deleted_user_id
remove_index :post_tags, :deleted_user_id
remove_column :post_tags, :active_unique_key
remove_column :post_tags, :is_active
remove_column :post_tags, :discarded_at
remove_column :post_tags, :deleted_user_id
remove_column :post_tags, :updated_at
execute <<~SQL
ALTER TABLE
post_tags
MODIFY COLUMN
id BIGINT NOT NULL
SQL
execute <<~SQL
ALTER TABLE
post_tags
DROP PRIMARY KEY
SQL
remove_column :post_tags, :id
execute <<~SQL
ALTER TABLE
post_tags
ADD PRIMARY KEY
(post_id, tag_id)
SQL
remove_index :post_tags, :post_id
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
@@ -0,0 +1,11 @@
class AddForeignKeyOnPostIdAndTagIdInPostTagSections < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :post_tag_sections, :posts, column: :post_id
remove_foreign_key :post_tag_sections, :tags, column: :tag_id
add_foreign_key :post_tag_sections, :post_tags,
column: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
on_delete: :cascade
end
end
+46
ファイルの表示
@@ -0,0 +1,46 @@
class DeleteDiscardedRecordsFromTags < ActiveRecord::Migration[8.0]
def up
remove_foreign_key :tag_versions, :tags, column: :tag_id
remove_foreign_key :nico_tag_versions, :tags, column: :tag_id
remove_foreign_key :material_versions, :tags, column: :tag_id
execute <<~SQL
DELETE
ntr
FROM
nico_tag_relations ntr
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ntr.tag_id, ntr.nico_tag_id)
SQL
execute <<~SQL
DELETE
ti
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ti.tag_id, ti.parent_tag_id)
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tags, :discarded_at
remove_column :tags, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+18
ファイルの表示
@@ -0,0 +1,18 @@
class DeleteDiscardedRecordsFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tag_names, :discarded_at
remove_column :tag_names, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+54
ファイルの表示
@@ -0,0 +1,54 @@
class CreateExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :external_tags do |t|
t.string :platform, limit: 16, null: false
t.string :name, limit: 255, null: false
t.integer :post_count, null: false, default: 0
t.datetime :created_at, null: false
t.index [:platform, :name], unique: true
end
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
t.id
, 'nico' AS platform
, SUBSTR(tn.name, 6) AS name
, t.post_count
, t.created_at
FROM
tags t
INNER JOIN
tag_names tn
ON
tn.id = t.tag_name_id
AND t.category = 'nico'
SQL
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
ntv.tag_id
, 'nico' AS platform
, SUBSTR(ntv.name, 6) AS name
, 0 AS post_count
, ntv.created_at
FROM
nico_tag_versions ntv
LEFT JOIN
external_tags et
ON
et.id = ntv.tag_id
WHERE
ntv.version_no = 1
AND et.id IS NULL
SQL
end
def down
drop_table :external_tags
end
end
+29
ファイルの表示
@@ -0,0 +1,29 @@
class CreatePostExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :post_external_tags, primary_key: [:post_id, :external_tag_id] do |t|
t.references :post, null: false, index: false, foreign_key: true
t.references :external_tag, null: false, foreign_key: true
t.datetime :created_at, null: false
end
execute <<~SQL
INSERT INTO
post_external_tags(post_id, external_tag_id, created_at)
SELECT
pt.post_id
, pt.tag_id AS external_tag_id
, pt.created_at
FROM
post_tags pt
INNER JOIN
tags t
ON
pt.tag_id = t.id
AND t.category = 'nico'
SQL
end
def down
drop_table :post_external_tags
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class ChangeForeignKeyOnNicoTagRelations < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :nico_tag_relations, :tags, column: :nico_tag_id
add_foreign_key :nico_tag_relations, :external_tags, column: :nico_tag_id
end
end
+199
ファイルの表示
@@ -0,0 +1,199 @@
class MigrateExternalTags < ActiveRecord::Migration[8.0]
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
def up
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
post_tag_sections pts
INNER JOIN
tags t
ON
t.id = pts.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "post_tag_sections に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
materials m
INNER JOIN
tags t
ON
t.id = m.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "materials に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.id = ti.tag_id
OR t.id = ti.parent_tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "tag_implications に #{ x } 件のチンカスがあります!"
end
execute <<~SQL
DELETE
ts
FROM
tag_similarities ts
INNER JOIN
tags t
ON
t.category = 'nico'
AND (t.id = ts.tag_id
OR t.id = ts.target_tag_id)
SQL
execute <<~SQL
DELETE
tset
FROM
theatre_skip_event_tags tset
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = tset.tag_id
SQL
remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'
say_with_time 'Migrate post_versions.tags_json' do
count = 0
MigrationPostVersion.find_each(batch_size: 500) do |version|
tags = version.tags_json.map do |tag|
if tag.fetch('category') == 'nico'
{ 'external_tag_id' => tag.fetch('id') }
else
{ 'tag_id' => tag.fetch('id'),
'version_no' => tag.fetch('version_no'),
'name' => tag.fetch('name'),
'category' => tag.fetch('category'),
'sections' => tag.fetch('sections') }
end
end
version.update_columns(tags_json: tags)
count += 1
end
count
end
add_tags_json_constraint!
tag_name_ids = connection.select_values(<<~SQL)
SELECT
tag_name_id
FROM
tags
WHERE
category = 'nico'
SQL
connection.transaction do
execute <<~SQL
DELETE
pt
FROM
post_tags pt
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = pt.tag_id
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
category = 'nico'
SQL
unless tag_name_ids.empty?
execute <<~SQL
DELETE
FROM
tag_names
WHERE
id IN (#{ tag_name_ids.join(', ') })
SQL
end
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def add_tags_json_constraint!
schema = { type: 'array',
items: { oneOf: [internal_tag_schema, external_tag_schema] } }
quoted_schema = connection.quote(JSON.generate(schema))
add_check_constraint :post_versions,
"JSON_SCHEMA_VALID(#{ quoted_schema }, tags_json)",
name: 'chk_post_versions_tags_json_schema'
end
def internal_tag_schema
{ type: 'object',
properties: { tag_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
category: { type: 'string',
enum: ['deerjikist',
'meme',
'character',
'general',
'material',
'meta'] },
sections: sections_schema },
required: ['tag_id', 'version_no', 'sections'],
additionalProperties: false }
end
def external_tag_schema
{ type: 'object',
properties: { external_tag_id: { type: 'integer', minimum: 1 } },
required: ['external_tag_id'],
additionalProperties: false }
end
def sections_schema
{ type: 'array',
items: { type: 'object',
properties: { begin_ms: { type: 'integer', minimum: 0 },
end_ms: { type: ['integer', 'null'], minimum: 0 } },
required: ['begin_ms', 'end_ms'],
additionalProperties: false } }
end
end
+20
ファイルの表示
@@ -0,0 +1,20 @@
class CreateLanguages < ActiveRecord::Migration[8.0]
def up
create_table :languages, id: { type: :string, limit: 16 }, primary_key: :code do |t|
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
end
execute <<~SQL
INSERT INTO
languages(code, name, created_at)
VALUES
('ja', '日本語', #{ connection.quote Time.current })
SQL
end
def down
drop_table :languages
end
end
+23
ファイルの表示
@@ -0,0 +1,23 @@
class CreateScripts < ActiveRecord::Migration[8.0]
def up
create_table :scripts, id: { type: 'CHAR(4)' }, primary_key: :code do |t|
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
t.check_constraint "REGEXP_LIKE(code, '^[A-Z][a-z]{3}$', 'c')",
name: 'scripts_code_check'
end
execute <<~SQL
INSERT INTO
scripts(code, name, created_at)
VALUES
('Jpan', '漢字、仮名文字およびラテン文字', #{ connection.quote Time.current })
SQL
end
def down
drop_table :scripts
end
end
+25
ファイルの表示
@@ -0,0 +1,25 @@
class CreateRegions < ActiveRecord::Migration[8.0]
def up
create_table :regions, id: { type: :integer, limit: 2 }, primary_key: :code do |t|
t.column :alpha_code, 'CHAR(2)'
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
t.check_constraint "REGEXP_LIKE(alpha_code, '^[A-Z]{2}$', 'c')",
name: 'regions_alpha_code_check'
t.index [:alpha_code], unique: true
end
execute <<~SQL
INSERT INTO
regions(code, alpha_code, name, created_at)
VALUES
(392, 'JP', '日本国', #{ connection.quote Time.current })
SQL
end
def down
drop_table :regions
end
end
+38
ファイルの表示
@@ -0,0 +1,38 @@
class CreateLocales < ActiveRecord::Migration[8.0]
def up
create_table :locales, id: { type: :string, limit: 32 }, primary_key: :code do |t|
t.string :language_code, limit: 16, null: false, index: true
t.column :script_code, 'CHAR(4)', null: false
t.integer :region_code, limit: 2, null: false
t.string :variants, limit: 255, null: false, default: ''
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
t.foreign_key :languages, column: :language_code, primary_key: :code
t.foreign_key :scripts, column: :script_code, primary_key: :code
t.foreign_key :regions, column: :region_code, primary_key: :code
t.check_constraint ('REGEXP_LIKE(variants, ' +
"'^(" +
'(([a-z0-9]{5,8}|[0-9][a-z0-9]{3})' +
'(-([a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*' +
'(-x(-[a-z0-9]{1,8})+)?)' +
'|(x(-[a-z0-9]{1,8})+)' +
")?$', 'c')"),
name: 'locales_variants_check'
t.index [:language_code, :script_code, :region_code, :variants], unique: true
end
execute <<~SQL
INSERT INTO
locales(code, language_code, script_code, region_code, name, created_at)
VALUES
('ja', 'ja', 'Jpan', 392, '日本語', #{ connection.quote Time.current })
SQL
end
def down
drop_table :locales
end
end
+69
ファイルの表示
@@ -0,0 +1,69 @@
class AddColumnsToTagNames < ActiveRecord::Migration[8.0]
def up
add_reference :tag_names, :tag, after: :id, foreign_key: true
add_column :tag_names, :language_code, :string,
limit: 16, null: false, after: :tag_id, default: 'ja'
add_column :tag_names, :script_code, 'CHAR(4)',
null: false, after: :name, default: 'Jpan'
add_column :tag_names, :primary_flg, :boolean,
null: false, after: :script_code, default: true
add_column :tag_names, :auto_generated, :boolean,
null: false, default: false, after: :primary_flg
add_foreign_key :tag_names, :languages, column: :language_code, primary_key: :code
add_foreign_key :tag_names, :scripts, column: :script_code, primary_key: :code
remove_index :tag_names, :name
add_index :tag_names, [:language_code, :name], unique: true
change_column_default :tag_names, :language_code, from: 'ja', to: nil
change_column_default :tag_names, :script_code, from: 'Jpan', to: nil
execute <<~SQL
UPDATE
tag_names tn
LEFT JOIN
tags AS t
ON
t.tag_name_id = COALESCE(tn.canonical_id, tn.id)
SET
tn.tag_id = t.id
, tn.primary_flg = CASE
WHEN tn.canonical_id IS NULL THEN
1
ELSE
0
END
SQL
change_column_default :tag_names, :primary_flg, from: true, to: nil
add_column :tag_names, :primary_tag_id, :bigint,
as: 'CASE WHEN primary_flg THEN tag_id ELSE NULL END'
add_index :tag_names, [:primary_tag_id, :language_code], unique: true
add_check_constraint :tag_names,
'primary_flg OR (NOT auto_generated)',
name: 'chk_tag_names_auto_generated_primary'
end
def down
remove_check_constraint :tag_names,
name: 'chk_tag_names_auto_generated_primary'
remove_index :tag_names, [:primary_tag_id, :language_code]
remove_column :tag_names, :primary_tag_id
remove_foreign_key :tag_names, column: :language_code
remove_foreign_key :tag_names, column: :script_code
remove_index :tag_names, [:language_code, :name]
add_index :tag_names, :name, unique: true
remove_column :tag_names, :auto_generated
remove_column :tag_names, :primary_flg
remove_column :tag_names, :script_code
remove_column :tag_names, :language_code
remove_reference :tag_names, :tag, foreign_key: true
end
end
+68
ファイルの表示
@@ -0,0 +1,68 @@
class AddNamesToTagVersions < ActiveRecord::Migration[8.0]
class TagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
def up
add_column :tag_versions, :names, :json
TagVersion.reset_column_information
say_with_time 'Backfilling multilingual tag version names' do
TagVersion.find_each do |version|
aliases = version.aliases.to_s.split.filter_map do |name|
name.present? && { name:, script_code: 'Jpan' }
end
names = { ja: { name: version.name,
script_code: 'Jpan',
auto_generated: false,
aliases: } }
version.update_columns(names:)
end
end
add_names_constraint!
change_column_null :tag_versions, :names, false
end
def down
remove_check_constraint :tag_versions,
name: 'chk_tag_versions_names_schema'
remove_column :tag_versions, :names
end
private
def add_names_constraint!
schema = { type: 'object',
minProperties: 1,
patternProperties: { '^.{1,16}$': name_schema },
additionalProperties: false }
quoted_schema = connection.quote(JSON.generate(schema))
add_check_constraint :tag_versions,
"JSON_SCHEMA_VALID(#{ quoted_schema }, names)",
name: 'chk_tag_versions_names_schema'
end
def name_schema
{ type: 'object',
required: ['name', 'script_code', 'auto_generated', 'aliases'],
properties: { **name_schema_base,
auto_generated: { type: 'boolean' },
aliases: { type: 'array',
uniqueItems: true,
items: { type: 'object',
required: ['name', 'script_code'],
properties: name_schema_base,
additionalProperties: false } } },
additionalProperties: false }
end
def name_schema_base
{ name: { type: 'string', minLength: 1, maxLength: 255 },
script_code: { type: 'string', pattern: '^[A-Z][a-z]{3}$' } }
end
end
生成ファイル
+96 -38
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -48,6 +48,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end
create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "platform", limit: 16, null: false
t.string "name", null: false
t.integer "post_count", default: 0, null: false
t.datetime "created_at", null: false
t.index ["platform", "name"], name: "index_external_tags_on_platform_and_name", unique: true
end
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "model", null: false
t.integer "input_tokens", default: 0, null: false
@@ -130,6 +138,29 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end
create_table "languages", primary_key: "code", id: { type: :string, limit: 16 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.index ["deprecated_at"], name: "index_languages_on_deprecated_at"
end
create_table "locales", primary_key: "code", id: { type: :string, limit: 32 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "language_code", limit: 16, null: false
t.string "script_code", limit: 4, null: false
t.integer "region_code", limit: 2, null: false
t.string "variants", default: "", null: false
t.string "name", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.index ["deprecated_at"], name: "index_locales_on_deprecated_at"
t.index ["language_code", "script_code", "region_code", "variants"], name: "idx_on_language_code_script_code_region_code_varian_b0ba85c39a", unique: true
t.index ["language_code"], name: "index_locales_on_language_code"
t.index ["region_code"], name: "fk_rails_175159992c"
t.index ["script_code"], name: "fk_rails_0b74ce96a8"
t.check_constraint "regexp_like(`variants`,_utf8mb4'^((([a-z0-9]{5,8}|[0-9][a-z0-9]{3})(-([a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(-x(-[a-z0-9]{1,8})+)?)|(x(-[a-z0-9]{1,8})+))?$',_utf8mb4'c')", name: "locales_variants_check"
end
create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false
t.string "profile", default: "legacy_drive", null: false
@@ -281,6 +312,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end
create_table "post_external_tags", primary_key: ["post_id", "external_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "external_tag_id", null: false
t.datetime "created_at", null: false
t.index ["external_tag_id"], name: "index_post_external_tags_on_external_tag_id"
end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
@@ -311,23 +349,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
create_table "post_tags", primary_key: ["post_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.bigint "created_user_id"
t.bigint "deleted_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "is_active", type: :boolean, as: "(`discarded_at` is null)", stored: true
t.virtual "active_unique_key", type: :string, as: "(case when (`discarded_at` is null) then concat(`post_id`,_utf8mb4':',`tag_id`) else NULL end)", stored: true
t.index ["active_unique_key"], name: "idx_post_tags_active_unique", unique: true
t.index ["created_user_id"], name: "index_post_tags_on_created_user_id"
t.index ["deleted_user_id"], name: "index_post_tags_on_deleted_user_id"
t.index ["discarded_at"], name: "index_post_tags_on_discarded_at"
t.index ["post_id", "discarded_at"], name: "index_post_tags_on_post_id_and_discarded_at"
t.index ["post_id"], name: "index_post_tags_on_post_id"
t.index ["tag_id", "discarded_at"], name: "index_post_tags_on_tag_id_and_discarded_at"
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end
@@ -349,6 +376,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000
t.text "tags", null: false
t.json "tags_json", null: false
t.text "parent_post_ids", null: false
t.datetime "original_created_from"
t.datetime "original_created_before"
@@ -362,6 +390,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"oneOf\":[{\"type\":\"object\",\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"tag_id\",\"version_no\",\"sections\"],\"additionalProperties\":false},{\"type\":\"object\",\"properties\":{\"external_tag_id\":{\"type\":\"integer\",\"minimum\":1}},\"required\":[\"external_tag_id\"],\"additionalProperties\":false}]}}',`tags_json`)", name: "chk_post_versions_tags_json_schema"
end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -382,6 +411,24 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
create_table "regions", primary_key: "code", id: { type: :integer, limit: 2 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "alpha_code", limit: 2
t.string "name", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.index ["alpha_code"], name: "index_regions_on_alpha_code", unique: true
t.index ["deprecated_at"], name: "index_regions_on_deprecated_at"
t.check_constraint "regexp_like(`alpha_code`,_utf8mb4'^[A-Z]{2}$',_utf8mb4'c')", name: "regions_alpha_code_check"
end
create_table "scripts", primary_key: "code", id: { type: :string, limit: 4 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.index ["deprecated_at"], name: "index_scripts_on_deprecated_at"
t.check_constraint "regexp_like(`code`,_utf8mb4'^[A-Z][a-z]{3}$',_utf8mb4'c')", name: "scripts_code_check"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.datetime "created_at", null: false
@@ -393,19 +440,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false
@@ -427,14 +461,22 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
end
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id"
t.string "language_code", limit: 16, null: false
t.string "name", null: false
t.string "script_code", limit: 4, null: false
t.boolean "primary_flg", null: false
t.boolean "auto_generated", default: false, null: false
t.bigint "canonical_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)"
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["discarded_at"], name: "index_tag_names_on_discarded_at"
t.index ["name"], name: "index_tag_names_on_name", unique: true
t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true
t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true
t.index ["script_code"], name: "fk_rails_dd783b3d1c"
t.index ["tag_id"], name: "index_tag_names_on_tag_id"
t.check_constraint "(0 <> `primary_flg`) or (0 = `auto_generated`)", name: "chk_tag_names_auto_generated_primary"
end
create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -456,11 +498,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.json "names", null: false
t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"object\",\"minProperties\":1,\"patternProperties\":{\"^.{1,16}$\":{\"type\":\"object\",\"required\":[\"name\",\"script_code\",\"auto_generated\",\"aliases\"],\"properties\":{\"name\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":255},\"script_code\":{\"type\":\"string\",\"pattern\":\"^[A-Z][a-z]{3}$\"},\"auto_generated\":{\"type\":\"boolean\"},\"aliases\":{\"type\":\"array\",\"uniqueItems\":true,\"items\":{\"type\":\"object\",\"required\":[\"name\",\"script_code\"],\"properties\":{\"name\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":255},\"script_code\":{\"type\":\"string\",\"pattern\":\"^[A-Z][a-z]{3}$\"}},\"additionalProperties\":false}}},\"additionalProperties\":false}},\"additionalProperties\":false}',`names`)", name: "chk_tag_versions_names_schema"
end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -470,10 +514,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
@@ -614,6 +656,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
@@ -699,6 +754,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
add_foreign_key "gekanator_questions", "users", column: "created_by_id"
add_foreign_key "locales", "languages", column: "language_code", primary_key: "code"
add_foreign_key "locales", "regions", column: "region_code", primary_key: "code"
add_foreign_key "locales", "scripts", column: "script_code", primary_key: "code"
add_foreign_key "material_export_items", "materials"
add_foreign_key "material_export_items", "users", column: "created_by_user_id"
add_foreign_key "material_import_blocks", "users", column: "created_by_user_id"
@@ -707,37 +765,37 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
add_foreign_key "material_versions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_by_user_id"
add_foreign_key "materials", "materials", column: "parent_id"
add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "external_tags", column: "nico_tag_id"
add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_external_tags", "external_tags"
add_foreign_key "post_external_tags", "posts"
add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tag_sections", "post_tags", column: ["post_id", "tag_id"], primary_key: ["post_id", "tag_id"], on_delete: :cascade
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags"
add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code"
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_names", "tags"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "tags"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
+58 -33
ファイルの表示
@@ -16,7 +16,7 @@ namespace :nico do
end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
@@ -30,9 +30,28 @@ namespace :nico do
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each(&:destroy!)
end
def sync_post_external_tags! post, desired_external_tag_ids, current_external_tag_ids: nil
current_external_tag_ids ||=
PostExternalTag.where(post_id: post.id).pluck(:external_tag_id).to_set
desired_external_tag_ids = desired_external_tag_ids.compact.to_set
to_add = desired_external_tag_ids - current_external_tag_ids
to_remove = current_external_tag_ids - desired_external_tag_ids
ExternalTag.where(id: to_add.to_a).find_each do |external_tag|
begin
PostExternalTag.create!(post:, external_tag:)
rescue ActiveRecord::RecordNotUnique
;
end
end
PostExternalTag
.where(post_id: post.id, external_tag_id: to_remove.to_a)
.find_each(&:destroy!)
end
mysql_user = ENV['MYSQL_USER']
@@ -111,58 +130,64 @@ namespace :nico do
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end
tags = post.tags
# 既存のタグ Id. 集合
kept_tag_ids = tags.pluck(:id).to_set
# うち内部タグ Id. 集合
kept_non_nico_tag_ids = tags.not_nico.pluck(:id).to_set
kept_tag_ids = post.tags.pluck(:id).to_set
# 既存の外部タグ Id. 集合
kept_external_tag_ids = post.external_tags.nico.pluck(:id).to_set
# 記載すべき外部タグ Id. のリスト
desired_external_tag_ids = []
# 記載すべき外部タグ Id. および連携される内部タグ Id. のリスト
desired_nico_tag_based_ids = []
# 記載すべき内部タグ Id. のリスト
desired_non_nico_tag_ids = []
desired_tag_ids = kept_tag_ids.to_a
datum['tags'].each do |raw|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
name = TagNameSanitisationRule.sanitise("nico:#{ raw }").delete_prefix('nico:')
tag = ExternalTag.find_or_create_by!(platform: :nico, name:)
event_type = tag.nico_tag_versions.exists? ? :update : :create
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
unless tag.nico_tag_versions.exists?
NicoTagVersionRecorder.record!(external_tag: tag,
event_type: :create,
created_by_user: nil)
end
desired_nico_tag_based_ids << tag.id
desired_external_tag_ids << tag.id
# 新たに記載される外部タグと連携される内部タグを記載
unless tag.id.in?(kept_tag_ids)
linked_ids = tag.linked_tags.pluck(:id)
desired_non_nico_tag_ids.concat(linked_ids)
desired_nico_tag_based_ids.concat(linked_ids)
# 連携タグは記載すれども消除せず.
unless tag.id.in?(kept_external_tag_ids)
desired_tag_ids.concat(tag.linked_tags.pluck(:id))
end
end
deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user'])
if deerjikist
desired_non_nico_tag_ids << deerjikist.tag_id
desired_nico_tag_based_ids << deerjikist.tag_id
elsif !(Tag.where(id: kept_non_nico_tag_ids).where(category: :deerjikist).exists?)
desired_non_nico_tag_ids << Tag.no_deerjikist.id
desired_nico_tag_based_ids << Tag.no_deerjikist.id
desired_tag_ids << deerjikist.tag_id
elsif !(Tag.where(id: kept_tag_ids).where(category: :deerjikist).exists?)
desired_tag_ids << Tag.no_deerjikist.id
end
desired_nico_tag_based_ids.uniq!
desired_external_tag_ids.uniq!
desired_tag_ids.uniq!
desired_all_tag_ids = kept_non_nico_tag_ids.to_a + desired_nico_tag_based_ids
desired_non_nico_tag_ids.concat(kept_non_nico_tag_ids.to_a)
desired_non_nico_tag_ids.uniq!
if kept_non_nico_tag_ids != desired_non_nico_tag_ids.to_set
desired_all_tag_ids << Tag.bot.id
# 外部タグの記載に際しては “bot 操作” タグを記載しなぃ.
if kept_tag_ids != desired_tag_ids.to_set
desired_tag_ids << Tag.bot.id
desired_tag_ids.uniq!
end
desired_all_tag_ids.uniq!
sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
tags_changed =
kept_tag_ids != desired_tag_ids.to_set ||
kept_external_tag_ids != desired_external_tag_ids.to_set
sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids)
sync_post_external_tags!(post, desired_external_tag_ids,
current_external_tag_ids: kept_external_tag_ids)
if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
elsif post_changed || tags_changed
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end
+124
ファイルの表示
@@ -0,0 +1,124 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921020000_delete_discarded_records_from_tags'
require_relative '../../db/migrate/20260921030000_delete_discarded_records_from_tag_names'
RSpec.describe 'discarded tag cleanup migrations' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |migration_class|
it "rejects rollback of #{ migration_class.name }" do
expect { migration_class.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
end
context 'with legacy records' do
# Reproduce the historical schema required by these pre-multilingual migrations.
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('TagCleanupMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_tag_cleanup_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
end
after do
@connection.drop_database(@database) if @database_created
ensure
TagCleanupMigrationRecord.remove_connection
end
before do
@connection.create_table(:tag_names) do |t|
t.string :name, null: false, index: { unique: true }
t.bigint :canonical_id
t.datetime :discarded_at, index: true
end
@connection.add_foreign_key(:tag_names, :tag_names, column: :canonical_id)
@connection.create_table(:tags) do |t|
t.references :tag_name, null: false, foreign_key: true, index: { unique: true }
t.datetime :discarded_at, index: true
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, null: false, foreign_key: true
t.references :nico_tag, null: false, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, null: false, foreign_key: true
t.references :parent_tag, null: false, foreign_key: { to_table: :tags }
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
@connection.create_table(table) do |t|
t.references :tag, null: false, foreign_key: true
end
end
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name, canonical_id, discarded_at) VALUES
(1, 'kept', NULL, NULL),
(2, 'merged_alias', 1, NULL),
(3, 'nico:deleted', NULL, '2026-09-20'),
(4, 'nico:kept', NULL, NULL),
(5, 'deleted_name', NULL, '2026-09-20')
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, discarded_at) VALUES
(1, 1, NULL), (2, 2, '2026-09-20'),
(3, 3, '2026-09-20'), (4, 4, NULL)
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_relations (id, tag_id, nico_tag_id) VALUES
(1, 1, 4), (2, 2, 4), (3, 1, 3), (4, 2, 3)
SQL
@connection.execute(<<~SQL)
INSERT INTO tag_implications (id, tag_id, parent_tag_id) VALUES
(1, 1, 4), (2, 2, 1), (3, 1, 2), (4, 2, 3)
SQL
@connection.execute('INSERT INTO tag_versions (tag_id) VALUES (1), (2)')
@connection.execute('INSERT INTO nico_tag_versions (tag_id) VALUES (3), (4)')
@connection.execute('INSERT INTO material_versions (tag_id) VALUES (1), (2)')
end
it 'removes discarded records and their links while retaining aliases and history' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |klass|
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.up }
end
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 4])
expect(@connection.select_rows('SELECT id, canonical_id FROM tag_names ORDER BY id'))
.to eq([[1, nil], [2, 1], [4, nil]])
expect(@connection.select_values('SELECT id FROM nico_tag_relations')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_implications')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM tag_versions ORDER BY tag_id'))
.to eq([1, 2])
expect(@connection.select_values('SELECT tag_id FROM nico_tag_versions ORDER BY tag_id'))
.to eq([3, 4])
expect(@connection.select_values('SELECT tag_id FROM material_versions ORDER BY tag_id'))
.to eq([1, 2])
[:tags, :tag_names].each do |table|
expect(@connection.column_exists?(table, :discarded_at)).to be(false)
expect(@connection.index_exists?(table, :discarded_at)).to be(false)
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
expect(@connection.foreign_key_exists?(table, :tags, column: :tag_id)).to be(false)
end
expect(@connection.foreign_key_exists?(:tags, :tag_names)).to be(true)
expect(@connection.foreign_key_exists?(:tag_names, :tag_names, column: :canonical_id))
.to be(true)
expect(@connection.index_exists?(:tag_names, :name, unique: true)).to be(true)
expect(@connection.index_exists?(:tags, :tag_name_id, unique: true)).to be(true)
[:nico_tag_relations, :tag_implications].each do |table|
expect(@connection.foreign_keys(table).map(&:to_table)).to eq(['tags', 'tags'])
end
end
end
end
+191
ファイルの表示
@@ -0,0 +1,191 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921040000_create_external_tags'
require_relative '../../db/migrate/20260921050000_create_post_external_tags'
require_relative '../../db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations'
require_relative '../../db/migrate/20260921230000_migrate_external_tags'
RSpec.describe 'external tag migrations' do
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('ExternalTagMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_external_tags_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
version_class = Class.new(record_class) do
self.table_name = 'post_versions'
end
stub_const('MigrateExternalTags::MigrationPostVersion', version_class)
create_legacy_tables
seed_legacy_records
end
after do
@connection.drop_database(@database) if @database_created
ensure
ExternalTagMigrationRecord.remove_connection
end
def migrate klass
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.migrate(:up) }
end
def create_legacy_tables
@connection.create_table(:tag_names) { |t| t.string :name }
@connection.create_table(:tags) do |t|
t.references :tag_name, foreign_key: true
t.string :category
t.integer :post_count
t.datetime :created_at
end
@connection.create_table(:posts)
@connection.create_table(:post_tags) do |t|
t.references :post, foreign_key: true
t.references :tag, foreign_key: true
t.datetime :created_at
end
@connection.create_table(:nico_tag_versions) do |t|
t.bigint :tag_id
t.integer :version_no
t.string :name
t.datetime :created_at
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, foreign_key: true
t.references :nico_tag, foreign_key: { to_table: :tags }
end
[:post_tag_sections, :materials, :theatre_skip_event_tags].each do |table|
@connection.create_table(table) { |t| t.references :tag, foreign_key: true }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, foreign_key: true
t.references :parent_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_similarities) do |t|
t.references :tag, foreign_key: true
t.references :target_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:post_versions) { |t| t.json :tags_json, null: false }
@connection.add_check_constraint(:post_versions, 'JSON_VALID(tags_json)',
name: 'chk_post_versions_tags_json_schema')
end
def seed_legacy_records
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name) VALUES (1, 'internal'), (2, 'nico:raw tag[]');
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, category, post_count, created_at) VALUES
(1, 1, 'general', 1, '2026-09-01'), (2, 2, 'nico', 1, '2026-09-02')
SQL
@connection.execute('INSERT INTO posts (id) VALUES (1)')
@connection.execute(<<~SQL)
INSERT INTO post_tags (post_id, tag_id, created_at) VALUES
(1, 1, '2026-09-03'), (1, 2, '2026-09-04')
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_versions (tag_id, version_no, name, created_at) VALUES
(2, 1, 'nico:raw tag[]', '2026-09-02'),
(3, 1, 'nico:deleted', '2026-09-01'),
(3, 2, 'nico:deleted_later', '2026-09-02')
SQL
@connection.execute('INSERT INTO nico_tag_relations (tag_id, nico_tag_id) VALUES (1, 2)')
@connection.execute(<<~SQL)
INSERT INTO tag_similarities (tag_id, target_tag_id) VALUES (1, 1), (1, 2), (2, 1)
SQL
@connection.execute('INSERT INTO theatre_skip_event_tags (tag_id) VALUES (1), (2)')
@legacy_internal = { 'id' => 1, 'version_no' => 2,
'name' => 'internal', 'category' => 'general',
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }] }
snapshots = [@legacy_internal,
{ 'id' => 2, 'category' => 'nico' },
{ 'id' => 3, 'category' => 'nico' }]
@connection.execute(<<~SQL)
INSERT INTO post_versions (tags_json) VALUES
(#{ @connection.quote(snapshots.to_json) }), ('[]')
SQL
end
def prepare_external_tables
migrate(CreateExternalTags)
migrate(CreatePostExternalTags)
migrate(ChangeForeignKeyOnNicoTagRelations)
end
it 'preserves ids, raw names, counts, timestamps, post links and historical-only tags' do
prepare_external_tables
rows = @connection.select_rows(<<~SQL)
SELECT id, platform, name, post_count, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM external_tags ORDER BY id
SQL
expect(rows).to eq([
[2, 'nico', 'raw tag[]', 1, '2026-09-02'],
[3, 'nico', 'deleted', 0, '2026-09-01']])
expect(@connection.select_rows(<<~SQL)).to eq([[1, 2, '2026-09-04']])
SELECT post_id, external_tag_id, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM post_external_tags
SQL
expect(@connection.foreign_key_exists?(:nico_tag_relations, :external_tags,
column: :nico_tag_id)).to be(true)
expect(@connection.foreign_key_exists?(:nico_tag_relations, :tags,
column: :tag_id)).to be(true)
end
it 'converts snapshots and removes only obsolete internal rows and derived links' do
prepare_external_tables
migrate(MigrateExternalTags)
expect(@connection.select_values('SELECT id FROM tags')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_names')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM post_tags')).to eq([1])
expect(@connection.select_rows('SELECT tag_id, nico_tag_id FROM nico_tag_relations'))
.to eq([[1, 2]])
expect(@connection.select_rows('SELECT tag_id, target_tag_id FROM tag_similarities'))
.to eq([[1, 1]])
expect(@connection.select_values('SELECT tag_id FROM theatre_skip_event_tags')).to eq([1])
expect(@connection.select_value('SELECT COUNT(*) FROM nico_tag_versions')).to eq(3)
versions = MigrateExternalTags::MigrationPostVersion.order(:id)
expect(versions.first.tags_json).to eq([
@legacy_internal.except('id').merge('tag_id' => 1),
{ 'external_tag_id' => 2 }, { 'external_tag_id' => 3 }])
expect(versions.last.tags_json).to eq([])
invalid_snapshots = [@legacy_internal,
{ 'external_tag_id' => 0 },
{ 'external_tag_id' => 2, 'tag_id' => 1 },
{ 'external_tag_id' => 2, 'name' => 'extra' },
{ 'tag_id' => 1, 'version_no' => 1 }]
invalid_snapshots.each do |snapshot|
expect {
MigrateExternalTags::MigrationPostVersion.create!(tags_json: [snapshot])
}.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
expect { MigrateExternalTags.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
{ post_tag_sections: '(tag_id) VALUES (2)',
materials: '(tag_id) VALUES (2)',
tag_implications: '(tag_id, parent_tag_id) VALUES (1, 2)' }.each do |table, values|
it "aborts before deleting data when #{ table } references a legacy nico tag" do
prepare_external_tables
@connection.execute("INSERT INTO #{ table } #{ values }")
expect { migrate(MigrateExternalTags) }.to raise_error(RuntimeError, /#{ table }/)
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 2])
expect(MigrateExternalTags::MigrationPostVersion.first.tags_json.first)
.to eq(@legacy_internal)
end
end
end
+36
ファイルの表示
@@ -0,0 +1,36 @@
require 'rails_helper'
RSpec.describe 'TagName database invariants' do
def insert_name!(**attributes)
TagName.insert_all!([{ name: 'constraint_name', language_code: 'ja',
script_code: 'Jpan', primary_flg: true,
created_at: Time.current, updated_at: Time.current,
**attributes }])
end
it 'defaults persisted names to human-confirmed provenance' do
insert_name!
expect(TagName.find_by!(name: 'constraint_name').auto_generated).to be(false)
end
[[true, true], [true, false], [false, false]].each do |primary_flg, auto_generated|
it "allows primary=#{ primary_flg }, auto_generated=#{ auto_generated }" do
expect { insert_name!(primary_flg:, auto_generated:) }
.to change(TagName, :count).by(1)
end
end
it 'rejects auto-generated aliases at the database boundary' do
expect { insert_name!(primary_flg: false, auto_generated: true) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
it 'rejects a second primary for the same tag and language' do
tag = create(:tag)
expect { insert_name!(tag_id: tag.id, auto_generated: false) }
.to raise_error(ActiveRecord::RecordNotUnique)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
end
end
+91
ファイルの表示
@@ -0,0 +1,91 @@
require 'rails_helper'
RSpec.describe 'TagVersion names database constraint' do
let(:tag) { create(:tag) }
let(:names) do
{ 'ja' => { 'name' => 'snapshot_name', 'script_code' => 'Jpan',
'auto_generated' => false, 'aliases' => [] } }
end
let(:alias_name) { { 'name' => 'snapshot_alias', 'script_code' => 'Jpan' } }
def insert_snapshot!(snapshot)
TagVersion.insert_all!([{ tag_id: tag.id, version_no: 1, event_type: 'create',
name: 'snapshot_name', category: 'general',
aliases: '', parent_tag_ids: '', names: snapshot,
created_at: Time.current }])
end
it 'accepts grouped Nipponese names with an empty alias list' do
expect { insert_snapshot!(names) }.to change(TagVersion, :count).by(1)
expect(TagVersion.find_by!(tag_id: tag.id).names).to eq(names)
end
it 'accepts aliases containing only their name and script' do
names['ja']['aliases'] = [alias_name]
expect { insert_snapshot!(names) }.to change(TagVersion, :count).by(1)
end
it 'rejects a flat array root' do
expect { insert_snapshot!([names.fetch('ja')]) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
it 'rejects an empty object root' do
expect { insert_snapshot!({ }) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
it 'rejects a language entry that is not an object' do
expect { insert_snapshot!({ 'ja' => 'not an object' }) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
['name', 'script_code', 'auto_generated', 'aliases'].each do |field|
it "rejects a primary entry missing #{ field }" do
names['ja'].delete(field)
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
end
it 'rejects an empty primary name' do
names['ja']['name'] = ''
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
it 'rejects incorrect script casing' do
names['ja']['script_code'] = 'JPAN'
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
['unknown', 'primary_flg', 'language_code'].each do |field|
it "rejects the extra primary field #{ field }" do
names['ja'][field] = true
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
end
['unknown', 'primary_flg', 'auto_generated'].each do |field|
it "rejects the extra alias field #{ field }" do
names['ja']['aliases'] = [alias_name.merge(field => true)]
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
end
it 'rejects duplicate alias objects' do
names['ja']['aliases'] = [alias_name, alias_name.deep_dup]
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :external_tag do
platform { :nico }
sequence(:name) { |n| "external_tag_#{ n }" }
end
end
+9 -1
ファイルの表示
@@ -1,5 +1,13 @@
FactoryBot.define do
factory :tag_name do
name { "tag-#{SecureRandom.hex(4)}" }
language_code { 'ja' }
script_code { 'Jpan' }
primary_flg { true }
auto_generated { false }
sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" }
trait :alias do
primary_flg { false }
end
end
end
+26 -7
ファイルの表示
@@ -1,21 +1,40 @@
FactoryBot.define do
factory :tag do
transient do
name { nil }
primary_name { nil }
primary_tag_name do
attributes = primary_name.nil? ? { } : { name: primary_name }
build(:tag_name, **attributes)
end
end
category { :general }
post_count { 0 }
association :tag_name
after(:build) do |tag, evaluator|
tag.name = evaluator.name if evaluator.name.present?
evaluator.primary_tag_name.primary_flg = true
tag.tag_names.target << evaluator.primary_tag_name
end
trait :nico do
category { :nico }
transient do
name { "nico:#{ SecureRandom.hex(4) }" }
before(:create) do |tag, evaluator|
evaluator.primary_tag_name.save!
# Compatibility write only; ownership is established through tag_id below.
tag.tag_name_id = evaluator.primary_tag_name.id
end
after(:create) do |tag, evaluator|
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
.uniq(&:language_code).each do |locale|
next if tag.tag_names.exists?(language_code: locale.language_code,
primary_flg: true)
create(:tag_name,
tag:, language_code: locale.language_code,
script_code: locale.script_code,
name: TagName.generate_name(locale, tag, evaluator.primary_tag_name),
primary_flg: true, auto_generated: true)
end
end
end
+38
ファイルの表示
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe ExternalTag, type: :model do
it 'preserves names without internal tag sanitisation or TagName records' do
external = nil
expect {
external = described_class.create!(platform: :nico, name: 'raw tag[]')
}.not_to change(TagName, :count)
expect(external.reload.name).to eq('raw tag[]')
end
it 'deletes post associations without deleting posts or version history' do
external = create(:external_tag)
post = create(:post)
PostExternalTag.create!(post:, external_tag: external)
version = NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: nil)
external.destroy!
expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty
expect(post.reload.external_tags).to be_empty
expect(version.reload.tag_id).to eq(external.id)
end
it 'deletes external links without deleting linked internal tags' do
external = create(:external_tag)
tag = create(:tag)
NicoTagRelation.create!(nico_tag: external, tag:)
external.destroy!
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
expect(Tag.exists?(tag.id)).to be(true)
end
end
+32
ファイルの表示
@@ -0,0 +1,32 @@
require 'rails_helper'
RSpec.describe Language, type: :model do
it 'accepts Nipponese as the current supported language' do
expect(described_class.find('ja')).to be_valid
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
end
it 'accepts English when multilingual support is enabled' do
pending '日本語以外の Language 有効化は別課題で実装する'
expect { described_class.create!(code: 'en', name: 'English') }
.to change(described_class, :count).by(1)
end
it 'rejects an unsupported language code' do
expect {
described_class.create!(code: 'xx', name: 'Unsupported')
}.to raise_error { |error|
expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid)
}
end
['en', 'fr'].each do |code|
it "does not register #{ code } as a currently supported language" do
expect {
described_class.create!(code:, name: 'Unsupported')
}.to raise_error { |error|
expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid)
}
end
end
end
+129
ファイルの表示
@@ -0,0 +1,129 @@
require 'rails_helper'
RSpec.describe Locale, type: :model do
include_context 'English locale'
def create_english_locale!
prepare_english_reference!
described_class.create!(code: 'en', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: '', name: 'English')
end
it 'keeps the active Nipponese locale consistent with its identity tuple' do
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
expect(described_class.nipponese).to have_attributes(
code: 'ja', language_code: 'ja', script_code: 'Jpan',
region_code: 392, variants: '')
end
it 'rejects a duplicate locale identity tuple at the database boundary' do
expect {
described_class.insert_all!([{ code: 'ja-duplicate', language_code: 'ja',
script_code: 'Jpan', region_code: 392,
variants: '', name: '日本語',
created_at: Time.current }])
}.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'generates a primary name for every existing tag using the name generator' do
pending '日本語以外の Locale 有効化は別課題で実装する'
first = create(:tag, primary_name: 'first_existing_tag')
second = create(:tag, primary_name: 'second_existing_tag')
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name)
.with(kind_of(described_class), first, primary_tag_name_for(first, 'ja'))
.and_return('name_from_generator')
locale = create_english_locale!
expect(TagName).to have_received(:generate_name)
.with(locale, first, primary_tag_name_for(first, 'ja'))
expect(TagName).to have_received(:generate_name)
.with(locale, second, primary_tag_name_for(second, 'ja'))
expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true))
.to have_attributes(name: 'name_from_generator', tag_id: first.id,
language_code: 'en', script_code: 'Latn',
primary_flg: true, auto_generated: true)
expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true))
.to have_attributes(tag_id: second.id, language_code: 'en',
script_code: 'Latn', primary_flg: true,
auto_generated: true)
expect(TagName.where(language_code: 'en', primary_flg: true,
tag_id: [first.id, second.id]).count).to eq(2)
end
it 'does not duplicate an existing primary name in the new language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
tag = create(:tag, primary_name: 'already_named')
existing = create(:tag_name, name: 'existing_english', tag:,
language_code: 'en', script_code: 'Latn')
create_english_locale!
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true))
.to contain_exactly(existing)
expect(existing.reload).to have_attributes(
tag_id: tag.id, language_code: 'en', script_code: 'Latn',
primary_flg: true, auto_generated: false)
end
it 'creates a primary name when the language has only an alias' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
tag = create(:tag, primary_name: 'alias_only_tag')
alias_name = create(:tag_name, :alias, name: 'alias_en',
tag:,
language_code: 'en', script_code: 'Latn')
create_english_locale!
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
language_code: 'en', script_code: 'Latn', primary_flg: false)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en',
script_code: 'Latn', primary_flg: true,
auto_generated: true)
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1)
end
it 'rolls back the locale and all generated names when backfill fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
first = create(:tag, primary_name: 'atomic_first')
second = create(:tag, primary_name: 'atomic_second')
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name)
.with(kind_of(described_class), second, primary_tag_name_for(second, 'ja'))
.and_raise('backfill failed')
expect { create_english_locale! }.to raise_error('backfill failed')
expect(described_class.exists?(code: 'en')).to be(false)
expect(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty
end
it 'accepts a stable locale code outside the historical enum values' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
locale = described_class.create!(
code: 'en-x-rspec', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: 'x-rspec', name: 'English')
expect(locale.reload).to have_attributes(
code: 'en-x-rspec', language_code: 'en', script_code: 'Latn',
region_code: 826, variants: 'x-rspec')
end
it 'registers the future British English locale tuple' do
pending '日本語以外の Locale 有効化は別課題で実装する'
expect(locale).to have_attributes(code: 'en', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: '')
end
end
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe MaterialExportItem, type: :model do
let(:user) { create(:user, :member) }
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) }
let(:tag) { create(:tag, primary_name: 'export_item', category: :material) }
let(:material) do
Material.create!(tag:, url: 'https://example.com/material',
created_by_user: user, updated_by_user: user)
+27
ファイルの表示
@@ -0,0 +1,27 @@
require 'rails_helper'
RSpec.describe NicoTagRelation, type: :model do
it 'does not constrain the ExternalTag association to the nico platform' do
external_tag = create(:external_tag)
tag = create(:tag)
allow(external_tag).to receive(:platform).and_return('registered_external')
allow(external_tag).to receive(:nico?).and_return(false)
expect {
described_class.create!(nico_tag: external_tag, tag:)
}.to change(described_class, :count).by(1)
expect(tag.linked_nico_tags).to contain_exactly(external_tag)
end
it 'rejects an internal Tag through the external association type' do
expect {
described_class.new(nico_tag: create(:tag), tag: create(:tag))
}.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
it 'rejects an ExternalTag through the internal association type' do
expect {
described_class.new(nico_tag: create(:external_tag), tag: create(:external_tag))
}.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
end
+25
ファイルの表示
@@ -0,0 +1,25 @@
require 'rails_helper'
RSpec.describe PostExternalTag, type: :model do
it 'exposes both sides and enforces a unique post and external tag pair' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
expect(post.external_tags).to contain_exactly(external_tag)
expect(external_tag.posts).to contain_exactly(post)
expect { described_class.create!(post:, external_tag:) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'removes associations when the post is deleted while retaining the external tag' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
post.destroy!
expect(described_class.where(post_id: post.id)).to be_empty
expect(external_tag.reload.posts).to be_empty
end
end
+19
ファイルの表示
@@ -6,6 +6,25 @@ RSpec.describe Post, type: :model do
PostUrlSanitisationRule.unscoped.delete_all
end
describe '#snapshot_tags_json' do
it 'keeps internal snapshots and external identifiers distinct, even with the same id' do
post = create(:post)
tag = create(:tag)
external = create(:external_tag, id: tag.id)
create(:post_tag, post:, tag:)
create(:post_tag_section, post:, tag:, begin_ms: 2000, end_ms: nil)
PostExternalTag.create!(post:, external_tag: external)
expect(post.snapshot_tags_json).to eq([
{ 'tag_id' => tag.id,
'version_no' => tag.version_no,
'name' => primary_tag_name_for(tag, 'ja').name,
'category' => tag.category,
'sections' => [{ 'begin_ms' => 2000, 'end_ms' => nil }] },
{ 'external_tag_id' => external.id }])
end
end
describe 'URL normalisation' do
it 'normalises the HTTP URL before applying sanitisation rules' do
PostUrlSanitisationRule.create!(
+79 -4
ファイルの表示
@@ -1,5 +1,73 @@
require 'rails_helper'
RSpec.describe PostTag, type: :model do
describe 'uniqueness' do
it 'rejects duplicate post and tag pairs but allows either to be reused' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(duplicate).not_to be_valid
expect(duplicate.errors.of_kind?(:post_id, :taken)).to be(true)
expect(build(:post_tag, post: post_tag.post, tag: create(:tag))).to be_valid
expect(build(:post_tag, post: create(:post), tag: post_tag.tag)).to be_valid
end
it 'enforces uniqueness in the database when validation is bypassed' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect { duplicate.save!(validate: false) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
end
describe '#destroy!' do
it 'deletes only the selected pair and its sections and updates the counter' do
post_tag = create(:post_tag)
same_post = create(:post_tag, post: post_tag.post)
same_tag = create(:post_tag, tag: post_tag.tag)
sections = [post_tag, same_post, same_tag].map do |link|
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
expect { post_tag.destroy! }.to change(described_class, :count).by(-1)
.and change(PostTagSection, :count).by(-1)
.and change { post_tag.tag.reload.post_count }.from(2).to(1)
expect(described_class.exists?(post: post_tag.post, tag: post_tag.tag)).to be(false)
expect(same_post.reload).to be_persisted
expect(same_tag.reload).to be_persisted
expect(PostTagSection.all).to contain_exactly(*sections.drop(1))
expect(post_tag.post.reload.tags).to contain_exactly(same_post.tag)
expect(post_tag.tag.reload.posts).to contain_exactly(same_tag.post)
end
it 'allows a removed tag to be added again without restoring old sections' do
post_tag = create(:post_tag)
create(:post_tag_section, post: post_tag.post, tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
post_tag.destroy!
replacement = create(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(replacement.reload.sections).to be_empty
expect(replacement.tag.reload.post_count).to eq(1)
end
end
describe '#sections' do
it 'loads the owning post_tag from a section using both keys' do
post_tag = create(:post_tag)
create(:post_tag, post: post_tag.post)
create(:post_tag, tag: post_tag.tag)
section = create(:post_tag_section, post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
expect(section.reload.post_tag).to eq(post_tag)
end
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
@@ -12,18 +80,25 @@ RSpec.describe PostTag, type: :model do
end
it 'does not load sections for another tag on the same post' do
post = create(:post)
tag = create(:tag)
post_tag = create(:post_tag)
post = post_tag.post
other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:)
own_section = create(:post_tag_section,
post:,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
create(:post_tag, post:, tag: other_tag)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to be_empty
expect(post_tag.reload.sections).to contain_exactly(own_section)
end
it 'allows open-ended sections' do
+3 -2
ファイルの表示
@@ -1,8 +1,8 @@
require 'rails_helper'
RSpec.describe PostVersion, type: :model do
let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
let!(:tag_name) { create(:tag_name, name: 'post_version_spec_tag') }
let!(:tag) { create(:tag, primary_tag_name: tag_name, category: :general) }
let!(:post_record) do
Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post|
@@ -19,6 +19,7 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url,
thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '),
tags_json: post_record.snapshot_tags_json,
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before,
+35
ファイルの表示
@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe Script, type: :model do
include_context 'English locale'
it 'selects locales by script code, not language code' do
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
end
it 'accepts supported script codes and rejects unsupported ones' do
expect(described_class.find('Jpan')).to be_valid
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['Jpan'])
expect {
described_class.create!(code: 'Zzzz', name: 'Unsupported')
}.to raise_error { |error|
expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid)
}
end
it 'does not register Latin as a currently supported script' do
expect {
described_class.create!(code: 'Latn', name: 'Latin')
}.to raise_error { |error|
expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid)
}
end
it 'selects Latin locales when multilingual support is enabled' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
expect(described_class.find('Latn').locales).to contain_exactly(locale)
expect(described_class.find('Jpan').locales).not_to include(locale)
end
end
+5 -5
ファイルの表示
@@ -2,8 +2,8 @@ require 'rails_helper'
RSpec.describe TagImplication, type: :model do
it 'rejects a parent tag that would create a cycle' do
child = create(:tag, name: 'tag_implication_cycle_child')
parent = create(:tag, name: 'tag_implication_cycle_parent')
child = create(:tag, primary_name: 'tag_implication_cycle_child')
parent = create(:tag, primary_name: 'tag_implication_cycle_parent')
described_class.create!(tag: child, parent_tag: parent)
@@ -17,9 +17,9 @@ RSpec.describe TagImplication, type: :model do
end
it 'terminates even when existing data already contains a cycle' do
child = create(:tag, name: 'tag_implication_existing_cycle_child')
parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
child = create(:tag, primary_name: 'tag_implication_existing_cycle_child')
parent = create(:tag, primary_name: 'tag_implication_existing_cycle_parent')
ancestor = create(:tag, primary_name: 'tag_implication_existing_cycle_ancestor')
described_class.create!(tag: parent, parent_tag: ancestor)
described_class.insert_all!(
+87 -21
ファイルの表示
@@ -31,12 +31,48 @@ RSpec.describe TagNameSanitisationRule, type: :model do
describe '.apply!' do
before do
described_class.create!(priority: 10, source_pattern: '_', replacement: '')
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
end
context 'when only another language has the sanitised name' do
include_context 'English locale'
it 'keeps both names when sanitisation produces a cross-language match' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag_name, name: 'foobar')
english = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn')
english.update_columns(name: 'foo_bar')
expect { described_class.apply! }.not_to change(TagName, :count)
expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en')
expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
end
it 'renames without merging tags or deleting either language identity' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'foobar')
english_name = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
english_name.update_columns(name: 'foo_bar')
described_class.apply!
expect(english_name.reload).to have_attributes(
name: 'foobar', language_code: 'en', tag_id: english.id)
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
name: 'foobar', language_code: 'ja', tag_id: nipponese.id)
expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name)
end
end
context 'when no conflicting tag_name exists' do
let!(:tag_name) do
TagName.create!(name: 'tmp').tap do |tn|
create(:tag_name, name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end
end
@@ -47,44 +83,72 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end
end
context 'when a conflicting canonical tag_name exists' do
let!(:existing) { TagName.create!(name: 'foobar') }
context 'when a conflicting primary tag name exists' do
let!(:existing) { create(:tag_name, name: 'foobar') }
let!(:source) do
TagName.create!(name: 'tmp').tap do |tn|
create(:tag_name, name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end
end
it 'deletes the source tag_name' do
described_class.apply!
expect(TagName.exists?(source.id)).to be(false)
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(existing.reload.name).to eq('foobar')
end
end
context 'when the source tag_name has a tag and the existing one has no tag' do
let!(:existing) { TagName.create!(name: 'foobar') }
let!(:source_tag) { create(:tag, name: 'tmp', category: :general) }
let!(:source_tag_name_id) { source_tag.tag_name_id }
let!(:existing) { create(:tag_name, name: 'foobar') }
let!(:source_tag) { create(:tag, primary_name: 'tmp', category: :general) }
let!(:source_tag_name_id) { primary_tag_name_for(source_tag, 'ja').id }
before do
source_tag.tag_name.update_columns(name: 'foo_bar', updated_at: Time.current)
primary_tag_name_for(source_tag, 'ja').update_columns(
name: 'foo_bar', updated_at: Time.current)
end
it 'moves the tag to the existing tag_name' do
described_class.apply!
expected_tag_name_id = existing.canonical_id || existing.id
expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id)
expect(TagName.exists?(source_tag_name_id)).to be(false)
expect(existing.reload).to have_attributes(
tag_id: source_tag.id, language_code: 'ja', primary_flg: true)
expect(primary_tag_name_for(source_tag, 'ja')).to eq(existing)
expect(primary_tag_name_for(source_tag.reload, 'ja')).to eq(existing)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
end
end
context 'when the sanitised name is an alias of an existing tag' do
let!(:existing_tag) { create(:tag) }
let!(:existing_primary) { primary_tag_name_for(existing_tag, 'ja') }
let!(:alias_name) do
create(:tag_name, :alias, name: 'foobar', tag: existing_tag)
end
let!(:source) do
create(:tag_name, name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end
end
it 'deletes only the source and preserves the alias and its owning tag' do
described_class.apply!
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(alias_name.reload).to have_attributes(
tag_id: existing_tag.id, language_code: 'ja', primary_flg: false)
expect(TagName.find_by!(tag_id: existing_tag.id,
language_code: 'ja', primary_flg: true))
.to eq(existing_primary)
expect(Tag.find(existing_tag.id)).to eq(existing_tag)
end
end
context 'when both source and existing tag_names have tags' do
let!(:existing_tn) { TagName.create!(name: 'foobar') }
let!(:existing_tag) { Tag.create!(tag_name: existing_tn, category: :general) }
let!(:existing_tn) { create(:tag_name, name: 'foobar') }
let!(:existing_tag) { create(:tag, primary_tag_name: existing_tn, category: :general) }
let!(:source_tn) { TagName.create!(name: 'tmp') }
let!(:source_tag) { Tag.create!(tag_name: source_tn, category: :general) }
let!(:source_tn) { create(:tag_name, name: 'tmp') }
let!(:source_tag) { create(:tag, primary_tag_name: source_tn, category: :general) }
let!(:source_tag_name_id) { source_tn.id }
before do
@@ -92,13 +156,15 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end
it 'merges the source tag into the existing tag and deletes the source tag_name' do
expect(TagName.find_by(name: 'foobar')&.tag&.id).to eq(existing_tag.id)
expect(TagName.find_by(name: 'foo_bar')&.tag&.id).to eq(source_tag.id)
post = create(:post)
PostTag.create!(post:, tag: source_tag)
described_class.apply!
expect(Tag.exists?(source_tag.id)).to be(false)
expect(TagName.exists?(source_tag.tag_name_id)).to be(false)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
expect(post.reload.tags).to contain_exactly(existing_tag)
expect(primary_tag_name_for(existing_tag.reload, 'ja').name).to eq('foobar')
end
end
end
+171
ファイルの表示
@@ -0,0 +1,171 @@
require 'rails_helper'
RSpec.describe TagName, type: :model do
include_context 'English locale'
it 'allows the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
create(:tag_name, name: 'shared_name')
english = build(:tag_name, name: 'shared_name',
language_code: 'en', script_code: 'Latn')
expect(english).to be_valid
expect { english.save! }.to change(described_class, :count).by(1)
end
it 'rejects a duplicate name within the same language' do
create(:tag_name, name: 'shared_name')
duplicate = build(:tag_name, name: 'shared_name')
expect(duplicate).to be_invalid
expect(duplicate.errors.of_kind?(:name, :taken)).to be(true)
end
it 'creates Nipponese factory names without requiring a tag' do
name = create(:tag_name)
expect(name.reload).to have_attributes(
language_code: 'ja', script_code: 'Jpan', primary_flg: true,
auto_generated: false, tag_id: nil)
end
it 'defaults ordinary persisted human names to confirmed provenance' do
name = described_class.create!(name: 'ordinary_human_name', language_code: 'ja',
script_code: 'Jpan', primary_flg: true)
expect(name.reload.auto_generated).to be(false)
end
it 'persists both sides of the representative tag factory association' do
name = create(:tag_name)
tag = create(:tag, primary_tag_name: name)
expect(name.reload).to have_attributes(
tag_id: tag.id, language_code: 'ja', script_code: 'Jpan', primary_flg: true)
expect(name.tag).to eq(tag)
expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true))
.to eq(name)
expect(tag.reload.tag_name_id).to eq(name.id)
end
it 'creates a complete ordinary tag factory under the active Nipponese language' do
tag = create(:tag, primary_name: 'factory_multilingual')
Locale.where(deprecated_at: nil).distinct.pluck(:language_code).each do |language_code|
expect(TagName.where(tag:, language_code:, primary_flg: true).count).to eq(1)
end
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(have_attributes(auto_generated: false))
end
it 'completes an ordinary tag factory under future active languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: 'future_factory')
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(have_attributes(auto_generated: true))
end
it 'builds a primary name without persisting the tag or its name' do
tag = nil
expect {
tag = build(:tag, primary_name: 'built_primary')
}.to change(Tag, :count).by(0).and change(described_class, :count).by(0)
expect(tag).to be_new_record
expect(tag.tag_names.target).to contain_exactly(have_attributes(
name: 'built_primary', language_code: 'ja', script_code: 'Jpan',
primary_flg: true))
end
it 'persists an alias owned by the same tag as its primary name' do
primary_name = create(:tag_name)
tag = create(:tag, primary_tag_name: primary_name)
alias_name = create(:tag_name, :alias, name: 'valid_alias', tag:)
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
primary_flg: false, language_code: 'ja')
primary = described_class.find_by!(tag_id: alias_name.tag_id,
language_code: alias_name.language_code,
primary_flg: true)
expect(primary).to eq(primary_name)
end
describe '.canonicalise' do
it 'leaves detached aliases unchanged' do
create(:tag_name, :alias, name: 'detached_alias')
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
.to eq(['detached_alias'])
end
it 'does not resolve a detached alias through another detached primary' do
create(:tag_name, :alias, name: 'detached_alias')
create(:tag_name, name: 'unrelated_detached_primary')
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
.to eq(['detached_alias'])
end
it 'resolves the primary name of the alias language on the same tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: '日本語正本')
english_primary = primary_tag_name_for(tag, 'en')
english_primary.update!(name: 'english_primary', auto_generated: false)
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
language_code: 'en', script_code: 'Latn')
representative_id = tag.tag_name_id
primary = described_class.find_by!(tag_id: english_alias.tag_id,
language_code: english_alias.language_code,
primary_flg: true)
expect(primary).to eq(english_primary)
expect(described_class.canonicalise(locale, [english_alias.name]))
.to eq([english_primary.name])
expect(tag.reload.tag_name_id).to eq(representative_id)
end
it 'resolves only aliases in the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'nipponese_canonical')
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
expect(described_class.canonicalise(locale, ['shared_alias']))
.to eq(['english_canonical'])
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
.to eq(['nipponese_canonical'])
end
end
it 'rejects the generated-name prefix in human input' do
human_name = build(:tag_name, name: 'Tag_#999', auto_generated: false)
expect(human_name).to be_invalid
end
it 'allows the generated-name prefix for system-created names' do
generated_name = build(:tag_name, name: 'Tag_#1000', auto_generated: true)
expect { generated_name.save! }.to change(described_class, :count).by(1)
end
end
+502 -141
ファイルの表示
@@ -1,129 +1,185 @@
require 'rails_helper'
RSpec.describe Tag, type: :model do
describe 'external tag separation' do
['nico:reserved', 'NiCo:reserved'].each do |name|
it "rejects the reserved prefix #{ name } for internal tags" do
tag = build(:tag, primary_name: name)
expect(tag).to be_invalid
expect(tag.errors[:name]).to be_present
expect {
described_class.normalise_tags!(Locale.nipponese, [name])
}.to raise_error(Tag::NicoTagNormalisationError)
end
end
it 'finds external links by the internal tag id even when ids differ' do
tag = create(:tag)
external = create(:external_tag, id: tag.id + 10_000)
# The migration retains existing links without running model validation.
NicoTagRelation.insert_all!([{ tag_id: tag.id, nico_tag_id: external.id }])
expect(tag.linked_nico_tags).to contain_exactly(external)
end
end
describe '.normalise_tags!' do
include_context 'English locale'
it 'canonicalises aliases in the supplied language only' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
nipponese = create(:tag, primary_name: 'nipponese_canonical')
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
primary_tag_name_for(english, 'ja').update!(
name: 'english_in_nipponese', auto_generated: false)
english_locale = locale
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
expect(described_class.normalise_tags!(
english_locale, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([english])
expect(described_class.normalise_tags!(
Locale.nipponese, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([nipponese])
end
it 'rejects deprecated tags when deny_deprecated is enabled' do
tag_name = TagName.create!(name: 'normalise deprecated tag')
deprecated_tag = Tag.create!(
tag_name:,
category: :general,
deprecated_at: 1.day.from_now
)
tag_name = create(:tag_name, name: 'normalise deprecated tag')
deprecated_tag = create(:tag,
primary_tag_name: tag_name,
category: :general,
deprecated_at: 1.day.from_now)
expect {
described_class.normalise_tags!(
[deprecated_tag.name],
deny_deprecated: true
)
described_class.normalise_tags!(Locale.nipponese,
[primary_tag_name_for(deprecated_tag, 'ja').name],
deny_deprecated: true)
}.to raise_error(Tag::DeprecatedTagNormalisationError) { |error|
expect(error.tag_names).to eq([deprecated_tag.name])
expect(error.tag_names).to eq([primary_tag_name_for(deprecated_tag, 'ja').name])
}
end
it 'rejects invalid section literals instead of treating them as zero' do
expect {
described_class.normalise_tags!(
['normalise_invalid_section[1:aa-2:00]'],
with_sections: true
)
described_class.normalise_tags!(Locale.nipponese,
['normalise_invalid_section[1:aa-2:00]'],
with_sections: true)
}.to raise_error(Tag::SectionLiteralParseError)
end
it 'parses open-ended section literals' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'parses omitted begin as zero' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-1:00]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[-1:00]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
end
it 'treats fully open section literals as plain tags' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[-]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'treats [0:00-] as a plain tag' do
result = described_class.normalise_tags!(
['伊地知ニジカ[0:00-]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[0:00-]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'expands zero-width sections to one millisecond' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-1:00]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-1:00]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]])
end
it 'swaps reversed section boundaries' do
result = described_class.normalise_tags!(
['伊地知ニジカ[2:00-1:00]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[2:00-1:00]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]])
end
it 'merges open-ended sections over later bounded sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-][2:00-3:00]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-][2:00-3:00]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'merges adjacent bounded and open-ended sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-3:00][3:00-]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-3:00][3:00-]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
end
describe '.expand_parent_tags' do
it 'expands through multiple deprecated parents to an active ancestor' do
child = create(:tag, name: 'expand_child')
deprecated_parent = create(
:tag,
name: 'expand_deprecated_parent',
deprecated_at: Time.current
)
deprecated_grandparent = create(
:tag,
name: 'expand_deprecated_grandparent',
deprecated_at: Time.current
)
active_ancestor = create(:tag, name: 'expand_active_ancestor')
child = create(:tag, primary_name: 'expand_child')
deprecated_parent = create(:tag,
primary_name: 'expand_deprecated_parent',
deprecated_at: Time.current)
deprecated_grandparent = create(:tag,
primary_name: 'expand_deprecated_grandparent',
deprecated_at: Time.current)
active_ancestor = create(:tag, primary_name: 'expand_active_ancestor')
TagImplication.create!(tag: child, parent_tag: deprecated_parent)
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
@@ -140,8 +196,8 @@ RSpec.describe Tag, type: :model do
end
it 'terminates when implications contain a cycle' do
first = create(:tag, name: 'expand_cycle_first')
second = create(:tag, name: 'expand_cycle_second')
first = create(:tag, primary_name: 'expand_cycle_first')
second = create(:tag, primary_name: 'expand_cycle_second')
TagImplication.create!(tag: first, parent_tag: second)
now = Time.current
TagImplication.insert_all!(
@@ -159,45 +215,282 @@ RSpec.describe Tag, type: :model do
end
end
describe 'deprecated validation' do
it 'rejects deprecated nico tags' do
tag = build(
:tag,
name: 'nico:deprecated_validation',
category: :nico,
deprecated_at: Time.current
)
describe '.find_or_create_by_tag_name!' do
context 'with an explicit locale' do
include_context 'English locale'
expect(tag).not_to be_valid
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
it 'creates the representative name with locale attributes and ownership' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = described_class.find_or_create_by_tag_name!(
locale, 'english_name', category: :character)
expect(primary_tag_name_for(tag, 'en')).to have_attributes(
language_code: 'en', script_code: 'Latn',
primary_flg: true, tag_id: tag.id)
expect(tag.category).to eq('character')
end
it 'uses the English primary as the source for a generated Nipponese name' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
allow(TagName).to receive(:generate_name).and_return('generated_nipponese')
tag = described_class.find_or_create_by_tag_name!(
locale, 'english_origin', category: :general)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(have_attributes(
name: 'english_origin', auto_generated: false))
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(have_attributes(
name: 'generated_nipponese', auto_generated: true))
source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true)
expect(TagName).to have_received(:generate_name)
.with(Locale.nipponese, tag, source_tag_name)
end
it 'gives a new tag one primary name for every active language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'new_multilingual_tag', category: :general)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
end
it 'creates one primary per language when multiple locales use English' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
described_class.find_or_create_by_tag_name!(
locale, 'before_second_english_locale', category: :general)
Locale.create!(code: 'en-x-rspec', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: 'x-rspec', name: 'English')
tag = described_class.find_or_create_by_tag_name!(
locale, 'one_english_primary', category: :general)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
end
it 'rolls back a new tag and requested name if name generation fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
original_tag_count = described_class.count
allow(TagName).to receive(:generate_name).and_raise('generation failed')
expect {
described_class.find_or_create_by_tag_name!(
locale, 'atomic_english_name', category: :general)
}.to raise_error('generation failed')
expect(described_class.count).to eq(original_tag_count)
expect(TagName.where(language_code: 'en', name: 'atomic_english_name')).to be_empty
expect(described_class.joins(:tag_names)
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
end
it 'keeps a detached requested name detached if generation fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
detached = create(:tag_name, :alias, name: 'atomic_detached',
language_code: 'en', script_code: 'Latn')
original_tag_count = described_class.count
allow(TagName).to receive(:generate_name).and_raise('generation failed')
expect {
described_class.find_or_create_by_tag_name!(
locale, detached.name, category: :general)
}.to raise_error('generation failed')
expect(described_class.count).to eq(original_tag_count)
expect(detached.reload).to have_attributes(tag_id: nil, primary_flg: false)
end
it 'promotes a detached alias into the requested primary name' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
detached = create(:tag_name, :alias, name: 'detached_english',
language_code: 'en', script_code: 'Latn')
tag = described_class.find_or_create_by_tag_name!(
locale, detached.name, category: :general)
expect(detached.reload).to have_attributes(
tag_id: tag.id, primary_flg: true, canonical_id: nil,
auto_generated: false)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(detached)
end
it 'creates separate tag identities for the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general)
english = described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :character)
expect(english).not_to eq(nipponese)
expect(primary_tag_name_for(english, 'en')).to have_attributes(
name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true)
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
expect(described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :general)).to eq(english)
expect(described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
end
it 'preserves the V1 representative when looking up another primary language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: '日本語代表名')
representative_id = tag.tag_name_id
english = primary_tag_name_for(tag, 'en')
english.update!(name: 'english_primary', auto_generated: false)
found = described_class.find_or_create_by_tag_name!(
locale, english.name, category: :general)
expect(found).to eq(tag)
expect(tag.reload.tag_name_id).to eq(representative_id)
expect(english.reload.tag_id).to eq(tag.id)
end
it 'normalises names using the supplied locale' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tags = described_class.normalise_tags!(
locale, ['character:normalised_english'],
with_tagme: false, with_no_deerjikist: false)
expect(tags.length).to eq(1)
expect(primary_tag_name_for(tags.first, 'en')).to have_attributes(
name: 'normalised_english', language_code: 'en', script_code: 'Latn',
primary_flg: true, tag_id: tags.first.id)
end
end
it 'creates a tag and name with the requested category after stripping whitespace' do
tag = nil
expect {
tag = described_class.find_or_create_by_tag_name!(Locale.nipponese,
' lookup_new ', category: :character)
}.to change(Tag, :count).by(1).and change(TagName, :count).by(1)
expect(primary_tag_name_for(tag, 'ja').name).to eq('lookup_new')
expect(tag.category).to eq('character')
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false)
end
it 'promotes a detached Nipponese alias into a human-confirmed primary' do
detached = create(:tag_name, :alias, name: 'detached_nipponese')
tag = described_class.find_or_create_by_tag_name!(
Locale.nipponese, detached.name, category: :general)
expect(detached.reload).to have_attributes(
tag_id: tag.id, primary_flg: true, canonical_id: nil,
auto_generated: false)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(detached)
end
it 'reuses the owning tag for an alias without changing its category' do
tag = create(:tag, category: :character)
representative_id = tag.tag_name_id
alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:)
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
primary_flg: false, language_code: 'ja')
found = nil
expect {
found = described_class.find_or_create_by_tag_name!(Locale.nipponese,
alias_name.name, category: :general)
}.to change(Tag, :count).by(0).and change(TagName, :count).by(0)
expect(found).to eq(tag)
expect(found.category).to eq('character')
expect(tag.reload.tag_name_id).to eq(representative_id)
end
it 'reuses the owning tag through another alias' do
tag = create(:tag)
primary = primary_tag_name_for(tag, 'ja')
alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:)
other_alias = create(:tag_name, :alias, name: 'lookup_other_alias', tag:)
expect([alias_name.reload, other_alias.reload]).to all(have_attributes(
tag_id: tag.id,
primary_flg: false, language_code: 'ja'))
expect {
found = described_class.find_or_create_by_tag_name!(Locale.nipponese,
alias_name.name, category: :general)
expect(found).to eq(tag)
}.to change(Tag, :count).by(0).and change(TagName, :count).by(0)
expect(primary_tag_name_for(tag.reload, 'ja')).to eq(primary)
end
end
describe '.merge_tags!' do
let!(:target_tag) { create(:tag, category: :general) }
let!(:source_tag) { create(:tag, category: :general) }
let!(:source_tag_name) { source_tag.tag_name }
let!(:source_tag_name) { primary_tag_name_for(source_tag, 'ja') }
let!(:post_record) do
Post.create!(url: 'https://example.com/posts/1', title: 'test post')
end
context 'with names in multiple languages' do
include_context 'English locale'
it 'keeps the target primary and moves source names as aliases' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
english_primary = primary_tag_name_for(target_tag, 'en')
source_primary = primary_tag_name_for(source_tag, 'en')
source_alias = create(:tag_name, :alias, tag: source_tag,
name: 'source_english_alias', language_code: 'en', script_code: 'Latn')
expect([english_primary, source_primary]).to all(have_attributes(
primary_flg: true, auto_generated: true))
described_class.merge_tags!(target_tag, [source_tag])
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary)
expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true))
.to contain_exactly(english_primary)
expect([source_primary.reload, source_alias.reload]).to all(have_attributes(
tag_id: target_tag.id, language_code: 'en', primary_flg: false))
expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true))
.to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
end
end
context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, creates an active target post_tag, discards the source tag, and aliases the source tag_name' do
it 'deletes the source tag, moves its post link, and keeps its name as an alias' do
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1)
end
end
@@ -206,39 +499,108 @@ RSpec.describe Tag, type: :model do
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, keeps one active target post_tag, discards the source tag, and aliases the source tag_name' do
it 'deletes the source link and preserves the existing target link' do
create(:post_tag_section, post: post_record, tag: source_tag,
begin_ms: 1000, end_ms: 2000)
target_section = create(:post_tag_section, post: post_record,
tag: target_tag,
begin_ms: 3000, end_ms: nil)
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id)
target_links = PostTag.where(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active.count).to eq(1)
expect(active.first.id).to eq(target_post_tag.id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_links).to contain_exactly(target_post_tag)
expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty
expect(target_post_tag.reload.sections).to contain_exactly(target_section)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1)
end
end
it 'keeps source history and records the new target alias after deleting the source' do
user = create_member_user!
source_name = primary_tag_name_for(source_tag, 'ja').name
source_alias = create(:tag_name, :alias, name: 'merge_alias', tag: source_tag)
TagVersioning.ensure_snapshot!(source_tag, created_by_user: user)
original_version = source_tag.tag_versions.first
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
versions = TagVersion.where(tag_id: source_tag.id).order(:version_no)
expect(versions.pluck(:version_no, :event_type))
.to eq([[1, 'create'], [2, 'discard']])
expect(versions.first).to eq(original_version)
expect(versions.last.created_by_user).to eq(user)
expect(versions.last.names.fetch('ja')).to eq(
'name' => source_name, 'script_code' => 'Jpan',
'auto_generated' => false,
'aliases' => [{ 'name' => source_alias.name, 'script_code' => 'Jpan' }])
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
target_versions = target_tag.tag_versions.order(:version_no)
expect(target_versions.pluck(:event_type)).to eq(['create', 'update'])
target_aliases = target_versions.last.names.fetch('ja').fetch('aliases').pluck('name')
expect(target_aliases).to contain_exactly('merge_alias', source_name)
end
it 'deletes source relationships while preserving unrelated relationships' do
parent = create(:tag)
child = create(:tag)
nico_tag = create(:external_tag)
TagImplication.create!(tag: source_tag, parent_tag: parent)
TagImplication.create!(tag: child, parent_tag: source_tag)
kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent)
NicoTagRelation.create!(tag: source_tag, nico_tag:)
kept_relation = NicoTagRelation.create!(tag: target_tag, nico_tag:)
TagSimilarity.create!(tag: source_tag, target_tag:, cos: 0.5)
TagSimilarity.create!(tag: target_tag, target_tag: source_tag, cos: 0.5)
kept_similarity = TagSimilarity.create!(tag: target_tag, target_tag: parent,
cos: 0.5)
described_class.merge_tags!(target_tag, [source_tag])
expect(TagImplication.all).to contain_exactly(kept_implication)
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
expect(TagSimilarity.all).to contain_exactly(kept_similarity)
expect(TagVersion.where(tag_id: source_tag.id).order(:version_no).last.parent_tag_ids)
.to eq(parent.id.to_s)
end
it 'preserves material history referencing the deleted source tag' do
source_tag.update!(category: :material)
target_tag.update!(category: :material)
material = Material.create!(tag: source_tag, url: 'https://example.com/material')
version = MaterialVersionRecorder.record!(
material:, event_type: :create, created_by_user: nil)
material.update!(tag: target_tag)
described_class.merge_tags!(target_tag, [source_tag])
expect(version.reload).to have_attributes(
tag_id: source_tag.id, tag_name: source_tag_name.name, tag_category: 'material')
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(material.reload.tag).to eq(target_tag)
end
context 'when source_tags includes the target itself' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'ignores the target in source_tags while still merging the source tag' do
described_class.merge_tags!(target_tag, [source_tag, target_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(Tag.find(target_tag.id)).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1)
end
end
@@ -260,18 +622,17 @@ RSpec.describe Tag, type: :model do
)
end
it 'still merges, but discards the source tag_name instead of aliasing it' do
it 'still merges and keeps the source name as an alias without validating it' do
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1)
end
end
@@ -288,34 +649,30 @@ RSpec.describe Tag, type: :model do
message: 'init')
end
it 'rolls back the transaction' do
it 'rolls back earlier deletions, links, and history when a later source has a wiki' do
earlier_source = create(:tag)
earlier_name = primary_tag_name_for(earlier_source, 'ja')
source_section = create(:post_tag_section, post: post_record,
tag: source_tag,
begin_ms: 1000, end_ms: 2000)
expect {
described_class.merge_tags!(target_tag, [source_tag])
described_class.merge_tags!(target_tag, [earlier_source, source_tag])
}.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id)
expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil
expect(source_tag_name.reload.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0)
end
end
context 'when merging a nico source tag' do
let!(:target_tag) { create(:tag, category: :nico, name: 'nico:foo') }
let!(:source_tag) { create(:tag, category: :nico, name: 'nico:bar') }
let!(:source_tag_name_id) { source_tag.tag_name_id }
it 'discards the source tag_name instead of aliasing it' do
described_class.merge_tags!(target_tag, [source_tag])
discarded_source_tag = Tag.with_discarded.find(source_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name_id)
expect(discarded_source_tag).to be_discarded
expect(discarded_source_tag_name).to be_discarded
expect(discarded_source_tag_name.canonical_id).to be_nil
expect(Tag.unscoped.exists?(earlier_source.id)).to be(true)
expect(earlier_name.reload).to have_attributes(
tag_id: earlier_source.id, language_code: 'ja', primary_flg: true)
expect(TagVersion.where(tag_id: [earlier_source.id, source_tag.id, target_tag.id]))
.to be_empty
expect(Tag.unscoped.exists?(source_tag.id)).to be(true)
expect(TagName.unscoped.exists?(source_tag_name.id)).to be(true)
expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
expect(source_post_tag.sections).to contain_exactly(source_section)
expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil
expect(source_tag.reload.post_count).to eq(1)
expect(source_tag_name.reload).to have_attributes(
tag_id: source_tag.id, language_code: 'ja', primary_flg: true)
expect(target_tag.reload.post_count).to eq(0)
end
end
@@ -333,6 +690,7 @@ RSpec.describe Tag, type: :model do
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
@@ -364,12 +722,15 @@ RSpec.describe Tag, type: :model do
expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload))
expect(latest.tags_json.map { |item| item.fetch('tag_id') }).to eq([target_tag.id])
expect(affected_versions.first.tags_json.map { |item| item.fetch('tag_id') })
.to eq([source_tag.id])
expect(unaffected_post.reload.post_versions.count).to eq(1)
end
end
context 'when the source tag has no active post_tags' do
context 'when the source tag has no post_tags' do
let!(:another_post) do
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
end
+14 -12
ファイルの表示
@@ -1,21 +1,22 @@
require 'rails_helper'
RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
let!(:tag) { create(:tag, primary_name: 'version_record_tag') }
let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') }
it 'makes TagVersion read only after create' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
name: primary_tag_name_for(tag, 'ja').name,
category: tag.category,
aliases: '',
names: { ja: { name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', auto_generated: false, aliases: [] } },
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
created_by_user: nil)
expect {
version.update!(name: 'changed')
@@ -27,13 +28,14 @@ RSpec.describe VersionRecord, type: :model do
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
name: primary_tag_name_for(tag, 'ja').name,
category: tag.category,
aliases: '',
names: { ja: { name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', auto_generated: false, aliases: [] } },
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
created_by_user: nil)
expect {
version.destroy!
@@ -42,10 +44,10 @@ RSpec.describe VersionRecord, type: :model do
it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!(
tag: nico_tag,
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
name: "nico:#{ nico_tag.name }",
linked_tags: '',
created_at: Time.current,
created_by_user: nil
@@ -58,10 +60,10 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!(
tag: nico_tag,
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
name: "nico:#{ nico_tag.name }",
linked_tags: '',
created_at: Time.current,
created_by_user: nil
+1 -1
ファイルの表示
@@ -30,7 +30,7 @@ RSpec.describe 'Deerjikists API', type: :request do
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag1.id)
expect(json['tag']['name']).to eq(tag1.name)
expect(json['tag']['name']).to eq(primary_tag_name_for(tag1, 'ja').name)
end
end
+1 -1
ファイルの表示
@@ -15,7 +15,7 @@ RSpec.describe 'error responses', type: :request do
it 'returns a stable field-error payload for unprocessable requests' do
member = create(:user, :member)
tag = create(:tag, :general, name: 'error_response_tag')
tag = create(:tag, category: :general, primary_name: 'error_response_tag')
sign_in_as(member)
patch "/tags/#{ tag.id }", params: { category: 'nico' }
+7 -8
ファイルの表示
@@ -898,23 +898,22 @@ RSpec.describe 'Gekanator learning API', type: :request do
describe 'GET /gekanator/questions' do
it 'omits questions for deprecated tags' do
active_tag = Tag.create!(name: 'active_question_tag', category: :general)
deprecated_tag = Tag.create!(
name: 'deprecated_question_tag',
category: :general,
deprecated_at: Time.current
)
active_tag = create(:tag, primary_name: 'active_question_tag', category: :general)
deprecated_tag = create(:tag,
primary_name: 'deprecated_question_tag',
category: :general,
deprecated_at: Time.current)
[active_tag, deprecated_tag].each do |question_tag|
GekanatorQuestion.create!(
text: "#{ question_tag.name }?",
text: "#{ primary_tag_name_for(question_tag, 'ja').name }?",
kind: 'tag',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: {
type: 'tag',
key: "#{ question_tag.category }:#{ question_tag.name }"
key: "#{ question_tag.category }:#{ primary_tag_name_for(question_tag, 'ja').name }"
},
created_by: admin
)
+5 -6
ファイルの表示
@@ -4,12 +4,11 @@ require 'rails_helper'
RSpec.describe 'Gekanator posts API', type: :request do
describe 'GET /gekanator/posts' do
it 'omits deprecated tags and returns the stored similarity cosine' do
active_tag = Tag.create!(name: 'active tag', category: :general)
deprecated_tag = Tag.create!(
name: 'deprecated tag',
category: :general,
deprecated_at: Time.current
)
active_tag = create(:tag, primary_name: 'active tag', category: :general)
deprecated_tag = create(:tag,
primary_name: 'deprecated tag',
category: :general,
deprecated_at: Time.current)
post_record = Post.create!(title: 'source', url: 'https://example.com/source')
target_post = Post.create!(title: 'target', url: 'https://example.com/target')
+363
ファイルの表示
@@ -0,0 +1,363 @@
require 'rails_helper'
RSpec.describe 'Locale propagation on write paths', type: :request do
include_context 'English locale'
let(:member) { create(:user, :member) }
before do
sign_in_as(member)
end
def expect_localised_tag(name, expected_locale)
tag_name = TagName.find_by!(name:, language_code: expected_locale.language_code)
expect(tag_name).to have_attributes(
script_code: expected_locale.script_code, primary_flg: true)
expect(tag_name.tag_id).to be_present
tag_name.tag
end
it 'confirms a generated Nipponese primary when a human renames it' do
tag = create(:tag, primary_name: 'generated_source')
primary = primary_tag_name_for(tag, 'ja')
primary.update!(name: "Tag_##{ tag.id }", auto_generated: true)
patch "/tags/#{ tag.id }", params: { locale: 'ja', name: '人間が確定した名称' }
expect(response).to have_http_status(:ok), response.body
expect(primary.reload).to have_attributes(
name: '人間が確定した名称', auto_generated: false)
end
it 'rolls back Nipponese edits when an alias conflicts with another primary' do
tag = create(:tag, primary_name: 'target_before_collision')
create(:tag_name, :alias, tag:, name: 'existing_alias')
other_tag = create(:tag, primary_name: 'other_primary')
other_primary = primary_tag_name_for(other_tag, 'ja')
primary_name_before = primary_tag_name_for(tag, 'ja').name
aliases_before = tag.tag_names.where(primary_flg: false).order(:name).pluck(:name)
category_before = tag.category
put "/tags/#{ tag.id }", params: {
locale: 'ja', name: 'renamed_before_collision', category: 'character',
deprecated: false, aliases: 'other_primary', parent_tags: '' }
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(other_primary.reload).to have_attributes(tag_id: other_tag.id, primary_flg: true)
expect(TagName.where(tag: other_tag, language_code: 'ja', primary_flg: true))
.to contain_exactly(other_primary)
expect(primary_tag_name_for(tag.reload, 'ja').name).to eq(primary_name_before)
expect(tag.category).to eq(category_before)
expect(tag.tag_names.where(primary_flg: false).order(:name).pluck(:name))
.to eq(aliases_before)
end
end
['en', nil].each do |requested_locale|
context "with locale #{ requested_locale.inspect }" do
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
it 'creates post tags in the requested language or Nipponese fallback' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
post '/posts', params: locale_params.merge(
title: 'Locale post', url: 'https://example.com/locale-post',
tags: 'locale_post_tag', parent_post_ids: '')
expect(response).to have_http_status(:created), response.body
tag = expect_localised_tag('locale_post_tag', expected_locale)
expect(Post.find(json.fetch('id')).tags).to include(tag)
end
it 'updates post tags in the requested language or Nipponese fallback' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
put "/posts/#{ record.id }", params: locale_params.merge(
title: record.title, tags: 'locale_updated_tag', parent_post_ids: '',
base_version_no: record.reload.version_no)
expect(response).to have_http_status(:ok), response.body
tag = expect_localised_tag('locale_updated_tag', expected_locale)
expect(record.reload.tags).to include(tag)
end
it 'passes the resolved locale to bulk creation' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
# Worker propagation is exercised in post_bulk_creator_spec.
expect_any_instance_of(PostBulkCreator).to receive(:run)
.with(expected_locale).and_return(results: [])
manifest = [{ title: 'Bulk locale', url: 'https://example.com/bulk-locale',
tags: 'bulk_locale_tag', parent_post_ids: '' }]
post '/posts/bulk', params: locale_params.merge(posts: JSON.generate(manifest)),
headers: { 'CONTENT_TYPE' => 'multipart/form-data' }
expect(response).to have_http_status(:ok), response.body
end
it 'creates materials with tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
post '/materials', params: locale_params.merge(
tag: 'locale_material', url: 'https://example.com/material')
expect(response).to have_http_status(:created), response.body
tag = expect_localised_tag('locale_material', expected_locale)
expect(Material.find(json.fetch('id')).tag).to eq(tag)
end
it 'updates materials with tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
tag = create(:tag, category: :material)
material = Material.create!(tag:, url: 'https://example.com/material')
put "/materials/#{ material.id }", params: locale_params.merge(
tag: 'locale_material_updated', url: material.url)
expect(response).to have_http_status(:ok), response.body
resolved_tag = expect_localised_tag('locale_material_updated', expected_locale)
expect(material.reload.tag).to eq(resolved_tag)
end
it 'links Nico external tags to internal tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
external = create(:external_tag)
NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: member)
put "/tags/nico/#{ external.id }", params: locale_params.merge(tags: 'locale_link')
expect(response).to have_http_status(:ok), response.body
tag = expect_localised_tag('locale_link', expected_locale)
expect(external.reload.linked_tags).to contain_exactly(tag)
end
it 'creates parent tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
tag = create(:tag)
put "/tags/#{ tag.id }", params: locale_params.merge(
name: primary_tag_name_for(tag, 'ja').name, category: tag.category, deprecated: false,
aliases: '', parent_tags: 'locale_parent')
expect(response).to have_http_status(:ok), response.body
parent = expect_localised_tag('locale_parent', expected_locale)
expect(TagImplication.where(tag:).pluck(:parent_tag_id)).to eq([parent.id])
end
end
end
context 'with existing names in both languages' do
let(:english_name) do
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn')
end
let(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) }
let(:nipponese_tag) do
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag|
# Isolate lookup from the separately tested uniqueness validation.
primary_tag_name_for(tag, 'ja').update_columns(name: 'shared_name')
end
end
it 'plans and creates the post using the English identity and category' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
english_tag
nipponese_tag
post '/posts', params: {
locale: 'en', title: 'English identity',
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' }
expect(response).to have_http_status(:created), response.body
record = Post.find(json.fetch('id'))
expect(record.tags).to include(english_tag)
expect(record.tags).not_to include(nipponese_tag)
expect(english_tag.reload.category).to eq('material')
expect(nipponese_tag.reload.category).to eq('general')
end
it 'updates the post using the English identity' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
english_tag
nipponese_tag
record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
put "/posts/#{ record.id }", params: {
locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '',
base_version_no: record.reload.version_no }
expect(response).to have_http_status(:ok), response.body
expect(record.reload.tags).to include(english_tag)
expect(record.tags).not_to include(nipponese_tag)
end
it 'resolves the English material tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
english_tag
nipponese_tag
post '/materials', params: {
locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' }
expect(response).to have_http_status(:created), response.body
expect(Material.find(json.fetch('id')).tag).to eq(english_tag)
expect(Material.where(tag: nipponese_tag)).to be_empty
end
it 'resolves the English parent tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
english_tag
nipponese_tag
child = create(:tag)
put "/tags/#{ child.id }", params: {
locale: 'en', name: primary_tag_name_for(child, 'ja').name,
category: child.category, deprecated: false,
aliases: '', parent_tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id])
end
it 'resolves the English internal tag for a Nico link' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
english_tag
nipponese_tag
external = create(:external_tag)
NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: member)
put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body
expect(external.reload.linked_tags).to contain_exactly(english_tag)
end
end
context 'when renaming or updating aliases in English' do
let(:english_name) do
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn')
end
let(:tag) { create(:tag, primary_tag_name: english_name) }
it 'marks a human-renamed generated primary as no longer auto-generated' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
generated_tag = create(:tag, primary_name: 'rename_generated_source')
generated_name = primary_tag_name_for(generated_tag, 'en')
expect(generated_name.auto_generated).to be(true)
patch "/tags/#{ generated_tag.id }", params: {
locale: 'en', name: 'human_english_name' }
expect(response).to have_http_status(:ok), response.body
expect(generated_name.reload).to have_attributes(
name: 'human_english_name', auto_generated: false)
end
it 'does not steal another tag primary for an alias' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
other_tag = create(:tag, primary_name: 'other_nipponese')
other_primary = primary_tag_name_for(other_tag, 'en')
other_primary.update!(name: 'other_english_primary', auto_generated: false)
create(:tag_name, :alias, tag:, name: 'existing_english_alias',
language_code: 'en', script_code: 'Latn')
primary_name_before = primary_tag_name_for(tag, 'en').name
aliases_before =
tag.tag_names.where(language_code: 'en', primary_flg: false)
.order(:name).pluck(:name)
category_before = tag.category
put "/tags/#{ tag.id }", params: {
locale: 'en', name: 'renamed_before_alias_collision',
category: 'character', deprecated: false,
aliases: 'other_english_primary', parent_tags: '' }
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(other_primary.reload).to have_attributes(
tag_id: other_tag.id, language_code: 'en', primary_flg: true)
expect(TagName.where(tag: other_tag, language_code: 'en', primary_flg: true))
.to contain_exactly(other_primary)
expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before)
expect(tag.category).to eq(category_before)
aliases_after =
tag.tag_names.where(language_code: 'en', primary_flg: false)
.order(:name).pluck(:name)
expect(aliases_after).to eq(aliases_before)
end
end
it 'allows a rename to a name already used in Nipponese' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'rename_target')
patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' }
expect(response).to have_http_status(:ok), response.body
expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes(
name: 'rename_target', language_code: 'en', tag_id: tag.id)
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
end
it 'creates aliases in the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
put "/tags/#{ tag.id }", params: {
locale: 'en', name: english_name.name,
category: tag.category, deprecated: false,
aliases: 'english_alias', parent_tags: '' }
expect(response).to have_http_status(:ok), response.body
expect(TagName.find_by!(language_code: 'en', name: 'english_alias'))
.to have_attributes(
script_code: 'Latn',
tag_id: tag.id, primary_flg: false, language_code: 'en')
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn')
end
it 'does not take an alias from another language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'nipponese_owner')
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
put "/tags/#{ tag.id }", params: {
locale: 'en', name: english_name.name,
category: tag.category, deprecated: false,
aliases: 'shared_alias', parent_tags: '' }
expect(response).to have_http_status(:ok), response.body
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(TagName.find_by!(language_code: 'en', name: 'shared_alias'))
.to have_attributes(
tag_id: tag.id, script_code: 'Latn',
primary_flg: false, language_code: 'en')
expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id))
.to contain_exactly(['ja', nipponese.id], ['en', tag.id])
end
end
end
+25 -23
ファイルの表示
@@ -26,10 +26,10 @@ RSpec.describe 'Materials API', type: :request do
describe 'GET /materials' do
let!(:tag_a) do
Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material)
create(:tag, primary_name: 'material_index_a', category: :material)
end
let!(:tag_b) do
Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material)
create(:tag, primary_name: 'material_index_b', category: :material)
end
let!(:material_a) do
@@ -116,20 +116,20 @@ RSpec.describe 'Materials API', type: :request do
it 'filters by descendant tags and returns stable parent tag groups' do
root =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_root'),
create(:tag, primary_name: 'material_scope_root',
category: :material)
child_b =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'),
create(:tag, primary_name: 'material_scope_b',
category: :material)
child_a =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'),
create(:tag, primary_name: 'material_scope_a',
category: :material)
deprecated =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'),
create(:tag, primary_name: 'material_scope_old',
category: :material,
deprecated_at: Time.current)
grandchild =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'),
create(:tag, primary_name: 'material_scope_grandchild',
category: :material)
root_material =
build_material(tag: root, user: member_user,
@@ -179,7 +179,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'GET /materials/:id' do
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material)
create(:tag, primary_name: 'material_show', category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
@@ -269,7 +269,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:created)
material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_new')
expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_create_new')
expect(material.tag.category).to eq('material')
expect(material.created_by_user).to eq(member_user)
expect(material.updated_by_user).to eq(member_user)
@@ -297,7 +297,8 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:created)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_create_versioned_tag' })
tag = TagName.find_by!(name: 'material_create_versioned_tag',
language_code: 'ja', primary_flg: true).tag
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
@@ -323,8 +324,8 @@ RSpec.describe 'Materials API', type: :request do
end
it 'returns 422 when the existing tag is not material/character' do
general_tag_name = TagName.create!(name: 'material_create_general_tag')
Tag.create!(tag_name: general_tag_name, category: :general)
general_tag_name = create(:tag_name, name: 'material_create_general_tag')
create(:tag, primary_tag_name: general_tag_name, category: :general)
post '/materials', params: {
tag: 'material_create_general_tag',
@@ -345,7 +346,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:created)
material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_url_only')
expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_create_url_only')
expect(material.url).to eq('https://example.com/material-source')
expect(material.file.attached?).to be(false)
end
@@ -384,7 +385,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'PUT /materials/:id' do
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material)
create(:tag, primary_name: 'material_update_old', category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
@@ -463,7 +464,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:ok)
material.reload
expect(material.tag.name).to eq('material_update_new')
expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_update_new')
expect(material.tag.category).to eq('material')
expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user)
@@ -497,7 +498,8 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:ok)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_update_versioned_tag' })
tag = TagName.find_by!(name: 'material_update_versioned_tag',
language_code: 'ja', primary_flg: true).tag
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
@@ -508,7 +510,7 @@ RSpec.describe 'Materials API', type: :request do
it 'backfills a create tag_version for an existing material tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'),
create(:tag, primary_name: 'material_update_existing_no_history',
category: :material)
expect(existing_tag.tag_versions).to be_empty
@@ -531,7 +533,7 @@ RSpec.describe 'Materials API', type: :request do
it 'backfills a create tag_version for an existing character tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'),
create(:tag, primary_name: 'material_update_character_no_history',
category: :character)
expect(existing_tag.tag_versions).to be_empty
@@ -563,7 +565,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:ok)
material.reload
expect(material.tag.name).to eq('material_update_remove_file')
expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_update_remove_file')
expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(false)
@@ -651,8 +653,8 @@ RSpec.describe 'Materials API', type: :request do
end
describe 'GET /materials/download.zip' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) }
let!(:tag_a) { create(:tag, primary_name: 'zip_a', category: :material) }
let!(:tag_b) { create(:tag, primary_name: 'zip_b', category: :material) }
let!(:material_a) do
build_material(tag: tag_a, user: member_user,
file: dummy_upload(filename: 'a.png', body: 'zip-a'))
@@ -701,7 +703,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'GET /materials/versions' do
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material)
create(:tag, primary_name: 'material_history', category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png'))
@@ -776,7 +778,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'DELETE /materials/:id' do
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material)
create(:tag, primary_name: 'material_destroy', category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
+111 -46
ファイルの表示
@@ -3,10 +3,34 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do
it 'returns paginated tags and total count' do
create_list(:tag, 3, :nico)
it 'returns the legacy Tag-compatible external fields' do
external = create(:external_tag, name: 'legacy_external', post_count: 3)
get '/tags/nico', params: { page: 2, limit: 2 }
get '/tags/nico', params: { name: 'legacy_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including(
'id' => external.id,
'name' => 'nico:legacy_external',
'category' => 'nico',
'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false,
'linked_tags' => []))
end
it 'returns paginated tags and total count' do
3.times { |i| create(:external_tag, name: "pagination_#{ i }") }
get '/tags/nico', params: { page: 2, limit: 2, name: 'pagination_' }
expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1)
@@ -14,16 +38,16 @@ RSpec.describe 'NicoTags', type: :request do
end
it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:tag, :nico)
linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:tag, :nico)
unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:tag, :nico)
other.tag_name.update!(name: 'nico:other')
destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search')
linked = create(:external_tag)
linked.update!(name: 'search_linked')
unlinked = create(:external_tag)
unlinked.update!(name: 'search_unlinked')
other = create(:external_tag)
other.update!(name: 'other')
destination = create(:tag, category: :general)
primary_tag_name_for(destination, 'ja').update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
NicoTagRelation.create!(nico_tag: other, tag: create(:tag, :general))
NicoTagRelation.create!(nico_tag: other, tag: create(:tag, category: :general))
get '/tags/nico', params: {
name: 'search_',
@@ -41,37 +65,77 @@ RSpec.describe 'NicoTags', type: :request do
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end
it 'filters by the qualified legacy name as well as the raw name' do
external = create(:external_tag, name: 'qualified_filter')
create(:external_tag, name: 'unrelated_filter')
['qualified_filter', 'nico:qualified_filter'].each do |name|
get '/tags/nico', params: { name: }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => external.id, 'name' => 'nico:qualified_filter'))
end
end
it 'sorts by name and timestamps' do
older = create(:tag, :nico)
older.tag_name.update!(name: 'nico:a')
older = create(:external_tag)
older.update!(name: 'ordered_a')
older.update_columns(created_at: 2.days.ago)
newer = create(:tag, :nico)
newer.tag_name.update!(name: 'nico:b')
newer = create(:external_tag)
newer.update!(name: 'ordered_b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
PostExternalTag.create!(post: create(:post), external_tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
PostExternalTag.create!(post: create(:post), external_tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc' }
get '/tags/nico', params: { order: 'name:desc', name: 'ordered_' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc' }
get '/tags/nico', params: { order: 'created_at:asc', name: 'ordered_' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc' }
get '/tags/nico', params: { order: 'updated_at:desc', name: 'ordered_' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at)
end
end
describe 'GET /tags/nico/:id' do
it 'returns the external tag even when an internal tag has the same id' do
internal = create(:tag)
external = create(
:external_tag,
id: internal.id,
name: 'external_detail')
get "/tags/nico/#{ external.id }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => external.id,
'name' => 'nico:external_detail',
'category' => 'nico')
end
it 'returns 404 when the external tag does not exist' do
internal = create(:tag)
get "/tags/nico/#{ internal.id }"
expect(response).to have_http_status(:not_found)
end
end
describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:tag, :nico) }
let(:nico_tag) { create(:external_tag) }
it '401 when not logged in' do
sign_out
@@ -85,30 +149,31 @@ RSpec.describe 'NicoTags', type: :request do
expect(response).to have_http_status(:forbidden)
end
it '400 when target is not nico category' do
it '404 when only an internal tag exists for the target id' do
sign_in_as(member)
non_nico = create(:tag, :general)
non_nico = create(:tag, category: :general)
expect(ExternalTag.exists?(non_nico.id)).to be(false)
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:not_found)
end
it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin)
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
nico_tag = create(:external_tag, name: 'nico_tags_spec_source')
linked_a_name = TagName.create!(name: 'nico_linked_a')
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
linked_a_name = create(:tag_name, name: 'nico_linked_a')
linked_a = create(:tag, primary_tag_name: linked_a_name, category: :general)
linked_b_name = TagName.create!(name: 'nico_linked_b')
linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
linked_b_name = create(:tag_name, name: 'nico_linked_b')
linked_b = create(:tag, primary_tag_name: linked_b_name, category: :general)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
NicoTagVersionRecorder.record!(external_tag: nico_tag,
event_type: :create, created_by_user: admin)
expect {
patch "/tags/nico/#{nico_tag.id}", params: {
tags: " #{linked_a.name}\n#{linked_b.name} "
tags: " #{ linked_a_name.name }\n#{ linked_b_name.name } "
}
}.to change(TagVersion, :count).by(2)
.and change(NicoTagVersion, :count).by(1)
@@ -126,26 +191,26 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a',
'nico_linked_b'
])
'nico_linked_b'])
expect(versions.last.created_by_user_id).to eq(admin.id)
end
it 'returns 422 when linked tag normalises to nico tag' do
it 'clears existing links and records the empty mapping for a member' do
sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
linked = create(:tag)
NicoTagRelation.insert_all!([{ nico_tag_id: nico_tag.id, tag_id: linked.id }])
NicoTagVersionRecorder.record!(external_tag: nico_tag,
event_type: :create, created_by_user: member)
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count)
patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' }
}.to change(NicoTagVersion, :count).by(1)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
expect(response).to have_http_status(:ok)
expect(json).to eq([])
expect(nico_tag.reload.linked_tags).to be_empty
expect(nico_tag.nico_tag_versions.order(:version_no).last)
.to have_attributes(linked_tags: '', created_by_user: member)
end
it 'returns the tags field error when a nico tag is specified directly' do
+501 -217
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読み込み
-58
ファイルの表示
@@ -80,38 +80,6 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
end
describe "DELETE /tag_children" do
@@ -186,31 +154,5 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
end
end
+13 -9
ファイルの表示
@@ -3,13 +3,13 @@ require 'rails_helper'
RSpec.describe 'TagVersions API', type: :request do
let(:member) { create(:user, :member, name: 'version member') }
let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) }
let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) }
let!(:tag) { create(:tag, primary_name: 'tag_versions_target', category: :general) }
let!(:other_tag) { create(:tag, primary_name: 'tag_versions_other', category: :general) }
let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) }
let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) }
let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) }
let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) }
let!(:parent_shared) { create(:tag, primary_name: 'parent_shared', category: :general) }
let!(:parent_old) { create(:tag, primary_name: 'parent_old', category: :general) }
let!(:parent_new) { create(:tag, primary_name: 'parent_new', category: :general) }
let!(:other_parent) { create(:tag, primary_name: 'other_parent', category: :general) }
let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) }
let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) }
@@ -36,6 +36,10 @@ RSpec.describe 'TagVersions API', type: :request do
category: category,
deprecated_at: deprecated_at,
aliases: Array(aliases).join(' '),
names: { ja: { name:, script_code: 'Jpan', auto_generated: false,
aliases: Array(aliases).sort.map { |alias_name|
{ name: alias_name, script_code: 'Jpan' }
} } },
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_by_user: created_by_user,
created_at: created_at)
@@ -211,7 +215,7 @@ RSpec.describe 'TagVersions API', type: :request do
end
it 'returns empty when the specified tag has no versions' do
fresh_tag = create(:tag, name: 'no_versions_tag', category: :general)
fresh_tag = create(:tag, primary_name: 'no_versions_tag', category: :general)
get '/tags/versions', params: { id: fresh_tag.id }
@@ -232,8 +236,8 @@ RSpec.describe 'TagVersions API', type: :request do
end
it 'does not create tag versions by wiki updates when tag has no versions yet' do
wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki')
wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general)
wiki_tag_name = create(:tag_name, name: 'tag_versions_from_wiki')
wiki_tag = create(:tag, primary_tag_name: wiki_tag_name, category: :general)
wiki_page =
Wiki::Commit.create_content!(
+7 -7
ファイルの表示
@@ -8,13 +8,13 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end
def create_tag! name:, category: :general
tag_name = TagName.create!(name:)
Tag.create!(tag_name:, category:)
tag_name = create(:tag_name, name:)
create(:tag, primary_tag_name: tag_name, category:)
end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
tag_name: primary_tag_name_for(tag, 'ja'),
body:,
created_by_user: user,
message: 'init')
@@ -43,7 +43,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(tag.name).to eq('patch_tag_wiki_after')
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after')
expect(wiki_page.title).to eq('patch_tag_wiki_after')
expect(version).to have_attributes(
@@ -72,7 +72,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
tag.reload
wiki_page.reload
expect(tag.name).to eq('patch_tag_category_only')
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_category_only')
expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end
@@ -101,7 +101,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(tag.name).to eq('put_tag_wiki_after')
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after')
expect(wiki_page.title).to eq('put_tag_wiki_after')
expect(version).to have_attributes(
@@ -134,7 +134,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
tag.reload
wiki_page.reload
expect(tag.name).to eq('put_tag_category_only')
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_category_only')
expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end

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